Files
openclaw/src/slack/sent-thread-cache.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

80 lines
2.1 KiB
TypeScript

import { resolveGlobalMap } from "../shared/global-singleton.js";
/**
* In-memory cache of Slack threads the bot has participated in.
* Used to auto-respond in threads without requiring @mention after the first reply.
* Follows a similar TTL pattern to the MS Teams and Telegram sent-message caches.
*/
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const MAX_ENTRIES = 5000;
/**
* Keep Slack thread participation shared across bundled chunks so thread
* auto-reply gating does not diverge between prepare/dispatch call paths.
*/
const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation");
const threadParticipation = resolveGlobalMap<string, number>(SLACK_THREAD_PARTICIPATION_KEY);
function makeKey(accountId: string, channelId: string, threadTs: string): string {
return `${accountId}:${channelId}:${threadTs}`;
}
function evictExpired(): void {
const now = Date.now();
for (const [key, timestamp] of threadParticipation) {
if (now - timestamp > TTL_MS) {
threadParticipation.delete(key);
}
}
}
function evictOldest(): void {
const oldest = threadParticipation.keys().next().value;
if (oldest) {
threadParticipation.delete(oldest);
}
}
export function recordSlackThreadParticipation(
accountId: string,
channelId: string,
threadTs: string,
): void {
if (!accountId || !channelId || !threadTs) {
return;
}
if (threadParticipation.size >= MAX_ENTRIES) {
evictExpired();
}
if (threadParticipation.size >= MAX_ENTRIES) {
evictOldest();
}
threadParticipation.set(makeKey(accountId, channelId, threadTs), Date.now());
}
export function hasSlackThreadParticipation(
accountId: string,
channelId: string,
threadTs: string,
): boolean {
if (!accountId || !channelId || !threadTs) {
return false;
}
const key = makeKey(accountId, channelId, threadTs);
const timestamp = threadParticipation.get(key);
if (timestamp == null) {
return false;
}
if (Date.now() - timestamp > TTL_MS) {
threadParticipation.delete(key);
return false;
}
return true;
}
export function clearSlackThreadParticipationCache(): void {
threadParticipation.clear();
}