Skip to content

Commit f2dfd36

Browse files
author
Mark L
committed
fix(line): dedupe webhook message replays by message id [AI-assisted]
1 parent 635c78a commit f2dfd36

File tree

3 files changed

+98
-1
lines changed

3 files changed

+98
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai
7878
### Fixes
7979

8080
- Onboarding/Custom providers: raise default custom-provider model context window to the runtime hard minimum (16k) and auto-heal existing custom model entries below that threshold during reconfiguration, preventing immediate `Model context window too small (4096 tokens)` failures. (#21653) Thanks @r4jiv007.
81+
- LINE/inbound dedupe: skip duplicate webhook message redeliveries by LINE message ID before dispatch, preventing the same inbound message from being replayed into sessions as repeated user turns after provider-side failures. (#30574)
8182
- Web UI/Assistant text: strip internal `<relevant-memories>...</relevant-memories>` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70.
8283
- Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz.
8384
- TUI/Session model status: clear stale runtime model identity when model overrides change so `/model` updates are reflected immediately in `sessions.patch` responses and `sessions.list` status surfaces. (#28619) Thanks @lejean2000.

src/line/bot-handlers.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({
6464
}));
6565

6666
let handleLineWebhookEvents: typeof import("./bot-handlers.js").handleLineWebhookEvents;
67+
let resetLineWebhookEventDedupeForTests: typeof import("./bot-handlers.js").resetLineWebhookEventDedupeForTests;
6768

6869
const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() });
6970

@@ -74,10 +75,12 @@ vi.mock("../pairing/pairing-store.js", () => ({
7475

7576
describe("handleLineWebhookEvents", () => {
7677
beforeAll(async () => {
77-
({ handleLineWebhookEvents } = await import("./bot-handlers.js"));
78+
({ handleLineWebhookEvents, resetLineWebhookEventDedupeForTests } =
79+
await import("./bot-handlers.js"));
7880
});
7981

8082
beforeEach(() => {
83+
resetLineWebhookEventDedupeForTests();
8184
buildLineMessageContextMock.mockClear();
8285
buildLinePostbackContextMock.mockClear();
8386
readAllowFromStoreMock.mockClear();
@@ -248,4 +251,64 @@ describe("handleLineWebhookEvents", () => {
248251
expect(processMessage).not.toHaveBeenCalled();
249252
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
250253
});
254+
255+
it("skips duplicate inbound LINE message events by message id", async () => {
256+
const processMessage = vi.fn();
257+
const event = {
258+
type: "message",
259+
message: { id: "m-dup-1", type: "text", text: "hello" },
260+
replyToken: "reply-token",
261+
timestamp: Date.now(),
262+
source: { type: "group", groupId: "group-dup", userId: "user-dup" },
263+
mode: "active",
264+
webhookEventId: "evt-dup-1",
265+
deliveryContext: { isRedelivery: false },
266+
} as MessageEvent;
267+
268+
await handleLineWebhookEvents([event], {
269+
cfg: {
270+
channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-dup"] } },
271+
},
272+
account: {
273+
accountId: "default",
274+
enabled: true,
275+
channelAccessToken: "token",
276+
channelSecret: "secret",
277+
tokenSource: "config",
278+
config: { groupPolicy: "allowlist", groupAllowFrom: ["user-dup"] },
279+
},
280+
runtime: createRuntime(),
281+
mediaMaxBytes: 1,
282+
processMessage,
283+
});
284+
285+
await handleLineWebhookEvents(
286+
[
287+
{
288+
...event,
289+
webhookEventId: "evt-dup-redelivery",
290+
deliveryContext: { isRedelivery: true },
291+
} as MessageEvent,
292+
],
293+
{
294+
cfg: {
295+
channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-dup"] } },
296+
},
297+
account: {
298+
accountId: "default",
299+
enabled: true,
300+
channelAccessToken: "token",
301+
channelSecret: "secret",
302+
tokenSource: "config",
303+
config: { groupPolicy: "allowlist", groupAllowFrom: ["user-dup"] },
304+
},
305+
runtime: createRuntime(),
306+
mediaMaxBytes: 1,
307+
processMessage,
308+
},
309+
);
310+
311+
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
312+
expect(processMessage).toHaveBeenCalledTimes(1);
313+
});
251314
});

src/line/bot-handlers.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
warnMissingProviderGroupPolicyFallbackOnce,
1515
} from "../config/runtime-group-policy.js";
1616
import { danger, logVerbose } from "../globals.js";
17+
import { createDedupeCache } from "../infra/dedupe.js";
1718
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
1819
import { buildPairingReply } from "../pairing/pairing-messages.js";
1920
import {
@@ -50,6 +51,33 @@ export interface LineHandlerContext {
5051
processMessage: (ctx: LineInboundContext) => Promise<void>;
5152
}
5253

54+
const lineWebhookEventDedupe = createDedupeCache({
55+
ttlMs: 20 * 60_000,
56+
maxSize: 5000,
57+
});
58+
59+
function buildLineWebhookEventDedupeKey(event: WebhookEvent): string | null {
60+
if (event.type === "message") {
61+
const messageId = event.message?.id?.trim();
62+
return messageId ? `message:${messageId}` : null;
63+
}
64+
if (event.type === "postback") {
65+
const replyToken = event.replyToken?.trim();
66+
if (replyToken) {
67+
return `postback:${replyToken}`;
68+
}
69+
}
70+
const webhookEventId = (event as { webhookEventId?: unknown }).webhookEventId;
71+
if (typeof webhookEventId === "string" && webhookEventId.trim()) {
72+
return `event:${webhookEventId.trim()}`;
73+
}
74+
return null;
75+
}
76+
77+
export function resetLineWebhookEventDedupeForTests(): void {
78+
lineWebhookEventDedupe.clear();
79+
}
80+
5381
function resolveLineGroupConfig(params: {
5482
config: ResolvedLineAccount["config"];
5583
groupId?: string;
@@ -319,6 +347,11 @@ export async function handleLineWebhookEvents(
319347
context: LineHandlerContext,
320348
): Promise<void> {
321349
for (const event of events) {
350+
const dedupeKey = buildLineWebhookEventDedupeKey(event);
351+
if (dedupeKey && lineWebhookEventDedupe.check(dedupeKey)) {
352+
logVerbose(`line: skipped duplicate webhook event (${dedupeKey})`);
353+
continue;
354+
}
322355
try {
323356
switch (event.type) {
324357
case "message":

0 commit comments

Comments
 (0)