diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d2d8c62b99..567b53e9a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,7 @@ Docs: https://docs.openclaw.ai - Diffs/config: preserve schema-shaped plugin config parsing from `diffsPluginConfigSchema.safeParse()`, so direct callers keep `defaults` and `security` sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras. - Diffs: fall back to plain text when `lang` hints are invalid during diff render and viewer hydration, so bad or stale language values no longer break the diff viewer. (#57902) Thanks @gumadeiras. - Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777. +- Zalo/webhooks: scope replay dedupe to the authenticated target so one configured account can no longer cause same-id inbound events for another target to be dropped. Thanks @smaeljaish771 and @vincentkoc. - Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras. - xAI/Responses: normalize image-bearing tool results for xAI responses payloads, including OpenResponses-style `input_image.source` parts, so image tool replays no longer 422 on the follow-up turn. (#58017) Thanks @neeravmakwana. - Cron/isolated sessions: carry the full live-session provider, model, and auth-profile selection across retry restarts so cron jobs with model overrides no longer fail or loop on mid-run model-switch requests. (#57972) Thanks @issaba1. diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 1c05ae6148f..d5c26fe6204 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -238,6 +238,62 @@ describe("handleZaloWebhookRequest", () => { } }); + it("keeps replay dedupe isolated per authenticated target", async () => { + const sinkA = vi.fn(); + const sinkB = vi.fn(); + const unregisterA = registerTarget({ + path: "/hook-replay-scope", + secret: "secret-a", + statusSink: sinkA, + }); + const unregisterB = registerTarget({ + path: "/hook-replay-scope", + secret: "secret-b", + statusSink: sinkB, + account: { + ...DEFAULT_ACCOUNT, + accountId: "work", + }, + }); + const payload = createTextUpdate({ + messageId: "msg-replay-scope-1", + userId: "123", + userName: "", + chatId: "123", + text: "hello", + }); + + try { + await withServer(webhookRequestHandler, async (baseUrl) => { + const first = await fetch(`${baseUrl}/hook-replay-scope`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret-a", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + const second = await fetch(`${baseUrl}/hook-replay-scope`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret-b", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + }); + + expect(sinkA).toHaveBeenCalledTimes(1); + expect(sinkB).toHaveBeenCalledTimes(1); + } finally { + unregisterA(); + unregisterB(); + } + }); + it("downloads inbound image media from webhook photo_url and preserves display_name", async () => { const { core, diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts index 02a82bf0544..647db1da395 100644 --- a/extensions/zalo/src/monitor.webhook.ts +++ b/extensions/zalo/src/monitor.webhook.ts @@ -59,6 +59,7 @@ const webhookAnomalyTracker = createWebhookAnomalyTracker({ export function clearZaloWebhookSecurityStateForTest(): void { webhookRateLimiter.clear(); + recentWebhookEvents.clear(); webhookAnomalyTracker.clear(); } @@ -87,12 +88,12 @@ function timingSafeEquals(left: string, right: string): boolean { return timingSafeEqual(leftBuffer, rightBuffer); } -function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean { +function isReplayEvent(target: ZaloWebhookTarget, update: ZaloUpdate, nowMs: number): boolean { const messageId = update.message?.message_id; if (!messageId) { return false; } - const key = `${update.event_name}:${messageId}`; + const key = `${target.path}:${target.account.accountId}:${update.event_name}:${messageId}`; return recentWebhookEvents.check(key, nowMs); } @@ -222,7 +223,7 @@ export async function handleZaloWebhookRequest( return true; } - if (isReplayEvent(update, nowMs)) { + if (isReplayEvent(target, update, nowMs)) { res.statusCode = 200; res.end("ok"); return true;