fix(zalo): scope webhook replay dedupe per target (#58196)

This commit is contained in:
Vincent Koc 2026-03-31 19:33:57 +09:00 committed by GitHub
parent 57fccca2dc
commit 4d038bb242
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 61 additions and 3 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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;