From 66a2e72beee278fd5557398c9bf8f8ffa8cdd122 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 14:06:37 +0000 Subject: [PATCH] fix: restore CI runtime seams --- extensions/discord/src/components-registry.ts | 44 +++++++++++---- .../src/monitor/thread-bindings.state.ts | 14 +++-- extensions/feishu/src/thread-bindings.ts | 19 ++++--- extensions/msteams/src/sent-message-cache.ts | 53 +++++++++++++++---- extensions/telegram/runtime-api.ts | 1 + extensions/telegram/src/draft-stream.ts | 26 ++++++--- extensions/telegram/src/sent-message-cache.ts | 50 +++++++++++++---- extensions/telegram/src/thread-bindings.ts | 21 ++++---- .../pi-embedded-runner/run/attempt.test.ts | 1 - src/commands/channels.mock-harness.ts | 5 +- .../local/auth-choice.ts | 14 +++++ src/plugin-sdk/discord.ts | 1 + 12 files changed, 183 insertions(+), 66 deletions(-) diff --git a/extensions/discord/src/components-registry.ts b/extensions/discord/src/components-registry.ts index b839eb433e7..294dfd35a55 100644 --- a/extensions/discord/src/components-registry.ts +++ b/extensions/discord/src/components-registry.ts @@ -1,14 +1,32 @@ -import { resolveGlobalMap } from "openclaw/plugin-sdk/text-runtime"; import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js"; const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000; const DISCORD_COMPONENT_ENTRIES_KEY = Symbol.for("openclaw.discord.componentEntries"); const DISCORD_MODAL_ENTRIES_KEY = Symbol.for("openclaw.discord.modalEntries"); -const componentEntries = resolveGlobalMap( - DISCORD_COMPONENT_ENTRIES_KEY, -); -const modalEntries = resolveGlobalMap(DISCORD_MODAL_ENTRIES_KEY); +let componentEntries: Map | undefined; +let modalEntries: Map | undefined; + +function resolveGlobalMap(key: symbol): Map { + const globalStore = globalThis as Record; + const existing = globalStore[key]; + if (existing instanceof Map) { + return existing as Map; + } + const created = new Map(); + globalStore[key] = created; + return created; +} + +function getComponentEntries(): Map { + componentEntries ??= resolveGlobalMap(DISCORD_COMPONENT_ENTRIES_KEY); + return componentEntries; +} + +function getModalEntries(): Map { + modalEntries ??= resolveGlobalMap(DISCORD_MODAL_ENTRIES_KEY); + return modalEntries; +} function isExpired(entry: { expiresAt?: number }, now: number) { return typeof entry.expiresAt === "number" && entry.expiresAt <= now; @@ -68,25 +86,29 @@ export function registerDiscordComponentEntries(params: { }): void { const now = Date.now(); const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS; - registerEntries(params.entries, componentEntries, { now, ttlMs, messageId: params.messageId }); - registerEntries(params.modals, modalEntries, { now, ttlMs, messageId: params.messageId }); + registerEntries(params.entries, getComponentEntries(), { + now, + ttlMs, + messageId: params.messageId, + }); + registerEntries(params.modals, getModalEntries(), { now, ttlMs, messageId: params.messageId }); } export function resolveDiscordComponentEntry(params: { id: string; consume?: boolean; }): DiscordComponentEntry | null { - return resolveEntry(componentEntries, params); + return resolveEntry(getComponentEntries(), params); } export function resolveDiscordModalEntry(params: { id: string; consume?: boolean; }): DiscordModalEntry | null { - return resolveEntry(modalEntries, params); + return resolveEntry(getModalEntries(), params); } export function clearDiscordComponentEntries(): void { - componentEntries.clear(); - modalEntries.clear(); + getComponentEntries().clear(); + getModalEntries().clear(); } diff --git a/extensions/discord/src/monitor/thread-bindings.state.ts b/extensions/discord/src/monitor/thread-bindings.state.ts index 1f5d60c9f28..c2ac2f15ada 100644 --- a/extensions/discord/src/monitor/thread-bindings.state.ts +++ b/extensions/discord/src/monitor/thread-bindings.state.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store"; import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; -import { resolveGlobalSingleton } from "openclaw/plugin-sdk/text-runtime"; import { DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, DEFAULT_THREAD_BINDING_MAX_AGE_MS, @@ -31,6 +30,7 @@ type ThreadBindingsGlobalState = { // Plugin hooks can load this module via Jiti while core imports it via ESM. // Store mutable state on globalThis so both loader paths share one registry. const THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.discordThreadBindingsState"); +let threadBindingsState: ThreadBindingsGlobalState | undefined; function createThreadBindingsGlobalState(): ThreadBindingsGlobalState { return { @@ -53,10 +53,14 @@ function createThreadBindingsGlobalState(): ThreadBindingsGlobalState { } function resolveThreadBindingsGlobalState(): ThreadBindingsGlobalState { - return resolveGlobalSingleton( - THREAD_BINDINGS_STATE_KEY, - createThreadBindingsGlobalState, - ); + if (!threadBindingsState) { + const globalStore = globalThis as Record; + threadBindingsState = + (globalStore[THREAD_BINDINGS_STATE_KEY] as ThreadBindingsGlobalState | undefined) ?? + createThreadBindingsGlobalState(); + globalStore[THREAD_BINDINGS_STATE_KEY] = threadBindingsState; + } + return threadBindingsState; } const THREAD_BINDINGS_STATE = resolveThreadBindingsGlobalState(); diff --git a/extensions/feishu/src/thread-bindings.ts b/extensions/feishu/src/thread-bindings.ts index 4ae79a35e0e..56545c1c234 100644 --- a/extensions/feishu/src/thread-bindings.ts +++ b/extensions/feishu/src/thread-bindings.ts @@ -10,7 +10,6 @@ import { type SessionBindingRecord, } from "openclaw/plugin-sdk/conversation-runtime"; import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; -import { resolveGlobalSingleton } from "openclaw/plugin-sdk/text-runtime"; type FeishuBindingTargetKind = "subagent" | "acp"; @@ -52,15 +51,19 @@ 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; function getState(): FeishuThreadBindingsState { + if (!state) { + const globalStore = globalThis as Record; + state = (globalStore[FEISHU_THREAD_BINDINGS_STATE_KEY] as + | FeishuThreadBindingsState + | undefined) ?? { + managersByAccountId: new Map(), + bindingsByAccountConversation: new Map(), + }; + globalStore[FEISHU_THREAD_BINDINGS_STATE_KEY] = state; + } return state; } diff --git a/extensions/msteams/src/sent-message-cache.ts b/extensions/msteams/src/sent-message-cache.ts index 26fedf2ddb5..41240c52f05 100644 --- a/extensions/msteams/src/sent-message-cache.ts +++ b/extensions/msteams/src/sent-message-cache.ts @@ -1,23 +1,56 @@ -import { createScopedExpiringIdCache } from "openclaw/plugin-sdk/text-runtime"; - const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours -const sentMessageCache = createScopedExpiringIdCache({ - store: new Map>(), - ttlMs: TTL_MS, - cleanupThreshold: 200, -}); +const MSTEAMS_SENT_MESSAGES_KEY = Symbol.for("openclaw.msteamsSentMessages"); + +let sentMessageCache: Map> | undefined; + +function getSentMessageCache(): Map> { + if (!sentMessageCache) { + const globalStore = globalThis as Record; + sentMessageCache = + (globalStore[MSTEAMS_SENT_MESSAGES_KEY] as Map> | undefined) ?? + new Map>(); + globalStore[MSTEAMS_SENT_MESSAGES_KEY] = sentMessageCache; + } + return sentMessageCache; +} + +function cleanupExpired(scopeKey: string, entry: Map, now: number): void { + for (const [id, timestamp] of entry) { + if (now - timestamp > TTL_MS) { + entry.delete(id); + } + } + if (entry.size === 0) { + getSentMessageCache().delete(scopeKey); + } +} export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void { if (!conversationId || !messageId) { return; } - sentMessageCache.record(conversationId, messageId); + const now = Date.now(); + const store = getSentMessageCache(); + let entry = store.get(conversationId); + if (!entry) { + entry = new Map(); + store.set(conversationId, entry); + } + entry.set(messageId, now); + if (entry.size > 200) { + cleanupExpired(conversationId, entry, now); + } } export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean { - return sentMessageCache.has(conversationId, messageId); + const entry = getSentMessageCache().get(conversationId); + if (!entry) { + return false; + } + cleanupExpired(conversationId, entry, Date.now()); + return entry.has(messageId); } export function clearMSTeamsSentMessageCache(): void { - sentMessageCache.clear(); + getSentMessageCache().clear(); } diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts index f6bdd452c87..900bc412c1d 100644 --- a/extensions/telegram/runtime-api.ts +++ b/extensions/telegram/runtime-api.ts @@ -84,6 +84,7 @@ export { setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, } from "./src/thread-bindings.js"; +export { __testing as telegramThreadBindingTesting } from "./src/thread-bindings.js"; export { resolveTelegramToken } from "./src/token.js"; export const telegramSessionBindingAdapterChannels = ["telegram"] as const; diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index 56a96e9c41a..b6c34fcf60f 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -1,6 +1,5 @@ import type { Bot } from "grammy"; import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-lifecycle"; -import { resolveGlobalSingleton } from "openclaw/plugin-sdk/text-runtime"; import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js"; @@ -27,14 +26,25 @@ type TelegramSendMessageDraft = ( * lanes do not accidentally reuse draft ids when code-split entries coexist. */ 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 } { + if (!draftStreamState) { + const globalStore = globalThis as Record; + draftStreamState = (globalStore[TELEGRAM_DRAFT_STREAM_STATE_KEY] as + | { nextDraftId: number } + | undefined) ?? { + nextDraftId: 0, + }; + globalStore[TELEGRAM_DRAFT_STREAM_STATE_KEY] = draftStreamState; + } + return draftStreamState; +} function allocateTelegramDraftId(): number { - draftStreamState.nextDraftId = - draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1; - return draftStreamState.nextDraftId; + const state = getDraftStreamState(); + state.nextDraftId = state.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : state.nextDraftId + 1; + return state.nextDraftId; } function resolveSendMessageDraftApi(api: Bot["api"]): TelegramSendMessageDraft | undefined { @@ -457,6 +467,6 @@ export function createTelegramDraftStream(params: { export const __testing = { resetTelegramDraftStreamForTests() { - draftStreamState.nextDraftId = 0; + getDraftStreamState().nextDraftId = 0; }, }; diff --git a/extensions/telegram/src/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts index 59101229d65..84bc6869566 100644 --- a/extensions/telegram/src/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -1,5 +1,3 @@ -import { createScopedExpiringIdCache, resolveGlobalMap } from "openclaw/plugin-sdk/text-runtime"; - /** * In-memory cache of sent message IDs per chat. * Used to identify bot's own messages for reaction filtering ("own" mode). @@ -16,33 +14,63 @@ const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages"); let sentMessages: Map> | undefined; function getSentMessages(): Map> { - sentMessages ??= resolveGlobalMap>(TELEGRAM_SENT_MESSAGES_KEY); + if (!sentMessages) { + const globalStore = globalThis as Record; + sentMessages = + (globalStore[TELEGRAM_SENT_MESSAGES_KEY] as Map> | undefined) ?? + new Map>(); + globalStore[TELEGRAM_SENT_MESSAGES_KEY] = sentMessages; + } return sentMessages; } -const sentMessageCache = createScopedExpiringIdCache({ - store: getSentMessages(), - ttlMs: TTL_MS, - cleanupThreshold: 100, -}); +function cleanupExpired(scopeKey: string, entry: Map, now: number): void { + for (const [id, timestamp] of entry) { + if (now - timestamp > TTL_MS) { + entry.delete(id); + } + } + if (entry.size === 0) { + getSentMessages().delete(scopeKey); + } +} /** * Record a message ID as sent by the bot. */ export function recordSentMessage(chatId: number | string, messageId: number): void { - sentMessageCache.record(chatId, messageId); + const scopeKey = String(chatId); + const idKey = String(messageId); + const now = Date.now(); + const store = getSentMessages(); + let entry = store.get(scopeKey); + if (!entry) { + entry = new Map(); + store.set(scopeKey, entry); + } + entry.set(idKey, now); + if (entry.size > 100) { + cleanupExpired(scopeKey, entry, now); + } } /** * Check if a message was sent by the bot. */ export function wasSentByBot(chatId: number | string, messageId: number): boolean { - return sentMessageCache.has(chatId, messageId); + const scopeKey = String(chatId); + const idKey = String(messageId); + const entry = getSentMessages().get(scopeKey); + if (!entry) { + return false; + } + cleanupExpired(scopeKey, entry, Date.now()); + return entry.has(idKey); } /** * Clear all cached entries (for testing). */ export function clearSentMessageCache(): void { - sentMessageCache.clear(); + getSentMessages().clear(); } diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index 7972d0bfc97..e145679c0cb 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -15,7 +15,6 @@ import { writeJsonAtomic } from "openclaw/plugin-sdk/infra-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; -import { resolveGlobalSingleton } from "openclaw/plugin-sdk/text-runtime"; const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000; const DEFAULT_THREAD_BINDING_MAX_AGE_MS = 0; @@ -77,16 +76,20 @@ type TelegramThreadBindingsState = { * binding lookups, and binding mutations all observe the same live registry. */ 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>(), - }), -); +let threadBindingsState: TelegramThreadBindingsState | undefined; function getThreadBindingsState(): TelegramThreadBindingsState { + if (!threadBindingsState) { + const globalStore = globalThis as Record; + threadBindingsState = (globalStore[TELEGRAM_THREAD_BINDINGS_STATE_KEY] as + | TelegramThreadBindingsState + | undefined) ?? { + managersByAccountId: new Map(), + bindingsByAccountConversation: new Map(), + persistQueueByAccountId: new Map>(), + }; + globalStore[TELEGRAM_THREAD_BINDINGS_STATE_KEY] = threadBindingsState; + } return threadBindingsState; } diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 48de705ae00..e80513d1853 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -8,7 +8,6 @@ import { shouldInjectOllamaCompatNumCtx, wrapOllamaCompatNumCtx, } from "../../../plugin-sdk/ollama.js"; -} from "../../../plugin-sdk/ollama.js"; import { appendBootstrapPromptWarning } from "../../bootstrap-budget.js"; import { buildAgentSystemPrompt } from "../../system-prompt.js"; import { buildEmbeddedSystemPrompt } from "../system-prompt.js"; diff --git a/src/commands/channels.mock-harness.ts b/src/commands/channels.mock-harness.ts index 3a123b0441f..10a237182ba 100644 --- a/src/commands/channels.mock-harness.ts +++ b/src/commands/channels.mock-harness.ts @@ -33,9 +33,8 @@ vi.mock("../../extensions/telegram/api.js", async (importOriginal) => { }); vi.mock("../../extensions/telegram/update-offset-runtime-api.js", async (importOriginal) => { - const actual = await importOriginal< - typeof import("../../extensions/telegram/update-offset-runtime-api.js") - >("../../extensions/telegram/update-offset-runtime-api.js"); + const actual = + await importOriginal(); return { ...actual, deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 098c373a639..a63556b87e9 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -79,6 +79,20 @@ export async function applyNonInteractiveAuthChoice(params: { ...input, secretInputMode: requestedSecretInputMode, }); + const maybeSetResolvedApiKey = async ( + resolved: ResolvedNonInteractiveApiKey, + setter: (value: SecretInput) => Promise | void, + ): Promise => { + if (resolved.source === "profile") { + return true; + } + const stored = toStoredSecretInput(resolved); + if (!stored) { + return false; + } + await setter(stored); + return true; + }; const toApiKeyCredential = (params: { provider: string; resolved: ResolvedNonInteractiveApiKey; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 76247e9b6e9..52e95630d00 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -68,6 +68,7 @@ export { export { buildDiscordComponentMessage, createDiscordActionGate, + handleDiscordMessageAction, listDiscordAccountIds, resolveDiscordAccount, resolveDefaultDiscordAccountId,