openclaw/src/auto-reply/reply/inbound-dedupe.ts
Vincent Koc 4ca84acf24
fix(runtime): duplicate messages, share singleton state across bundled chunks (#43683)
* Tests: add fresh module import helper

* Process: share command queue runtime state

* Agents: share embedded run runtime state

* Reply: share followup queue runtime state

* Reply: share followup drain callback state

* Reply: share queued message dedupe state

* Reply: share inbound dedupe state

* Tests: cover shared command queue runtime state

* Tests: cover shared embedded run runtime state

* Tests: cover shared followup queue runtime state

* Tests: cover shared inbound dedupe state

* Tests: cover shared Slack thread participation state

* Slack: share sent thread participation state

* Tests: document fresh import helper

* Telegram: share draft stream runtime state

* Tests: cover shared Telegram draft stream state

* Telegram: share sent message cache state

* Tests: cover shared Telegram sent message cache

* Telegram: share thread binding runtime state

* Tests: cover shared Telegram thread binding state

* Tests: avoid duplicate shared queue reset

* refactor(runtime): centralize global singleton access

* refactor(runtime): preserve undefined global singleton values

* test(runtime): cover undefined global singleton values

---------

Co-authored-by: Nimrod Gutman <nimrod.gutman@gmail.com>
2026-03-12 14:59:27 -04:00

83 lines
2.8 KiB
TypeScript

import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { createDedupeCache, type DedupeCache } from "../../infra/dedupe.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
import type { MsgContext } from "../templating.js";
const DEFAULT_INBOUND_DEDUPE_TTL_MS = 20 * 60_000;
const DEFAULT_INBOUND_DEDUPE_MAX = 5000;
/**
* Keep inbound dedupe shared across bundled chunks so the same provider
* message cannot bypass dedupe by entering through a different chunk copy.
*/
const INBOUND_DEDUPE_CACHE_KEY = Symbol.for("openclaw.inboundDedupeCache");
const inboundDedupeCache = resolveGlobalSingleton<DedupeCache>(INBOUND_DEDUPE_CACHE_KEY, () =>
createDedupeCache({
ttlMs: DEFAULT_INBOUND_DEDUPE_TTL_MS,
maxSize: DEFAULT_INBOUND_DEDUPE_MAX,
}),
);
const normalizeProvider = (value?: string | null) => value?.trim().toLowerCase() || "";
const resolveInboundPeerId = (ctx: MsgContext) =>
ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? ctx.SessionKey;
function resolveInboundDedupeSessionScope(ctx: MsgContext): string {
const sessionKey =
(ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey : undefined)?.trim() ||
ctx.SessionKey?.trim() ||
"";
if (!sessionKey) {
return "";
}
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed) {
return sessionKey;
}
// The same physical inbound message should never run twice for the same
// agent, even if a routing bug presents it under both main and direct keys.
return `agent:${parsed.agentId}`;
}
export function buildInboundDedupeKey(ctx: MsgContext): string | null {
const provider = normalizeProvider(ctx.OriginatingChannel ?? ctx.Provider ?? ctx.Surface);
const messageId = ctx.MessageSid?.trim();
if (!provider || !messageId) {
return null;
}
const peerId = resolveInboundPeerId(ctx);
if (!peerId) {
return null;
}
const sessionScope = resolveInboundDedupeSessionScope(ctx);
const accountId = ctx.AccountId?.trim() ?? "";
const threadId =
ctx.MessageThreadId !== undefined && ctx.MessageThreadId !== null
? String(ctx.MessageThreadId)
: "";
return [provider, accountId, sessionScope, peerId, threadId, messageId].filter(Boolean).join("|");
}
export function shouldSkipDuplicateInbound(
ctx: MsgContext,
opts?: { cache?: DedupeCache; now?: number },
): boolean {
const key = buildInboundDedupeKey(ctx);
if (!key) {
return false;
}
const cache = opts?.cache ?? inboundDedupeCache;
const skipped = cache.check(key, opts?.now);
if (skipped && shouldLogVerbose()) {
logVerbose(`inbound dedupe: skipped ${key}`);
}
return skipped;
}
export function resetInboundDedupe(): void {
inboundDedupeCache.clear();
}