Skip to content

Commit 8a1bf70

Browse files
committed
Address LINE webhook ack policy review
1 parent 2afc69d commit 8a1bf70

9 files changed

Lines changed: 199 additions & 8 deletions

File tree

docs/channels/line.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ openclaw plugins install ./path/to/local/line-plugin
4242
https://gateway-host/line/webhook
4343
```
4444

45-
The Gateway answers LINE's webhook verification (GET) and acknowledges signed
46-
inbound events (POST) immediately after signature and payload validation; agent
47-
processing continues asynchronously.
45+
The Gateway answers LINE's webhook verification (GET). For signed inbound events
46+
(POST), it verifies the signature and payload, starts local event dispatch, and
47+
acknowledges as soon as dispatch is accepted; if dispatch fails immediately,
48+
OpenClaw returns an error so LINE can redeliver. Agent processing continues
49+
asynchronously after acknowledgement.
4850
If you need a custom path, set `channels.line.webhookPath` or
4951
`channels.line.accounts.<id>.webhookPath` and update the URL accordingly.
5052

extensions/line/src/channel.sendPayload.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,11 @@ describe("line outbound sendPayload", () => {
624624
adapterName: "line",
625625
adapter: linePlugin.message!,
626626
proofs: {
627+
after_receive_record: () => {
628+
expect(linePlugin.message?.receive?.supportedAckPolicies).toContain(
629+
"after_receive_record",
630+
);
631+
},
627632
after_agent_dispatch: () => {
628633
expect(linePlugin.message?.receive?.defaultAckPolicy).toBe("after_agent_dispatch");
629634
expect(linePlugin.message?.receive?.supportedAckPolicies).toContain(
@@ -637,7 +642,7 @@ describe("line outbound sendPayload", () => {
637642
"verified",
638643
);
639644
expect(proofResults.find((result) => result.policy === "after_receive_record")?.status).toBe(
640-
"not_declared",
645+
"verified",
641646
);
642647
});
643648
});

extensions/line/src/monitor.lifecycle.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,48 @@ describe("monitorLineProvider lifecycle", () => {
452452
monitor.stop();
453453
});
454454

455+
it("supports after_receive_record acknowledgement on shared-path POST requests", async () => {
456+
const monitor = await monitorLineProvider({
457+
channelAccessToken: "token",
458+
channelSecret: "secret", // pragma: allowlist secret
459+
accountId: "default",
460+
ackPolicy: "after_receive_record",
461+
config: {} as OpenClawConfig,
462+
runtime: {} as RuntimeEnv,
463+
});
464+
465+
let releaseWebhook: (() => void) | undefined;
466+
const bot = createLineBotMock.mock.results[0]?.value as {
467+
handleWebhook: ReturnType<typeof vi.fn<LineHandleWebhook>>;
468+
};
469+
bot.handleWebhook.mockImplementation(
470+
() =>
471+
new Promise<void>((resolve) => {
472+
releaseWebhook = resolve;
473+
}),
474+
);
475+
476+
const route = requireRegisteredRoute();
477+
const payload = JSON.stringify({ events: [{ type: "message" }] });
478+
const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
479+
const req = Object.assign(createMockIncomingRequest([payload]), {
480+
method: "POST",
481+
headers: { "x-line-signature": signature },
482+
}) as unknown as IncomingMessage;
483+
const res = createRouteResponse();
484+
485+
await route.handler(req, res);
486+
487+
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
488+
expect(res.statusCode).toBe(200);
489+
expect(res.headersSent).toBe(true);
490+
if (!releaseWebhook) {
491+
throw new Error("expected pending LINE webhook handler");
492+
}
493+
releaseWebhook();
494+
monitor.stop();
495+
});
496+
455497
it("returns 500 for shared-path POST requests when matched event processing fails", async () => {
456498
const runtime = {
457499
error: vi.fn(),

extensions/line/src/monitor.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ import {
4848
import { buildTemplateMessageFromPayload } from "./template-messages.js";
4949
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
5050
import {
51+
type LineWebhookAckPolicy,
5152
logLineWebhookDispatchError,
53+
normalizeLineWebhookAckPolicy,
5254
waitForLineWebhookDispatchAcceptance,
5355
} from "./webhook-ack.js";
5456
import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js";
@@ -63,6 +65,7 @@ interface MonitorLineProviderOptions {
6365
abortSignal?: AbortSignal;
6466
webhookUrl?: string;
6567
webhookPath?: string;
68+
ackPolicy?: LineWebhookAckPolicy;
6669
}
6770

6871
interface LineProviderMonitor {
@@ -91,6 +94,7 @@ type LineWebhookTarget = {
9194
bot: ReturnType<typeof createLineBot>;
9295
channelSecret: string;
9396
path: string;
97+
ackPolicy: LineWebhookAckPolicy;
9498
runtime: RuntimeEnv;
9599
};
96100

@@ -171,7 +175,9 @@ export async function monitorLineProvider(
171175
runtime,
172176
abortSignal,
173177
webhookPath,
178+
ackPolicy,
174179
} = opts;
180+
const normalizedAckPolicy = normalizeLineWebhookAckPolicy(ackPolicy);
175181
const resolvedAccountId = accountId ?? resolveDefaultLineAccountId(config);
176182
const token = channelAccessToken.trim();
177183
const secret = channelSecret.trim();
@@ -353,6 +359,7 @@ export async function monitorLineProvider(
353359
createLineNodeWebhookHandler({
354360
channelSecret: target.channelSecret,
355361
bot: target.bot,
362+
ackPolicy: target.ackPolicy,
356363
runtime: target.runtime,
357364
});
358365
const { unregister: unregisterHttp } = registerWebhookTargetWithPluginRoute({
@@ -362,6 +369,7 @@ export async function monitorLineProvider(
362369
bot,
363370
channelSecret: secret,
364371
path: normalizedPath,
372+
ackPolicy: normalizedAckPolicy,
365373
runtime,
366374
},
367375
route: {
@@ -447,13 +455,16 @@ export async function monitorLineProvider(
447455
id: `${Date.now()}:line:webhook`,
448456
channel: "line",
449457
message: body,
450-
ackPolicy: "after_agent_dispatch",
458+
ackPolicy: match.target.ackPolicy,
451459
onAck: () => {
452460
res.statusCode = 200;
453461
res.setHeader("Content-Type", "application/json");
454462
res.end(JSON.stringify({ status: "ok" }));
455463
},
456464
});
465+
if (receiveContext.shouldAckAfter("receive_record")) {
466+
await receiveContext.ack();
467+
}
457468

458469
requestLifecycle.release();
459470
if (!body.events || body.events.length === 0) {
@@ -463,6 +474,12 @@ export async function monitorLineProvider(
463474

464475
logVerbose(`line: received ${body.events.length} webhook events`);
465476
const dispatch = Promise.resolve().then(() => match.target.bot.handleWebhook(body));
477+
if (receiveContext.shouldAckAfter("receive_record")) {
478+
void dispatch.catch((err: unknown) =>
479+
logLineWebhookDispatchError(match.target.runtime, err),
480+
);
481+
return;
482+
}
466483
const acceptance = await waitForLineWebhookDispatchAcceptance(dispatch);
467484
if (acceptance.status === "failed") {
468485
throw acceptance.error;

extensions/line/src/outbound.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,6 @@ export const lineMessageAdapter = defineChannelMessageAdapter({
445445
},
446446
receive: {
447447
defaultAckPolicy: "after_agent_dispatch",
448-
supportedAckPolicies: ["after_agent_dispatch"],
448+
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
449449
},
450450
});

extensions/line/src/webhook-ack.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
// Line plugin module implements webhook acknowledgement helpers.
2+
import type { MessageAckPolicy } from "openclaw/plugin-sdk/channel-outbound";
23
import { danger, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
34

5+
export type LineWebhookAckPolicy = Extract<
6+
MessageAckPolicy,
7+
"after_receive_record" | "after_agent_dispatch"
8+
>;
9+
410
type LineWebhookDispatchAcceptance =
511
| { status: "completed" }
612
| { status: "failed"; error: unknown }
713
| { status: "pending" };
814

15+
export function normalizeLineWebhookAckPolicy(policy: unknown): LineWebhookAckPolicy {
16+
return policy === "after_receive_record" || policy === "after_agent_dispatch"
17+
? policy
18+
: "after_agent_dispatch";
19+
}
20+
921
export function logLineWebhookDispatchError(runtime: RuntimeEnv | undefined, err: unknown): void {
1022
runtime?.error?.(danger(`line webhook dispatch failed: ${String(err)}`));
1123
}

extensions/line/src/webhook-node.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ const runSignedPost = async (params: {
105105
);
106106

107107
async function invokeWebhook(params: {
108+
ackPolicy?: "after_agent_dispatch" | "after_receive_record";
108109
body: unknown;
109110
headers?: Record<string, string>;
110111
onEvents?: ReturnType<typeof vi.fn>;
@@ -115,6 +116,7 @@ async function invokeWebhook(params: {
115116
const middleware = createLineWebhookMiddleware({
116117
channelSecret: SECRET,
117118
onEvents: onEventsMock as never,
119+
ackPolicy: params.ackPolicy,
118120
runtime: params.runtime,
119121
});
120122

@@ -475,6 +477,70 @@ describe("createLineNodeWebhookHandler", () => {
475477
releaseAuthenticated();
476478
});
477479

480+
it("supports after_receive_record acknowledgement before event processing completes", async () => {
481+
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
482+
let releaseAuthenticated: (() => void) | undefined;
483+
const bot = {
484+
handleWebhook: vi.fn(
485+
async () =>
486+
await new Promise<void>((resolve) => {
487+
releaseAuthenticated = resolve;
488+
}),
489+
),
490+
};
491+
const runtime = createRuntimeMock();
492+
const handler = createLineNodeWebhookHandler({
493+
channelSecret: SECRET,
494+
bot,
495+
runtime,
496+
readBody: async () => rawBody,
497+
ackPolicy: "after_receive_record",
498+
});
499+
500+
const { res } = createRes();
501+
await runSignedPost({ handler, rawBody, secret: SECRET, res });
502+
503+
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
504+
expect(res.statusCode).toBe(200);
505+
expect(res.headersSent).toBe(true);
506+
if (!releaseAuthenticated) {
507+
throw new Error("Expected LINE authenticated request release callback to be initialized");
508+
}
509+
releaseAuthenticated();
510+
});
511+
512+
it("normalizes unsupported acknowledgement policies to dispatch acceptance", async () => {
513+
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
514+
let releaseAuthenticated: (() => void) | undefined;
515+
const bot = {
516+
handleWebhook: vi.fn(
517+
async () =>
518+
await new Promise<void>((resolve) => {
519+
releaseAuthenticated = resolve;
520+
}),
521+
),
522+
};
523+
const runtime = createRuntimeMock();
524+
const handler = createLineNodeWebhookHandler({
525+
channelSecret: SECRET,
526+
bot,
527+
runtime,
528+
readBody: async () => rawBody,
529+
ackPolicy: "manual" as never,
530+
});
531+
532+
const { res } = createRes();
533+
await runSignedPost({ handler, rawBody, secret: SECRET, res });
534+
535+
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
536+
expect(res.statusCode).toBe(200);
537+
expect(res.headersSent).toBe(true);
538+
if (!releaseAuthenticated) {
539+
throw new Error("Expected LINE authenticated request release callback to be initialized");
540+
}
541+
releaseAuthenticated();
542+
});
543+
478544
it("returns 400 for invalid JSON payload even when signature is valid", async () => {
479545
const rawBody = "not json";
480546
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
@@ -572,6 +638,29 @@ describe("createLineWebhookMiddleware", () => {
572638
});
573639
});
574640

641+
it("supports middleware after_receive_record acknowledgement before event processing completes", async () => {
642+
let releaseEvents: (() => void) | undefined;
643+
const onEvents = vi.fn(
644+
async () =>
645+
await new Promise<void>((resolve) => {
646+
releaseEvents = resolve;
647+
}),
648+
);
649+
650+
const { res } = await invokeWebhook({
651+
ackPolicy: "after_receive_record",
652+
body: JSON.stringify({ events: [{ type: "message" }] }),
653+
onEvents,
654+
});
655+
656+
expect(onEvents).toHaveBeenCalledTimes(1);
657+
expect(res.status).toHaveBeenCalledWith(200);
658+
if (!releaseEvents) {
659+
throw new Error("expected pending LINE middleware handler");
660+
}
661+
releaseEvents();
662+
});
663+
575664
it("rejects invalid signed raw JSON even when req.body is a valid object", async () => {
576665
const onEvents = vi.fn(async () => {});
577666
const rawBody = "not-json";

extensions/line/src/webhook-node.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import {
1212
requestBodyErrorToText,
1313
} from "openclaw/plugin-sdk/webhook-request-guards";
1414
import {
15+
type LineWebhookAckPolicy,
1516
logLineWebhookDispatchError,
17+
normalizeLineWebhookAckPolicy,
1618
waitForLineWebhookDispatchAcceptance,
1719
} from "./webhook-ack.js";
1820
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
@@ -40,10 +42,12 @@ export function createLineNodeWebhookHandler(params: {
4042
runtime: RuntimeEnv;
4143
readBody?: ReadBodyFn;
4244
maxBodyBytes?: number;
45+
ackPolicy?: LineWebhookAckPolicy;
4346
onRequestAuthenticated?: () => void;
4447
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
4548
const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES;
4649
const readBody = params.readBody ?? readLineWebhookRequestBody;
50+
const ackPolicy = normalizeLineWebhookAckPolicy(params.ackPolicy);
4751

4852
return async (req: IncomingMessage, res: ServerResponse) => {
4953
if (req.method === "GET" || req.method === "HEAD") {
@@ -113,13 +117,16 @@ export function createLineNodeWebhookHandler(params: {
113117
id: `${Date.now()}:line:webhook`,
114118
channel: "line",
115119
message: body,
116-
ackPolicy: "after_agent_dispatch",
120+
ackPolicy,
117121
onAck: () => {
118122
res.statusCode = 200;
119123
res.setHeader("Content-Type", "application/json");
120124
res.end(JSON.stringify({ status: "ok" }));
121125
},
122126
});
127+
if (receiveContext.shouldAckAfter("receive_record")) {
128+
await receiveContext.ack();
129+
}
123130

124131
if (!body.events || body.events.length === 0) {
125132
await receiveContext.ack();
@@ -128,6 +135,10 @@ export function createLineNodeWebhookHandler(params: {
128135

129136
logVerbose(`line: received ${body.events.length} webhook events`);
130137
const dispatch = Promise.resolve().then(() => params.bot.handleWebhook(body));
138+
if (receiveContext.shouldAckAfter("receive_record")) {
139+
void dispatch.catch((err: unknown) => logLineWebhookDispatchError(params.runtime, err));
140+
return;
141+
}
131142
const acceptance = await waitForLineWebhookDispatchAcceptance(dispatch);
132143
if (acceptance.status === "failed") {
133144
throw acceptance.error;

0 commit comments

Comments
 (0)