fix(line): preserve webhook events through dispatch failures (#109655)

* test(line): remove obsolete replay-cache coverage

* fix(line): durably spool webhook events

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* test(line): tighten webhook spool lifecycle proof

* fix(line): use canonical turn adoption lifecycle

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* refactor(line): keep spool types private

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

---------

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-16 22:13:26 -07:00
committed by GitHub
parent cc0b8479d9
commit 5199bfafea
11 changed files with 1341 additions and 462 deletions

View File

@@ -42,9 +42,12 @@ openclaw plugins install ./path/to/local/line-plugin
https://gateway-host/line/webhook
```
The Gateway answers LINE's webhook verification (GET) and acknowledges signed
inbound events (POST) immediately after signature and payload validation; agent
processing continues asynchronously.
The Gateway answers LINE's webhook verification (GET). For signed inbound events
(POST), it writes each event to the durable ingress queue before returning `200`;
agent processing continues asynchronously. Failed delivery is retried from the
queue, including after a Gateway restart, and poison events become failed queue
records after bounded retries. If durable persistence fails, the request returns
`500` instead of acknowledging an event that could be lost.
If you need a custom path, set `channels.line.webhookPath` or
`channels.line.accounts.<id>.webhookPath` and update the URL accordingly.

View File

@@ -5,7 +5,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
import type { LineAccountConfig } from "./types.js";
type MessageEvent = webhook.MessageEvent;
type PostbackEvent = webhook.PostbackEvent;
// Avoid pulling in globals/pairing/media dependencies; this suite only asserts
// allowlist/groupPolicy gating and message-context wiring.
@@ -176,7 +175,6 @@ vi.mock("./bot-message-context.js", () => ({
}));
let handleLineWebhookEvents: typeof import("./bot-handlers.js").handleLineWebhookEvents;
let createLineWebhookReplayCache: typeof import("./bot-handlers.js").createLineWebhookReplayCache;
type LineWebhookContext = Parameters<typeof import("./bot-handlers.js").handleLineWebhookEvents>[1];
const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() });
@@ -228,7 +226,6 @@ function createLineWebhookTestContext(params: {
groupAllowFrom?: LineAccountConfig["groupAllowFrom"];
requireMention?: boolean;
groupHistories?: Map<string, HistoryEntry[]>;
replayCache?: ReturnType<typeof createLineWebhookReplayCache>;
accessGroups?: Record<string, { type: "message.senders"; members: Record<string, string[]> }>;
}): Parameters<typeof handleLineWebhookEvents>[1] {
const allowFrom = params.allowFrom ?? (params.dmPolicy === "open" ? ["*"] : undefined);
@@ -260,22 +257,9 @@ function createLineWebhookTestContext(params: {
mediaMaxBytes: 1,
processMessage: params.processMessage,
...(params.groupHistories ? { groupHistories: params.groupHistories } : {}),
...(params.replayCache ? { replayCache: params.replayCache } : {}),
};
}
function createOpenGroupReplayContext(
processMessage: LineWebhookContext["processMessage"],
replayCache: ReturnType<typeof createLineWebhookReplayCache>,
): Parameters<typeof handleLineWebhookEvents>[1] {
return createLineWebhookTestContext({
processMessage,
groupPolicy: "open",
requireMention: false,
replayCache,
});
}
async function expectGroupMessageBlocked(params: {
processMessage: LineWebhookContext["processMessage"];
event: MessageEvent;
@@ -300,23 +284,9 @@ async function expectRequireMentionGroupMessageProcessed(event: MessageEvent) {
expect(processMessage).toHaveBeenCalledTimes(1);
}
async function startInflightReplayDuplicate(params: {
event: MessageEvent;
processMessage: LineWebhookContext["processMessage"];
}) {
const context = createOpenGroupReplayContext(
params.processMessage,
createLineWebhookReplayCache(),
);
const firstRun = handleLineWebhookEvents([params.event], context);
await Promise.resolve();
const secondRun = handleLineWebhookEvents([params.event], context);
return { firstRun, secondRun };
}
describe("handleLineWebhookEvents", () => {
beforeAll(async () => {
({ handleLineWebhookEvents, createLineWebhookReplayCache } = await import("./bot-handlers.js"));
({ handleLineWebhookEvents } = await import("./bot-handlers.js"));
});
afterAll(() => {
@@ -803,173 +773,6 @@ describe("handleLineWebhookEvents", () => {
expect(pairingRequest?.accountId).toBe("work");
});
it("deduplicates replayed webhook events by webhookEventId before processing", async () => {
const processMessage = vi.fn();
const event = createReplayMessageEvent({
messageId: "m-replay",
groupId: "group-replay",
userId: "user-replay",
webhookEventId: "evt-replay-1",
isRedelivery: true,
});
const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
await handleLineWebhookEvents([event], context);
await handleLineWebhookEvents([event], context);
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("skips concurrent redeliveries while the first event is still processing", async () => {
let resolveFirst: (() => void) | undefined;
const firstDone = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const processMessage = vi.fn(async () => {
await firstDone;
});
const event = createReplayMessageEvent({
messageId: "m-inflight",
groupId: "group-inflight",
userId: "user-inflight",
webhookEventId: "evt-inflight-1",
isRedelivery: true,
});
const { firstRun, secondRun } = await startInflightReplayDuplicate({ event, processMessage });
resolveFirst?.();
await Promise.all([firstRun, secondRun]);
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("commits in-flight failures so concurrent duplicates do not retry", async () => {
let rejectFirst: ((err: Error) => void) | undefined;
const firstDone = new Promise<void>((_, reject) => {
rejectFirst = reject;
});
const processMessage = vi.fn(async () => {
await firstDone;
});
const event = createReplayMessageEvent({
messageId: "m-inflight-fail",
groupId: "group-inflight",
userId: "user-inflight",
webhookEventId: "evt-inflight-fail-1",
isRedelivery: true,
});
const { firstRun, secondRun } = await startInflightReplayDuplicate({ event, processMessage });
const firstFailure = expect(firstRun).rejects.toThrow("transient inflight failure");
rejectFirst?.(new Error("transient inflight failure"));
await firstFailure;
await expect(secondRun).resolves.toBeUndefined();
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("deduplicates redeliveries by LINE message id when webhookEventId changes", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: { id: "m-dup-1", type: "text", text: "hello" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-dup", userId: "user-dup" },
mode: "active",
webhookEventId: "evt-dup-1",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
const context: Parameters<typeof handleLineWebhookEvents>[1] = {
cfg: {
channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-dup"] } },
},
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {
groupPolicy: "allowlist",
groupAllowFrom: ["user-dup"],
groups: { "*": { requireMention: false } },
},
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
replayCache: createLineWebhookReplayCache(),
};
await handleLineWebhookEvents([event], context);
await handleLineWebhookEvents(
[
{
...event,
webhookEventId: "evt-dup-redelivery",
deliveryContext: { isRedelivery: true },
} as MessageEvent,
],
context,
);
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("deduplicates postback redeliveries by webhookEventId when replyToken changes", async () => {
const processMessage = vi.fn();
buildLinePostbackContextMock.mockResolvedValue({
ctxPayload: { From: "line:user:user-postback" },
route: { agentId: "default" },
isGroup: false,
accountId: "default",
});
const event = {
type: "postback",
postback: { data: "action=confirm" },
replyToken: "reply-token-1",
timestamp: Date.now(),
source: { type: "user", userId: "user-postback" },
mode: "active",
webhookEventId: "evt-postback-1",
deliveryContext: { isRedelivery: false },
} as PostbackEvent;
const context: Parameters<typeof handleLineWebhookEvents>[1] = {
cfg: { channels: { line: { dmPolicy: "open", allowFrom: ["*"] } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { dmPolicy: "open", allowFrom: ["*"] },
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
replayCache: createLineWebhookReplayCache(),
};
await handleLineWebhookEvents([event], context);
await handleLineWebhookEvents(
[
{
...event,
replyToken: "reply-token-2",
deliveryContext: { isRedelivery: true },
} as PostbackEvent,
],
context,
);
expect(buildLinePostbackContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("skips group messages by default when requireMention is not configured", async () => {
const processMessage = vi.fn();
const event = createTestMessageEvent({
@@ -1508,29 +1311,5 @@ describe("handleLineWebhookEvents", () => {
// Should be skipped because there is a non-bot mention and the bot was not mentioned.
expect(processMessage).not.toHaveBeenCalled();
});
it("keeps replay cache committed after a non-retryable event failure", async () => {
const processMessage = vi
.fn()
.mockRejectedValueOnce(new Error("transient failure"))
.mockResolvedValueOnce(undefined);
const event = createReplayMessageEvent({
messageId: "m-fail-then-retry",
groupId: "group-retry",
userId: "user-retry",
webhookEventId: "evt-fail-then-retry",
isRedelivery: false,
});
const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
await expect(handleLineWebhookEvents([event], context)).rejects.toThrow("transient failure");
await handleLineWebhookEvents([event], context);
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
expect(context.runtime.error).toHaveBeenCalledWith(
"line: event handler failed: Error: transient failure",
);
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View File

@@ -10,7 +10,6 @@ import {
resolvePairingIdLabel,
upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime";
import { createChannelReplayGuard } from "openclaw/plugin-sdk/persistent-dedupe";
import {
DEFAULT_GROUP_HISTORY_LIMIT,
createChannelHistoryWindow,
@@ -72,65 +71,20 @@ interface LineHandlerContext {
account: ResolvedLineAccount;
runtime: RuntimeEnv;
mediaMaxBytes: number;
processMessage: (ctx: LineInboundContext) => Promise<void>;
replayCache?: LineWebhookReplayCache;
processMessage: (
ctx: LineInboundContext,
control: { abortSignal?: AbortSignal; onTurnAdopted?: () => Promise<void> },
) => Promise<void>;
abortSignal?: AbortSignal;
onTurnAdopted?: () => Promise<void>;
groupHistories?: Map<string, HistoryEntry[]>;
historyLimit?: number;
}
const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000;
const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096;
function normalizeLineIngressEntry(value: string): string | null {
return normalizeLineAllowEntry(value) || null;
}
type LineReplayEvent = { event: WebhookEvent; accountId: string };
export function createLineWebhookReplayCache() {
return createChannelReplayGuard<LineReplayEvent>({
dedupe: {
ttlMs: LINE_WEBHOOK_REPLAY_WINDOW_MS,
memoryMaxSize: LINE_WEBHOOK_REPLAY_MAX_ENTRIES,
},
buildReplayKey: ({ event, accountId }) => buildLineWebhookReplayKey(event, accountId)?.key,
});
}
type LineWebhookReplayCache = ReturnType<typeof createLineWebhookReplayCache>;
function buildLineWebhookReplayKey(
event: WebhookEvent,
accountId: string,
): { key: string; eventId: string } | null {
if (event.type === "message") {
const messageId = event.message?.id?.trim();
if (messageId) {
return {
key: `${accountId}|message:${messageId}`,
eventId: `message:${messageId}`,
};
}
}
const eventId = (event as { webhookEventId?: string }).webhookEventId?.trim();
if (!eventId) {
return null;
}
const source = (
event as {
source?: { type?: string; userId?: string; groupId?: string; roomId?: string };
}
).source;
const sourceId =
source?.type === "group"
? `group:${source.groupId ?? ""}`
: source?.type === "room"
? `room:${source.roomId ?? ""}`
: `user:${source?.userId ?? ""}`;
return { key: `${accountId}|${event.type}|${sourceId}|${eventId}`, eventId: `event:${eventId}` };
}
function resolveLineGroupConfig(params: {
config: ResolvedLineAccount["config"];
groupId?: string;
@@ -485,7 +439,10 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte
return;
}
await processMessage(messageContext);
await processMessage(messageContext, {
...(context.abortSignal ? { abortSignal: context.abortSignal } : {}),
...(context.onTurnAdopted ? { onTurnAdopted: context.onTurnAdopted } : {}),
});
historyReservation.commit();
} finally {
historyReservation.release();
@@ -537,7 +494,10 @@ async function handlePostbackEvent(
return;
}
await context.processMessage(postbackContext);
await context.processMessage(postbackContext, {
...(context.abortSignal ? { abortSignal: context.abortSignal } : {}),
...(context.onTurnAdopted ? { onTurnAdopted: context.onTurnAdopted } : {}),
});
}
export async function handleLineWebhookEvents(
@@ -547,28 +507,7 @@ export async function handleLineWebhookEvents(
let firstError: unknown;
for (const event of events) {
try {
if (!context.replayCache) {
await handleLineWebhookEvent(event, context);
continue;
}
const replayEvent = { event, accountId: context.account.accountId };
const result = await context.replayCache.processGuarded(
replayEvent,
async () => await handleLineWebhookEvent(event, context),
{ onError: "commit" },
);
const replayId = buildLineWebhookReplayKey(event, context.account.accountId)?.eventId;
if (result.kind === "inflight") {
logVerbose(`line: skipped in-flight replayed webhook event ${replayId ?? "unknown"}`);
try {
await result.pending;
} catch (err) {
context.runtime.error?.(danger(`line: replayed in-flight event failed: ${String(err)}`));
firstError ??= err;
}
} else if (result.kind === "duplicate") {
logVerbose(`line: skipped replayed webhook event ${replayId ?? "unknown"}`);
}
await handleLineWebhookEvent(event, context);
} catch (err) {
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
firstError ??= err;

View File

@@ -9,9 +9,10 @@ import {
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import { resolveLineAccount } from "./accounts.js";
import { createLineWebhookReplayCache, handleLineWebhookEvents } from "./bot-handlers.js";
import { handleLineWebhookEvents } from "./bot-handlers.js";
import type { LineInboundContext } from "./bot-message-context.js";
import type { ResolvedLineAccount } from "./types.js";
import { createLineWebhookSpool } from "./webhook-spool.js";
interface LineBotOptions {
channelAccessToken: string;
@@ -20,12 +21,16 @@ interface LineBotOptions {
runtime?: RuntimeEnv;
config?: OpenClawConfig;
mediaMaxMb?: number;
onMessage?: (ctx: LineInboundContext) => Promise<void>;
onMessage?: (
ctx: LineInboundContext,
control: { abortSignal?: AbortSignal; onTurnAdopted?: () => Promise<void> },
) => Promise<void>;
}
interface LineBot {
handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
account: ResolvedLineAccount;
stop: () => Promise<void>;
}
export function createLineBot(opts: LineBotOptions): LineBot {
@@ -44,28 +49,28 @@ export function createLineBot(opts: LineBotOptions): LineBot {
(async () => {
logVerbose("line: no message handler configured");
});
const replayCache = createLineWebhookReplayCache();
const groupHistories = new Map<string, HistoryEntry[]>();
const handleWebhook = async (body: webhook.CallbackRequest): Promise<void> => {
if (!body.events || body.events.length === 0) {
return;
}
await handleLineWebhookEvents(body.events, {
cfg,
account,
runtime,
mediaMaxBytes,
processMessage,
replayCache,
groupHistories,
historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
});
};
const spool = createLineWebhookSpool({
accountId: account.accountId,
runtime,
deliver: async (event, _destination, control) =>
await handleLineWebhookEvents([event], {
cfg,
account,
runtime,
mediaMaxBytes,
processMessage,
...(control.abortSignal ? { abortSignal: control.abortSignal } : {}),
...(control.onTurnAdopted ? { onTurnAdopted: control.onTurnAdopted } : {}),
groupHistories,
historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
}),
});
spool.start();
return {
handleWebhook,
handleWebhook: spool.accept,
account,
stop: spool.stop,
};
}

View File

@@ -21,6 +21,7 @@ const {
createLineBotMock: vi.fn(() => ({
account: { accountId: "default" },
handleWebhook: vi.fn<LineHandleWebhook>(),
stop: vi.fn(),
})),
createLineNodeWebhookHandlerMock: vi.fn<() => LineNodeWebhookHandler>(() =>
vi.fn<LineNodeWebhookHandler>(async () => {}),
@@ -176,6 +177,7 @@ describe("monitorLineProvider lifecycle", () => {
createLineBotMock.mockImplementation(() => ({
account: { accountId: "default" },
handleWebhook: vi.fn<LineHandleWebhook>(),
stop: vi.fn(),
}));
// Clear call history only; the implementation was wired to the actual
// helper once in the module mock factory.
@@ -266,7 +268,7 @@ describe("monitorLineProvider lifecycle", () => {
expect(registration.route.pluginId).toBe("line");
expect(registration.route).not.toHaveProperty("path");
expect(registration.route).not.toHaveProperty("replaceExisting");
monitor.stop();
await monitor.stop();
});
it("stops immediately when signal is already aborted", async () => {
@@ -293,8 +295,8 @@ describe("monitorLineProvider lifecycle", () => {
});
expect(unregisterHttpMock).not.toHaveBeenCalled();
monitor.stop();
monitor.stop();
await monitor.stop();
await monitor.stop();
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
});
@@ -322,7 +324,7 @@ describe("monitorLineProvider lifecycle", () => {
expect(registration.target.accountId).toBe("work");
expect(registration.route.accountId).toBe("work");
monitor.stop();
await monitor.stop();
});
it("does not register a webhook when bot startup fails", async () => {
@@ -380,8 +382,8 @@ describe("monitorLineProvider lifecycle", () => {
expect(firstBot.handleWebhook).not.toHaveBeenCalled();
expect(secondBot.handleWebhook).toHaveBeenCalledTimes(1);
firstMonitor.stop();
secondMonitor.stop();
await firstMonitor.stop();
await secondMonitor.stop();
});
it("dispatches a signed POST to a configured trailing-slash webhook path", async () => {
@@ -414,10 +416,10 @@ describe("monitorLineProvider lifecycle", () => {
expect(res.statusCode).toBe(200);
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
monitor.stop();
await monitor.stop();
});
it("runs matched event processing on a detached admitted work root", async () => {
it("durably admits matched events before acknowledging", async () => {
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
@@ -441,16 +443,13 @@ describe("monitorLineProvider lifecycle", () => {
handleWebhook: ReturnType<typeof vi.fn>;
};
expect(res.statusCode).toBe(200);
// The request admission is released once the route handler returns, so
// event processing dispatched on the inherited chain would be refused as
// draining; the dispatch must reserve its own root.
expect(runDetachedWebhookWorkMock).toHaveBeenCalledTimes(1);
expect(runDetachedWebhookWorkMock).not.toHaveBeenCalled();
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
monitor.stop();
await monitor.stop();
});
it("acknowledges shared-path POST requests before matched event processing completes", async () => {
it("waits for shared-path durable admission before acknowledging", async () => {
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
@@ -479,16 +478,20 @@ describe("monitorLineProvider lifecycle", () => {
}) as unknown as IncomingMessage;
const res = createRouteResponse();
await route.handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.headersSent).toBe(true);
const request = route.handler(req, res);
await vi.waitFor(() => {
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
});
expect(res.headersSent).toBe(false);
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
if (!releaseWebhook) {
throw new Error("expected pending LINE webhook handler");
}
releaseWebhook();
monitor.stop();
await request;
expect(res.statusCode).toBe(200);
expect(res.headersSent).toBe(true);
await monitor.stop();
});
it("rejects ambiguous shared-path webhook signatures", async () => {
@@ -530,8 +533,8 @@ describe("monitorLineProvider lifecycle", () => {
expect(firstBot.handleWebhook).not.toHaveBeenCalled();
expect(secondBot.handleWebhook).not.toHaveBeenCalled();
firstMonitor.stop();
secondMonitor.stop();
await firstMonitor.stop();
await secondMonitor.stop();
});
it("rejects webhook requests above the shared in-flight limit before body handling", async () => {
@@ -589,6 +592,6 @@ describe("monitorLineProvider lifecycle", () => {
heldRequests.splice(0).forEach((req) => req.destroy());
await Promise.allSettled(firstRequests);
monitor.stop();
await monitor.stop();
});
});

View File

@@ -20,7 +20,6 @@ import {
import {
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
runDetachedWebhookWork,
} from "openclaw/plugin-sdk/webhook-request-guards";
import { resolveDefaultLineAccountId } from "./accounts.js";
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
@@ -45,6 +44,7 @@ import {
import { buildTemplateMessageFromPayload } from "./template-messages.js";
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js";
import { LineWebhookTerminalDeliveryError } from "./webhook-spool.js";
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
interface MonitorLineProviderOptions {
@@ -61,7 +61,7 @@ interface MonitorLineProviderOptions {
interface LineProviderMonitor {
account: ResolvedLineAccount;
handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
stop: () => void;
stop: () => Promise<void>;
}
const lineWebhookInFlightLimiter = createWebhookInFlightLimiter();
@@ -141,7 +141,7 @@ export async function monitorLineProvider(
accountId,
runtime,
config,
onMessage: async (ctx) => {
onMessage: async (ctx, deliveryControl) => {
if (!ctx) {
return;
}
@@ -164,15 +164,24 @@ export async function monitorLineProvider(
const displayName = await displayNamePromise;
logVerbose(`line: received message from ${displayName} (${ctxPayload.From})`);
let replyTokenUsed = false;
let turnAdopted = false;
try {
const textLimit = 5000;
let replyTokenUsed = false;
const core = getLineRuntime();
const turnResult = await core.channel.inbound.run({
channel: "line",
accountId: route.accountId,
raw: ctx,
turnAdoptionLifecycle: {
admission: "exclusive",
onAdopted: async () => {
await deliveryControl.onTurnAdopted?.();
turnAdopted = true;
},
...(deliveryControl.abortSignal ? { abortSignal: deliveryControl.abortSignal } : {}),
},
adapter: {
ingest: () => ({
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
@@ -191,6 +200,9 @@ export async function monitorLineProvider(
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
record: ctx.turn.record,
replyPipeline: {},
...(deliveryControl.abortSignal
? { replyOptions: { abortSignal: deliveryControl.abortSignal } }
: {}),
delivery: {
durable: (payload, info) =>
resolveLineDurableReplyOptions({
@@ -265,18 +277,13 @@ export async function monitorLineProvider(
}
} catch (err) {
runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
if (replyToken) {
try {
await replyMessageLine(
replyToken,
[{ type: "text", text: "Sorry, I encountered an error processing your message." }],
{ cfg: config, accountId: ctx.accountId },
);
} catch (replyErr) {
runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
}
if (turnAdopted || replyTokenUsed) {
throw new LineWebhookTerminalDeliveryError(
"LINE delivery failed after consuming the event reply token.",
{ cause: err },
);
}
throw err;
} finally {
stopLoading?.();
}
@@ -377,23 +384,13 @@ export async function monitorLineProvider(
return;
}
requestLifecycle.release();
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
await match.target.bot.handleWebhook(body);
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" }));
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
// Detach event processing from the request admission before the ack
// releases it; an inherited released admission refuses queue work.
void runDetachedWebhookWork(() => match.target.bot.handleWebhook(body)).catch(
(err: unknown) => {
match.target.runtime.error?.(
danger(`line webhook dispatch failed: ${String(err)}`),
);
},
);
}
} catch (err) {
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
res.statusCode = 413;
@@ -423,28 +420,36 @@ export async function monitorLineProvider(
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
let stopped = false;
const stopHandler = () => {
let stopPromise: Promise<void> | undefined;
const stopHandler = (): Promise<void> => {
if (stopPromise) {
return stopPromise;
}
if (stopped) {
return;
return Promise.resolve();
}
stopped = true;
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
unregisterHttp();
stopPromise = bot.stop();
return stopPromise;
};
const stopOnAbort = () => void stopHandler();
if (abortSignal?.aborted) {
stopHandler();
await stopHandler();
} else if (abortSignal) {
abortSignal.addEventListener("abort", stopHandler, { once: true });
abortSignal.addEventListener("abort", stopOnAbort, { once: true });
await waitForAbortSignal(abortSignal);
await stopHandler();
}
return {
account: bot.account,
handleWebhook: bot.handleWebhook,
stop: () => {
stopHandler();
abortSignal?.removeEventListener("abort", stopHandler);
stop: async () => {
await stopHandler();
abortSignal?.removeEventListener("abort", stopOnAbort);
},
};
}

View File

@@ -329,21 +329,29 @@ describe("LINE webhook shared POST contract", () => {
},
);
it.each(sharedWebhookPostContractCases)("$name dispatches signed events", async ({ invoke }) => {
const result = await invoke({
rawBody: JSON.stringify({ events: [{ type: "message" }] }),
signed: true,
});
expect(result.status).toBe(200);
expect(result.body).toEqual({ status: "ok" });
expect(result.dispatched).toHaveBeenCalledTimes(1);
});
it.each(sharedWebhookPostContractCases)(
"$name acknowledges signed events before failed background processing is logged",
"$name returns 500 when durable admission fails",
async ({ invoke }) => {
const result = await invoke({
failWith: new Error("transient failure"),
failWith: new Error("persist failed"),
rawBody: JSON.stringify({ events: [{ type: "message" }] }),
signed: true,
});
expect(result.status).toBe(200);
expect(result.body).toEqual({ status: "ok" });
expect(result.dispatched).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(result.runtimeError).toHaveBeenCalledTimes(1);
});
expect(result.status).toBe(500);
expect(result.body).toEqual({ error: "Internal server error" });
expect(result.runtimeError).toHaveBeenCalledTimes(1);
},
);
});
@@ -413,7 +421,7 @@ describe("createLineNodeWebhookHandler", () => {
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
it("dispatches signed POST event processing through the detached admitted work root", async () => {
it("durably admits signed POST events before acknowledging", async () => {
runDetachedWebhookWorkSpy.mockClear();
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
@@ -422,9 +430,7 @@ describe("createLineNodeWebhookHandler", () => {
await runSignedPost({ handler, rawBody, secret, res });
expect(res.statusCode).toBe(200);
// The request admission is released once the handler returns; the inherited
// chain would be refused as draining, so dispatch must reserve its own root.
expect(runDetachedWebhookWorkSpy).toHaveBeenCalledTimes(1);
expect(runDetachedWebhookWorkSpy).not.toHaveBeenCalled();
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
});
@@ -480,7 +486,7 @@ describe("createLineNodeWebhookHandler", () => {
expect(payload.events).toEqual([{ type: "message" }]);
});
it("acknowledges signed event requests before event processing completes", async () => {
it("waits for durable admission before acknowledging signed event requests", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
let releaseAuthenticated: (() => void) | undefined;
const bot = {
@@ -509,14 +515,14 @@ describe("createLineNodeWebhookHandler", () => {
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
});
await request;
expect(res.statusCode).toBe(200);
expect(res.headersSent).toBe(true);
expect(res.headersSent).toBe(false);
if (!releaseAuthenticated) {
throw new Error("Expected LINE authenticated request release callback to be initialized");
}
releaseAuthenticated();
await request;
expect(res.statusCode).toBe(200);
expect(res.headersSent).toBe(true);
});
it("returns 400 for invalid JSON payload even when signature is valid", async () => {
@@ -560,13 +566,13 @@ describe("createLineWebhookMiddleware", () => {
expect(payload.events).toEqual(expectedEvents);
});
it("dispatches middleware event processing through the detached admitted work root", async () => {
it("waits for middleware event admission before acknowledging", async () => {
runDetachedWebhookWorkSpy.mockClear();
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [{ type: "message" }] }),
});
expect(res.status).toHaveBeenCalledWith(200);
expect(runDetachedWebhookWorkSpy).toHaveBeenCalledTimes(1);
expect(runDetachedWebhookWorkSpy).not.toHaveBeenCalled();
expect(onEvents).toHaveBeenCalledTimes(1);
});

View File

@@ -1,16 +1,11 @@
// Line plugin module implements webhook node behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import type { webhook } from "@line/bot-sdk";
import {
createMessageReceiveContext,
type MessageReceiveContext,
} from "openclaw/plugin-sdk/channel-outbound";
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
runDetachedWebhookWork,
} from "openclaw/plugin-sdk/webhook-request-guards";
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
@@ -31,10 +26,6 @@ export async function readLineWebhookRequestBody(
type ReadBodyFn = (req: IncomingMessage, maxBytes: number, timeoutMs?: number) => Promise<string>;
function logLineWebhookDispatchError(runtime: RuntimeEnv | undefined, err: unknown): void {
runtime?.error?.(danger(`line webhook dispatch failed: ${String(err)}`));
}
export function createLineNodeWebhookHandler(params: {
channelSecret: string;
bot: { handleWebhook: (body: webhook.CallbackRequest) => Promise<void> };
@@ -67,7 +58,6 @@ export function createLineNodeWebhookHandler(params: {
return;
}
let receiveContext: MessageReceiveContext<webhook.CallbackRequest> | undefined;
try {
const signatureHeader = req.headers["x-line-signature"];
const signature =
@@ -109,33 +99,14 @@ export function createLineNodeWebhookHandler(params: {
}
params.onRequestAuthenticated?.();
receiveContext = createMessageReceiveContext({
id: `${Date.now()}:line:webhook`,
channel: "line",
message: body,
ackPolicy: "after_receive_record",
onAck: () => {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" }));
},
});
if (receiveContext.shouldAckAfter("receive_record")) {
await receiveContext.ack();
}
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
// Detach event processing from the request admission before the ack
// releases it; an inherited released admission refuses queue work.
void runDetachedWebhookWork(() => params.bot.handleWebhook(body)).catch((err: unknown) =>
logLineWebhookDispatchError(params.runtime, err),
);
await params.bot.handleWebhook(body);
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" }));
} catch (err) {
await receiveContext?.nack(err);
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
res.statusCode = 413;
res.setHeader("Content-Type", "application/json");

View File

@@ -0,0 +1,656 @@
// Line tests cover durable webhook admission, replay, and dead-lettering.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import os from "node:os";
import path from "node:path";
import type { webhook } from "@line/bot-sdk";
import {
closeOpenClawStateDatabaseForTest,
createChannelIngressQueueForTests as createChannelIngressQueue,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createLineNodeWebhookHandler } from "./webhook-node.js";
import { createLineWebhookSpool, LineWebhookTerminalDeliveryError } from "./webhook-spool.js";
type LineWebhookDeliveryOutcome = Parameters<
NonNullable<Parameters<typeof createLineWebhookSpool>[0]["onOutcome"]>
>[0];
type SpoolPayload = {
version: number;
destination: string;
event: webhook.Event;
};
const runtime = (): RuntimeEnv => ({ error: vi.fn(), exit: vi.fn(), log: vi.fn() });
function createEvent(eventId: string, userId = "user-1"): webhook.Event {
return {
type: "message",
message: { id: `message-${eventId}`, type: "text", text: "hello" },
replyToken: "test-auth-token",
timestamp: Date.now(),
source: { type: "user", userId },
mode: "active",
webhookEventId: eventId,
deliveryContext: { isRedelivery: false },
} as webhook.MessageEvent;
}
function callback(event: webhook.Event): webhook.CallbackRequest {
return { destination: "destination-1", events: [event] };
}
async function withQueue<T>(
fn: (queue: ReturnType<typeof createChannelIngressQueue<SpoolPayload>>) => Promise<T>,
): Promise<T> {
const stateDir = path.join(os.tmpdir(), `openclaw-line-spool-${crypto.randomUUID()}`);
await fs.mkdir(stateDir);
const queue = createChannelIngressQueue<SpoolPayload>({
channelId: "line",
accountId: "default",
stateDir,
});
try {
return await fn(queue);
} finally {
closeOpenClawStateDatabaseForTest();
await fs.rm(stateDir, { recursive: true, force: true });
}
}
async function waitForOutcome(
outcomes: LineWebhookDeliveryOutcome[],
kind: LineWebhookDeliveryOutcome["kind"],
): Promise<LineWebhookDeliveryOutcome> {
await vi.waitFor(() => {
expect(outcomes.some((outcome) => outcome.kind === kind)).toBe(true);
});
const outcome = outcomes.find((candidate) => candidate.kind === kind);
if (!outcome) {
throw new Error(`Expected LINE webhook spool outcome ${kind}`);
}
return outcome;
}
function createResponse(): ServerResponse & { body?: string } {
const response = {
statusCode: 0,
headersSent: false,
setHeader: vi.fn(),
end: vi.fn((body?: string) => {
response.headersSent = true;
response.body = body;
}),
body: undefined as string | undefined,
};
return response as unknown as ServerResponse & { body?: string };
}
describe("LINE webhook spool", () => {
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
it("acknowledges only after durable persistence even when delivery fails", async () => {
await withQueue(async (queue) => {
const outcomes: LineWebhookDeliveryOutcome[] = [];
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue,
maxAttempts: 1,
retryBaseMs: 0,
deliver: async () => {
throw new Error("dispatch failed");
},
onOutcome: (outcome) => outcomes.push(outcome),
});
spool.start();
const body = JSON.stringify(callback(createEvent("event-ack")));
const channelSecret = "test-auth-token";
const handler = createLineNodeWebhookHandler({
channelSecret,
bot: { handleWebhook: spool.accept },
runtime: runtime(),
readBody: async () => body,
});
const response = createResponse();
await handler(
{
method: "POST",
headers: {
"x-line-signature": crypto
.createHmac("SHA256", channelSecret)
.update(body)
.digest("base64"),
},
} as unknown as IncomingMessage,
response,
);
expect(response.statusCode).toBe(200);
expect(response.body).toBe(JSON.stringify({ status: "ok" }));
const outcome = await waitForOutcome(outcomes, "dead-lettered");
expect(outcome).toMatchObject({
eventId: "event-ack",
attempt: 1,
reason: "retry-limit-exceeded",
});
expect((await queue.enqueue("event-ack", {} as SpoolPayload)).kind).toBe("failed");
await spool.stop();
});
});
it("recovers an in-flight event after restart and delivers it", async () => {
await withQueue(async (queue) => {
const event = createEvent("event-restart");
await queue.enqueue(
"event-restart",
{ version: 1, destination: "destination-1", event },
{ laneKey: "user:user-1" },
);
expect(await queue.claim("event-restart", { ownerId: "dead-gateway" })).not.toBeNull();
const delivered = vi.fn(async () => {});
const outcomes: LineWebhookDeliveryOutcome[] = [];
const restartedSpool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue,
claimStaleMs: 25,
retryBaseMs: 0,
deliver: delivered,
onOutcome: (outcome) => outcomes.push(outcome),
});
restartedSpool.start();
await waitForOutcome(outcomes, "completed");
expect(delivered).toHaveBeenCalledTimes(1);
expect((await queue.enqueue("event-restart", {} as SpoolPayload)).kind).toBe("completed");
await restartedSpool.stop();
});
});
it("does not reclaim a fresh claim held by another live spool", async () => {
await withQueue(async (queue) => {
const event = createEvent("event-live-owner");
await queue.enqueue(
"event-live-owner",
{ version: 1, destination: "destination-1", event },
{ laneKey: "user:user-1" },
);
expect(await queue.claim("event-live-owner", { ownerId: "live-gateway" })).not.toBeNull();
const deliver = vi.fn(async () => {});
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue,
claimStaleMs: 60_000,
deliver,
});
spool.start();
await vi.waitFor(async () => {
expect(await queue.listClaims()).toHaveLength(1);
});
expect(deliver).not.toHaveBeenCalled();
await spool.stop();
});
});
it("bounds poison-event retries and records a typed dead letter", async () => {
await withQueue(async (queue) => {
const deliver = vi.fn(async () => {
throw new Error("poison");
});
const outcomes: LineWebhookDeliveryOutcome[] = [];
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue,
maxAttempts: 3,
retryBaseMs: 0,
retryMaxMs: 0,
deliver,
onOutcome: (outcome) => outcomes.push(outcome),
});
spool.start();
await spool.accept(callback(createEvent("event-poison")));
const outcome = await waitForOutcome(outcomes, "dead-lettered");
expect(outcome).toEqual({
kind: "dead-lettered",
eventId: "event-poison",
attempt: 3,
reason: "retry-limit-exceeded",
error: "poison",
});
expect(deliver).toHaveBeenCalledTimes(3);
const duplicate = await queue.enqueue("event-poison", {} as SpoolPayload);
expect(duplicate.kind).toBe("failed");
if (duplicate.kind === "failed") {
expect(duplicate.record.reason).toBe("retry-limit-exceeded");
}
await spool.stop();
});
});
it("dead-letters without retry after delivery side effects commit", async () => {
await withQueue(async (queue) => {
const deliver = vi.fn(async () => {
throw new LineWebhookTerminalDeliveryError("reply token consumed");
});
const outcomes: LineWebhookDeliveryOutcome[] = [];
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue,
deliver,
onOutcome: (outcome) => outcomes.push(outcome),
});
spool.start();
await spool.accept(callback(createEvent("event-terminal")));
const outcome = await waitForOutcome(outcomes, "dead-lettered");
expect(outcome).toMatchObject({
eventId: "event-terminal",
attempt: 1,
reason: "delivery-side-effects-committed",
});
expect(deliver).toHaveBeenCalledTimes(1);
await spool.stop();
});
});
it("retries completion persistence without redelivering", async () => {
await withQueue(async (queue) => {
const complete = vi
.fn(queue.complete.bind(queue))
.mockRejectedValueOnce(new Error("database busy"));
const deliver = vi.fn(async () => {});
const outcomes: LineWebhookDeliveryOutcome[] = [];
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue: { ...queue, complete },
claimRefreshMs: 1,
deliver,
onOutcome: (outcome) => outcomes.push(outcome),
});
spool.start();
await spool.accept(callback(createEvent("event-completion-retry")));
await waitForOutcome(outcomes, "completed");
expect(complete).toHaveBeenCalledTimes(2);
expect(deliver).toHaveBeenCalledTimes(1);
await spool.stop();
});
});
it("finishes completion persistence when shutdown follows successful delivery", async () => {
await withQueue(async (queue) => {
let reportFirstFailure: (() => void) | undefined;
const firstFailure = new Promise<void>((resolve) => {
reportFirstFailure = resolve;
});
const complete = vi.fn(queue.complete.bind(queue)).mockImplementationOnce(async () => {
reportFirstFailure?.();
throw new Error("database busy");
});
const deliver = vi.fn(async () => {});
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue: { ...queue, complete },
deliver,
});
spool.start();
await spool.accept(callback(createEvent("event-completion-stop")));
await firstFailure;
await spool.stop();
expect(complete).toHaveBeenCalledTimes(2);
expect(deliver).toHaveBeenCalledTimes(1);
expect((await queue.enqueue("event-completion-stop", {} as SpoolPayload)).kind).toBe(
"completed",
);
});
});
it("completes at durable turn adoption without replaying later failures", async () => {
await withQueue(async (queue) => {
const deliver = vi.fn(async (_event, _destination, control) => {
await control.onTurnAdopted();
throw new Error("settle failed after adoption");
});
const outcomes: LineWebhookDeliveryOutcome[] = [];
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue,
deliver,
onOutcome: (outcome) => outcomes.push(outcome),
});
spool.start();
await spool.accept(callback(createEvent("event-adopted")));
await waitForOutcome(outcomes, "completed");
expect(deliver).toHaveBeenCalledTimes(1);
expect((await queue.enqueue("event-adopted", {} as SpoolPayload)).kind).toBe("completed");
await spool.stop();
});
});
it("keeps an adopted delivery cancellable until the turn settles", async () => {
await withQueue(async (queue) => {
const adopted = vi.fn();
const deliver = vi.fn(async (_event, _destination, control) => {
await control.onTurnAdopted();
adopted();
await new Promise<void>((_resolve, reject) => {
control.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
});
});
});
const outcomes: LineWebhookDeliveryOutcome[] = [];
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue,
deliver,
onOutcome: (outcome) => outcomes.push(outcome),
});
spool.start();
await spool.accept(callback(createEvent("event-adopted-stop")));
await vi.waitFor(() => {
expect(adopted).toHaveBeenCalledTimes(1);
});
await spool.stop();
expect(outcomes).toContainEqual({ kind: "completed", eventId: "event-adopted-stop" });
expect((await queue.enqueue("event-adopted-stop", {} as SpoolPayload)).kind).toBe(
"completed",
);
});
});
it("retries terminal-state persistence without redelivering", async () => {
await withQueue(async (queue) => {
const fail = vi.fn(queue.fail.bind(queue)).mockRejectedValueOnce(new Error("database busy"));
const deliver = vi.fn(async () => {
throw new LineWebhookTerminalDeliveryError("reply token consumed");
});
const outcomes: LineWebhookDeliveryOutcome[] = [];
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue: { ...queue, fail },
claimRefreshMs: 1,
deliver,
onOutcome: (outcome) => outcomes.push(outcome),
});
spool.start();
await spool.accept(callback(createEvent("event-terminal-retry")));
await waitForOutcome(outcomes, "dead-lettered");
expect(fail).toHaveBeenCalledTimes(2);
expect(deliver).toHaveBeenCalledTimes(1);
await spool.stop();
});
});
it("drains a persisted batch prefix when a later enqueue fails", async () => {
await withQueue(async (queue) => {
let enqueueCount = 0;
const enqueue: typeof queue.enqueue = async (...args) => {
enqueueCount += 1;
if (enqueueCount === 2) {
throw new Error("database busy");
}
return await queue.enqueue(...args);
};
const deliver = vi.fn(async () => {});
const outcomes: LineWebhookDeliveryOutcome[] = [];
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue: { ...queue, enqueue },
deliver,
onOutcome: (outcome) => outcomes.push(outcome),
});
spool.start();
await expect(
spool.accept({
destination: "destination-1",
events: [createEvent("event-prefix-1"), createEvent("event-prefix-2")],
}),
).rejects.toThrow("database busy");
await waitForOutcome(outcomes, "completed");
expect(deliver).toHaveBeenCalledTimes(1);
expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({ webhookEventId: "event-prefix-1" }),
"destination-1",
expect.objectContaining({ abortSignal: expect.any(AbortSignal) }),
);
await spool.stop();
});
});
it("stops refreshing and releases a pre-adoption claim on shutdown", async () => {
await withQueue(async (queue) => {
let finishRelease: (() => void) | undefined;
const releaseAllowed = new Promise<void>((resolve) => {
finishRelease = resolve;
});
const release = vi.fn(async (...args: Parameters<typeof queue.release>) => {
await releaseAllowed;
return await queue.release(...args);
});
const deliver = vi.fn(
async (
_event: webhook.Event,
_destination: string,
control: { abortSignal: AbortSignal },
) =>
await new Promise<void>((_resolve, reject) => {
control.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
});
}),
);
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue: { ...queue, release },
deliver,
});
spool.start();
await spool.accept(callback(createEvent("event-stop")));
await vi.waitFor(async () => {
expect(await queue.listClaims()).toHaveLength(1);
});
let stopped = false;
const stop = spool.stop().then(() => {
stopped = true;
});
await vi.waitFor(() => {
expect(release).toHaveBeenCalledTimes(1);
});
expect(stopped).toBe(false);
finishRelease?.();
await stop;
expect(await queue.listClaims()).toHaveLength(0);
expect(await queue.listPending()).toHaveLength(1);
expect(deliver).toHaveBeenCalledTimes(1);
});
});
it("dead-letters terminal delivery errors even when shutdown aborts the attempt", async () => {
await withQueue(async (queue) => {
const deliver = vi.fn(
async (
_event: webhook.Event,
_destination: string,
control: { abortSignal: AbortSignal },
) =>
await new Promise<void>((_resolve, reject) => {
control.abortSignal.addEventListener(
"abort",
() => reject(new LineWebhookTerminalDeliveryError("reply token consumed")),
{ once: true },
);
}),
);
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue,
deliver,
});
spool.start();
await spool.accept(callback(createEvent("event-terminal-stop")));
await vi.waitFor(async () => {
expect(await queue.listClaims()).toHaveLength(1);
});
await spool.stop();
expect(await queue.listPending()).toHaveLength(0);
const duplicate = await queue.enqueue("event-terminal-stop", {} as SpoolPayload);
expect(duplicate.kind).toBe("failed");
if (duplicate.kind === "failed") {
expect(duplicate.record.reason).toBe("delivery-side-effects-committed");
}
});
});
it("aborts delivery and releases the row when claim refresh loses ownership", async () => {
await withQueue(async (queue) => {
const refreshClaim = vi.fn(async () => false);
const deliver = vi.fn(
async (
_event: webhook.Event,
_destination: string,
control: { abortSignal: AbortSignal },
) =>
await new Promise<void>((_resolve, reject) => {
control.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
});
}),
);
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue: { ...queue, refreshClaim },
claimRefreshMs: 1,
deliver,
});
spool.start();
await spool.accept(callback(createEvent("event-refresh-loss")));
await vi.waitFor(async () => {
expect(refreshClaim).toHaveBeenCalled();
expect(await queue.listClaims()).toHaveLength(0);
expect(await queue.listPending()).toHaveLength(1);
});
expect(deliver).toHaveBeenCalledTimes(1);
await spool.stop();
});
});
it("releases a claim won concurrently with shutdown before delivery starts", async () => {
await withQueue(async (queue) => {
let reportClaimed: (() => void) | undefined;
const claimed = new Promise<void>((resolve) => {
reportClaimed = resolve;
});
let releaseClaimResult: (() => void) | undefined;
const returnClaim = new Promise<void>((resolve) => {
releaseClaimResult = resolve;
});
const claimNext: typeof queue.claimNext = async (...args) => {
const claim = await queue.claimNext(...args);
reportClaimed?.();
await returnClaim;
return claim;
};
const deliver = vi.fn(async () => {});
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue: { ...queue, claimNext },
deliver,
});
spool.start();
await spool.accept(callback(createEvent("event-stop-race")));
await claimed;
const stop = spool.stop();
releaseClaimResult?.();
await stop;
await vi.waitFor(async () => {
expect(await queue.listClaims()).toHaveLength(0);
expect(await queue.listPending()).toHaveLength(1);
});
expect(deliver).not.toHaveBeenCalled();
});
});
it("delivers ready lanes beyond a full page of delayed retries", async () => {
await withQueue(async (queue) => {
for (let index = 0; index < 100; index += 1) {
const eventId = `a-delayed-${String(index).padStart(3, "0")}`;
const event = createEvent(eventId, `user-delayed-${index}`);
await queue.enqueue(
eventId,
{ version: 1, destination: "destination-1", event },
{
laneKey: `user:user-delayed-${index}`,
},
);
const claim = await queue.claim(eventId);
if (!claim) {
throw new Error(`Expected delayed LINE claim ${eventId}`);
}
await queue.release(claim, { lastError: "delayed" });
}
const readyEvent = createEvent("z-ready", "user-ready");
await queue.enqueue(
"z-ready",
{ version: 1, destination: "destination-1", event: readyEvent },
{ laneKey: "user:user-ready" },
);
const outcomes: LineWebhookDeliveryOutcome[] = [];
const deliver = vi.fn(async () => {});
const spool = createLineWebhookSpool({
accountId: "default",
runtime: runtime(),
queue,
retryBaseMs: 60_000,
retryMaxMs: 60_000,
deliver,
onOutcome: (outcome) => outcomes.push(outcome),
});
spool.start();
await waitForOutcome(outcomes, "completed");
expect(deliver).toHaveBeenCalledWith(
readyEvent,
"destination-1",
expect.objectContaining({ abortSignal: expect.any(AbortSignal) }),
);
await spool.stop();
});
});
});

View File

@@ -0,0 +1,540 @@
// Line plugin module owns durable webhook admission and replay.
import { createHash, randomUUID } from "node:crypto";
import type { webhook } from "@line/bot-sdk";
import type {
ChannelIngressQueue,
ChannelIngressQueueClaim,
ChannelIngressQueueRecord,
} from "openclaw/plugin-sdk/channel-outbound";
import { danger, sleepWithAbort, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards";
import { getLineRuntime } from "./runtime.js";
const LINE_WEBHOOK_SPOOL_VERSION = 1;
const LINE_WEBHOOK_MAX_ATTEMPTS = 8;
const LINE_WEBHOOK_RETRY_BASE_MS = 1_000;
const LINE_WEBHOOK_RETRY_MAX_MS = 3 * 60_000;
const LINE_WEBHOOK_CLAIM_STALE_MS = 30_000;
const LINE_WEBHOOK_CLAIM_REFRESH_MS = 10_000;
const LINE_WEBHOOK_MAX_CONCURRENT_DELIVERIES = 8;
const LINE_WEBHOOK_DRAIN_SCAN_LIMIT = 100;
const LINE_WEBHOOK_TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60_000;
type LineWebhookDeadLetterReason =
| "delivery-side-effects-committed"
| "invalid-event"
| "retry-limit-exceeded";
type LineWebhookDeliveryOutcome =
| { kind: "completed"; eventId: string }
| { kind: "retry-scheduled"; eventId: string; attempt: number; error: string }
| {
kind: "dead-lettered";
eventId: string;
attempt: number;
reason: LineWebhookDeadLetterReason;
error: string;
};
type LineWebhookSpoolPayload = {
version: number;
destination: string;
event: webhook.Event;
};
type LineWebhookSpoolOptions = {
accountId: string;
runtime: RuntimeEnv;
deliver: (
event: webhook.Event,
destination: string,
control: { abortSignal: AbortSignal; onTurnAdopted: () => Promise<void> },
) => Promise<void>;
queue?: ChannelIngressQueue<LineWebhookSpoolPayload>;
maxAttempts?: number;
retryBaseMs?: number;
retryMaxMs?: number;
claimStaleMs?: number;
claimRefreshMs?: number;
onOutcome?: (outcome: LineWebhookDeliveryOutcome) => void;
};
class LineWebhookClaimOwnershipError extends Error {
constructor(eventId: string) {
super(`LINE webhook spool event ${eventId} lost claim ownership.`);
this.name = "LineWebhookClaimOwnershipError";
}
}
class LineWebhookClaimRefreshError extends Error {
constructor(eventId: string, options?: { cause?: unknown }) {
super(`LINE webhook spool event ${eventId} claim refresh failed.`, options);
this.name = "LineWebhookClaimRefreshError";
}
}
export class LineWebhookTerminalDeliveryError extends Error {
readonly reason = "delivery-side-effects-committed" as const;
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "LineWebhookTerminalDeliveryError";
}
}
type LineWebhookSpool = {
accept: (body: webhook.CallbackRequest) => Promise<void>;
start: () => void;
stop: () => Promise<void>;
};
function eventIdFor(event: webhook.Event): string {
const eventId = (event as { webhookEventId?: unknown }).webhookEventId;
if (typeof eventId === "string" && eventId.trim()) {
return eventId.trim();
}
return `invalid:${createHash("sha256").update(JSON.stringify(event)).digest("hex")}`;
}
function laneKeyFor(event: webhook.Event): string {
const source = (event as { source?: webhook.Event["source"] }).source;
if (source?.type === "group") {
return `group:${source.groupId}`;
}
if (source?.type === "room") {
return `room:${source.roomId}`;
}
if (source?.type === "user") {
return `user:${source.userId}`;
}
return `event:${eventIdFor(event)}`;
}
function isValidPayload(payload: LineWebhookSpoolPayload): boolean {
return (
payload.version === LINE_WEBHOOK_SPOOL_VERSION &&
typeof payload.destination === "string" &&
typeof payload.event === "object" &&
payload.event !== null &&
typeof (payload.event as { webhookEventId?: unknown }).webhookEventId === "string" &&
Boolean((payload.event as { webhookEventId: string }).webhookEventId.trim())
);
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function retryDelayMs(
record: ChannelIngressQueueRecord<LineWebhookSpoolPayload>,
now: number,
baseMs: number,
maxMs: number,
): number {
if (record.attempts < 1 || record.lastAttemptAt === undefined) {
return 0;
}
const delay = Math.min(maxMs, baseMs * 2 ** Math.min(record.attempts - 1, 8));
return Math.max(0, record.lastAttemptAt + delay - now);
}
export function createLineWebhookSpool(options: LineWebhookSpoolOptions): LineWebhookSpool {
const queue =
options.queue ??
getLineRuntime().state.openChannelIngressQueue<LineWebhookSpoolPayload>({
accountId: options.accountId,
});
const ownerId = `${process.pid}:${randomUUID()}`;
const maxAttempts = Math.max(1, options.maxAttempts ?? LINE_WEBHOOK_MAX_ATTEMPTS);
const retryBaseMs = Math.max(0, options.retryBaseMs ?? LINE_WEBHOOK_RETRY_BASE_MS);
const retryMaxMs = Math.max(retryBaseMs, options.retryMaxMs ?? LINE_WEBHOOK_RETRY_MAX_MS);
const claimStaleMs = Math.max(0, options.claimStaleMs ?? LINE_WEBHOOK_CLAIM_STALE_MS);
const claimRefreshMs = Math.max(1, options.claimRefreshMs ?? LINE_WEBHOOK_CLAIM_REFRESH_MS);
let running = false;
let draining = false;
let drainRequested = false;
let drainTimer: ReturnType<typeof setTimeout> | undefined;
let drainDeadline: number | undefined;
const drainTasks = new Set<Promise<void>>();
const activeDeliveries = new Map<string, Promise<void>>();
const activeClaims = new Map<
string,
{ abortController: AbortController; stopRefresh: () => void }
>();
const backoffBlockedLanes = new Map<string, number>();
const prune = async (): Promise<void> => {
await queue.prune({
completedTtlMs: LINE_WEBHOOK_TOMBSTONE_TTL_MS,
failedTtlMs: LINE_WEBHOOK_TOMBSTONE_TTL_MS,
});
};
const scheduleDrain = (delayMs: number): void => {
if (!running) {
return;
}
const deadline = Date.now() + Math.max(0, delayMs);
if (drainTimer && drainDeadline !== undefined && drainDeadline <= deadline) {
return;
}
if (drainTimer) {
clearTimeout(drainTimer);
}
drainDeadline = deadline;
drainTimer = setTimeout(
() => {
drainTimer = undefined;
drainDeadline = undefined;
// Timers preserve the HTTP request's admission context. Give every drain
// its own tracked root so durable work can continue after the request ACK.
const task = runDetachedWebhookWork(drain).catch((error: unknown) => {
options.runtime.error?.(
danger(`line: webhook spool admission failed: ${errorText(error)}`),
);
scheduleDrain(retryBaseMs || 1_000);
});
drainTasks.add(task);
void task.finally(() => drainTasks.delete(task));
},
Math.max(0, deadline - Date.now()),
);
drainTimer.unref?.();
};
const persistClaimTransition = async (params: {
claim: ChannelIngressQueueClaim<LineWebhookSpoolPayload>;
label: string;
transition: () => Promise<boolean>;
abortSignal?: AbortSignal;
}): Promise<void> => {
let persistenceAttempt = 0;
while (true) {
try {
if (!(await params.transition())) {
throw new LineWebhookClaimOwnershipError(params.claim.id);
}
return;
} catch (error) {
if (error instanceof LineWebhookClaimOwnershipError) {
throw error;
}
persistenceAttempt += 1;
const delayMs = Math.min(5_000, 250 * 2 ** Math.min(persistenceAttempt - 1, 5));
options.runtime.error?.(
danger(
`line: webhook event ${params.claim.id} ${params.label} persist failed; retrying: ${errorText(error)}`,
),
);
await sleepWithAbort(delayMs, params.abortSignal);
}
}
};
const finishClaim = async (
claim: ChannelIngressQueueClaim<LineWebhookSpoolPayload>,
): Promise<LineWebhookDeliveryOutcome> => {
const attempt = claim.attempts + 1;
const abortController = new AbortController();
let adopted = false;
let refreshing = true;
const claimRefreshTimer = setInterval(() => {
if (!refreshing) {
return;
}
void (async () => {
try {
const refreshed = await queue.refreshClaim?.(claim);
if (refreshed !== true && !adopted && refreshing) {
throw new LineWebhookClaimOwnershipError(claim.id);
}
} catch (error) {
if (adopted || !refreshing) {
return;
}
options.runtime.error?.(
danger(`line: webhook spool claim refresh failed: ${errorText(error)}`),
);
stopRefresh();
abortController.abort(new LineWebhookClaimRefreshError(claim.id, { cause: error }));
}
})();
}, claimRefreshMs);
claimRefreshTimer.unref?.();
const stopRefresh = () => {
if (!refreshing) {
return;
}
refreshing = false;
clearInterval(claimRefreshTimer);
};
activeClaims.set(claim.id, { abortController, stopRefresh });
try {
if (!isValidPayload(claim.payload)) {
const error = "LINE webhook spool payload was invalid.";
await persistClaimTransition({
claim,
label: "invalid dead-letter",
transition: async () =>
await queue.fail(claim, { reason: "invalid-event", message: error }),
});
return {
kind: "dead-lettered",
eventId: claim.id,
attempt,
reason: "invalid-event",
error,
};
}
try {
await options.deliver(claim.payload.event, claim.payload.destination, {
abortSignal: abortController.signal,
onTurnAdopted: async () => {
await persistClaimTransition({
claim,
label: "adoption completion",
transition: async () => await queue.complete(claim),
});
adopted = true;
stopRefresh();
},
});
} catch (error) {
if (adopted) {
return { kind: "completed", eventId: claim.id };
}
const message = errorText(error);
if (error instanceof LineWebhookTerminalDeliveryError) {
await persistClaimTransition({
claim,
label: "terminal dead-letter",
transition: async () => await queue.fail(claim, { reason: error.reason, message }),
});
return {
kind: "dead-lettered",
eventId: claim.id,
attempt,
reason: error.reason,
error: message,
};
}
if (abortController.signal.aborted) {
const refreshFailed =
abortController.signal.reason instanceof LineWebhookClaimRefreshError;
await persistClaimTransition({
claim,
label: refreshFailed ? "refresh-failure release" : "shutdown release",
transition: async () =>
await queue.release(
claim,
refreshFailed
? { lastError: errorText(abortController.signal.reason) }
: { recordAttempt: false },
),
});
throw error;
}
if (attempt >= maxAttempts) {
await persistClaimTransition({
claim,
label: "retry-limit dead-letter",
transition: async () =>
await queue.fail(claim, { reason: "retry-limit-exceeded", message }),
});
return {
kind: "dead-lettered",
eventId: claim.id,
attempt,
reason: "retry-limit-exceeded",
error: message,
};
}
await persistClaimTransition({
claim,
label: "retry release",
transition: async () => await queue.release(claim, { lastError: message }),
});
return { kind: "retry-scheduled", eventId: claim.id, attempt, error: message };
}
if (adopted) {
return { kind: "completed", eventId: claim.id };
}
await persistClaimTransition({
claim,
label: "completion",
transition: async () => await queue.complete(claim),
});
return { kind: "completed", eventId: claim.id };
} finally {
activeClaims.delete(claim.id);
stopRefresh();
}
};
const drain = async (): Promise<void> => {
if (!running) {
return;
}
if (draining) {
drainRequested = true;
return;
}
draining = true;
drainRequested = false;
try {
// The short lease recovers a crashed predecessor before LINE's reply-token
// use window, while refreshes protect a still-live rolling-restart owner.
await queue.recoverStaleClaims({ staleMs: claimStaleMs });
const claims = await queue.listClaims();
const now = Date.now();
let nextDelay = Number.POSITIVE_INFINITY;
for (const claim of claims) {
nextDelay = Math.min(nextDelay, Math.max(0, claim.claim.claimedAt + claimStaleMs - now));
}
const blockedLaneKeys = new Set(activeDeliveries.keys());
for (const [laneKey, deadline] of backoffBlockedLanes) {
if (deadline <= now) {
backoffBlockedLanes.delete(laneKey);
} else {
blockedLaneKeys.add(laneKey);
nextDelay = Math.min(nextDelay, deadline - now);
}
}
for (const claim of claims) {
if (claim.laneKey) {
blockedLaneKeys.add(claim.laneKey);
}
}
let scanned = 0;
while (
activeDeliveries.size < LINE_WEBHOOK_MAX_CONCURRENT_DELIVERIES &&
scanned < LINE_WEBHOOK_DRAIN_SCAN_LIMIT
) {
if (!running) {
break;
}
const claim = await queue.claimNext({
ownerId,
blockedLaneKeys,
orderBy: "id",
scanLimit: LINE_WEBHOOK_DRAIN_SCAN_LIMIT,
});
if (!claim) {
break;
}
scanned += 1;
if (!running) {
await persistClaimTransition({
claim,
label: "shutdown claim release",
transition: async () => await queue.release(claim, { recordAttempt: false }),
});
break;
}
const laneKey = claim.laneKey ?? `event:${claim.id}`;
blockedLaneKeys.add(laneKey);
const delay = retryDelayMs(claim, now, retryBaseMs, retryMaxMs);
if (delay > 0) {
backoffBlockedLanes.set(laneKey, now + delay);
nextDelay = Math.min(nextDelay, delay);
await persistClaimTransition({
claim,
label: "backoff release",
transition: async () => await queue.release(claim, { recordAttempt: false }),
});
continue;
}
// The scan root may finish immediately after launching work. Reserve a
// separate admitted continuation for the full delivery lifecycle.
const delivery = runDetachedWebhookWork(() => finishClaim(claim))
.then((outcome) => {
options.onOutcome?.(outcome);
if (outcome.kind === "retry-scheduled") {
options.runtime.error?.(
danger(
`line: webhook event ${outcome.eventId} delivery failed on attempt ${outcome.attempt}: ${outcome.error}`,
),
);
} else if (outcome.kind === "dead-lettered") {
options.runtime.error?.(
danger(
`line: webhook event ${outcome.eventId} dead-lettered (${outcome.reason}): ${outcome.error}`,
),
);
}
})
.catch((error: unknown) => {
options.runtime.error?.(
danger(`line: webhook event ${claim.id} worker failed: ${errorText(error)}`),
);
})
.finally(() => {
if (activeDeliveries.get(laneKey) === delivery) {
activeDeliveries.delete(laneKey);
}
scheduleDrain(0);
});
activeDeliveries.set(laneKey, delivery);
}
if (scanned >= LINE_WEBHOOK_DRAIN_SCAN_LIMIT) {
scheduleDrain(0);
}
if (Number.isFinite(nextDelay)) {
scheduleDrain(nextDelay);
}
} catch (error) {
options.runtime.error?.(danger(`line: webhook spool drain failed: ${errorText(error)}`));
scheduleDrain(retryBaseMs || 1_000);
} finally {
draining = false;
if (drainRequested) {
scheduleDrain(0);
}
}
};
return {
accept: async (body) => {
const events = body.events ?? [];
if (events.length === 0) {
return;
}
await prune();
const receivedAt = Date.now();
for (const event of events) {
await queue.enqueue(
eventIdFor(event),
{
version: LINE_WEBHOOK_SPOOL_VERSION,
destination: body.destination ?? "",
event,
},
{ receivedAt, laneKey: laneKeyFor(event) },
);
scheduleDrain(0);
}
},
start: () => {
if (running) {
return;
}
running = true;
scheduleDrain(0);
},
stop: async () => {
running = false;
if (drainTimer) {
clearTimeout(drainTimer);
drainTimer = undefined;
drainDeadline = undefined;
}
for (const active of activeClaims.values()) {
active.stopRefresh();
active.abortController.abort();
}
await Promise.allSettled([...drainTasks, ...activeDeliveries.values()]);
},
};
}

View File

@@ -1,12 +1,7 @@
// Line plugin module implements webhook behavior.
import type { webhook } from "@line/bot-sdk";
import type { NextFunction, Request, Response } from "express";
import {
createMessageReceiveContext,
type MessageReceiveContext,
} from "openclaw/plugin-sdk/channel-outbound";
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards";
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
const LINE_WEBHOOK_MAX_RAW_BODY_BYTES = 64 * 1024;
@@ -34,17 +29,12 @@ function parseWebhookBody(rawBody?: string | null): webhook.CallbackRequest | nu
return parseLineWebhookBody(rawBody);
}
function logLineWebhookDispatchError(runtime: RuntimeEnv | undefined, err: unknown): void {
runtime?.error?.(danger(`line webhook dispatch failed: ${String(err)}`));
}
export function createLineWebhookMiddleware(
options: LineWebhookOptions,
): (req: Request, res: Response, _next: NextFunction) => Promise<void> {
const { channelSecret, onEvents, runtime } = options;
return async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
let receiveContext: MessageReceiveContext<webhook.CallbackRequest> | undefined;
try {
const signature = req.headers["x-line-signature"];
@@ -77,30 +67,12 @@ export function createLineWebhookMiddleware(
return;
}
receiveContext = createMessageReceiveContext({
id: `${Date.now()}:line:webhook`,
channel: "line",
message: body,
ackPolicy: "after_receive_record",
onAck: () => {
res.status(200).json({ status: "ok" });
},
});
if (receiveContext.shouldAckAfter("receive_record")) {
await receiveContext.ack();
}
if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`);
// Detach event processing from the request admission before the ack
// releases it; an inherited released admission refuses queue work.
void runDetachedWebhookWork(() => onEvents(body)).catch((err: unknown) =>
logLineWebhookDispatchError(runtime, err),
);
await onEvents(body);
}
res.status(200).json({ status: "ok" });
} catch (err) {
await receiveContext?.nack(err);
runtime?.error?.(danger(`line webhook error: ${String(err)}`));
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });