mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 04:40:42 +00:00
162 lines
4.7 KiB
TypeScript
162 lines
4.7 KiB
TypeScript
import { resolveGlobalDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
|
|
import { getOptionalSlackRuntime } from "./runtime.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;
|
|
const PERSISTENT_MAX_ENTRIES = 1000;
|
|
const PERSISTENT_NAMESPACE = "slack.thread-participation";
|
|
|
|
type SlackThreadParticipationRecord = {
|
|
agentId?: string;
|
|
repliedAt: number;
|
|
};
|
|
|
|
type SlackThreadParticipationStore = {
|
|
register(
|
|
key: string,
|
|
value: SlackThreadParticipationRecord,
|
|
opts?: { ttlMs?: number },
|
|
): Promise<void>;
|
|
lookup(key: string): Promise<SlackThreadParticipationRecord | undefined>;
|
|
};
|
|
|
|
/**
|
|
* 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 = resolveGlobalDedupeCache(SLACK_THREAD_PARTICIPATION_KEY, {
|
|
ttlMs: TTL_MS,
|
|
maxSize: MAX_ENTRIES,
|
|
});
|
|
|
|
let persistentStore: SlackThreadParticipationStore | undefined;
|
|
let persistentStoreDisabled = false;
|
|
|
|
function makeKey(accountId: string, channelId: string, threadTs: string): string {
|
|
return `${accountId}:${channelId}:${threadTs}`;
|
|
}
|
|
|
|
function reportPersistentThreadParticipationError(error: unknown): void {
|
|
try {
|
|
getOptionalSlackRuntime()
|
|
?.logging.getChildLogger({ plugin: "slack", feature: "thread-participation-state" })
|
|
.warn("Slack persistent thread participation state failed", { error: String(error) });
|
|
} catch {
|
|
// Best effort only: persistent state must never break Slack message handling.
|
|
}
|
|
}
|
|
|
|
function disablePersistentThreadParticipation(error: unknown): void {
|
|
persistentStoreDisabled = true;
|
|
persistentStore = undefined;
|
|
reportPersistentThreadParticipationError(error);
|
|
}
|
|
|
|
function getPersistentThreadParticipationStore(): SlackThreadParticipationStore | undefined {
|
|
if (persistentStoreDisabled) {
|
|
return undefined;
|
|
}
|
|
if (persistentStore) {
|
|
return persistentStore;
|
|
}
|
|
const runtime = getOptionalSlackRuntime();
|
|
if (!runtime) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
persistentStore = runtime.state.openKeyedStore<SlackThreadParticipationRecord>({
|
|
namespace: PERSISTENT_NAMESPACE,
|
|
maxEntries: PERSISTENT_MAX_ENTRIES,
|
|
defaultTtlMs: TTL_MS,
|
|
});
|
|
return persistentStore;
|
|
} catch (error) {
|
|
disablePersistentThreadParticipation(error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function rememberPersistentThreadParticipation(params: { key: string; agentId?: string }): void {
|
|
const store = getPersistentThreadParticipationStore();
|
|
if (!store) {
|
|
return;
|
|
}
|
|
void store
|
|
.register(params.key, {
|
|
// Stored for future per-agent thread routing; current reads only need presence.
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
repliedAt: Date.now(),
|
|
})
|
|
.catch(disablePersistentThreadParticipation);
|
|
}
|
|
|
|
async function lookupPersistentThreadParticipation(key: string): Promise<boolean> {
|
|
const store = getPersistentThreadParticipationStore();
|
|
if (!store) {
|
|
return false;
|
|
}
|
|
try {
|
|
return Boolean(await store.lookup(key));
|
|
} catch (error) {
|
|
disablePersistentThreadParticipation(error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function recordSlackThreadParticipation(
|
|
accountId: string,
|
|
channelId: string,
|
|
threadTs: string,
|
|
opts?: { agentId?: string },
|
|
): void {
|
|
if (!accountId || !channelId || !threadTs) {
|
|
return;
|
|
}
|
|
const key = makeKey(accountId, channelId, threadTs);
|
|
threadParticipation.check(key);
|
|
rememberPersistentThreadParticipation({ key, agentId: opts?.agentId });
|
|
}
|
|
|
|
export function hasSlackThreadParticipation(
|
|
accountId: string,
|
|
channelId: string,
|
|
threadTs: string,
|
|
): boolean {
|
|
if (!accountId || !channelId || !threadTs) {
|
|
return false;
|
|
}
|
|
return threadParticipation.peek(makeKey(accountId, channelId, threadTs));
|
|
}
|
|
|
|
export async function hasSlackThreadParticipationWithPersistence(params: {
|
|
accountId: string;
|
|
channelId: string;
|
|
threadTs: string;
|
|
}): Promise<boolean> {
|
|
if (!params.accountId || !params.channelId || !params.threadTs) {
|
|
return false;
|
|
}
|
|
const key = makeKey(params.accountId, params.channelId, params.threadTs);
|
|
if (threadParticipation.peek(key)) {
|
|
return true;
|
|
}
|
|
const found = await lookupPersistentThreadParticipation(key);
|
|
if (found) {
|
|
threadParticipation.check(key);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export function clearSlackThreadParticipationCache(): void {
|
|
threadParticipation.clear();
|
|
persistentStore = undefined;
|
|
persistentStoreDisabled = false;
|
|
}
|