From 6787c2f65359e57ce39bfd842907d69d00aedb35 Mon Sep 17 00:00:00 2001 From: Marcus Castro <7562095+mcaxtr@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:15:53 -0300 Subject: [PATCH] refactor(whatsapp): reuse SDK dedupe cache (#104962) --- .../group-gating.allowlist-warn.test.ts | 17 ++++-- .../src/auto-reply/monitor/group-gating.ts | 19 ++----- extensions/whatsapp/src/inbound/dedupe.ts | 57 +------------------ 3 files changed, 19 insertions(+), 74 deletions(-) diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.allowlist-warn.test.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.allowlist-warn.test.ts index b7065c9b4f5a..eefbd37ed0da 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.allowlist-warn.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.allowlist-warn.test.ts @@ -203,18 +203,23 @@ describe("applyGroupGating allowlist drop warning", () => { expect(warn.mock.calls[1]?.[1]).toContain("b@g.us"); }); - it("evicts old warning keys instead of growing without bound", async () => { + it("bounds warning keys by least-recently-used conversations", async () => { const warn = vi.fn(); + const apply = (conversationId: string) => + applyGroupGating(makeParams(makeUnregisteredGroupMsg(conversationId), warn)); - await applyGroupGating(makeParams(makeUnregisteredGroupMsg("evicted@g.us"), warn)); for (let index = 0; index < 100; index += 1) { - await applyGroupGating(makeParams(makeUnregisteredGroupMsg(`overflow-${index}@g.us`), warn)); + await apply(`${index}@g.us`); } - await applyGroupGating(makeParams(makeUnregisteredGroupMsg("evicted@g.us"), warn)); + await apply("0@g.us"); + await apply("100@g.us"); + await apply("0@g.us"); + await apply("1@g.us"); + await apply("100@g.us"); expect(warn).toHaveBeenCalledTimes(102); - expect(warn.mock.calls[0]?.[1]).toContain("evicted@g.us"); - expect(warn.mock.calls[101]?.[1]).toContain("evicted@g.us"); + expect(warn.mock.calls[100]?.[1]).toContain("100@g.us"); + expect(warn.mock.calls[101]?.[1]).toContain("1@g.us"); }); it("does not warn when the group is registered", async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index 1ee7b4b77848..f3c4f7e41933 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -1,6 +1,7 @@ // Whatsapp plugin module implements group gating behavior. import type { BuildMentionRegexesOptions } from "openclaw/plugin-sdk/channel-mention-gating"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { createDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime"; import { resolveWhatsAppGroupsConfigPath } from "../../group-config-path.js"; import { getPrimaryIdentityId, @@ -57,25 +58,17 @@ type ApplyGroupGatingParams = { }; const MAX_GROUP_DROP_WARNINGS = 100; -const groupDropWarned = new Set(); +const groupDropWarned = createDedupeCache({ + ttlMs: 0, + maxSize: MAX_GROUP_DROP_WARNINGS, +}); export function resetGroupDropWarningsForTests() { groupDropWarned.clear(); } function shouldWarnForGroupDrop(warnKey: string): boolean { - if (groupDropWarned.has(warnKey)) { - return false; - } - groupDropWarned.add(warnKey); - while (groupDropWarned.size > MAX_GROUP_DROP_WARNINGS) { - const oldest = groupDropWarned.values().next().value; - if (!oldest) { - break; - } - groupDropWarned.delete(oldest); - } - return true; + return !groupDropWarned.check(warnKey); } function isOwnerSender( diff --git a/extensions/whatsapp/src/inbound/dedupe.ts b/extensions/whatsapp/src/inbound/dedupe.ts index a6e57cf755f0..d99e4de66257 100644 --- a/extensions/whatsapp/src/inbound/dedupe.ts +++ b/extensions/whatsapp/src/inbound/dedupe.ts @@ -1,4 +1,5 @@ // Whatsapp plugin module implements dedupe behavior. +import { createDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime"; import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; export const WHATSAPP_INBOUND_DEDUPE_TTL_MS = 20 * 60_000; @@ -10,65 +11,11 @@ const claimableInboundMessages = createClaimableDedupe({ ttlMs: WHATSAPP_INBOUND_DEDUPE_TTL_MS, memoryMaxSize: RECENT_WEB_MESSAGE_MAX, }); -const recentOutboundMessages = createRecentMessageCache({ +const recentOutboundMessages = createDedupeCache({ ttlMs: RECENT_OUTBOUND_MESSAGE_TTL_MS, maxSize: RECENT_OUTBOUND_MESSAGE_MAX, }); -function createRecentMessageCache(options: { ttlMs: number; maxSize: number }) { - const ttlMs = Math.max(0, options.ttlMs); - const maxSize = Math.max(0, Math.floor(options.maxSize)); - const cache = new Map(); - - const prune = (now: number) => { - if (ttlMs > 0) { - const cutoff = now - ttlMs; - for (const [key, timestamp] of cache) { - if (timestamp < cutoff) { - cache.delete(key); - } - } - } - while (cache.size > maxSize) { - const oldest = cache.keys().next().value; - if (!oldest) { - break; - } - cache.delete(oldest); - } - }; - - const peek = (key: string | null, now = Date.now()): boolean => { - if (!key) { - return false; - } - const timestamp = cache.get(key); - if (timestamp === undefined) { - return false; - } - if (ttlMs > 0 && now - timestamp >= ttlMs) { - cache.delete(key); - return false; - } - return true; - }; - - return { - check: (key: string | null, now = Date.now()): boolean => { - if (!key) { - return false; - } - const existed = peek(key, now); - cache.delete(key); - cache.set(key, now); - prune(now); - return existed; - }, - peek, - clear: () => cache.clear(), - }; -} - export class WhatsAppRetryableInboundError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options);