mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-23 07:01:40 +00:00
fix(zalo): scope replay dedupe cache key to path and account [AI] (#59387)
* fix: address issue #139 * changelog: add zalo replay dedupe fix entry --------- Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
This commit is contained in:
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
|
||||
- Plugins/browser: block SSRF redirect bypass by installing a real-time Playwright route handler before `page.goto()` so navigation to private/internal IPs is intercepted and aborted mid-redirect instead of checked post-hoc. (#58771) Thanks @pgondhi987.
|
||||
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
|
||||
- Zalo/webhook: scope replay-dedupe cache key to path and account using `JSON.stringify` so multi-account deployments do not silently drop events due to cross-account cache poisoning. (#59387) Thanks @pgondhi987.
|
||||
|
||||
## 2026.4.2-beta.1
|
||||
|
||||
|
||||
@@ -397,6 +397,120 @@ describe("handleZaloWebhookRequest", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps replay dedupe isolated when path/account values collide under colon-joined keys", async () => {
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
// Old key format `${path}:${accountId}:${event_name}:${messageId}` would collide for these two targets.
|
||||
const unregisterA = registerTarget({
|
||||
path: "/hook-replay-collision:a",
|
||||
secret: "secret-a",
|
||||
statusSink: sinkA,
|
||||
account: {
|
||||
...DEFAULT_ACCOUNT,
|
||||
accountId: "team",
|
||||
},
|
||||
});
|
||||
const unregisterB = registerTarget({
|
||||
path: "/hook-replay-collision",
|
||||
secret: "secret-b",
|
||||
statusSink: sinkB,
|
||||
account: {
|
||||
...DEFAULT_ACCOUNT,
|
||||
accountId: "a:team",
|
||||
},
|
||||
});
|
||||
const payload = createTextUpdate({
|
||||
messageId: "msg-replay-collision-1",
|
||||
userId: "123",
|
||||
userName: "",
|
||||
chatId: "123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const first = await fetch(`${baseUrl}/hook-replay-collision:a`, {
|
||||
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-collision`, {
|
||||
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("keeps replay dedupe isolated across different webhook paths", async () => {
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
const sharedSecret = "secret";
|
||||
const unregisterA = registerTarget({
|
||||
path: "/hook-replay-scope-a",
|
||||
secret: sharedSecret,
|
||||
statusSink: sinkA,
|
||||
});
|
||||
const unregisterB = registerTarget({
|
||||
path: "/hook-replay-scope-b",
|
||||
secret: sharedSecret,
|
||||
statusSink: sinkB,
|
||||
});
|
||||
const payload = createTextUpdate({
|
||||
messageId: "msg-replay-cross-path-1",
|
||||
userId: "123",
|
||||
userName: "",
|
||||
chatId: "123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const first = await fetch(`${baseUrl}/hook-replay-scope-a`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": sharedSecret,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const second = await fetch(`${baseUrl}/hook-replay-scope-b`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": sharedSecret,
|
||||
"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,
|
||||
|
||||
@@ -75,23 +75,22 @@ function timingSafeEquals(left: string, right: string): boolean {
|
||||
return safeEqualSecret(left, right);
|
||||
}
|
||||
|
||||
function buildReplayEventCacheKey(
|
||||
target: ZaloWebhookTarget,
|
||||
update: ZaloUpdate,
|
||||
messageId: string,
|
||||
): string {
|
||||
const chatId = update.message?.chat?.id ?? "";
|
||||
const senderId = update.message?.from?.id ?? "";
|
||||
return JSON.stringify([target.path, target.account.accountId, update.event_name, chatId, senderId, messageId]);
|
||||
}
|
||||
|
||||
function isReplayEvent(target: ZaloWebhookTarget, update: ZaloUpdate, nowMs: number): boolean {
|
||||
const messageId = update.message?.message_id;
|
||||
if (!messageId) {
|
||||
return false;
|
||||
}
|
||||
const chatId = update.message?.chat?.id ?? "";
|
||||
const senderId = update.message?.from?.id ?? "";
|
||||
// Scope replay dedupe to the authenticated target and the message origin so
|
||||
// reused message ids in other chats or from other senders do not collide.
|
||||
const key = [
|
||||
target.path,
|
||||
target.account.accountId,
|
||||
update.event_name,
|
||||
chatId,
|
||||
senderId,
|
||||
messageId,
|
||||
].join(":");
|
||||
const key = buildReplayEventCacheKey(target, update, messageId);
|
||||
return recentWebhookEvents.check(key, nowMs);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user