diff --git a/extensions/feishu/src/thread-bindings.ts b/extensions/feishu/src/thread-bindings.ts index 842374155b3..fefbe083347 100644 --- a/extensions/feishu/src/thread-bindings.ts +++ b/extensions/feishu/src/thread-bindings.ts @@ -51,16 +51,18 @@ type FeishuThreadBindingsState = { }; const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState"); -const state = resolveGlobalSingleton( - FEISHU_THREAD_BINDINGS_STATE_KEY, - () => ({ - managersByAccountId: new Map(), - bindingsByAccountConversation: new Map(), - }), -); +let state: FeishuThreadBindingsState | undefined; -const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId; -const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation; +function getState(): FeishuThreadBindingsState { + state ??= resolveGlobalSingleton( + FEISHU_THREAD_BINDINGS_STATE_KEY, + () => ({ + managersByAccountId: new Map(), + bindingsByAccountConversation: new Map(), + }), + ); + return state; +} function resolveBindingKey(params: { accountId: string; conversationId: string }): string { return `${params.accountId}:${params.conversationId}`; @@ -119,7 +121,7 @@ export function createFeishuThreadBindingManager(params: { cfg: OpenClawConfig; }): FeishuThreadBindingManager { const accountId = normalizeAccountId(params.accountId); - const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId); + const existing = getState().managersByAccountId.get(accountId); if (existing) { return existing; } @@ -138,9 +140,11 @@ export function createFeishuThreadBindingManager(params: { const manager: FeishuThreadBindingManager = { accountId, getByConversationId: (conversationId) => - BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })), + getState().bindingsByAccountConversation.get( + resolveBindingKey({ accountId, conversationId }), + ), listBySessionKey: (targetSessionKey) => - [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + [...getState().bindingsByAccountConversation.values()].filter( (record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey, ), bindConversation: ({ @@ -184,7 +188,7 @@ export function createFeishuThreadBindingManager(params: { boundAt: now, lastActivityAt: now, }; - BINDINGS_BY_ACCOUNT_CONVERSATION.set( + getState().bindingsByAccountConversation.set( resolveBindingKey({ accountId, conversationId: normalizedConversationId }), record, ); @@ -192,30 +196,30 @@ export function createFeishuThreadBindingManager(params: { }, touchConversation: (conversationId, at = Date.now()) => { const key = resolveBindingKey({ accountId, conversationId }); - const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); + const existingRecord = getState().bindingsByAccountConversation.get(key); if (!existingRecord) { return null; } const updated = { ...existingRecord, lastActivityAt: at }; - BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated); + getState().bindingsByAccountConversation.set(key, updated); return updated; }, unbindConversation: (conversationId) => { const key = resolveBindingKey({ accountId, conversationId }); - const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); + const existingRecord = getState().bindingsByAccountConversation.get(key); if (!existingRecord) { return null; } - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + getState().bindingsByAccountConversation.delete(key); return existingRecord; }, unbindBySessionKey: (targetSessionKey) => { const removed: FeishuThreadBindingRecord[] = []; - for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) { + for (const record of [...getState().bindingsByAccountConversation.values()]) { if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) { continue; } - BINDINGS_BY_ACCOUNT_CONVERSATION.delete( + getState().bindingsByAccountConversation.delete( resolveBindingKey({ accountId, conversationId: record.conversationId }), ); removed.push(record); @@ -223,12 +227,12 @@ export function createFeishuThreadBindingManager(params: { return removed; }, stop: () => { - for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) { + for (const key of [...getState().bindingsByAccountConversation.keys()]) { if (key.startsWith(`${accountId}:`)) { - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + getState().bindingsByAccountConversation.delete(key); } } - MANAGERS_BY_ACCOUNT_ID.delete(accountId); + getState().managersByAccountId.delete(accountId); unregisterSessionBindingAdapter({ channel: "feishu", accountId }); }, }; @@ -290,22 +294,22 @@ export function createFeishuThreadBindingManager(params: { }, }); - MANAGERS_BY_ACCOUNT_ID.set(accountId, manager); + getState().managersByAccountId.set(accountId, manager); return manager; } export function getFeishuThreadBindingManager( accountId?: string, ): FeishuThreadBindingManager | null { - return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null; + return getState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null; } export const __testing = { resetFeishuThreadBindingsForTests() { - for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { + for (const manager of getState().managersByAccountId.values()) { manager.stop(); } - MANAGERS_BY_ACCOUNT_ID.clear(); - BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); + getState().managersByAccountId.clear(); + getState().bindingsByAccountConversation.clear(); }, }; diff --git a/extensions/slack/src/sent-thread-cache.ts b/extensions/slack/src/sent-thread-cache.ts index f155571a1b4..332a7d65496 100644 --- a/extensions/slack/src/sent-thread-cache.ts +++ b/extensions/slack/src/sent-thread-cache.ts @@ -15,7 +15,12 @@ const MAX_ENTRIES = 5000; */ const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation"); -const threadParticipation = resolveGlobalMap(SLACK_THREAD_PARTICIPATION_KEY); +let threadParticipation: Map | undefined; + +function getThreadParticipation(): Map { + threadParticipation ??= resolveGlobalMap(SLACK_THREAD_PARTICIPATION_KEY); + return threadParticipation; +} function makeKey(accountId: string, channelId: string, threadTs: string): string { return `${accountId}:${channelId}:${threadTs}`; @@ -23,17 +28,17 @@ function makeKey(accountId: string, channelId: string, threadTs: string): string function evictExpired(): void { const now = Date.now(); - for (const [key, timestamp] of threadParticipation) { + for (const [key, timestamp] of getThreadParticipation()) { if (now - timestamp > TTL_MS) { - threadParticipation.delete(key); + getThreadParticipation().delete(key); } } } function evictOldest(): void { - const oldest = threadParticipation.keys().next().value; + const oldest = getThreadParticipation().keys().next().value; if (oldest) { - threadParticipation.delete(oldest); + getThreadParticipation().delete(oldest); } } @@ -45,6 +50,7 @@ export function recordSlackThreadParticipation( if (!accountId || !channelId || !threadTs) { return; } + const threadParticipation = getThreadParticipation(); if (threadParticipation.size >= MAX_ENTRIES) { evictExpired(); } @@ -63,6 +69,7 @@ export function hasSlackThreadParticipation( return false; } const key = makeKey(accountId, channelId, threadTs); + const threadParticipation = getThreadParticipation(); const timestamp = threadParticipation.get(key); if (timestamp == null) { return false; @@ -75,5 +82,5 @@ export function hasSlackThreadParticipation( } export function clearSlackThreadParticipationCache(): void { - threadParticipation.clear(); + getThreadParticipation().clear(); } diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index ae943f169d3..7b10e52312a 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -28,11 +28,17 @@ type TelegramSendMessageDraft = ( */ const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState"); -const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({ - nextDraftId: 0, -})); +let draftStreamState: { nextDraftId: number } | undefined; + +function getDraftStreamState(): { nextDraftId: number } { + draftStreamState ??= resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({ + nextDraftId: 0, + })); + return draftStreamState; +} function allocateTelegramDraftId(): number { + const draftStreamState = getDraftStreamState(); draftStreamState.nextDraftId = draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1; return draftStreamState.nextDraftId; @@ -454,6 +460,6 @@ export function createTelegramDraftStream(params: { export const __testing = { resetTelegramDraftStreamForTests() { - draftStreamState.nextDraftId = 0; + getDraftStreamState().nextDraftId = 0; }, }; diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index 4d14f179b2f..591e4c35a84 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -103,17 +103,34 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); const AUTO_LINKED_ANCHOR_PATTERN = /]*>\1<\/a>/gi; -const FILE_REFERENCE_PATTERN = new RegExp( - `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`, - "gi", -); -const ORPHANED_TLD_PATTERN = new RegExp( - `([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`, - "g", -); const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi; +let fileReferencePattern: RegExp | undefined; +let orphanedTldPattern: RegExp | undefined; + +function getFileReferencePattern(): RegExp { + if (fileReferencePattern) { + return fileReferencePattern; + } + const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); + fileReferencePattern = new RegExp( + `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${fileExtensionsPattern}))(?=$|[^a-zA-Z0-9_\\-/])`, + "gi", + ); + return fileReferencePattern; +} + +function getOrphanedTldPattern(): RegExp { + if (orphanedTldPattern) { + return orphanedTldPattern; + } + const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); + orphanedTldPattern = new RegExp( + `([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${fileExtensionsPattern}))(?=[^a-zA-Z0-9/]|$)`, + "g", + ); + return orphanedTldPattern; +} function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string { if (filename.startsWith("//")) { @@ -134,8 +151,8 @@ function wrapSegmentFileRefs( if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) { return text; } - const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef); - return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) => + const wrappedStandalone = text.replace(getFileReferencePattern(), wrapStandaloneFileRef); + return wrappedStandalone.replace(getOrphanedTldPattern(), (match, prefix: string, tld: string) => prefix === ">" ? match : `${prefix}${escapeHtml(tld)}`, ); } diff --git a/extensions/telegram/src/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts index bb48bce3c0f..f10f56b68f7 100644 --- a/extensions/telegram/src/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -17,7 +17,12 @@ type CacheEntry = { */ const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages"); -const sentMessages = resolveGlobalMap(TELEGRAM_SENT_MESSAGES_KEY); +let sentMessages: Map | undefined; + +function getSentMessages(): Map { + sentMessages ??= resolveGlobalMap(TELEGRAM_SENT_MESSAGES_KEY); + return sentMessages; +} function getChatKey(chatId: number | string): string { return String(chatId); @@ -37,6 +42,7 @@ function cleanupExpired(entry: CacheEntry): void { */ export function recordSentMessage(chatId: number | string, messageId: number): void { const key = getChatKey(chatId); + const sentMessages = getSentMessages(); let entry = sentMessages.get(key); if (!entry) { entry = { timestamps: new Map() }; @@ -54,7 +60,7 @@ export function recordSentMessage(chatId: number | string, messageId: number): v */ export function wasSentByBot(chatId: number | string, messageId: number): boolean { const key = getChatKey(chatId); - const entry = sentMessages.get(key); + const entry = getSentMessages().get(key); if (!entry) { return false; } @@ -67,5 +73,5 @@ export function wasSentByBot(chatId: number | string, messageId: number): boolea * Clear all cached entries (for testing). */ export function clearSentMessageCache(): void { - sentMessages.clear(); + getSentMessages().clear(); } diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index be734804efb..d4d1c3fbab4 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -77,17 +77,19 @@ type TelegramThreadBindingsState = { */ const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState"); -const threadBindingsState = resolveGlobalSingleton( - TELEGRAM_THREAD_BINDINGS_STATE_KEY, - () => ({ - managersByAccountId: new Map(), - bindingsByAccountConversation: new Map(), - persistQueueByAccountId: new Map>(), - }), -); -const MANAGERS_BY_ACCOUNT_ID = threadBindingsState.managersByAccountId; -const BINDINGS_BY_ACCOUNT_CONVERSATION = threadBindingsState.bindingsByAccountConversation; -const PERSIST_QUEUE_BY_ACCOUNT_ID = threadBindingsState.persistQueueByAccountId; +let threadBindingsState: TelegramThreadBindingsState | undefined; + +function getThreadBindingsState(): TelegramThreadBindingsState { + threadBindingsState ??= resolveGlobalSingleton( + TELEGRAM_THREAD_BINDINGS_STATE_KEY, + () => ({ + managersByAccountId: new Map(), + bindingsByAccountConversation: new Map(), + persistQueueByAccountId: new Map>(), + }), + ); + return threadBindingsState; +} function normalizeDurationMs(raw: unknown, fallback: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { @@ -168,7 +170,7 @@ function fromSessionBindingInput(params: { }): TelegramThreadBindingRecord { const now = Date.now(); const metadata = params.input.metadata ?? {}; - const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get( + const existing = getThreadBindingsState().bindingsByAccountConversation.get( resolveBindingKey({ accountId: params.accountId, conversationId: params.input.conversationId, @@ -310,7 +312,7 @@ async function persistBindingsToDisk(params: { version: STORE_VERSION, bindings: params.bindings ?? - [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + [...getThreadBindingsState().bindingsByAccountConversation.values()].filter( (entry) => entry.accountId === params.accountId, ), }; @@ -322,7 +324,7 @@ async function persistBindingsToDisk(params: { } function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] { - return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + return [...getThreadBindingsState().bindingsByAccountConversation.values()].filter( (entry) => entry.accountId === accountId, ); } @@ -335,16 +337,17 @@ function enqueuePersistBindings(params: { if (!params.persist) { return Promise.resolve(); } - const previous = PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) ?? Promise.resolve(); + const previous = + getThreadBindingsState().persistQueueByAccountId.get(params.accountId) ?? Promise.resolve(); const next = previous .catch(() => undefined) .then(async () => { await persistBindingsToDisk(params); }); - PERSIST_QUEUE_BY_ACCOUNT_ID.set(params.accountId, next); + getThreadBindingsState().persistQueueByAccountId.set(params.accountId, next); void next.finally(() => { - if (PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) === next) { - PERSIST_QUEUE_BY_ACCOUNT_ID.delete(params.accountId); + if (getThreadBindingsState().persistQueueByAccountId.get(params.accountId) === next) { + getThreadBindingsState().persistQueueByAccountId.delete(params.accountId); } }); return next; @@ -412,7 +415,7 @@ export function createTelegramThreadBindingManager( } = {}, ): TelegramThreadBindingManager { const accountId = normalizeAccountId(params.accountId); - const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId); + const existing = getThreadBindingsState().managersByAccountId.get(accountId); if (existing) { return existing; } @@ -430,7 +433,7 @@ export function createTelegramThreadBindingManager( accountId, conversationId: entry.conversationId, }); - BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, { + getThreadBindingsState().bindingsByAccountConversation.set(key, { ...entry, accountId, }); @@ -448,7 +451,7 @@ export function createTelegramThreadBindingManager( if (!conversationId) { return undefined; } - return BINDINGS_BY_ACCOUNT_CONVERSATION.get( + return getThreadBindingsState().bindingsByAccountConversation.get( resolveBindingKey({ accountId, conversationId, @@ -471,7 +474,7 @@ export function createTelegramThreadBindingManager( return null; } const key = resolveBindingKey({ accountId, conversationId }); - const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); + const existing = getThreadBindingsState().bindingsByAccountConversation.get(key); if (!existing) { return null; } @@ -479,7 +482,7 @@ export function createTelegramThreadBindingManager( ...existing, lastActivityAt: normalizeTimestampMs(at ?? Date.now()), }; - BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, nextRecord); + getThreadBindingsState().bindingsByAccountConversation.set(key, nextRecord); persistBindingsSafely({ accountId, persist: manager.shouldPersistMutations(), @@ -494,11 +497,11 @@ export function createTelegramThreadBindingManager( return null; } const key = resolveBindingKey({ accountId, conversationId }); - const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; + const removed = getThreadBindingsState().bindingsByAccountConversation.get(key) ?? null; if (!removed) { return null; } - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + getThreadBindingsState().bindingsByAccountConversation.delete(key); persistBindingsSafely({ accountId, persist: manager.shouldPersistMutations(), @@ -521,7 +524,7 @@ export function createTelegramThreadBindingManager( accountId, conversationId: entry.conversationId, }); - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + getThreadBindingsState().bindingsByAccountConversation.delete(key); removed.push(entry); } if (removed.length > 0) { @@ -540,9 +543,9 @@ export function createTelegramThreadBindingManager( sweepTimer = null; } unregisterSessionBindingAdapter({ channel: "telegram", accountId }); - const existingManager = MANAGERS_BY_ACCOUNT_ID.get(accountId); + const existingManager = getThreadBindingsState().managersByAccountId.get(accountId); if (existingManager === manager) { - MANAGERS_BY_ACCOUNT_ID.delete(accountId); + getThreadBindingsState().managersByAccountId.delete(accountId); } }, }; @@ -574,7 +577,7 @@ export function createTelegramThreadBindingManager( metadata: input.metadata, }, }); - BINDINGS_BY_ACCOUNT_CONVERSATION.set( + getThreadBindingsState().bindingsByAccountConversation.set( resolveBindingKey({ accountId, conversationId }), record, ); @@ -714,14 +717,14 @@ export function createTelegramThreadBindingManager( sweepTimer.unref?.(); } - MANAGERS_BY_ACCOUNT_ID.set(accountId, manager); + getThreadBindingsState().managersByAccountId.set(accountId, manager); return manager; } export function getTelegramThreadBindingManager( accountId?: string, ): TelegramThreadBindingManager | null { - return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null; + return getThreadBindingsState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null; } function updateTelegramBindingsBySessionKey(params: { @@ -741,7 +744,7 @@ function updateTelegramBindingsBySessionKey(params: { conversationId: entry.conversationId, }); const next = params.update(entry, now); - BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, next); + getThreadBindingsState().bindingsByAccountConversation.set(key, next); updated.push(next); } if (updated.length > 0) { @@ -799,12 +802,12 @@ export function setTelegramThreadBindingMaxAgeBySessionKey(params: { export const __testing = { async resetTelegramThreadBindingsForTests() { - for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { + for (const manager of getThreadBindingsState().managersByAccountId.values()) { manager.stop(); } - await Promise.allSettled(PERSIST_QUEUE_BY_ACCOUNT_ID.values()); - PERSIST_QUEUE_BY_ACCOUNT_ID.clear(); - MANAGERS_BY_ACCOUNT_ID.clear(); - BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); + await Promise.allSettled(getThreadBindingsState().persistQueueByAccountId.values()); + getThreadBindingsState().persistQueueByAccountId.clear(); + getThreadBindingsState().managersByAccountId.clear(); + getThreadBindingsState().bindingsByAccountConversation.clear(); }, };