diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ce842f8be..540ecde213c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Gateway/security default response headers: add `Permissions-Policy: camera=(), microphone=(), geolocation=()` to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan. - Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy. +- Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras. - Security/auth labels: remove token and API-key snippets from user-facing auth status labels so `/status` and `/models` do not expose credential fragments. (#33262) thanks @cu1ch3n. - Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown `gateway.nodes.denyCommands` entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot. - Docs/security hardening guidance: document Docker `DOCKER-USER` + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan. diff --git a/src/agents/command-poll-backoff.runtime.ts b/src/agents/command-poll-backoff.runtime.ts new file mode 100644 index 00000000000..1667abba083 --- /dev/null +++ b/src/agents/command-poll-backoff.runtime.ts @@ -0,0 +1 @@ +export { pruneStaleCommandPolls } from "./command-poll-backoff.js"; diff --git a/src/agents/pi-tools.before-tool-call.runtime.ts b/src/agents/pi-tools.before-tool-call.runtime.ts new file mode 100644 index 00000000000..b78a58231a2 --- /dev/null +++ b/src/agents/pi-tools.before-tool-call.runtime.ts @@ -0,0 +1,7 @@ +export { getDiagnosticSessionState } from "../logging/diagnostic-session-state.js"; +export { logToolLoopAction } from "../logging/diagnostic.js"; +export { + detectToolCallLoop, + recordToolCall, + recordToolCallOutcome, +} from "./tool-loop-detection.js"; diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index c1435c92de8..99a470e8bd0 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -23,6 +23,14 @@ const adjustedParamsByToolCallId = new Map(); const MAX_TRACKED_ADJUSTED_PARAMS = 1024; const LOOP_WARNING_BUCKET_SIZE = 10; const MAX_LOOP_WARNING_KEYS = 256; +let beforeToolCallRuntimePromise: Promise< + typeof import("./pi-tools.before-tool-call.runtime.js") +> | null = null; + +function loadBeforeToolCallRuntime() { + beforeToolCallRuntimePromise ??= import("./pi-tools.before-tool-call.runtime.js"); + return beforeToolCallRuntimePromise; +} function buildAdjustedParamsKey(params: { runId?: string; toolCallId: string }): string { if (params.runId && params.runId.trim()) { @@ -62,8 +70,7 @@ async function recordLoopOutcome(args: { return; } try { - const { getDiagnosticSessionState } = await import("../logging/diagnostic-session-state.js"); - const { recordToolCallOutcome } = await import("./tool-loop-detection.js"); + const { getDiagnosticSessionState, recordToolCallOutcome } = await loadBeforeToolCallRuntime(); const sessionState = getDiagnosticSessionState({ sessionKey: args.ctx.sessionKey, sessionId: args.ctx?.agentId, @@ -91,10 +98,8 @@ export async function runBeforeToolCallHook(args: { const params = args.params; if (args.ctx?.sessionKey) { - const { getDiagnosticSessionState } = await import("../logging/diagnostic-session-state.js"); - const { logToolLoopAction } = await import("../logging/diagnostic.js"); - const { detectToolCallLoop, recordToolCall } = await import("./tool-loop-detection.js"); - + const { getDiagnosticSessionState, logToolLoopAction, detectToolCallLoop, recordToolCall } = + await loadBeforeToolCallRuntime(); const sessionState = getDiagnosticSessionState({ sessionKey: args.ctx.sessionKey, sessionId: args.ctx?.agentId, diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 3b45234ea12..bbb618b3239 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -49,6 +49,15 @@ const FAST_TEST_RETRY_INTERVAL_MS = 8; const FAST_TEST_REPLY_CHANGE_WAIT_MS = 20; const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 60_000; const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000; +let subagentRegistryRuntimePromise: Promise< + typeof import("./subagent-registry-runtime.js") +> | null = null; + +function loadSubagentRegistryRuntime() { + subagentRegistryRuntimePromise ??= import("./subagent-registry-runtime.js"); + return subagentRegistryRuntimePromise; +} + const DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS = FAST_TEST_MODE ? ([8, 16, 32] as const) : ([5_000, 10_000, 20_000] as const); @@ -773,12 +782,9 @@ async function sendSubagentAnnounceDirectly(params: { if (!forceBoundSessionDirectDelivery) { let pendingDescendantRuns = 0; try { - const { - countPendingDescendantRuns, - countPendingDescendantRunsExcludingRun, - countActiveDescendantRuns, - } = await import("./subagent-registry.js"); - if (params.currentRunId && typeof countPendingDescendantRunsExcludingRun === "function") { + const { countPendingDescendantRuns, countPendingDescendantRunsExcludingRun } = + await loadSubagentRegistryRuntime(); + if (params.currentRunId) { pendingDescendantRuns = Math.max( 0, countPendingDescendantRunsExcludingRun( @@ -789,9 +795,7 @@ async function sendSubagentAnnounceDirectly(params: { } else { pendingDescendantRuns = Math.max( 0, - typeof countPendingDescendantRuns === "function" - ? countPendingDescendantRuns(canonicalRequesterSessionKey) - : countActiveDescendantRuns(canonicalRequesterSessionKey), + countPendingDescendantRuns(canonicalRequesterSessionKey), ); } } catch { @@ -1224,14 +1228,8 @@ export async function runSubagentAnnounceFlow(params: { let pendingChildDescendantRuns = 0; try { - const { countPendingDescendantRuns, countActiveDescendantRuns } = - await import("./subagent-registry.js"); - pendingChildDescendantRuns = Math.max( - 0, - typeof countPendingDescendantRuns === "function" - ? countPendingDescendantRuns(params.childSessionKey) - : countActiveDescendantRuns(params.childSessionKey), - ); + const { countPendingDescendantRuns } = await loadSubagentRegistryRuntime(); + pendingChildDescendantRuns = Math.max(0, countPendingDescendantRuns(params.childSessionKey)); } catch { // Best-effort only; fall back to direct announce behavior when unavailable. } @@ -1281,7 +1279,7 @@ export async function runSubagentAnnounceFlow(params: { // still receive the announce — injecting will start a new agent turn. if (requesterIsSubagent) { const { isSubagentSessionRunActive, resolveRequesterForChildSession } = - await import("./subagent-registry.js"); + await loadSubagentRegistryRuntime(); if (!isSubagentSessionRunActive(targetRequesterSessionKey)) { // Parent run has ended. Check if parent SESSION still exists. // If it does, the parent may be waiting for child results — inject there. @@ -1314,7 +1312,7 @@ export async function runSubagentAnnounceFlow(params: { let remainingActiveSubagentRuns = 0; try { - const { countActiveDescendantRuns } = await import("./subagent-registry.js"); + const { countActiveDescendantRuns } = await loadSubagentRegistryRuntime(); remainingActiveSubagentRuns = Math.max( 0, countActiveDescendantRuns(targetRequesterSessionKey), diff --git a/src/agents/subagent-registry-runtime.ts b/src/agents/subagent-registry-runtime.ts new file mode 100644 index 00000000000..e47e4c1bfcc --- /dev/null +++ b/src/agents/subagent-registry-runtime.ts @@ -0,0 +1,7 @@ +export { + countActiveDescendantRuns, + countPendingDescendantRuns, + countPendingDescendantRunsExcludingRun, + isSubagentSessionRunActive, + resolveRequesterForChildSession, +} from "./subagent-registry.js"; diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 1c620d6e3ef..a489bedcbbf 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -18,6 +18,15 @@ import type { ReplyPayload } from "../types.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; import { shouldSuppressReasoningPayload } from "./reply-payloads.js"; +let deliverRuntimePromise: Promise< + typeof import("../../infra/outbound/deliver-runtime.js") +> | null = null; + +function loadDeliverRuntime() { + deliverRuntimePromise ??= import("../../infra/outbound/deliver-runtime.js"); + return deliverRuntimePromise; +} + export type RouteReplyParams = { /** The reply payload to send. */ payload: ReplyPayload; @@ -126,7 +135,7 @@ export async function routeReply(params: RouteReplyParams): Promise | null = + null; +let telegramSenderRuntimePromise: Promise | null = + null; +let discordSenderRuntimePromise: Promise | null = + null; +let slackSenderRuntimePromise: Promise | null = null; +let signalSenderRuntimePromise: Promise | null = + null; +let imessageSenderRuntimePromise: Promise | null = + null; + +function loadWhatsAppSenderRuntime() { + whatsappSenderRuntimePromise ??= import("./deps-send-whatsapp.runtime.js"); + return whatsappSenderRuntimePromise; +} + +function loadTelegramSenderRuntime() { + telegramSenderRuntimePromise ??= import("./deps-send-telegram.runtime.js"); + return telegramSenderRuntimePromise; +} + +function loadDiscordSenderRuntime() { + discordSenderRuntimePromise ??= import("./deps-send-discord.runtime.js"); + return discordSenderRuntimePromise; +} + +function loadSlackSenderRuntime() { + slackSenderRuntimePromise ??= import("./deps-send-slack.runtime.js"); + return slackSenderRuntimePromise; +} + +function loadSignalSenderRuntime() { + signalSenderRuntimePromise ??= import("./deps-send-signal.runtime.js"); + return signalSenderRuntimePromise; +} + +function loadIMessageSenderRuntime() { + imessageSenderRuntimePromise ??= import("./deps-send-imessage.runtime.js"); + return imessageSenderRuntimePromise; +} + export function createDefaultDeps(): CliDeps { return { sendMessageWhatsApp: async (...args) => { - const { sendMessageWhatsApp } = await import("../channels/web/index.js"); + const { sendMessageWhatsApp } = await loadWhatsAppSenderRuntime(); return await sendMessageWhatsApp(...args); }, sendMessageTelegram: async (...args) => { - const { sendMessageTelegram } = await import("../telegram/send.js"); + const { sendMessageTelegram } = await loadTelegramSenderRuntime(); return await sendMessageTelegram(...args); }, sendMessageDiscord: async (...args) => { - const { sendMessageDiscord } = await import("../discord/send.js"); + const { sendMessageDiscord } = await loadDiscordSenderRuntime(); return await sendMessageDiscord(...args); }, sendMessageSlack: async (...args) => { - const { sendMessageSlack } = await import("../slack/send.js"); + const { sendMessageSlack } = await loadSlackSenderRuntime(); return await sendMessageSlack(...args); }, sendMessageSignal: async (...args) => { - const { sendMessageSignal } = await import("../signal/send.js"); + const { sendMessageSignal } = await loadSignalSenderRuntime(); return await sendMessageSignal(...args); }, sendMessageIMessage: async (...args) => { - const { sendMessageIMessage } = await import("../imessage/send.js"); + const { sendMessageIMessage } = await loadIMessageSenderRuntime(); return await sendMessageIMessage(...args); }, }; diff --git a/src/infra/outbound/deliver-runtime.ts b/src/infra/outbound/deliver-runtime.ts new file mode 100644 index 00000000000..a3f51a0272a --- /dev/null +++ b/src/infra/outbound/deliver-runtime.ts @@ -0,0 +1 @@ +export { deliverOutboundPayloads } from "./deliver.js"; diff --git a/src/infra/session-maintenance-warning.ts b/src/infra/session-maintenance-warning.ts index df803f88411..5dd220a7691 100644 --- a/src/infra/session-maintenance-warning.ts +++ b/src/infra/session-maintenance-warning.ts @@ -15,6 +15,12 @@ type WarningParams = { const warnedContexts = new Map(); const log = createSubsystemLogger("session-maintenance-warning"); +let deliverRuntimePromise: Promise | null = null; + +function loadDeliverRuntime() { + deliverRuntimePromise ??= import("./outbound/deliver-runtime.js"); + return deliverRuntimePromise; +} function shouldSendWarning(): boolean { return !process.env.VITEST && process.env.NODE_ENV !== "test"; @@ -95,7 +101,7 @@ export async function deliverSessionMaintenanceWarning(params: WarningParams): P } try { - const { deliverOutboundPayloads } = await import("./outbound/deliver.js"); + const { deliverOutboundPayloads } = await loadDeliverRuntime(); const outboundSession = buildOutboundSessionContext({ cfg: params.cfg, sessionKey: params.sessionKey, diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts index 48f7da84d15..2fb2f2f6ed6 100644 --- a/src/logging/diagnostic.ts +++ b/src/logging/diagnostic.ts @@ -25,6 +25,14 @@ let lastActivityAt = 0; const DEFAULT_STUCK_SESSION_WARN_MS = 120_000; const MIN_STUCK_SESSION_WARN_MS = 1_000; const MAX_STUCK_SESSION_WARN_MS = 24 * 60 * 60 * 1000; +let commandPollBackoffRuntimePromise: Promise< + typeof import("../agents/command-poll-backoff.runtime.js") +> | null = null; + +function loadCommandPollBackoffRuntime() { + commandPollBackoffRuntimePromise ??= import("../agents/command-poll-backoff.runtime.js"); + return commandPollBackoffRuntimePromise; +} function markActivity() { lastActivityAt = Date.now(); @@ -376,7 +384,7 @@ export function startDiagnosticHeartbeat(config?: OpenClawConfig) { queued: totalQueued, }); - import("../agents/command-poll-backoff.js") + void loadCommandPollBackoffRuntime() .then(({ pruneStaleCommandPolls }) => { for (const [, state] of diagnosticSessionStates) { pruneStaleCommandPolls(state); diff --git a/src/media-understanding/echo-transcript.ts b/src/media-understanding/echo-transcript.ts index 88764066963..d9a7edf4cc6 100644 --- a/src/media-understanding/echo-transcript.ts +++ b/src/media-understanding/echo-transcript.ts @@ -3,6 +3,14 @@ import type { OpenClawConfig } from "../config/config.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { isDeliverableMessageChannel } from "../utils/message-channel.js"; +let deliverRuntimePromise: Promise | null = + null; + +function loadDeliverRuntime() { + deliverRuntimePromise ??= import("../infra/outbound/deliver-runtime.js"); + return deliverRuntimePromise; +} + export const DEFAULT_ECHO_TRANSCRIPT_FORMAT = '📝 "{transcript}"'; function formatEchoTranscript(transcript: string, format: string): string { @@ -43,7 +51,7 @@ export async function sendTranscriptEcho(params: { const text = formatEchoTranscript(transcript, params.format ?? DEFAULT_ECHO_TRANSCRIPT_FORMAT); try { - const { deliverOutboundPayloads } = await import("../infra/outbound/deliver.js"); + const { deliverOutboundPayloads } = await loadDeliverRuntime(); await deliverOutboundPayloads({ cfg, channel: normalizedChannel, diff --git a/src/media-understanding/providers/image-runtime.ts b/src/media-understanding/providers/image-runtime.ts new file mode 100644 index 00000000000..051072d809e --- /dev/null +++ b/src/media-understanding/providers/image-runtime.ts @@ -0,0 +1 @@ +export { describeImageWithModel } from "./image.js"; diff --git a/src/memory/manager-runtime.ts b/src/memory/manager-runtime.ts new file mode 100644 index 00000000000..b46b3708a6e --- /dev/null +++ b/src/memory/manager-runtime.ts @@ -0,0 +1 @@ +export { MemoryIndexManager } from "./manager.js"; diff --git a/src/memory/search-manager.ts b/src/memory/search-manager.ts index 64c48078aa2..f4e351fdc1a 100644 --- a/src/memory/search-manager.ts +++ b/src/memory/search-manager.ts @@ -10,6 +10,12 @@ import type { const log = createSubsystemLogger("memory"); const QMD_MANAGER_CACHE = new Map(); +let managerRuntimePromise: Promise | null = null; + +function loadManagerRuntime() { + managerRuntimePromise ??= import("./manager-runtime.js"); + return managerRuntimePromise; +} export type MemorySearchManagerResult = { manager: MemorySearchManager | null; @@ -48,7 +54,7 @@ export async function getMemorySearchManager(params: { { primary, fallbackFactory: async () => { - const { MemoryIndexManager } = await import("./manager.js"); + const { MemoryIndexManager } = await loadManagerRuntime(); return await MemoryIndexManager.get(params); }, }, @@ -70,7 +76,7 @@ export async function getMemorySearchManager(params: { } try { - const { MemoryIndexManager } = await import("./manager.js"); + const { MemoryIndexManager } = await loadManagerRuntime(); const manager = await MemoryIndexManager.get(params); return { manager }; } catch (err) { diff --git a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts new file mode 100644 index 00000000000..eb38f5eda69 --- /dev/null +++ b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts @@ -0,0 +1 @@ +export { loginWeb } from "../../web/login.js"; diff --git a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts new file mode 100644 index 00000000000..e6be144c081 --- /dev/null +++ b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts @@ -0,0 +1 @@ +export { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 976c83b2871..cf7daa6daa9 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -55,21 +55,22 @@ const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhat return handleWhatsAppAction(...args); }; -let webOutboundPromise: Promise | null = null; -let webLoginPromise: Promise | null = null; let webLoginQrPromise: Promise | null = null; let webChannelPromise: Promise | null = null; +let webOutboundPromise: Promise | null = + null; +let webLoginPromise: Promise | null = null; let whatsappActionsPromise: Promise< typeof import("../../agents/tools/whatsapp-actions.js") > | null = null; function loadWebOutbound() { - webOutboundPromise ??= import("../../web/outbound.js"); + webOutboundPromise ??= import("./runtime-whatsapp-outbound.runtime.js"); return webOutboundPromise; } function loadWebLogin() { - webLoginPromise ??= import("../../web/login.js"); + webLoginPromise ??= import("./runtime-whatsapp-login.runtime.js"); return webLoginPromise; } diff --git a/src/slack/monitor/slash-commands.runtime.ts b/src/slack/monitor/slash-commands.runtime.ts new file mode 100644 index 00000000000..c6225a9d7e5 --- /dev/null +++ b/src/slack/monitor/slash-commands.runtime.ts @@ -0,0 +1,7 @@ +export { + buildCommandTextFromArgs, + findCommandByNativeName, + listNativeCommandSpecsForConfig, + parseCommandArgs, + resolveCommandArgMenu, +} from "../../auto-reply/commands-registry.js"; diff --git a/src/slack/monitor/slash-dispatch.runtime.ts b/src/slack/monitor/slash-dispatch.runtime.ts new file mode 100644 index 00000000000..4c4832cff3b --- /dev/null +++ b/src/slack/monitor/slash-dispatch.runtime.ts @@ -0,0 +1,9 @@ +export { resolveChunkMode } from "../../auto-reply/chunk.js"; +export { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; +export { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; +export { resolveConversationLabel } from "../../channels/conversation-label.js"; +export { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; +export { recordInboundSessionMetaSafe } from "../../channels/session-meta.js"; +export { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; +export { resolveAgentRoute } from "../../routing/resolve-route.js"; +export { deliverSlackSlashReplies } from "./replies.js"; diff --git a/src/slack/monitor/slash-skill-commands.runtime.ts b/src/slack/monitor/slash-skill-commands.runtime.ts new file mode 100644 index 00000000000..4d49d66190b --- /dev/null +++ b/src/slack/monitor/slash-skill-commands.runtime.ts @@ -0,0 +1 @@ +export { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 596ca83ba93..a8df6900153 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -1,5 +1,8 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt"; -import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js"; +import { + type ChatCommandDefinition, + type CommandArgs, +} from "../../auto-reply/commands-registry.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; @@ -32,6 +35,28 @@ const SLACK_COMMAND_ARG_OVERFLOW_MAX = 5; const SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX = 100; const SLACK_COMMAND_ARG_SELECT_OPTION_VALUE_MAX = 75; const SLACK_HEADER_TEXT_MAX = 150; +let slashCommandsRuntimePromise: Promise | null = + null; +let slashDispatchRuntimePromise: Promise | null = + null; +let slashSkillCommandsRuntimePromise: Promise< + typeof import("./slash-skill-commands.runtime.js") +> | null = null; + +function loadSlashCommandsRuntime() { + slashCommandsRuntimePromise ??= import("./slash-commands.runtime.js"); + return slashCommandsRuntimePromise; +} + +function loadSlashDispatchRuntime() { + slashDispatchRuntimePromise ??= import("./slash-dispatch.runtime.js"); + return slashDispatchRuntimePromise; +} + +function loadSlashSkillCommandsRuntime() { + slashSkillCommandsRuntimePromise ??= import("./slash-skill-commands.runtime.js"); + return slashSkillCommandsRuntimePromise; +} type EncodedMenuChoice = SlackExternalArgMenuChoice; const slackExternalArgMenuStore = createSlackExternalArgMenuStore(); @@ -75,15 +100,6 @@ function readSlackExternalArgMenuToken(raw: unknown): string | undefined { return slackExternalArgMenuStore.readToken(raw); } -type CommandsRegistry = typeof import("../../auto-reply/commands-registry.js"); -let commandsRegistry: CommandsRegistry | undefined; -async function getCommandsRegistry(): Promise { - if (!commandsRegistry) { - commandsRegistry = await import("../../auto-reply/commands-registry.js"); - } - return commandsRegistry; -} - function encodeSlackCommandArgValue(parts: { command: string; arg: string; @@ -470,8 +486,8 @@ export async function registerSlackMonitorSlashCommands(params: { } if (commandDefinition && supportsInteractiveArgMenus) { - const reg = await getCommandsRegistry(); - const menu = reg.resolveCommandArgMenu({ + const { resolveCommandArgMenu } = await loadSlashCommandsRuntime(); + const menu = resolveCommandArgMenu({ command: commandDefinition, args: commandArgs, cfg, @@ -501,21 +517,17 @@ export async function registerSlackMonitorSlashCommands(params: { const channelName = channelInfo?.name; const roomLabel = channelName ? `#${channelName}` : `#${command.channel_id}`; - const [{ resolveAgentRoute }, { finalizeInboundContext }, { dispatchReplyWithDispatcher }] = - await Promise.all([ - import("../../routing/resolve-route.js"), - import("../../auto-reply/reply/inbound-context.js"), - import("../../auto-reply/reply/provider-dispatcher.js"), - ]); - const [ - { resolveConversationLabel }, - { createReplyPrefixOptions }, - { recordInboundSessionMetaSafe }, - ] = await Promise.all([ - import("../../channels/conversation-label.js"), - import("../../channels/reply-prefix.js"), - import("../../channels/session-meta.js"), - ]); + const { + createReplyPrefixOptions, + deliverSlackSlashReplies, + dispatchReplyWithDispatcher, + finalizeInboundContext, + recordInboundSessionMetaSafe, + resolveAgentRoute, + resolveChunkMode, + resolveConversationLabel, + resolveMarkdownTableMode, + } = await loadSlashDispatchRuntime(); const route = resolveAgentRoute({ cfg, @@ -595,12 +607,6 @@ export async function registerSlackMonitorSlashCommands(params: { }); const deliverSlashPayloads = async (replies: ReplyPayload[]) => { - const [{ deliverSlackSlashReplies }, { resolveChunkMode }, { resolveMarkdownTableMode }] = - await Promise.all([ - import("./replies.js"), - import("../../auto-reply/chunk.js"), - import("../../config/markdown-tables.js"), - ]); await deliverSlackSlashReplies({ replies, respond, @@ -653,34 +659,39 @@ export async function registerSlackMonitorSlashCommands(params: { globalSetting: cfg.commands?.nativeSkills, }); - let reg: CommandsRegistry | undefined; let nativeCommands: Array<{ name: string }> = []; + let slashCommandsRuntime: typeof import("./slash-commands.runtime.js") | null = null; if (nativeEnabled) { - reg = await getCommandsRegistry(); + slashCommandsRuntime = await loadSlashCommandsRuntime(); const skillCommands = nativeSkillsEnabled - ? (await import("../../auto-reply/skill-commands.js")).listSkillCommandsForAgents({ cfg }) + ? (await loadSlashSkillCommandsRuntime()).listSkillCommandsForAgents({ cfg }) : []; - nativeCommands = reg.listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "slack" }); + nativeCommands = slashCommandsRuntime.listNativeCommandSpecsForConfig(cfg, { + skillCommands, + provider: "slack", + }); } if (nativeCommands.length > 0) { - const registry = reg; - if (!registry) { - throw new Error("Missing commands registry for native Slack commands."); + if (!slashCommandsRuntime) { + throw new Error("Missing commands runtime for native Slack commands."); } for (const command of nativeCommands) { ctx.app.command( `/${command.name}`, async ({ command: cmd, ack, respond, body }: SlackCommandMiddlewareArgs) => { - const commandDefinition = registry.findCommandByNativeName(command.name, "slack"); + const commandDefinition = slashCommandsRuntime.findCommandByNativeName( + command.name, + "slack", + ); const rawText = cmd.text?.trim() ?? ""; const commandArgs = commandDefinition - ? registry.parseCommandArgs(commandDefinition, rawText) + ? slashCommandsRuntime.parseCommandArgs(commandDefinition, rawText) : rawText ? ({ raw: rawText } satisfies CommandArgs) : undefined; const prompt = commandDefinition - ? registry.buildCommandTextFromArgs(commandDefinition, commandArgs) + ? slashCommandsRuntime.buildCommandTextFromArgs(commandDefinition, commandArgs) : rawText ? `/${command.name} ${rawText}` : `/${command.name}`; @@ -824,13 +835,14 @@ export async function registerSlackMonitorSlashCommands(params: { }); return; } - const reg = await getCommandsRegistry(); - const commandDefinition = reg.findCommandByNativeName(parsed.command, "slack"); + const { buildCommandTextFromArgs, findCommandByNativeName } = + await loadSlashCommandsRuntime(); + const commandDefinition = findCommandByNativeName(parsed.command, "slack"); const commandArgs: CommandArgs = { values: { [parsed.arg]: parsed.value }, }; const prompt = commandDefinition - ? reg.buildCommandTextFromArgs(commandDefinition, commandArgs) + ? buildCommandTextFromArgs(commandDefinition, commandArgs) : `/${parsed.command} ${parsed.value}`; const user = body.user; const userName = diff --git a/src/telegram/audit-membership-runtime.ts b/src/telegram/audit-membership-runtime.ts new file mode 100644 index 00000000000..4f2c5a43710 --- /dev/null +++ b/src/telegram/audit-membership-runtime.ts @@ -0,0 +1,74 @@ +import { isRecord } from "../utils.js"; +import { fetchWithTimeout } from "../utils/fetch-timeout.js"; +import type { + AuditTelegramGroupMembershipParams, + TelegramGroupMembershipAudit, + TelegramGroupMembershipAuditEntry, +} from "./audit.js"; +import { makeProxyFetch } from "./proxy.js"; + +const TELEGRAM_API_BASE = "https://api.telegram.org"; + +type TelegramApiOk = { ok: true; result: T }; +type TelegramApiErr = { ok: false; description?: string }; +type TelegramGroupMembershipAuditData = Omit; + +export async function auditTelegramGroupMembershipImpl( + params: AuditTelegramGroupMembershipParams, +): Promise { + const fetcher = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : fetch; + const base = `${TELEGRAM_API_BASE}/bot${params.token}`; + const groups: TelegramGroupMembershipAuditEntry[] = []; + + for (const chatId of params.groupIds) { + try { + const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`; + const res = await fetchWithTimeout(url, {}, params.timeoutMs, fetcher); + const json = (await res.json()) as TelegramApiOk<{ status?: string }> | TelegramApiErr; + if (!res.ok || !isRecord(json) || !json.ok) { + const desc = + isRecord(json) && !json.ok && typeof json.description === "string" + ? json.description + : `getChatMember failed (${res.status})`; + groups.push({ + chatId, + ok: false, + status: null, + error: desc, + matchKey: chatId, + matchSource: "id", + }); + continue; + } + const status = isRecord((json as TelegramApiOk).result) + ? ((json as TelegramApiOk<{ status?: string }>).result.status ?? null) + : null; + const ok = status === "creator" || status === "administrator" || status === "member"; + groups.push({ + chatId, + ok, + status, + error: ok ? null : "bot not in group", + matchKey: chatId, + matchSource: "id", + }); + } catch (err) { + groups.push({ + chatId, + ok: false, + status: null, + error: err instanceof Error ? err.message : String(err), + matchKey: chatId, + matchSource: "id", + }); + } + } + + return { + ok: groups.every((g) => g.ok), + checkedGroups: groups.length, + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + groups, + }; +} diff --git a/src/telegram/audit.ts b/src/telegram/audit.ts index b86953fa1b1..24e5f58957a 100644 --- a/src/telegram/audit.ts +++ b/src/telegram/audit.ts @@ -1,7 +1,4 @@ import type { TelegramGroupConfig } from "../config/types.js"; -import { isRecord } from "../utils.js"; - -const TELEGRAM_API_BASE = "https://api.telegram.org"; export type TelegramGroupMembershipAuditEntry = { chatId: string; @@ -21,9 +18,6 @@ export type TelegramGroupMembershipAudit = { elapsedMs: number; }; -type TelegramApiOk = { ok: true; result: T }; -type TelegramApiErr = { ok: false; description?: string }; - export function collectTelegramUnmentionedGroupIds( groups: Record | undefined, ) { @@ -65,13 +59,25 @@ export function collectTelegramUnmentionedGroupIds( return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups }; } -export async function auditTelegramGroupMembership(params: { +export type AuditTelegramGroupMembershipParams = { token: string; botId: number; groupIds: string[]; proxyUrl?: string; timeoutMs: number; -}): Promise { +}; + +let auditMembershipRuntimePromise: Promise | null = + null; + +function loadAuditMembershipRuntime() { + auditMembershipRuntimePromise ??= import("./audit-membership-runtime.js"); + return auditMembershipRuntimePromise; +} + +export async function auditTelegramGroupMembership( + params: AuditTelegramGroupMembershipParams, +): Promise { const started = Date.now(); const token = params.token?.trim() ?? ""; if (!token || params.groupIds.length === 0) { @@ -87,63 +93,13 @@ export async function auditTelegramGroupMembership(params: { // Lazy import to avoid pulling `undici` (ProxyAgent) into cold-path callers that only need // `collectTelegramUnmentionedGroupIds` (e.g. config audits). - const fetcher = params.proxyUrl - ? (await import("./proxy.js")).makeProxyFetch(params.proxyUrl) - : fetch; - const { fetchWithTimeout } = await import("../utils/fetch-timeout.js"); - const base = `${TELEGRAM_API_BASE}/bot${token}`; - const groups: TelegramGroupMembershipAuditEntry[] = []; - - for (const chatId of params.groupIds) { - try { - const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`; - const res = await fetchWithTimeout(url, {}, params.timeoutMs, fetcher); - const json = (await res.json()) as TelegramApiOk<{ status?: string }> | TelegramApiErr; - if (!res.ok || !isRecord(json) || !json.ok) { - const desc = - isRecord(json) && !json.ok && typeof json.description === "string" - ? json.description - : `getChatMember failed (${res.status})`; - groups.push({ - chatId, - ok: false, - status: null, - error: desc, - matchKey: chatId, - matchSource: "id", - }); - continue; - } - const status = isRecord((json as TelegramApiOk).result) - ? ((json as TelegramApiOk<{ status?: string }>).result.status ?? null) - : null; - const ok = status === "creator" || status === "administrator" || status === "member"; - groups.push({ - chatId, - ok, - status, - error: ok ? null : "bot not in group", - matchKey: chatId, - matchSource: "id", - }); - } catch (err) { - groups.push({ - chatId, - ok: false, - status: null, - error: err instanceof Error ? err.message : String(err), - matchKey: chatId, - matchSource: "id", - }); - } - } - + const { auditTelegramGroupMembershipImpl } = await loadAuditMembershipRuntime(); + const result = await auditTelegramGroupMembershipImpl({ + ...params, + token, + }); return { - ok: groups.every((g) => g.ok), - checkedGroups: groups.length, - unresolvedGroups: 0, - hasWildcardUnmentionedGroups: false, - groups, + ...result, elapsedMs: Date.now() - started, }; } diff --git a/src/telegram/sticker-cache.ts b/src/telegram/sticker-cache.ts index 4fea08b3836..26fb33ee538 100644 --- a/src/telegram/sticker-cache.ts +++ b/src/telegram/sticker-cache.ts @@ -143,6 +143,14 @@ export function getCacheStats(): { count: number; oldestAt?: string; newestAt?: const STICKER_DESCRIPTION_PROMPT = "Describe this sticker image in 1-2 sentences. Focus on what the sticker depicts (character, object, action, emotion). Be concise and objective."; const VISION_PROVIDERS = ["openai", "anthropic", "google", "minimax"] as const; +let imageRuntimePromise: Promise< + typeof import("../media-understanding/providers/image-runtime.js") +> | null = null; + +function loadImageRuntime() { + imageRuntimePromise ??= import("../media-understanding/providers/image-runtime.js"); + return imageRuntimePromise; +} export interface DescribeStickerParams { imagePath: string; @@ -242,8 +250,8 @@ export async function describeStickerImage(params: DescribeStickerParams): Promi try { const buffer = await fs.readFile(imagePath); - // Dynamic import to avoid circular dependency - const { describeImageWithModel } = await import("../media-understanding/providers/image.js"); + // Lazy import to avoid circular dependency + const { describeImageWithModel } = await loadImageRuntime(); const result = await describeImageWithModel({ buffer, fileName: "sticker.webp",