diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 9a81775995b3..27753e029aa0 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -247,8 +247,6 @@ extensions/slack/src/send.ts extensions/telegram/src/action-runtime.test.ts extensions/telegram/src/action-runtime.ts extensions/telegram/src/bot-message-context.session.ts -extensions/telegram/src/bot-native-commands.session-meta.test.ts -extensions/telegram/src/bot-native-commands.ts extensions/telegram/src/bot.create-telegram-bot.test.ts extensions/telegram/src/bot.test.ts extensions/telegram/src/bot/delivery.replies.ts diff --git a/extensions/telegram/src/bot-handlers.callback-router.ts b/extensions/telegram/src/bot-handlers.callback-router.ts index cc40fc2303cf..f26c2614bb55 100644 --- a/extensions/telegram/src/bot-handlers.callback-router.ts +++ b/extensions/telegram/src/bot-handlers.callback-router.ts @@ -38,7 +38,6 @@ import type { RegisterTelegramHandlerParams, TelegramCallbackRouter, } from "./bot-handlers.types.js"; -import { parseTelegramNativeCommandCallbackData } from "./bot-native-commands.js"; import { isTelegramSpooledReplayUpdate, recordTelegramMessageProcessingResult, @@ -63,6 +62,7 @@ import { } from "./model-buttons.js"; import { hasTelegramOpaqueCallbackPrefix, + parseTelegramNativeCommandCallbackData, parseTelegramOpaqueCallbackData, } from "./native-command-callback-data.js"; import { isTelegramMessageNotModifiedError } from "./network-errors.js"; diff --git a/extensions/telegram/src/bot-handlers.message-context.ts b/extensions/telegram/src/bot-handlers.message-context.ts index 88873980d04b..da0b7b5267d9 100644 --- a/extensions/telegram/src/bot-handlers.message-context.ts +++ b/extensions/telegram/src/bot-handlers.message-context.ts @@ -4,7 +4,6 @@ import { formatMediaPlaceholderText } from "openclaw/plugin-sdk/channel-inbound" import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history"; -import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { getSessionEntry, readAmbientTranscriptWatermark, @@ -25,13 +24,12 @@ import { getTelegramTextParts, resolveTelegramPrimaryMedia, resolveTelegramForumThreadId, - shouldUseTelegramDmThreadSession, type TelegramThreadSpec, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { - resolveTelegramConversationBaseSessionKey, resolveTelegramConversationRoute, + resolveTelegramTargetSession, } from "./conversation-route.js"; import { resolveTelegramDmHistoryLimit } from "./dm-history.js"; import { @@ -212,24 +210,15 @@ export function createTelegramMessageSessionRuntime({ senderId: params.senderId, topicAgentId: topicConfig?.agentId, }); - const baseSessionKey = resolveTelegramConversationBaseSessionKey({ + const sessionKey = resolveTelegramTargetSession({ cfg: params.runtimeCfg, route, chatId: params.chatId, isGroup: params.isGroup, senderId: params.senderId, + dmThreadId, + botHasTopicsEnabled: params.botHasTopicsEnabled, }); - const threadKeys = - shouldUseTelegramDmThreadSession({ - dmThreadId, - botHasTopicsEnabled: params.botHasTopicsEnabled, - }) && dmThreadId != null - ? resolveThreadSessionKeys({ - baseSessionKey, - threadId: `${params.chatId}:${dmThreadId}`, - }) - : null; - const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const storePath = telegramDeps.resolveStorePath(params.runtimeCfg.session?.store, { agentId: route.agentId, }); diff --git a/extensions/telegram/src/bot-native-command-builtins.test.ts b/extensions/telegram/src/bot-native-command-builtins.test.ts new file mode 100644 index 000000000000..ff59db837da4 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-builtins.test.ts @@ -0,0 +1,457 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + executorTestMocks, + expectRecordFields, + expectSendMessageCall, + registerAndResolveCommandHandler, + resetSessionMetaMocks, +} from "./bot-native-command-executors.test-support.js"; +import { createTelegramPrivateCommandContext } from "./bot-native-commands.fixture-test-support.js"; + +const { agentRuntimeMocks, commandAuthMocks, replyMocks, sessionMocks } = executorTestMocks; + +describe("Telegram native command built-ins", () => { + beforeEach(resetSessionMetaMocks); + + it("uses the target session model when building native argument menus", async () => { + const cfg = { + agents: { + defaults: { + thinkingDefault: "low", + models: { + "anthropic/claude-opus-4-7": { + params: { thinking: "xhigh" }, + }, + }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "user", + thinkingLevel: "high", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "anthropic", + )?.[0]; + expectRecordFields( + menuCall, + { provider: "anthropic", model: "claude-opus-4-7" }, + "thinking menu call", + ); + expect(sessionMocks.getSessionEntry).toHaveBeenCalledWith({ + storePath: "/tmp/openclaw-sessions.json", + sessionKey: "agent:main:main", + }); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: high.\nChoose level for /think.", + requireReplyMarkup: true, + label: "thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it.each([ + { sessionRuntime: undefined, expectedRuntime: "codex" }, + { sessionRuntime: "openclaw", expectedRuntime: "openclaw" }, + ])( + "uses the effective $expectedRuntime runtime for native /think menus", + async ({ sessionRuntime, expectedRuntime }) => { + const cfg = { + agents: { + defaults: { + models: { + "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } }, + }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "openai", + modelOverride: "gpt-5.6-luna", + modelOverrideSource: "user", + ...(sessionRuntime ? { agentRuntimeOverride: sessionRuntime } : {}), + updatedAt: 0, + }, + }); + + const { handler } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.model === "gpt-5.6-luna", + )?.[0]; + expectRecordFields( + menuCall, + { + provider: "openai", + model: "gpt-5.6-luna", + agentRuntime: expectedRuntime, + }, + "runtime-aware thinking menu call", + ); + }, + ); + + it("resolves /think menu choices against the runtime catalog for live-discovered models", async () => { + const cfg = { + agents: { defaults: { models: { "ollama/*": {} } } }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "ollama", + modelOverride: "glm-5.2:cloud", + modelOverrideSource: "user", + updatedAt: 0, + }, + }); + const runtimeCatalog = [ + { provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true }, + ]; + agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog); + + const { handler } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "ollama", + )?.[0]; + const menuRecord = expectRecordFields( + menuCall, + { provider: "ollama", model: "glm-5.2:cloud" }, + "ollama thinking menu call", + ); + expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled(); + expect(menuRecord.catalog).toEqual(runtimeCatalog); + }); + + it("loads the runtime catalog for /think when no session model override is set", async () => { + const cfg = { + agents: { defaults: { model: "ollama/glm-5.2:cloud", models: { "ollama/*": {} } } }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({}); + const runtimeCatalog = [ + { provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true }, + ]; + agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog); + + const { handler } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled(); + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think", + )?.[0]; + const menuRecord = expectRecordFields(menuCall, {}, "default-model thinking menu call"); + expect(menuRecord.provider).toBeUndefined(); + expect(menuRecord.catalog).toEqual(runtimeCatalog); + }); + + it("inherits the parent session model when building DM thread native argument menus", async () => { + const cfg: OpenClawConfig = {}; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "user", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext({ threadId: 77 })); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "anthropic", + )?.[0]; + expectRecordFields( + menuCall, + { provider: "anthropic", model: "claude-opus-4-7" }, + "thread thinking menu call", + ); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Choose level for /think.", + requireReplyMarkup: true, + label: "thread thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses the configured default model instead of temporary auto fallback overrides", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + thinkingDefault: "medium", + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "auto", + modelProvider: "anthropic", + model: "claude-opus-4-7", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "think" && params.provider === "openai", + )?.[0]; + expectRecordFields( + menuCall, + { provider: "openai", model: "gpt-5.5" }, + "default model thinking menu call", + ); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: medium.\nChoose level for /think.", + requireReplyMarkup: true, + label: "default model thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses configured model defaults instead of runtime auth metadata for the fast menu", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { + params: { fastMode: "auto", fastAutoOnSeconds: 30 }, + }, + }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + modelProvider: "openai-codex", + model: "gpt-5.5", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "fast", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( + ([params]) => params.command.key === "fast", + )?.[0]; + expectRecordFields(menuCall, { cfg }, "fast menu call"); + expect( + commandAuthMocks.resolveCommandArgMenu.mock.calls.some( + ([params]) => + params.command.key === "fast" && + params.provider === "openai" && + params.model === "gpt-5.5", + ), + ).toBe(true); + const options = expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: + "Current fast mode: auto (30 sec) (default: model).\nOptions: on, off, auto (30 sec), default, status.", + requireReplyMarkup: true, + label: "fast menu", + }); + const replyMarkup = options.reply_markup as + | { inline_keyboard?: Array> } + | undefined; + const labels = (replyMarkup?.inline_keyboard ?? []).flatMap((row) => + row.map((button) => button.text), + ); + expect(labels).toContain("auto (30 sec)"); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses the read-only catalog for Claude CLI thinking menus", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-8" }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({}); + agentRuntimeMocks.loadModelCatalog.mockImplementation(async (params) => { + if (!params?.readOnly) { + throw new Error("native /think must not start full model discovery"); + } + return [ + { + provider: "anthropic", + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + reasoning: true, + }, + ]; + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + agentDir: expect.any(String), + readOnly: true, + }), + ); + expect(agentRuntimeMocks.loadModelCatalog.mock.calls[0]?.[0]).not.toHaveProperty( + "workspaceDir", + ); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: off.\nChoose level for /think.", + requireReplyMarkup: true, + label: "Claude CLI thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses target model thinking defaults before global thinking defaults", async () => { + const cfg = { + agents: { + defaults: { + thinkingDefault: "low", + models: { + "anthropic/claude-opus-4-7": { + params: { thinking: "xhigh" }, + }, + }, + }, + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({ + "agent:main:main": { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelOverrideSource: "user", + updatedAt: 0, + }, + }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: xhigh.\nChoose level for /think.", + requireReplyMarkup: true, + label: "target model thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("uses per-agent thinking defaults before target model and global thinking defaults", async () => { + const cfg = { + agents: { + defaults: { + thinkingDefault: "low", + models: { + "anthropic/claude-opus-4-7": { + params: { thinking: "xhigh" }, + }, + }, + }, + list: [ + { + id: "alpha", + model: { primary: "anthropic/claude-opus-4-7" }, + thinkingDefault: "minimal", + }, + ], + }, + } as OpenClawConfig; + sessionMocks.sessionStoreEntries.mockReturnValue({}); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: minimal.\nChoose level for /think.", + requireReplyMarkup: true, + label: "agent thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("does not load the session store when a native argument menu is skipped", async () => { + const { handler } = registerAndResolveCommandHandler({ + commandName: "think", + cfg: {}, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext({ match: "high" })); + + expect(sessionMocks.sessionStoreEntries).not.toHaveBeenCalled(); + expect(agentRuntimeMocks.loadModelCatalog).not.toHaveBeenCalled(); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/telegram/src/bot-native-command-builtins.ts b/extensions/telegram/src/bot-native-command-builtins.ts new file mode 100644 index 000000000000..b59c9321b4b9 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-builtins.ts @@ -0,0 +1,375 @@ +// Telegram plugin module implements built-in native command behavior. +import { + loadPreparedModelCatalog, + resolveAgentConfig, + resolveAgentDir, + resolveDefaultModelForAgent, + resolveThinkingDefaultWithRuntimeCatalog, +} from "openclaw/plugin-sdk/agent-runtime"; +import { + buildCommandTextFromArgs, + findCommandByNativeName, + formatCommandArgMenuTitle, + formatFastModeCurrentStatus, + parseCommandArgs, + resolveCommandArgMenu, + resolveEffectiveAgentRuntime, + resolveFastModeState, + resolveStoredModelOverride, + type CommandArgs, +} from "openclaw/plugin-sdk/command-auth-native"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { + getSessionEntry, + resolveStorePath, + type SessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { + dispatchTelegramBuiltinTurn, + prepareTelegramCommandDispatch, + type TelegramCommandExecutorParams, +} from "./bot-native-command-dispatch.js"; +import { buildInlineKeyboard } from "./inline-keyboard.js"; +import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; + +const loadTelegramLoginCommandExecutor = createLazyRuntimeModule( + () => import("./bot-native-command-login.js"), +); + +type TelegramCommandMenuModelContext = { + provider?: string; + model?: string; + agentRuntime?: string; + thinkingLevel?: string; + fastMode?: SessionEntry["fastMode"]; +}; + +function buildTelegramCommandMenuModelContext(params: { + provider: string; + model: string; + thinkingLevel?: string; + fastMode?: SessionEntry["fastMode"]; +}): TelegramCommandMenuModelContext { + return { + provider: params.provider, + model: params.model, + ...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}), + ...(params.fastMode !== undefined ? { fastMode: params.fastMode } : {}), + }; +} + +function resolveTelegramCommandMenuModelContext(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}): TelegramCommandMenuModelContext { + if (!params.sessionKey.trim()) { + return {}; + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); + const thinkingLevel = normalizeOptionalString(entry?.thinkingLevel); + const fastMode = entry?.fastMode; + let context: TelegramCommandMenuModelContext; + if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { + context = buildTelegramCommandMenuModelContext({ + provider: defaultModel.provider, + model: defaultModel.model, + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }); + } else { + const override = resolveStoredModelOverride({ + sessionEntry: entry, + loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), + sessionKey: params.sessionKey, + defaultProvider: defaultModel.provider, + }); + if (override?.model) { + context = buildTelegramCommandMenuModelContext({ + provider: override.provider || defaultModel.provider, + model: override.model, + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }); + } else { + const provider = + normalizeOptionalString(entry?.providerOverride) ?? + normalizeOptionalString(entry?.modelProvider); + const model = + normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model); + context = { + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }; + } + } + return { + ...context, + agentRuntime: resolveEffectiveAgentRuntime({ + cfg: params.cfg, + provider: context.provider ?? defaultModel.provider, + modelId: context.model ?? defaultModel.model, + agentId: params.agentId, + sessionKey: params.sessionKey, + sessionEntry: entry, + }), + }; + } catch { + return {}; + } +} + +function resolveTelegramFastCommandModelContext(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}): { provider?: string; model?: string } { + const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); + const fallback = () => ({ provider: defaultModel.provider, model: defaultModel.model }); + if (!params.sessionKey.trim()) { + return fallback(); + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); + if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { + return fallback(); + } + const override = resolveStoredModelOverride({ + sessionEntry: entry, + loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), + sessionKey: params.sessionKey, + defaultProvider: defaultModel.provider, + }); + return { + provider: override?.provider ?? defaultModel.provider, + model: override?.model ?? defaultModel.model, + }; + } catch { + return fallback(); + } +} + +function resolveTelegramFastCommandState(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}) { + const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); + const fallback = () => + resolveFastModeState({ + cfg: params.cfg, + provider: defaultModel.provider, + model: defaultModel.model, + agentId: params.agentId, + }); + if (!params.sessionKey.trim()) { + return fallback(); + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); + const modelContext = resolveTelegramFastCommandModelContext(params); + return resolveFastModeState({ + cfg: params.cfg, + provider: modelContext.provider ?? defaultModel.provider, + model: modelContext.model ?? defaultModel.model, + agentId: params.agentId, + sessionEntry: + entry?.fastMode !== undefined + ? { + fastMode: entry.fastMode, + } + : undefined, + }); + } catch { + return fallback(); + } +} + +async function resolveTelegramThinkMenuCurrentLevel(params: { + cfg: OpenClawConfig; + agentId: string; + provider?: string; + model?: string; + agentRuntime?: string; + thinkingLevel?: string; + catalog: Awaited>; +}): Promise { + const explicit = normalizeOptionalString(params.thinkingLevel); + if (explicit) { + return explicit; + } + const agentThinkingDefault = normalizeOptionalString( + resolveAgentConfig(params.cfg, params.agentId)?.thinkingDefault, + ); + if (agentThinkingDefault) { + return agentThinkingDefault; + } + const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); + return await resolveThinkingDefaultWithRuntimeCatalog({ + cfg: params.cfg, + provider: params.provider ?? defaultModel.provider, + model: params.model ?? defaultModel.model, + agentRuntime: params.agentRuntime, + loadRuntimeCatalog: async () => params.catalog, + }); +} + +function formatTelegramCommandArgMenuTitle(params: { + command: NonNullable>; + menu: NonNullable>; + currentThinkingLevel?: string; + currentFastModeStatus?: string; +}): string { + const title = formatCommandArgMenuTitle({ command: params.command, menu: params.menu }); + if (params.command.key === "think" && params.currentThinkingLevel) { + return `Current thinking level: ${params.currentThinkingLevel}.\n${title}`; + } + if (params.command.key === "fast" && params.currentFastModeStatus) { + const options = params.menu.choices + .map((choice) => choice.label.trim()) + .filter(Boolean) + .join(", "); + return options + ? `${params.currentFastModeStatus}\nOptions: ${options}.` + : params.currentFastModeStatus; + } + return title; +} + +export async function executeTelegramBuiltinCommand( + params: TelegramCommandExecutorParams & { commandName: string }, +): Promise { + const dispatch = await prepareTelegramCommandDispatch({ ...params, requireAuth: true }); + if (!dispatch) { + return false; + } + const commandDefinition = findCommandByNativeName(params.commandName, "telegram"); + const commandArgs = commandDefinition + ? parseCommandArgs(commandDefinition, params.rawText) + : params.rawText + ? ({ raw: params.rawText } satisfies CommandArgs) + : undefined; + const prompt = commandDefinition + ? buildCommandTextFromArgs(commandDefinition, commandArgs) + : params.rawText + ? `/${params.commandName} ${params.rawText}` + : `/${params.commandName}`; + if (commandDefinition?.key === "login") { + const { executeTelegramLoginCommand } = await loadTelegramLoginCommandExecutor(); + return await executeTelegramLoginCommand({ dispatch, commandArgs }); + } + + const menuNeedsModelContext = + commandDefinition?.argsMenu && + !(commandArgs?.raw && !commandArgs.values) && + commandDefinition.args?.some( + (arg) => typeof arg.choices === "function" && commandArgs?.values?.[arg.name] == null, + ); + const sessionKeyForMenu = + commandDefinition && menuNeedsModelContext ? dispatch.targetSessionKey : ""; + const fastCommandState = + commandDefinition?.key === "fast" && menuNeedsModelContext + ? resolveTelegramFastCommandState({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: sessionKeyForMenu, + }) + : undefined; + const fastMenuModelContext = + commandDefinition?.key === "fast" && menuNeedsModelContext + ? resolveTelegramFastCommandModelContext({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: sessionKeyForMenu, + }) + : undefined; + const menuModelContext = + commandDefinition && menuNeedsModelContext + ? (fastMenuModelContext ?? + resolveTelegramCommandMenuModelContext({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: sessionKeyForMenu, + })) + : {}; + // Native /think must not wait on provider discovery; persisted rows retain its metadata. + const menuModelCatalog = + commandDefinition?.key === "think" && menuNeedsModelContext + ? await loadPreparedModelCatalog({ + config: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + agentDir: resolveAgentDir(dispatch.runtimeCfg, dispatch.route.agentId), + readOnly: true, + }) + : undefined; + const menu = commandDefinition + ? resolveCommandArgMenu({ + command: commandDefinition, + args: commandArgs, + cfg: dispatch.runtimeCfg, + ...menuModelContext, + ...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}), + }) + : null; + if (menu && commandDefinition) { + const title = formatTelegramCommandArgMenuTitle({ + command: commandDefinition, + menu, + currentThinkingLevel: + commandDefinition.key === "think" + ? await resolveTelegramThinkMenuCurrentLevel({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + ...menuModelContext, + catalog: menuModelCatalog ?? [], + }) + : undefined, + currentFastModeStatus: + commandDefinition.key === "fast" + ? formatFastModeCurrentStatus({ + ...(fastCommandState ?? + resolveTelegramFastCommandState({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: sessionKeyForMenu, + })), + }) + : undefined, + }); + const rows: Array> = []; + for (let index = 0; index < menu.choices.length; index += 2) { + rows.push( + menu.choices.slice(index, index + 2).map((choice) => ({ + text: choice.label, + callback_data: buildTelegramNativeCommandCallbackData( + buildCommandTextFromArgs(commandDefinition, { + values: { [menu.arg.name]: choice.value }, + }), + ), + })), + ); + } + const replyMarkup = buildInlineKeyboard(rows); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: dispatch.runtime, + fn: () => + dispatch.bot.api.sendMessage(dispatch.chatId, title, { + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + ...dispatch.threadParams, + }), + }); + return false; + } + return await dispatchTelegramBuiltinTurn({ dispatch, prompt, commandArgs }); +} diff --git a/extensions/telegram/src/bot-native-commands.group-auth.test.ts b/extensions/telegram/src/bot-native-command-dispatch.auth.test.ts similarity index 100% rename from extensions/telegram/src/bot-native-commands.group-auth.test.ts rename to extensions/telegram/src/bot-native-command-dispatch.auth.test.ts diff --git a/extensions/telegram/src/bot-native-command-dispatch.delivery.test.ts b/extensions/telegram/src/bot-native-command-dispatch.delivery.test.ts new file mode 100644 index 000000000000..47eebf8d0b90 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-dispatch.delivery.test.ts @@ -0,0 +1,421 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createChannelPartialDeliveryError, + createDeferred, + dispatchReplyResult, + dispatchChannelInboundTurnMock, + executorTestMocks, + firstMockArg, + registerAndResolveStatusHandler, + requireRecord, + requireValue, + resetSessionMetaMocks, +} from "./bot-native-command-executors.test-support.js"; +import type { DispatchReplyWithBufferedBlockDispatcherParams } from "./bot-native-command-executors.test-support.js"; +import { createTelegramPrivateCommandContext } from "./bot-native-commands.fixture-test-support.js"; + +type DeliverRepliesParams = Parameters[0]; + +const { deliveryMocks, replyMocks, sessionMocks } = executorTestMocks; + +describe("Telegram native command dispatch delivery", () => { + beforeEach(resetSessionMetaMocks); + + it("awaits routed session metadata persistence before command dispatch", async () => { + const deferred = createDeferred(); + sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise); + + const cfg: OpenClawConfig = {}; + const { handler } = registerAndResolveStatusHandler({ cfg }); + const runPromise = handler(createTelegramPrivateCommandContext()); + + await vi.waitFor(() => { + expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + + deferred.resolve(); + await runPromise; + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + + const dispatcherOptions = requireRecord( + requireRecord( + firstMockArg( + replyMocks.dispatchReplyWithBufferedBlockDispatcher, + "dispatchReplyWithBufferedBlockDispatcher", + ), + "dispatch reply params", + ).dispatcherOptions, + "dispatcher options", + ); + expect(dispatcherOptions.beforeDeliver).toBeTypeOf("function"); + }); + + it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver( + { + text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).", + }, + { kind: "final" }, + ); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + await handler(createTelegramPrivateCommandContext()); + + const deliveredCall = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as + | DeliverRepliesParams + | undefined; + const deliveredPayload = deliveredCall?.replies?.[0]; + if (!deliveredPayload) { + throw new Error("expected approval reply payload to be delivered"); + } + expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once"); + expect(deliveredPayload?.["channelData"]).toBeUndefined(); + }); + + it("suppresses local structured exec approval replies for native commands", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver( + { + text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", + channelData: { + execApproval: { + approvalId: "7f423fdc-1111-2222-3333-444444444444", + approvalSlug: "7f423fdc", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }, + { kind: "tool" }, + ); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("does not emit the empty fallback when reply-payload hooks cancel a native reply", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + await plan.delivery.onDelivered?.( + { text: "cancelled" }, + { kind: "final" }, + { + visibleReplySent: false, + suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, + }, + ); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("does not emit the empty fallback for a message-tool-only native reply", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("retains the native fallback when message-tool-only delivery also fails", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); + plan.delivery.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "No response generated. Please try again." }], + }), + ); + }); + + it("emits the fallback when a non-final suppression precedes a final failure", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + await plan.delivery.onDelivered?.( + { text: "cancelled tool reply" }, + { kind: "tool" }, + { + visibleReplySent: false, + suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, + }, + ); + plan.delivery.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "No response generated. Please try again." }], + }), + ); + }); + + it("emits the fallback when a suppressed block reply precedes a final failure", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + await plan.delivery.onDelivered?.( + { text: "cancelled block reply" }, + { kind: "block" }, + { + visibleReplySent: false, + suppression: { reason: "empty_after_reply_payload_sending_hook" }, + }, + ); + plan.delivery.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + }); + + it("emits the fallback when a final failure precedes a later suppressed final", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.delivery.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + await plan.delivery.onDelivered?.( + { text: "cancelled final reply" }, + { kind: "final" }, + { + visibleReplySent: false, + suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, + }, + ); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + }); + + it("preserves a suppressed final after a non-final delivery failure", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.delivery.onError?.(new Error("Telegram tool delivery failed"), { + kind: "tool", + }); + await plan.delivery.onDelivered?.( + { text: "cancelled final reply" }, + { kind: "final" }, + { + visibleReplySent: false, + suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, + }, + ); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("does not emit the fallback after a partially delivered final", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.delivery.onError?.( + createChannelPartialDeliveryError(new Error("Telegram final delivery failed"), { + visibleReplySent: true, + }), + { kind: "final" }, + ); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("retains the empty fallback for a true non-silent metadata-only native reply", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "No response generated. Please try again." }], + }), + ); + }); + + it("sends native command error replies silently when silentErrorReplies is enabled", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + silentErrorReplies: true, + }, + }, + }, + telegramCfg: { silentErrorReplies: true }, + }); + await handler(createTelegramPrivateCommandContext()); + + const deliveredCall = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as + | DeliverRepliesParams + | undefined; + const deliveryParams = requireValue(deliveredCall, "silent error delivery params"); + expect(deliveryParams.silent).toBe(true); + expect(deliveryParams.replies).toHaveLength(1); + expect(deliveryParams.replies[0]?.isError).toBe(true); + }); +}); diff --git a/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts b/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts new file mode 100644 index 000000000000..30146d6946bd --- /dev/null +++ b/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts @@ -0,0 +1,432 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createConfiguredAcpTopicBinding, + createConfiguredBindingRoute, +} from "./bot-native-command-dispatch.test-support.js"; +import { + activePluginRegistry, + dispatchChannelInboundTurnMock, + executorTestMocks, + expectRecordFields, + expectSendMessageCall, + expectUnauthorizedNewCommandBlocked, + firstMockArg, + registerAndResolveCommandHandler, + registerAndResolveStatusHandler, + requireRecord, + resetSessionMetaMocks, + runWithTelegramUpdateProcessingFrame, +} from "./bot-native-command-executors.test-support.js"; +import { + createTelegramGroupCommandContext, + createTelegramPrivateCommandContext, + createTelegramTopicCommandContext, +} from "./bot-native-commands.fixture-test-support.js"; + +const { persistentBindingMocks, replyMocks, sessionBindingMocks, sessionMocks } = executorTestMocks; + +describe("Telegram native command dispatch routing", () => { + beforeEach(resetSessionMetaMocks); + + it("calls recordSessionMetaFromInbound after a native slash command", async () => { + const shadowHandler = vi.fn(async () => ({ text: "wrong plugin" })); + activePluginRegistry.commands.push({ + pluginId: "shadow-plugin", + source: "test", + command: { + name: "status", + description: "Shadow status", + channels: ["telegram"], + requireAuth: false, + handler: shadowHandler, + }, + }); + const cfg: OpenClawConfig = {}; + const { handler } = registerAndResolveStatusHandler({ cfg }); + await handler(createTelegramPrivateCommandContext()); + + expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); + expect(shadowHandler).not.toHaveBeenCalled(); + const turnPlan = dispatchChannelInboundTurnMock.mock.calls[0]?.[0]; + expect(turnPlan?.replyOptions?.[Symbol.for("openclaw.pluginCommandDispatch") as never]).toEqual( + { kind: "non-plugin" }, + ); + const call = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string; ctx?: { OriginatingChannel?: string; Provider?: string } }] + > + )[0]?.[0]; + expect(call?.ctx?.OriginatingChannel).toBe("telegram"); + expect(call?.ctx?.Provider).toBe("telegram"); + expect(call?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); + expect(turnPlan?.record?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); + }); + + it("leaves native-command outcomes to the update middleware owner", async () => { + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + const { result } = await runWithTelegramUpdateProcessingFrame(async () => { + await handler(createTelegramPrivateCommandContext()); + }); + + expect(result).toBeUndefined(); + }); + + it("preserves every argument on native queue command turns", async () => { + const { handler } = registerAndResolveCommandHandler({ + commandName: "queue", + cfg: {}, + allowFrom: ["*"], + }); + + await handler(createTelegramPrivateCommandContext({ match: "Can you diagnose this?" })); + + expect(dispatchChannelInboundTurnMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctxPayload: expect.objectContaining({ + Body: "/queue Can you diagnose this?", + CommandBody: "/queue Can you diagnose this?", + CommandTurn: expect.objectContaining({ + kind: "native", + body: "/queue Can you diagnose this?", + }), + }), + }), + ); + }); + + it("keeps one live config snapshot through native command execution", async () => { + const startupCfg: OpenClawConfig = { session: { store: "/tmp/startup-sessions.json" } }; + const runtimeCfg: OpenClawConfig = { session: { store: "/tmp/runtime-sessions.json" } }; + const { handler } = registerAndResolveStatusHandler({ cfg: startupCfg, runtimeCfg }); + + await handler(createTelegramPrivateCommandContext()); + + const dispatchCall = requireRecord( + firstMockArg( + replyMocks.dispatchReplyWithBufferedBlockDispatcher, + "dispatchReplyWithBufferedBlockDispatcher", + ), + "dispatch call", + ); + expect(dispatchCall.cfg).toBe(runtimeCfg); + }); + + it.each([ + { blockStreamingEnabled: false, expectedDisableBlockStreaming: true }, + { blockStreamingEnabled: true, expectedDisableBlockStreaming: false }, + ])( + "uses nested streaming.block.enabled=$blockStreamingEnabled for native command dispatch", + async ({ blockStreamingEnabled, expectedDisableBlockStreaming }) => { + const cfg = { + channels: { + telegram: { + streaming: { block: { enabled: blockStreamingEnabled } }, + }, + }, + } satisfies OpenClawConfig; + const { handler } = registerAndResolveStatusHandler({ cfg }); + + await handler(createTelegramPrivateCommandContext()); + + const dispatchCall = requireRecord( + firstMockArg( + replyMocks.dispatchReplyWithBufferedBlockDispatcher, + "dispatchReplyWithBufferedBlockDispatcher", + ), + "dispatch call", + ); + expect(dispatchCall.replyOptions).toMatchObject({ + disableBlockStreaming: expectedDisableBlockStreaming, + }); + }, + ); + + it("routes Telegram native commands through configured ACP topic bindings", async () => { + const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); + const sessionMetaCall = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string }] + > + )[0]?.[0]; + expect(sessionMetaCall?.sessionKey).toBe(boundSessionKey); + }); + + it("routes Telegram native commands through topic-specific agent sessions", async () => { + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { agentId: "zu" }, + }), + }); + await handler(createTelegramTopicCommandContext()); + + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe( + "agent:zu:telegram:group:-1001234567890:topic:42", + ); + const sessionMetaCall = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string; ctx?: { From?: string; ChatType?: string } }] + > + )[0]?.[0]; + expect(sessionMetaCall?.sessionKey).toBe("agent:zu:telegram:group:-1001234567890:topic:42"); + expect(sessionMetaCall?.ctx?.From).toBe("telegram:group:-1001234567890:topic:42"); + expect(sessionMetaCall?.ctx?.ChatType).toBe("group"); + }); + + it("does not mark paired Telegram DM allowlist entries as native group command owners", async () => { + const { handler, sendMessage } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + storeAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + expectUnauthorizedNewCommandBlocked(sendMessage); + }); + + it("authorizes paired Telegram DMs without marking them as owners", async () => { + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + storeAllowFrom: ["200"], + }); + await handler(createTelegramPrivateCommandContext()); + + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [ + { + ctx?: { + CommandAuthorized?: boolean; + }; + }, + ] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandAuthorized).toBe(true); + expect(dispatchCall?.ctx).not.toHaveProperty("OwnerAllowFrom"); + }); + + it("routes Telegram native commands through bound topic sessions", async () => { + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "default:-1001234567890:topic:42", + targetSessionKey: "agent:codex-acp:session-1", + }); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + }); + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-1"); + const sessionMetaCall = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string }] + > + )[0]?.[0]; + expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-1"); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith( + "default:-1001234567890:topic:42", + undefined, + ); + }); + + it("routes Telegram native commands through bound top-level group sessions", async () => { + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "default:-1001234567890", + targetSessionKey: "agent:codex-acp:session-group", + }); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramGroupCommandContext()); + + expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890", + }); + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [{ ctx?: { CommandTargetSessionKey?: string; OriginatingTo?: string } }] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-group"); + expect(dispatchCall?.ctx?.OriginatingTo).toBe("telegram:-1001234567890"); + const sessionMetaCall = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string }] + > + )[0]?.[0]; + expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-group"); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith("default:-1001234567890", undefined); + }); + + it.each(["new", "reset"] as const)( + "preserves the topic-qualified origin target for native /%s in forum topics", + async (commandName) => { + const { handler } = registerAndResolveCommandHandler({ + commandName, + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [ + { + ctx?: { + CommandTargetSessionKey?: string; + MessageThreadId?: number; + OriginatingTo?: string; + }; + }, + ] + > + )[0]?.[0]; + expectRecordFields( + dispatchCall?.ctx, + { + CommandTargetSessionKey: "agent:main:telegram:group:-1001234567890:topic:42", + MessageThreadId: 42, + OriginatingTo: "telegram:-1001234567890:topic:42", + }, + "topic dispatch context", + ); + }, + ); + + it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => { + const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ + ok: false, + error: "gateway unavailable", + }); + + const { handler, sendMessage } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: ["200"], + groupAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + expectSendMessageCall({ + sendMessage, + chatId: -1001234567890, + text: "Configured ACP binding is unavailable right now. Please try again.", + optionFields: { message_thread_id: 42 }, + label: "unavailable ACP binding", + }); + }); + + it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => { + const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute( + { + ...route, + sessionKey: boundSessionKey, + agentId: "codex", + matchedBy: "binding.channel", + }, + createConfiguredAcpTopicBinding(boundSessionKey), + ), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "new", + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + }); + await handler(createTelegramTopicCommandContext()); + + expectUnauthorizedNewCommandBlocked(sendMessage); + }); + + it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => { + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute(route, null), + ); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "new", + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + }); + await handler(createTelegramTopicCommandContext()); + + expectUnauthorizedNewCommandBlocked(sendMessage); + }); +}); diff --git a/extensions/telegram/src/bot-native-command-dispatch.test-support.ts b/extensions/telegram/src/bot-native-command-dispatch.test-support.ts new file mode 100644 index 000000000000..09b758694fcb --- /dev/null +++ b/extensions/telegram/src/bot-native-command-dispatch.test-support.ts @@ -0,0 +1,107 @@ +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; + +export function createConfiguredAcpTopicBinding(boundSessionKey: string) { + return { + spec: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:telegram:default:-1001234567890:topic:42", + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + status: "active", + boundAt: 0, + }, + } as const; +} + +export function createConfiguredBindingRoute( + route: ResolvedAgentRoute, + binding: ReturnType | null, +) { + return { + bindingResolution: binding + ? { + conversation: binding.record.conversation, + compiledBinding: { + channel: "telegram" as const, + binding: { + type: "acp" as const, + agentId: binding.spec.agentId, + match: { + channel: "telegram", + accountId: binding.spec.accountId, + peer: { + kind: "group" as const, + id: binding.spec.conversationId, + }, + }, + acp: { + mode: binding.spec.mode, + }, + }, + bindingConversationId: binding.spec.conversationId, + target: { + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }, + agentId: binding.spec.agentId, + provider: { + compileConfiguredBinding: () => ({ + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }), + matchInboundConversation: () => ({ + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }), + }, + targetFactory: { + driverId: "acp" as const, + materialize: () => ({ + record: binding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp" as const, + sessionKey: binding.record.targetSessionKey, + agentId: binding.spec.agentId, + }, + }), + }, + }, + match: { + conversationId: binding.spec.conversationId, + ...(binding.spec.parentConversationId + ? { parentConversationId: binding.spec.parentConversationId } + : {}), + }, + record: binding.record, + statefulTarget: { + kind: "stateful" as const, + driverId: "acp" as const, + sessionKey: binding.record.targetSessionKey, + agentId: binding.spec.agentId, + }, + } + : null, + ...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}), + route, + }; +} diff --git a/extensions/telegram/src/bot-native-command-dispatch.ts b/extensions/telegram/src/bot-native-command-dispatch.ts new file mode 100644 index 000000000000..c614b9171729 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-dispatch.ts @@ -0,0 +1,699 @@ +// Telegram plugin module implements native command admission and dispatch behavior. +import type { Bot, Context } from "grammy"; +import { + isChannelPartialDeliveryError, + type ChannelInboundTurnPlan, +} from "openclaw/plugin-sdk/channel-inbound"; +import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound"; +import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; +import type { + ChannelGroupPolicy, + OpenClawConfig, + TelegramAccountConfig, +} from "openclaw/plugin-sdk/config-contracts"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; +import { + PLUGIN_COMMAND_DISPATCH, + type PluginCommandCatalogDecision, +} from "openclaw/plugin-sdk/plugin-command-runtime"; +import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; +import { resolveTelegramAccount } from "./accounts.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy } from "./bot-access.js"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramResolvedGroupConfig } from "./bot-handlers.types.js"; +import { resolveTelegramMessageTurnSettings } from "./bot-message.js"; +import { + defaultTelegramNativeCommandDeps, + type TelegramNativeCommandDeps, +} from "./bot-native-command-deps.runtime.js"; +import type { TelegramBotOptions } from "./bot.types.js"; +import { + buildSenderName, + buildTelegramGroupFrom, + buildTelegramRoutingTarget, + buildTelegramThreadParams, + extractTelegramForumFlag, + isTelegramCommandsAllowFromConfigured, + resolveTelegramBotHasTopicsEnabled, + resolveTelegramCommandAuthorization, + resolveTelegramForumFlag, + resolveTelegramGroupAllowFromContext, + resolveTelegramMessageThreadSpec, + resolveTelegramThreadSpec, +} from "./bot/helpers.js"; +import type { TelegramGetChat } from "./bot/types.js"; +import { + resolveTelegramConversationRoute, + resolveTelegramTargetSession, +} from "./conversation-route.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; +import { + evaluateTelegramGroupBaseAccess, + evaluateTelegramGroupPolicyAccess, +} from "./group-access.js"; +import { + resolveTelegramDirectToolPolicy, + resolveTelegramGroupPromptSettings, +} from "./group-config-helpers.js"; +import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; +import { getTopicName, resolveTopicNameCacheScope } from "./topic-name-cache.js"; + +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; +const NON_PLUGIN_COMMAND_DISPATCH = Object.freeze({ + kind: "non-plugin", +}) satisfies PluginCommandCatalogDecision; + +const loadTelegramNativeCommandDeliveryRuntime = createLazyRuntimeModule( + () => import("./bot-native-commands.delivery.runtime.js"), +); +const loadTelegramNativeCommandRuntime = createLazyRuntimeModule( + () => import("./bot-native-commands.runtime.js"), +); + +type TelegramNativeCommandRuntime = Awaited>; +type TelegramNativeCommandDeliveryRuntime = Awaited< + ReturnType +>; +type DeliveryBaseOptions = Omit< + Parameters[0], + "replies" | "silent" +>; + +export type TelegramCommandExecutorParams = { + botUser: Context["me"]; + msg: NonNullable; + rawText: string; + bot: Bot; + runtime: RuntimeEnv; + accountId: string; + mediaMaxBytes?: number; + resolveGroupPolicy: (chatId: string | number, cfg: OpenClawConfig) => ChannelGroupPolicy; + resolveTelegramGroupConfig: ( + chatId: string | number, + messageThreadId: number | undefined, + cfg: OpenClawConfig, + ) => TelegramResolvedGroupConfig; + telegramDeps?: TelegramNativeCommandDeps; + opts: Pick< + TelegramBotOptions, + "token" | "botInfo" | "allowFrom" | "groupAllowFrom" | "replyToMode" | "accountAbortSignal" + >; +}; + +type TelegramCommandAuthResult = NonNullable< + Awaited> +>; + +export type TelegramCommandDispatch = TelegramCommandExecutorParams & + TelegramCommandAuthResult & { + telegramDeps: TelegramNativeCommandDeps; + runtimeCfg: OpenClawConfig; + runtimeTelegramCfg: TelegramAccountConfig; + turnSettings: ReturnType; + threadSpec: ReturnType; + threadParams: ReturnType; + route: ReturnType["route"]; + mediaLocalRoots: readonly string[] | undefined; + targetSessionKey: string; + nativeCommandRuntime: TelegramNativeCommandRuntime; + buildDeliveryBaseOptions: (params?: { + sessionKeyForInternalHooks?: string; + policySessionKey?: string; + }) => DeliveryBaseOptions; + loadDeliveryRuntime: () => Promise; + }; + +async function resolveTelegramNativeCommandThreadContext(params: { + msg: NonNullable; + bot: Bot; +}) { + const { msg, bot } = params; + const chatId = msg.chat.id; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const getChat = + typeof bot.api.getChat === "function" + ? (bot.api.getChat.bind(bot.api) as TelegramGetChat) + : undefined; + const isForum = + msg.chat.is_direct_messages === true + ? false + : await resolveTelegramForumFlag({ + chatId, + chatType: msg.chat.type, + isGroup, + isForum: extractTelegramForumFlag(msg.chat), + isTopicMessage: msg.is_topic_message, + getChat, + }); + const threadSpec = resolveTelegramMessageThreadSpec(msg, isForum); + return { + chatId, + isGroup, + isForum, + threadSpec, + threadParams: buildTelegramThreadParams(threadSpec), + }; +} + +async function resolveTelegramCommandAuth(params: { + msg: NonNullable; + bot: Bot; + cfg: OpenClawConfig; + accountId: string; + telegramCfg: TelegramAccountConfig; + readChannelAllowFromStore: TelegramBotDeps["readChannelAllowFromStore"]; + allowFrom?: Array; + groupAllowFrom?: Array; + resolveGroupPolicy: TelegramCommandExecutorParams["resolveGroupPolicy"]; + resolveTelegramGroupConfig: TelegramCommandExecutorParams["resolveTelegramGroupConfig"]; + requireAuth: boolean; +}) { + const { msg, bot, cfg, accountId, telegramCfg, requireAuth } = params; + const { chatId, isGroup, isForum, threadSpec, threadParams } = + await resolveTelegramNativeCommandThreadContext({ msg, bot }); + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const senderUsername = msg.from?.username ?? ""; + const commandsAllowFromConfigured = isTelegramCommandsAllowFromConfigured(cfg); + const preContextCommandsAllowFromAccess = commandsAllowFromConfigured + ? resolveTelegramCommandAuthorization({ + cfg, + accountId, + chatId, + isGroup, + senderId, + senderUsername, + }) + : null; + const groupAllowContext = await resolveTelegramGroupAllowFromContext({ + cfg, + chatId, + accountId, + dmPolicy: telegramCfg.dmPolicy, + allowFrom: params.allowFrom, + senderId, + isGroup, + threadSpec, + groupAllowFrom: params.groupAllowFrom, + skipPairingStoreRead: Boolean(preContextCommandsAllowFromAccess?.isAuthorizedSender), + readChannelAllowFromStore: params.readChannelAllowFromStore, + resolveTelegramGroupConfig: params.resolveTelegramGroupConfig, + }); + const { + resolvedThreadId, + dmThreadId, + storeAllowFrom, + groupConfig, + topicConfig, + groupAllowOverride, + effectiveGroupAllow, + hasGroupAllowOverride, + } = groupAllowContext; + const effectiveDmPolicy = resolveTelegramEffectiveDmPolicy({ + isGroup, + groupConfig, + dmPolicy: telegramCfg.dmPolicy, + }); + const requireTopic = + !isGroup && groupConfig && "requireTopic" in groupConfig ? groupConfig.requireTopic : undefined; + if (!isGroup && requireTopic === true && dmThreadId == null) { + logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`); + return null; + } + const commandsAllowFromAccess = commandsAllowFromConfigured + ? resolveTelegramCommandAuthorization({ + cfg, + accountId, + chatId, + isGroup, + resolvedThreadId, + senderId, + senderUsername, + }) + : null; + const ownerAccess = resolveTelegramCommandAuthorization({ + cfg, + accountId, + chatId, + isGroup, + resolvedThreadId, + senderId, + senderUsername, + }); + const sendAuthMessage = async (text: string) => { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + fn: () => bot.api.sendMessage(chatId, text, threadParams ?? {}), + }); + return null; + }; + const rejectNotAuthorized = async () => + await sendAuthMessage("You are not authorized to use this command."); + + const baseAccess = evaluateTelegramGroupBaseAccess({ + isGroup, + groupConfig, + topicConfig, + hasGroupAllowOverride, + effectiveGroupAllow, + senderId, + senderUsername, + enforceAllowOverride: requireAuth, + requireSenderForAllowOverride: true, + }); + if (!baseAccess.allowed) { + if (baseAccess.reason === "group-disabled") { + logVerbose(`Blocked telegram command in group ${chatId} (group disabled)`); + return null; + } + if (baseAccess.reason === "topic-disabled") { + logVerbose( + `Blocked telegram command in topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, + ); + return null; + } + return await rejectNotAuthorized(); + } + + const policyAccess = evaluateTelegramGroupPolicyAccess({ + isGroup, + chatId, + cfg, + telegramCfg, + topicConfig, + groupConfig, + effectiveGroupAllow, + senderId, + senderUsername, + resolveGroupPolicy: params.resolveGroupPolicy, + enforcePolicy: true, + enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured, + allowEmptyAllowlistEntries: true, + requireSenderForAllowlistAuthorization: true, + checkChatAllowlist: true, + }); + if (!policyAccess.allowed) { + if (policyAccess.reason === "group-policy-disabled") { + logVerbose("Blocked telegram command (groupPolicy: disabled)"); + return null; + } + if ( + policyAccess.reason === "group-policy-allowlist-no-sender" || + policyAccess.reason === "group-policy-allowlist-unauthorized" + ) { + return await rejectNotAuthorized(); + } + if (policyAccess.reason === "group-chat-not-allowed") { + logVerbose(`Blocked telegram command in group ${chatId} (group not allowed)`); + return null; + } + } + + const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({ + cfg, + allowFrom: groupAllowOverride ?? params.allowFrom, + accountId, + senderId, + }); + const dmAllow = normalizeDmAllowFromWithStore({ + allowFrom: expandedDmAllowFrom, + storeAllowFrom: isGroup ? [] : storeAllowFrom, + dmPolicy: effectiveDmPolicy, + }); + const commandAuthorized = commandsAllowFromConfigured + ? Boolean(commandsAllowFromAccess?.isAuthorizedSender) + : ( + await resolveTelegramCommandIngressAuthorization({ + accountId, + cfg, + dmPolicy: effectiveDmPolicy, + isGroup, + chatId, + resolvedThreadId, + senderId, + effectiveDmAllow: dmAllow, + effectiveGroupAllow, + ownerAccess, + eventKind: "native-command", + }) + ).authorized; + if (requireAuth && !commandAuthorized) { + return await rejectNotAuthorized(); + } + return { + chatId, + isGroup, + isForum, + resolvedThreadId, + senderId, + senderUsername, + groupConfig, + topicConfig, + commandAuthorized, + senderIsOwner: ownerAccess.senderIsOwner, + }; +} + +export async function prepareTelegramCommandDispatch( + params: TelegramCommandExecutorParams & { requireAuth: boolean }, +): Promise { + const telegramDeps = params.telegramDeps ?? defaultTelegramNativeCommandDeps; + const runtimeCfg = telegramDeps.getRuntimeConfig(); + const runtimeTelegramCfg = resolveTelegramAccount({ + cfg: runtimeCfg, + accountId: params.accountId, + }).config; + const turnSettings = resolveTelegramMessageTurnSettings({ + accountId: params.accountId, + cfg: runtimeCfg, + telegramCfg: runtimeTelegramCfg, + opts: params.opts, + }); + const auth = await resolveTelegramCommandAuth({ + msg: params.msg, + bot: params.bot, + cfg: runtimeCfg, + accountId: params.accountId, + telegramCfg: runtimeTelegramCfg, + readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, + allowFrom: turnSettings.allowFrom, + groupAllowFrom: turnSettings.groupAllowFrom, + resolveGroupPolicy: params.resolveGroupPolicy, + resolveTelegramGroupConfig: params.resolveTelegramGroupConfig, + requireAuth: params.requireAuth, + }); + if (!auth) { + return null; + } + const threadSpec = resolveTelegramMessageThreadSpec(params.msg, auth.isForum); + const { route, bindingMode } = resolveTelegramConversationRoute({ + cfg: runtimeCfg, + accountId: params.accountId, + chatId: auth.chatId, + isGroup: auth.isGroup, + resolvedThreadId: auth.resolvedThreadId, + replyThreadId: threadSpec.id, + senderId: auth.senderId, + topicAgentId: auth.topicConfig?.agentId, + }); + const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); + if (bindingMode.kind === "configured") { + const ensured = await nativeCommandRuntime.ensureConfiguredBindingRouteReady({ + cfg: runtimeCfg, + bindingResolution: bindingMode.binding, + }); + if (!ensured.ok) { + logVerbose( + `telegram native command: configured ACP binding unavailable for topic ${bindingMode.binding.record.conversation.conversationId}: ${ensured.error}`, + ); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: params.runtime, + fn: () => + params.bot.api.sendMessage( + auth.chatId, + "Configured ACP binding is unavailable right now. Please try again.", + buildTelegramThreadParams(threadSpec) ?? {}, + ), + }); + return null; + } + } + const mediaLocalRoots = nativeCommandRuntime.getAgentScopedMediaLocalRoots( + runtimeCfg, + route.agentId, + ); + const tableMode = resolveMarkdownTableMode({ + cfg: runtimeCfg, + channel: "telegram", + accountId: route.accountId, + supportsBlockTables: true, + }); + const chunkMode = nativeCommandRuntime.resolveChunkMode(runtimeCfg, "telegram", route.accountId); + const targetSessionKey = resolveTelegramTargetSession({ + cfg: runtimeCfg, + route, + chatId: auth.chatId, + isGroup: auth.isGroup, + senderId: auth.senderId, + dmThreadId: threadSpec.scope === "dm" ? threadSpec.id : undefined, + botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(params.botUser), + }); + const buildDeliveryBaseOptions = (keys?: { + sessionKeyForInternalHooks?: string; + policySessionKey?: string; + }): DeliveryBaseOptions => ({ + cfg: runtimeCfg, + chatId: String(auth.chatId), + accountId: route.accountId, + sessionKeyForInternalHooks: keys?.sessionKeyForInternalHooks, + policySessionKey: keys?.policySessionKey, + mirrorIsGroup: auth.isGroup, + mirrorGroupId: auth.isGroup ? String(auth.chatId) : undefined, + token: params.opts.token, + runtime: params.runtime, + bot: params.bot, + mediaLocalRoots, + mediaMaxBytes: params.mediaMaxBytes, + replyToMode: turnSettings.replyToMode, + textLimit: turnSettings.textLimit, + thread: threadSpec, + tableMode, + chunkMode, + linkPreview: runtimeTelegramCfg.linkPreview, + richMessages: runtimeTelegramCfg.richMessages, + }); + return { + ...params, + telegramDeps, + runtimeCfg, + runtimeTelegramCfg, + turnSettings, + ...auth, + threadSpec, + threadParams: buildTelegramThreadParams(threadSpec), + route, + mediaLocalRoots, + targetSessionKey, + nativeCommandRuntime, + buildDeliveryBaseOptions, + loadDeliveryRuntime: loadTelegramNativeCommandDeliveryRuntime, + }; +} + +export async function dispatchTelegramBuiltinTurn(params: { + dispatch: TelegramCommandDispatch; + prompt: string; + commandArgs?: import("openclaw/plugin-sdk/command-auth-native").CommandArgs; +}): Promise { + const { dispatch } = params; + const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({ + groupConfig: dispatch.groupConfig, + topicConfig: dispatch.topicConfig, + }); + const { sessionKey: commandSessionKey, commandTargetSessionKey } = + resolveNativeCommandSessionTargets({ + agentId: dispatch.route.agentId, + sessionPrefix: "telegram:slash", + userId: String(dispatch.senderId || dispatch.chatId), + targetSessionKey: dispatch.targetSessionKey, + }); + let topicName: string | undefined; + if (dispatch.isForum && dispatch.resolvedThreadId != null) { + try { + const storePath = resolveStorePath(dispatch.runtimeCfg.session?.store, { + agentId: dispatch.route.accountId, + }); + topicName = await getTopicName( + dispatch.chatId, + dispatch.resolvedThreadId, + resolveTopicNameCacheScope(storePath), + ); + } catch { + // best-effort: topic name is supplementary metadata + } + } + const conversationLabel = dispatch.isGroup + ? dispatch.msg.chat.title + ? `${dispatch.msg.chat.title} id:${dispatch.chatId}` + : `group:${dispatch.chatId}` + : (buildSenderName(dispatch.msg) ?? String(dispatch.senderId || dispatch.chatId)); + const ctxPayload = dispatch.nativeCommandRuntime.finalizeInboundContext({ + Body: params.prompt, + BodyForAgent: params.prompt, + RawBody: params.prompt, + CommandBody: params.prompt, + CommandArgs: params.commandArgs, + From: dispatch.isGroup + ? buildTelegramGroupFrom(dispatch.chatId, dispatch.resolvedThreadId) + : `telegram:${dispatch.chatId}`, + To: `slash:${dispatch.senderId || dispatch.chatId}`, + ChatType: dispatch.isGroup ? "group" : "direct", + ConversationToolPolicy: dispatch.isGroup + ? undefined + : resolveTelegramDirectToolPolicy({ + directConfig: dispatch.groupConfig, + senderId: dispatch.senderId, + senderName: buildSenderName(dispatch.msg), + senderUsername: dispatch.senderUsername, + }), + ConversationLabel: conversationLabel, + GroupSubject: dispatch.isGroup ? (dispatch.msg.chat.title ?? undefined) : undefined, + GroupSystemPrompt: + dispatch.isGroup || (!dispatch.isGroup && dispatch.groupConfig) + ? groupSystemPrompt + : undefined, + SenderName: buildSenderName(dispatch.msg), + SenderId: dispatch.senderId || undefined, + SenderUsername: dispatch.senderUsername || undefined, + Surface: "telegram", + Provider: "telegram", + MessageSid: String(dispatch.msg.message_id), + Timestamp: dispatch.msg.date ? dispatch.msg.date * 1000 : undefined, + WasMentioned: true, + CommandAuthorized: dispatch.commandAuthorized, + CommandTurn: { + kind: "native" as const, + source: "native" as const, + authorized: dispatch.commandAuthorized, + body: params.prompt, + }, + CommandSource: "native" as const, + SessionKey: commandSessionKey, + AccountId: dispatch.route.accountId, + CommandTargetSessionKey: commandTargetSessionKey, + MessageThreadId: dispatch.threadSpec.id, + IsForum: dispatch.isForum, + TopicName: dispatch.isForum && topicName ? topicName : undefined, + OriginatingChannel: "telegram" as const, + OriginatingTo: buildTelegramRoutingTarget(dispatch.chatId, dispatch.threadSpec), + }); + const deliveryState = { delivered: false, skippedNonSilent: 0, failedNonSilent: 0 }; + let finalReplyOutcome: "accepted" | "failed" | "suppressed" | undefined; + let recordSessionMetaTask: Promise | undefined; + const deliveryBaseOptions = dispatch.buildDeliveryBaseOptions({ + sessionKeyForInternalHooks: commandSessionKey, + policySessionKey: commandTargetSessionKey, + }); + const { deliverReplies } = await dispatch.loadDeliveryRuntime(); + const turnPlan: ChannelInboundTurnPlan<"provider_message_sending"> = { + cfg: dispatch.runtimeCfg, + channel: "telegram", + accountId: dispatch.route.accountId, + route: { agentId: dispatch.route.agentId, sessionKey: commandSessionKey }, + ctxPayload, + record: { + sessionKey: commandTargetSessionKey, + trackSessionMetaTask: (task) => { + recordSessionMetaTask = task; + }, + onRecordError: (error) => + dispatch.runtime.error?.( + danger(`telegram slash: failed updating session meta: ${String(error)}`), + ), + }, + afterRecord: async () => { + await recordSessionMetaTask; + }, + replyPipeline: {}, + dispatcherOptions: { + beforeDeliver: async (payload) => payload, + onSkip: (_payload, info) => { + if (info.reason !== "silent") { + deliveryState.skippedNonSilent += 1; + } + }, + }, + delivery: { + deliverWithProviderMessageSending: async (payload, info) => { + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg: dispatch.runtimeCfg, + accountId: dispatch.route.accountId, + payload, + }) + ) { + deliveryState.delivered = true; + return { visibleReplySent: false, suppression: { reason: "no_visible_result" } }; + } + const targetedPayload = payload.replyToId + ? payload + : { ...payload, replyToId: String(dispatch.msg.message_id) }; + const result = await deliverReplies({ + replies: [ + info.bindPendingFinalDelivery + ? info.bindPendingFinalDelivery(targetedPayload) + : targetedPayload, + ], + ...deliveryBaseOptions, + silent: + dispatch.runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true, + onPlatformSendDispatch: info.onPlatformSendDispatch, + }); + if (result.delivered) { + deliveryState.delivered = true; + } + return result.delivered + ? { visibleReplySent: true } + : { visibleReplySent: false, suppression: { reason: "no_visible_result" as const } }; + }, + onDelivered: (_payload, info, result) => { + const reason = result?.suppression?.reason; + if (info.kind === "final" && result?.visibleReplySent) { + finalReplyOutcome = "accepted"; + } + if ( + info.kind === "final" && + finalReplyOutcome !== "failed" && + (reason === "cancelled_by_reply_payload_sending_hook" || + reason === "empty_after_reply_payload_sending_hook") + ) { + finalReplyOutcome = "suppressed"; + } + }, + onError: (error, info) => { + deliveryState.failedNonSilent += 1; + const partialDelivery = isChannelPartialDeliveryError(error); + if (partialDelivery) { + deliveryState.delivered = true; + logVerbose("telegram slash reply partially delivered before failure"); + } + if (info.kind === "final") { + finalReplyOutcome = partialDelivery ? "accepted" : "failed"; + } + dispatch.runtime.error?.( + danger(`telegram slash ${info.kind} reply failed: ${String(error)}`), + ); + }, + }, + replyOptions: { + skillFilter, + disableBlockStreaming: (() => { + const enabled = resolveChannelStreamingBlockEnabled(dispatch.runtimeTelegramCfg); + return typeof enabled === "boolean" ? !enabled : undefined; + })(), + [PLUGIN_COMMAND_DISPATCH]: NON_PLUGIN_COMMAND_DISPATCH, + }, + }; + const turnResult = await ( + dispatch.telegramDeps.dispatchChannelInboundTurn ?? + defaultTelegramNativeCommandDeps.dispatchChannelInboundTurn + )(turnPlan); + if ( + !deliveryState.delivered && + finalReplyOutcome !== "suppressed" && + (deliveryState.skippedNonSilent > 0 || deliveryState.failedNonSilent > 0) && + (!turnResult.dispatched || + turnResult.dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" || + deliveryState.failedNonSilent > 0) + ) { + await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + ...deliveryBaseOptions, + }); + } + return false; +} diff --git a/extensions/telegram/src/bot-native-command-executors.test-support.ts b/extensions/telegram/src/bot-native-command-executors.test-support.ts new file mode 100644 index 000000000000..8a3b5a9722f6 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-executors.test-support.ts @@ -0,0 +1,580 @@ +export { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound"; +import { + createEmptyPluginRegistry, + withPluginRuntimeRegistryScope, +} from "openclaw/plugin-sdk/channel-test-helpers"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +export { createDeferred } from "openclaw/plugin-sdk/extension-shared"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; +import { resolveChunkMode } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +// Telegram tests cover bot native commands.session meta plugin behavior. +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; +import { expect, vi } from "vitest"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; +import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; +import { createConfiguredBindingRoute } from "./bot-native-command-dispatch.test-support.js"; +import { + createNativeCommandTestParams, + createTelegramPrivateCommandContext, + type NativeCommandTestParams, +} from "./bot-native-commands.fixture-test-support.js"; +export { runWithTelegramUpdateProcessingFrame } from "./bot-processing-outcome.js"; + +// Shared executor test harness; each importing suite resets the state before use. + +type ResolveConfiguredBindingRouteFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute; +type EnsureConfiguredBindingRouteReadyFn = + typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher; +export type DispatchReplyWithBufferedBlockDispatcherParams = + Parameters[0]; +type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< + ReturnType +>; +type DispatchChannelInboundTurnFn = + typeof import("openclaw/plugin-sdk/channel-inbound").dispatchChannelInboundTurn; +type ResolveCommandArgMenuFn = + typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgMenu; +type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; +type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog; +type ResolveDefaultModelForAgentFn = + typeof import("openclaw/plugin-sdk/agent-runtime").resolveDefaultModelForAgent; + +export const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { + queuedFinal: false, + counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], +}; + +const persistentBindingMocks = vi.hoisted(() => ({ + resolveConfiguredBindingRoute: vi.fn(({ route }) => ({ + bindingResolution: null, + route, + })), + ensureConfiguredBindingRouteReady: vi.fn(async () => ({ + ok: true, + })), +})); +const sessionMocks = vi.hoisted(() => ({ + getSessionEntry: vi.fn(), + sessionStoreEntries: vi.fn(), + recordSessionMetaFromInbound: vi.fn(), + resolveStorePath: vi.fn(), + updateSessionStoreEntry: vi.fn(), +})); +const commandAuthMocks = vi.hoisted(() => ({ + resolveCommandArgMenu: vi.fn(), +})); +const agentRuntimeMocks = vi.hoisted(() => ({ + loadModelCatalog: vi.fn(async () => [ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]), + resolveDefaultModelForAgent: vi.fn(), +})); +const pluginRuntimeMocks = vi.hoisted(() => ({ + executePluginCommand: vi.fn(async (_params?: unknown) => ({ text: "ok" })), +})); +const replyMocks = vi.hoisted(() => ({ + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => dispatchReplyResult, + ), +})); +const deliveryMocks = vi.hoisted(() => ({ + deliverReplies: vi.fn(async () => ({ delivered: true })), +})); +export const dispatchChannelInboundTurnMock = vi.fn(async (plan) => { + const recordTask = sessionMocks.recordSessionMetaFromInbound({ + storePath: sessionMocks.resolveStorePath(plan.cfg.session?.store, { + agentId: plan.route.agentId, + }), + sessionKey: plan.record?.sessionKey ?? plan.ctxPayload.SessionKey ?? plan.route.sessionKey, + ctx: plan.ctxPayload, + }); + const trackedRecordTask = Promise.resolve(recordTask).catch((error: unknown) => + plan.record?.onRecordError?.(error), + ); + plan.record?.trackSessionMetaTask?.(trackedRecordTask); + await plan.afterRecord?.(); + const deliver = async ( + payload: Parameters< + DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] + >[0], + info: Parameters< + DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] + >[1], + ) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; + const result = + "deliverWithProviderMessageSending" in plan.delivery + ? await plan.delivery.deliverWithProviderMessageSending(payload, providerInfo) + : await plan.delivery.deliver(payload, info); + await plan.delivery.onDelivered?.(payload, info, result); + return result; + }; + const dispatchResult = await replyMocks.dispatchReplyWithBufferedBlockDispatcher({ + ctx: plan.ctxPayload, + cfg: plan.cfg, + dispatcherOptions: { + ...plan.dispatcherOptions, + deliver, + onError: plan.delivery.onError, + }, + replyOptions: plan.replyOptions, + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult, + }; +}); +const sessionBindingMocks = vi.hoisted(() => ({ + resolveByConversation: vi.fn< + (ref: unknown) => { bindingId: string; targetSessionKey: string } | null + >(() => null), + touch: vi.fn(), +})); +const conversationStoreMocks = vi.hoisted(() => ({ + readChannelAllowFromStore: vi.fn(async () => []), + upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", created: true })), +})); + +export const executorTestMocks = { + agentRuntimeMocks, + commandAuthMocks, + conversationStoreMocks, + deliveryMocks, + persistentBindingMocks, + pluginRuntimeMocks, + replyMocks, + sessionBindingMocks, + sessionMocks, +}; + +vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/conversation-runtime", + ); + return { + ...actual, + resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute: ( + params: Parameters[0], + ) => { + const conversation = + "conversation" in params + ? params.conversation + : { + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }; + const bindingRecord = sessionBindingMocks.resolveByConversation(conversation); + const boundSessionKey = bindingRecord?.targetSessionKey?.trim(); + if (!bindingRecord || !boundSessionKey) { + return { bindingRecord: null, route: params.route }; + } + sessionBindingMocks.touch(bindingRecord.bindingId, undefined); + return { + bindingRecord, + boundSessionKey, + boundAgentId: params.route.agentId, + route: { + ...params.route, + sessionKey: boundSessionKey, + lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session", + matchedBy: "binding.channel", + }, + }; + }, + ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, + readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore, + upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest, + getSessionBindingService: () => ({ + bind: vi.fn(), + getCapabilities: vi.fn(), + listBySession: vi.fn(), + resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref), + touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at), + unbind: vi.fn(), + }), + }; +}); +vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/session-store-runtime", + ); + return { + ...actual, + getSessionEntry: sessionMocks.getSessionEntry, + sessionStoreEntries: sessionMocks.sessionStoreEntries, + resolveStorePath: sessionMocks.resolveStorePath, + updateSessionStoreEntry: sessionMocks.updateSessionStoreEntry, + }; +}); +vi.mock("openclaw/plugin-sdk/command-auth-native", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/command-auth-native", + ); + commandAuthMocks.resolveCommandArgMenu.mockImplementation(actual.resolveCommandArgMenu); + return { + ...actual, + resolveCommandArgMenu: commandAuthMocks.resolveCommandArgMenu, + }; +}); +vi.mock("openclaw/plugin-sdk/agent-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/agent-runtime", + ); + agentRuntimeMocks.resolveDefaultModelForAgent.mockImplementation( + actual.resolveDefaultModelForAgent, + ); + return { + ...actual, + loadPreparedModelCatalog: agentRuntimeMocks.loadModelCatalog, + resolveDefaultModelForAgent: agentRuntimeMocks.resolveDefaultModelForAgent, + }; +}); +vi.mock("./bot-native-commands.runtime.js", () => { + return { + ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), + getAgentScopedMediaLocalRoots, + getSessionEntry: sessionMocks.getSessionEntry, + resolveChunkMode, + resolveThreadSessionKeys, + dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable< + TelegramNativeCommandDeps["dispatchChannelInboundTurn"] + >, + }; +}); +vi.mock("./bot/delivery.js", () => ({ + deliverReplies: deliveryMocks.deliverReplies, +})); +vi.mock("./bot/delivery.replies.js", () => ({ + deliverReplies: deliveryMocks.deliverReplies, +})); + +export let activePluginRegistry: ReturnType; + +type TelegramCommandHandler = (ctx: unknown) => Promise; +type TelegramPluginCommandSpecs = Array<{ + name: string; + description: string; + acceptsArgs?: boolean; +}>; +type TelegramLoginFlow = NonNullable; + +export function registerAndResolveStatusHandler(params: { + cfg: OpenClawConfig; + runtimeCfg?: OpenClawConfig; + allowFrom?: string[]; + groupAllowFrom?: string[]; + storeAllowFrom?: string[]; + telegramCfg?: NativeCommandTestParams["telegramCfg"]; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; +}): { + handler: TelegramCommandHandler; + sendMessage: ReturnType; +} { + const { + cfg, + runtimeCfg, + allowFrom, + groupAllowFrom, + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + } = params; + return registerAndResolveCommandHandlerBase({ + commandName: "status", + cfg, + runtimeCfg, + allowFrom: allowFrom ?? ["*"], + groupAllowFrom: groupAllowFrom ?? [], + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + }); +} + +function registerAndResolveCommandHandlerBase(params: { + commandName: string; + cfg: OpenClawConfig; + runtimeCfg?: OpenClawConfig; + allowFrom: string[]; + groupAllowFrom: string[]; + storeAllowFrom?: string[]; + telegramCfg?: NativeCommandTestParams["telegramCfg"]; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; + pluginCommandSpecs?: TelegramPluginCommandSpecs; + runModelsAuthLoginFlow?: TelegramLoginFlow; +}): { + handler: TelegramCommandHandler; + sendMessage: ReturnType; +} { + const { + commandName, + cfg, + runtimeCfg, + allowFrom, + groupAllowFrom, + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + pluginCommandSpecs, + runModelsAuthLoginFlow, + } = params; + const commandHandlers = new Map(); + const sendMessage = vi.fn().mockResolvedValue(undefined); + const baseRuntimeCfg = runtimeCfg ?? cfg; + const commandRuntimeCfg = baseRuntimeCfg; + const telegramDeps: TelegramNativeCommandDeps = { + getRuntimeConfig: vi.fn(() => commandRuntimeCfg), + readChannelAllowFromStore: vi.fn(async () => storeAllowFrom ?? []), + dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable< + TelegramNativeCommandDeps["dispatchChannelInboundTurn"] + >, + listSkillCommandsForAgents: vi.fn(() => []), + syncTelegramMenuCommands: vi.fn(), + sendMessageTelegram: vi.fn(async (_to, text) => { + await sendMessage(100, text, {}); + return { messageId: "999", chatId: "100" }; + }), + ...(runModelsAuthLoginFlow ? { runModelsAuthLoginFlow } : {}), + }; + withPluginRuntimeRegistryScope(activePluginRegistry, () => { + for (const spec of pluginCommandSpecs ?? []) { + expect( + registerPluginCommand(`test-${spec.name}`, { + ...spec, + requireAuth: true, + handler: pluginRuntimeMocks.executePluginCommand, + }), + ).toEqual({ ok: true }); + } + registerTelegramNativeCommands({ + ...createNativeCommandTestParams({ + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage, + }, + command: vi.fn((name: string, cb: TelegramCommandHandler) => { + commandHandlers.set(name, cb); + }), + } as unknown as NativeCommandTestParams["bot"], + cfg, + allowFrom, + groupAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + telegramDeps, + }), + }); + }); + + const handler = commandHandlers.get(commandName); + if (!handler) { + throw new Error(`expected ${commandName} command handler to be registered`); + } + return { handler, sendMessage }; +} + +export function registerAndResolveCommandHandler(params: { + commandName: string; + cfg: OpenClawConfig; + allowFrom?: string[]; + groupAllowFrom?: string[]; + storeAllowFrom?: string[]; + telegramCfg?: NativeCommandTestParams["telegramCfg"]; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; + pluginCommandSpecs?: TelegramPluginCommandSpecs; + runModelsAuthLoginFlow?: TelegramLoginFlow; +}): { + handler: TelegramCommandHandler; + sendMessage: ReturnType; +} { + const { + commandName, + cfg, + allowFrom, + groupAllowFrom, + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + pluginCommandSpecs, + runModelsAuthLoginFlow, + } = params; + return registerAndResolveCommandHandlerBase({ + commandName, + cfg, + allowFrom: allowFrom ?? [], + groupAllowFrom: groupAllowFrom ?? [], + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + pluginCommandSpecs, + runModelsAuthLoginFlow, + }); +} + +export function requireValue(value: T | null | undefined, label: string): T { + if (value == null) { + throw new Error(`expected ${label}`); + } + return value; +} + +export const requireRecord = createRequireRecord("record", "expected-label-object"); + +export function firstMockArg( + mockFn: ReturnType, + label: string, + callIndex = 0, +): unknown { + const call = mockFn.mock.calls.at(callIndex); + if (!call) { + throw new Error(`expected ${label} call ${callIndex}`); + } + return call.at(0); +} + +export function expectRecordFields( + value: unknown, + expected: Record, + label: string, +): Record { + const record = requireRecord(value, label); + for (const [key, expectedValue] of Object.entries(expected)) { + expect(record[key], `${label}.${key}`).toEqual(expectedValue); + } + return record; +} + +export function expectSendMessageCall(params: { + sendMessage: ReturnType; + callIndex?: number; + chatId: unknown; + text?: string; + textIncludes?: string; + optionFields?: Record; + requireReplyMarkup?: boolean; + label: string; +}): Record { + const call = requireValue( + params.sendMessage.mock.calls[params.callIndex ?? 0], + `${params.label} sendMessage call`, + ); + expect(call[0]).toBe(params.chatId); + if (params.text !== undefined) { + expect(call[1]).toBe(params.text); + } + if (params.textIncludes !== undefined) { + expect(String(call[1])).toContain(params.textIncludes); + } + const options = params.optionFields + ? expectRecordFields(call[2], params.optionFields, `${params.label} sendMessage options`) + : requireRecord(call[2], `${params.label} sendMessage options`); + if (params.requireReplyMarkup) { + requireRecord(options.reply_markup, `${params.label} reply markup`); + } + return options; +} + +export function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled(); + expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled(); + expectSendMessageCall({ + sendMessage, + chatId: -1001234567890, + text: "You are not authorized to use this command.", + optionFields: { message_thread_id: 42 }, + label: "unauthorized /new", + }); +} + +export function resetSessionMetaMocks() { + persistentBindingMocks.resolveConfiguredBindingRoute.mockClear(); + persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => + createConfiguredBindingRoute(route, null), + ); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); + persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); + commandAuthMocks.resolveCommandArgMenu.mockClear().mockImplementation(({ command, args }) => { + if (args?.raw || (args?.values && Object.keys(args.values).length > 0)) { + return null; + } + const arg = command.args?.[0]; + if (!arg) { + return null; + } + if (command.key === "think") { + return { + arg, + choices: ["low", "medium", "high"].map((value) => ({ label: value, value })), + }; + } + if (command.key === "fast") { + const choices = ["on", "off", "auto (30 sec)", "default", "status"]; + return { + arg, + choices: choices.map((value) => ({ label: value, value })), + }; + } + return null; + }); + agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]); + sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined); + sessionMocks.sessionStoreEntries.mockClear().mockReturnValue({}); + sessionMocks.getSessionEntry.mockImplementation( + ({ storePath, sessionKey }: { storePath: string; sessionKey: string }) => + sessionMocks.sessionStoreEntries(storePath)[sessionKey], + ); + sessionMocks.updateSessionStoreEntry.mockClear().mockImplementation(async (params) => { + const current = sessionMocks.sessionStoreEntries(params.storePath)[params.sessionKey]; + if (!current) { + return null; + } + const patch = await params.update({ ...current }); + return patch ? { ...current, ...patch } : current; + }); + sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); + sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); + pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" }); + activePluginRegistry = createEmptyPluginRegistry(); + replyMocks.dispatchReplyWithBufferedBlockDispatcher + .mockClear() + .mockResolvedValue(dispatchReplyResult); + dispatchChannelInboundTurnMock.mockClear(); + sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null); + sessionBindingMocks.touch.mockReset(); + deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true }); +} + +activePluginRegistry = createEmptyPluginRegistry(); +const { registerTelegramNativeCommands } = await import("./bot-native-commands.js"); +resetSessionMetaMocks(); +const warmStatusHandler = registerAndResolveStatusHandler({ cfg: {} }); +await warmStatusHandler.handler(createTelegramPrivateCommandContext()); diff --git a/extensions/telegram/src/bot-native-commands.login.test.ts b/extensions/telegram/src/bot-native-command-login.test.ts similarity index 57% rename from extensions/telegram/src/bot-native-commands.login.test.ts rename to extensions/telegram/src/bot-native-command-login.test.ts index a1ae41c28a1c..d7b7f06bf941 100644 --- a/extensions/telegram/src/bot-native-commands.login.test.ts +++ b/extensions/telegram/src/bot-native-command-login.test.ts @@ -7,7 +7,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import type { ModelsAuthLoginFlowOptions } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; import { createTelegramGroupCommandContext } from "./bot-native-commands.fixture-test-support.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; import { @@ -19,12 +21,18 @@ import { import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js"; import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js"; +const loginSessionMocks = vi.hoisted(() => ({ + getSessionEntry: vi.fn(), + loadSessionStore: vi.fn(), + resolveStorePath: vi.fn(), + updateSessionStoreEntry: vi.fn(), +})); + vi.mock("./bot-native-commands.runtime.js", () => ({ ensureConfiguredBindingRouteReady: vi.fn(async () => ({ ok: true })), finalizeInboundContext: vi.fn((ctx: unknown) => ctx), getAgentScopedMediaLocalRoots: vi.fn(() => []), - getSessionEntry: vi.fn(() => undefined), - recordInboundSessionMetaSafe: vi.fn(async () => undefined), + getSessionEntry: loginSessionMocks.getSessionEntry, resolveChunkMode: vi.fn(() => "length"), resolveThreadSessionKeys: vi.fn( ({ @@ -39,26 +47,33 @@ vi.mock("./bot-native-commands.runtime.js", () => ({ }), ), })); -vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({ - formatSqliteSessionFileMarker: vi.fn(() => "sqlite:test"), - getSessionEntry: vi.fn(() => undefined), - resolveStorePath: vi.fn(() => "/tmp/openclaw-login-test.sqlite"), - updateSessionStoreEntry: vi.fn(async () => undefined), -})); +vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/session-store-runtime", + ); + return { + ...actual, + getSessionEntry: loginSessionMocks.getSessionEntry, + resolveStorePath: loginSessionMocks.resolveStorePath, + updateSessionStoreEntry: loginSessionMocks.updateSessionStoreEntry, + }; +}); type LoginFlowMock = ReturnType; +type TelegramLoginFlow = NonNullable; let loginAccountIndex = 0; function registerLoginCommand(params: { cfg: OpenClawConfig; loginFlow: LoginFlowMock; + accountId?: string; allowFrom?: string[]; abortSignal?: AbortSignal; runtime?: RuntimeEnv; }) { const botHarness = createCommandBot(); - const accountId = `login-test-${++loginAccountIndex}`; + const accountId = params.accountId ?? `login-test-${++loginAccountIndex}`; const nativeParams = createNativeCommandTestParams(params.cfg, { accountId, bot: botHarness.bot, @@ -106,6 +121,22 @@ describe("registerTelegramNativeCommands /login", () => { beforeEach(() => { resetTelegramForumFlagCacheForTest(); resetNativeCommandMenuMocks(); + loginSessionMocks.loadSessionStore.mockReset().mockReturnValue({}); + loginSessionMocks.getSessionEntry + .mockReset() + .mockImplementation( + ({ storePath, sessionKey }: { storePath: string; sessionKey: string }) => + loginSessionMocks.loadSessionStore(storePath)[sessionKey], + ); + loginSessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); + loginSessionMocks.updateSessionStoreEntry.mockReset().mockImplementation(async (params) => { + const current = loginSessionMocks.loadSessionStore(params.storePath)[params.sessionKey]; + if (!current) { + return null; + } + const patch = await params.update({ ...current }); + return patch ? { ...current, ...patch } : current; + }); }); it("handles /login codex by sending the device code before login completes", async () => { @@ -532,4 +563,366 @@ describe("registerTelegramNativeCommands /login", () => { ); expect(sendMessage).toHaveBeenCalledTimes(1); }); + it("moves the target session to the profile returned by Telegram /login codex", async () => { + const finishLogin = createDeferred(); + loginSessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + authProfileOverride: "openai:owner@example.com", + sessionId: "sess-main", + updatedAt: 1, + }, + }); + const runModelsAuthLoginFlow = vi.fn(async (opts) => { + await opts.prompter.deviceCode?.({ + title: "OpenAI Codex device code", + code: "ABCD-EFGH", + expiresInMinutes: 15, + message: "URL: https://auth.openai.com/codex/device", + }); + await finishLogin.promise; + return { + providerId: "openai", + methodId: "device-code", + profiles: [ + { profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }, + ], + }; + }); + + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + expect(loginSessionMocks.updateSessionStoreEntry).not.toHaveBeenCalled(); + finishLogin.resolve(); + + expect(runModelsAuthLoginFlow).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + method: "device-code", + agent: "main", + }), + ); + expect( + (runModelsAuthLoginFlow.mock.calls[0]?.[0] as { profileId?: string } | undefined)?.profileId, + ).toBeUndefined(); + await vi.waitFor(() => + expect(loginSessionMocks.updateSessionStoreEntry).toHaveBeenCalledWith({ + sessionKey: "agent:main:main", + storePath: "/tmp/openclaw-sessions.json", + requireWriteSuccess: true, + skipMaintenance: true, + update: expect.any(Function), + }), + ); + const patchUpdate = ( + loginSessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { + update?: (entry: Record) => Record; + } + )?.update?.({ + authProfileOverride: "openai:owner@example.com", + sessionId: "sess-main", + updatedAt: 1, + }); + expect(patchUpdate).toEqual({ + authProfileOverride: "openai:new-owner@example.com", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: undefined, + }); + await vi.waitFor(() => + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + {}, + ), + ); + }); + + it("moves a session created while Telegram login is pending to the returned profile", async () => { + const finishLogin = createDeferred(); + let sessionStore: Record = {}; + loginSessionMocks.loadSessionStore.mockImplementation(() => sessionStore); + const runModelsAuthLoginFlow = vi.fn(async (opts) => { + await opts.prompter.deviceCode?.({ + title: "OpenAI Codex device code", + code: "NEW-SESSION", + }); + await finishLogin.promise; + return { + providerId: "openai", + methodId: "device-code", + profiles: [ + { profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }, + ], + }; + }); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + sessionStore = { + "agent:main:main": { + sessionId: "sess-created-during-login", + updatedAt: 2, + }, + }; + finishLogin.resolve(); + + await vi.waitFor(() => + expect(loginSessionMocks.updateSessionStoreEntry).toHaveBeenCalledTimes(1), + ); + const update = ( + loginSessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { + update?: (entry: SessionEntry) => Partial | null; + } + )?.update; + expect( + update?.({ + sessionId: "sess-created-during-login", + updatedAt: 2, + }), + ).toEqual({ + authProfileOverride: "openai:new-owner@example.com", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: undefined, + }); + await vi.waitFor(() => + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + {}, + ), + ); + }); + + it("preserves a later user-selected profile on a session created during Telegram login", async () => { + const finishLogin = createDeferred(); + let sessionStore: Record = {}; + loginSessionMocks.loadSessionStore.mockImplementation(() => sessionStore); + const runModelsAuthLoginFlow = vi.fn(async (opts) => { + await opts.prompter.deviceCode?.({ + title: "OpenAI Codex device code", + code: "LATER-USER-SELECTION", + }); + await finishLogin.promise; + return { + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:login-profile", provider: "openai", mode: "oauth" }], + }; + }); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + sessionStore = { + "agent:main:main": { + authProfileOverride: "openai:later-user-profile", + authProfileOverrideSource: "user", + sessionId: "sess-created-during-login", + updatedAt: 2, + }, + }; + finishLogin.resolve(); + + await vi.waitFor(() => + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", + {}, + ), + ); + expect(sessionStore["agent:main:main"]?.authProfileOverride).toBe("openai:later-user-profile"); + expect(sendMessage).not.toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + expect.any(Object), + ); + }); + + it("marks a same-profile Telegram login as user-selected", async () => { + loginSessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: 2, + sessionId: "sess-main", + updatedAt: 1, + }, + }); + const runModelsAuthLoginFlow = vi.fn(async () => ({ + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }], + })); + const { handler } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + + const update = ( + loginSessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { + update?: (entry: Record) => Record; + } + )?.update; + expect(update).toBeTypeOf("function"); + expect( + update?.({ + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: 2, + sessionId: "sess-main", + updatedAt: 1, + }), + ).toEqual({ + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: undefined, + }); + expect( + update?.({ + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "user", + sessionId: "sess-main", + updatedAt: 2, + }), + ).toBeNull(); + }); + + it("reports partial success when Telegram cannot persist the returned profile", async () => { + loginSessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + authProfileOverride: "openai:old-owner@example.com", + sessionId: "sess-main", + updatedAt: 1, + }, + }); + loginSessionMocks.updateSessionStoreEntry.mockRejectedValueOnce(new Error("write failed")); + const runModelsAuthLoginFlow = vi.fn(async () => ({ + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }], + })); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", + {}, + ); + expect(sendMessage).not.toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + expect.any(Object), + ); + }); + + it("reports partial success when Telegram login returns no OpenAI profile", async () => { + const runModelsAuthLoginFlow = vi.fn(async () => ({ + providerId: "openai", + methodId: "device-code", + profiles: [], + })); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", + {}, + ); + expect(sendMessage).not.toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + expect.any(Object), + ); + }); + + it("revalidates an unchanged Telegram profile after device login", async () => { + const previousEntry = { + authProfileOverride: "openai:owner@example.com", + authProfileOverrideSource: "user", + sessionId: "sess-main", + updatedAt: 1, + }; + loginSessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": previousEntry, + }); + loginSessionMocks.updateSessionStoreEntry.mockImplementationOnce(async (params) => { + const concurrentEntry = { + ...previousEntry, + authProfileOverride: "openai:concurrent-owner@example.com", + updatedAt: 2, + }; + const patch = await params.update({ ...concurrentEntry }); + return patch ? { ...concurrentEntry, ...patch } : concurrentEntry; + }); + const runModelsAuthLoginFlow = vi.fn(async () => ({ + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }], + })); + const { handler, sendMessage } = registerLoginCommand({ + accountId: "default", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + loginFlow: runModelsAuthLoginFlow, + }); + + await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); + + expect(sendMessage).toHaveBeenCalledWith( + 100, + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", + {}, + ); + expect(sendMessage).not.toHaveBeenCalledWith( + 100, + "Codex login complete. Try your request again now.", + expect.any(Object), + ); + }); }); diff --git a/extensions/telegram/src/bot-native-command-login.ts b/extensions/telegram/src/bot-native-command-login.ts new file mode 100644 index 000000000000..69c17747f45d --- /dev/null +++ b/extensions/telegram/src/bot-native-command-login.ts @@ -0,0 +1,271 @@ +// Telegram plugin module implements native Codex login behavior. +import type { CommandArgs } from "openclaw/plugin-sdk/command-auth-native"; +import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; +import { codexChannelLoginRuntime } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; +import { + resolveStorePath, + updateSessionStoreEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; +import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { defaultTelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; +import type { TelegramCommandDispatch } from "./bot-native-command-dispatch.js"; +import { buildTelegramRoutingTarget } from "./bot/helpers.js"; + +const activeTelegramCodexLoginFlows = codexChannelLoginRuntime.createFlowRegistry(); + +type TelegramLoginDeviceCode = { + title: string; + code: string; + expiresInMinutes?: number; + message?: string; +}; + +// Telegram's inline-code entity provides the tap-to-copy affordance needed for +// short-lived device codes; plain text and literal backticks do not. +function formatTelegramLoginDeviceCode(params: TelegramLoginDeviceCode): string { + return [ + `${escapeHtml(params.title)}`, + "", + ...(params.message ? [escapeHtml(params.message)] : []), + `Code: ${escapeHtml(params.code)}`, + ...(params.expiresInMinutes + ? [`Code expires in ${params.expiresInMinutes} minutes. Never share it.`] + : []), + ].join("\n"); +} + +function resolveTelegramCodexLoginProviderInput(commandArgs: CommandArgs | undefined): string { + const providerValue = commandArgs?.values?.provider; + return typeof providerValue === "string" && providerValue.trim() + ? providerValue + : (commandArgs?.raw ?? "codex"); +} + +function buildTelegramCodexLoginFlowKey(params: { + dispatch: TelegramCommandDispatch; + provider: string; +}): string { + const { dispatch } = params; + const threadKey = + dispatch.threadSpec.id == null + ? dispatch.threadSpec.scope + : `${dispatch.threadSpec.scope}:${dispatch.threadSpec.id}`; + return [ + "telegram", + dispatch.route.accountId, + String(dispatch.chatId), + threadKey, + dispatch.route.agentId, + params.provider, + ].join(":"); +} + +export async function executeTelegramLoginCommand(params: { + dispatch: TelegramCommandDispatch; + commandArgs?: CommandArgs; +}): Promise { + const { dispatch } = params; + const sendLoginMessage = async (text: string) => { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: dispatch.runtime, + fn: () => dispatch.bot.api.sendMessage(dispatch.chatId, text, dispatch.threadParams ?? {}), + }); + }; + const sendLoginDeviceCode = async (deviceCode: TelegramLoginDeviceCode) => { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: dispatch.runtime, + fn: () => + dispatch.bot.api.sendMessage(dispatch.chatId, formatTelegramLoginDeviceCode(deviceCode), { + ...dispatch.threadParams, + parse_mode: "HTML", + }), + }); + }; + const sendLoginResultMessage = async (text: string) => { + await dispatch.telegramDeps.sendMessageTelegram( + buildTelegramRoutingTarget(dispatch.chatId, dispatch.threadSpec), + text, + { + cfg: dispatch.runtimeCfg, + token: dispatch.opts.token, + accountId: dispatch.route.accountId, + }, + ); + }; + if ( + !dispatch.senderIsOwner || + !codexChannelLoginRuntime.hasConfiguredCommandOwnerAllowlist(dispatch.runtimeCfg) + ) { + await sendLoginMessage("Only a configured OpenClaw owner can start Codex login from Telegram."); + return false; + } + if (dispatch.isGroup) { + await sendLoginMessage( + "For safety, Codex login codes are only sent in a private chat with this bot. DM this bot `/login codex` to pair Codex.", + ); + return true; + } + const loginProvider = codexChannelLoginRuntime.resolveProvider( + resolveTelegramCodexLoginProviderInput(params.commandArgs), + ); + if (!loginProvider) { + await sendLoginMessage("Unsupported login provider. Use `/login codex`."); + return false; + } + const flowKey = buildTelegramCodexLoginFlowKey({ dispatch, provider: loginProvider }); + const reservation = codexChannelLoginRuntime.reserveFlow({ + flows: activeTelegramCodexLoginFlows, + flowKey, + }); + if (reservation.status === "active") { + await sendLoginMessage( + "A Codex login code is already active for this Telegram chat. Complete it, or wait for it to expire before requesting a new one.", + ); + return true; + } + const flowSignal = dispatch.opts.accountAbortSignal + ? AbortSignal.any([reservation.record.signal, dispatch.opts.accountAbortSignal]) + : reservation.record.signal; + const deviceCodeDelivered = createDeferred(); + let deviceCodeWasDelivered = false; + // Device-code delivery releases Telegram's serialized chat lane. The + // reservation and account signal still own polling through completion. + const completion = (async () => { + const sessionSwitchFailedMessage = + "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually."; + let terminalMessage: string; + const loginFlow = + dispatch.telegramDeps.runModelsAuthLoginFlow ?? + defaultTelegramNativeCommandDeps.runModelsAuthLoginFlow; + try { + if (!loginFlow) { + throw new Error("Codex login flow is unavailable."); + } + const targetSessionEntryAtStart = dispatch.nativeCommandRuntime.getSessionEntry({ + agentId: dispatch.route.agentId, + sessionKey: dispatch.targetSessionKey, + }); + const loginResult = await codexChannelLoginRuntime.runDeviceLoginFlow({ + runLoginFlow: loginFlow, + provider: loginProvider, + agentId: dispatch.route.agentId, + config: dispatch.runtimeCfg, + runtime: dispatch.runtime, + signal: flowSignal, + sendMessage: sendLoginMessage, + sendDeviceCode: async (deviceCode) => { + flowSignal.throwIfAborted(); + await sendLoginDeviceCode(deviceCode); + flowSignal.throwIfAborted(); + deviceCodeWasDelivered = true; + deviceCodeDelivered.resolve(); + }, + unsupportedPromptMessage: "Telegram /login supports only fixed Codex device-code auth.", + }); + flowSignal.throwIfAborted(); + const nextProfileId = loginResult.profiles.find( + (profile) => profile.provider === loginProvider, + )?.profileId; + terminalMessage = "Codex login complete. Try your request again now."; + if (!nextProfileId) { + terminalMessage = sessionSwitchFailedMessage; + } else { + const storePath = resolveStorePath(dispatch.runtimeCfg.session?.store, { + agentId: dispatch.route.agentId, + }); + let entryObserved = false; + let adoptionAllowed = false; + try { + const persisted = await updateSessionStoreEntry({ + sessionKey: dispatch.targetSessionKey, + storePath, + requireWriteSuccess: true, + skipMaintenance: true, + update: (entry) => { + entryObserved = true; + const source = + entry.authProfileOverrideSource ?? + (typeof entry.authProfileOverrideCompactionCount === "number" + ? "auto" + : entry.authProfileOverride + ? "user" + : undefined); + if ( + flowSignal.aborted || + (targetSessionEntryAtStart + ? entry.sessionId !== targetSessionEntryAtStart.sessionId || + entry.authProfileOverride !== targetSessionEntryAtStart.authProfileOverride || + entry.authProfileOverrideSource !== + targetSessionEntryAtStart.authProfileOverrideSource || + entry.authProfileOverrideCompactionCount !== + targetSessionEntryAtStart.authProfileOverrideCompactionCount + : source === "user" && entry.authProfileOverride !== nextProfileId) + ) { + return null; + } + adoptionAllowed = true; + return entry.authProfileOverride !== nextProfileId || + entry.authProfileOverrideSource !== "user" || + entry.authProfileOverrideCompactionCount !== undefined + ? { + authProfileOverride: nextProfileId, + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: undefined, + } + : null; + }, + }); + flowSignal.throwIfAborted(); + if ( + entryObserved && + (!adoptionAllowed || + !persisted || + persisted.authProfileOverride !== nextProfileId || + persisted.authProfileOverrideSource !== "user" || + persisted.authProfileOverrideCompactionCount !== undefined) + ) { + terminalMessage = sessionSwitchFailedMessage; + } + } catch (error) { + flowSignal.throwIfAborted(); + dispatch.runtime.error?.( + danger( + `telegram /login codex completed but failed to update session auth profile: ${String( + error, + )}`, + ), + ); + terminalMessage = sessionSwitchFailedMessage; + } + } + } catch (error) { + if (flowSignal.aborted) { + return; + } + dispatch.runtime.error?.(danger(`telegram /login codex failed: ${String(error)}`)); + terminalMessage = "Codex login did not complete. Send `/login codex` to request a new code."; + } + if (flowSignal.aborted) { + return; + } + try { + await sendLoginResultMessage(terminalMessage); + } catch (error) { + dispatch.runtime.error?.( + danger(`telegram /login codex result notification failed: ${String(error)}`), + ); + } + })().finally(() => { + codexChannelLoginRuntime.releaseFlow({ + flows: activeTelegramCodexLoginFlows, + flowKey, + record: reservation.record, + }); + }); + await Promise.race([deviceCodeDelivered.promise, completion]); + return deviceCodeWasDelivered; +} diff --git a/extensions/telegram/src/bot-native-command-plugins.test.ts b/extensions/telegram/src/bot-native-command-plugins.test.ts new file mode 100644 index 000000000000..acf7a1cb2b76 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-plugins.test.ts @@ -0,0 +1,692 @@ +import { + createEmptyPluginRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/channel-test-helpers"; +// Telegram tests cover bot native commands plugin behavior. +import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; +import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; +import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTelegramTopicCommandContext } from "./bot-native-commands.fixture-test-support.js"; +import { + createCommandBot, + createNativeCommandTestParams, + createPrivateCommandContext, + deliverReplies, + editMessageTelegram, + emitTelegramMessageSentHooks, + resetNativeCommandMenuMocks, +} from "./bot-native-commands.menu-test-support.js"; +import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js"; + +const pluginSessionMocks = vi.hoisted(() => ({ + getSessionEntry: vi.fn(), + resolveStorePath: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/session-store-runtime", + ); + return { + ...actual, + getSessionEntry: pluginSessionMocks.getSessionEntry, + resolveStorePath: pluginSessionMocks.resolveStorePath, + }; +}); +type CommandBotHarness = ReturnType; +type PlugCommandHarnessParams = { + botHarness?: CommandBotHarness; + cfg?: OpenClawConfig; + command?: Record; + acceptsArgs?: boolean; + args?: string; + result?: Record; + registerOverrides?: Partial[0]>; +}; + +const pluginCommandHandler = vi.fn(async (_ctx: Record) => ({ text: "ok" })); + +function registerTestPluginCommand(params: { + name: string; + description: string; + acceptsArgs?: boolean; + command?: Record; + result?: Record; +}) { + expect( + registerPluginCommand(`test-${params.name}`, { + name: params.name, + description: params.description, + acceptsArgs: params.acceptsArgs, + requireAuth: false, + ...params.command, + handler: async (ctx) => { + const handlerResult = await pluginCommandHandler(ctx as unknown as Record); + return params.result ?? handlerResult; + }, + }), + ).toEqual({ ok: true }); +} + +function primePlugCommand(params: PlugCommandHarnessParams = {}) { + registerTestPluginCommand({ + name: "plug", + description: "Plugin command", + acceptsArgs: params.acceptsArgs ?? true, + command: params.command, + result: params.result, + }); +} + +function registerPlugCommand(params: PlugCommandHarnessParams = {}) { + const botHarness = params.botHarness ?? createCommandBot(); + primePlugCommand(params); + registerTelegramNativeCommands({ + ...createNativeCommandTestParams(params.cfg ?? {}, { + bot: botHarness.bot, + }), + ...params.registerOverrides, + }); + const handler = botHarness.commandHandlers.get("plug"); + if (!handler) { + throw new Error("expected plug command handler to be registered"); + } + return { + ...botHarness, + handler, + }; +} + +function firstCall(mock: { mock: { calls: Array> } }) { + const call = mock.mock.calls.at(0); + if (!call) { + throw new Error("expected first mock call"); + } + return call; +} + +function firstCallArg(mock: { mock: { calls: Array> } }, argIndex = 0) { + const arg = firstCall(mock)[argIndex]; + if (!arg || typeof arg !== "object") { + throw new Error(`expected first mock call arg ${argIndex}`); + } + return arg as Record; +} + +function firstDeliverRepliesParams() { + return firstCallArg(deliverReplies as unknown as { mock: { calls: Array> } }); +} + +function firstExecutePluginCommandParams() { + return firstCallArg( + pluginCommandHandler as unknown as { + mock: { calls: Array> }; + }, + ); +} + +function replyAt(params: Record, index = 0) { + const replies = params.replies as Array> | undefined; + const reply = replies?.[index]; + if (!reply) { + throw new Error(`expected reply ${index}`); + } + return reply; +} + +resetPluginRuntimeStateForTest(); +setActivePluginRegistry(createEmptyPluginRegistry()); +const { registerTelegramNativeCommands } = await import("./bot-native-commands.js"); +registerTelegramNativeCommands(createNativeCommandTestParams({})); + +describe("registerTelegramNativeCommands", () => { + beforeEach(() => { + resetTelegramForumFlagCacheForTest(); + resetNativeCommandMenuMocks(); + resetPluginRuntimeStateForTest(); + setActivePluginRegistry(createEmptyPluginRegistry()); + clearPluginCommands(); + pluginCommandHandler.mockReset().mockResolvedValue({ text: "ok" }); + pluginSessionMocks.getSessionEntry.mockReset().mockReturnValue(undefined); + pluginSessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); + }); + + it("passes agent-scoped media roots for plugin command replies with media", async () => { + const mediaMaxBytes = 50 * 1024 * 1024; + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + bindings: [{ agentId: "work", match: { channel: "telegram", accountId: "default" } }], + }; + + const { handler, sendMessage } = registerPlugCommand({ + cfg, + result: { + text: "with media", + mediaUrl: "/tmp/workspace-work/render.png", + }, + registerOverrides: { + mediaMaxBytes, + } as Partial[0]>, + }); + + await handler(createPrivateCommandContext()); + + const deliverParams = firstDeliverRepliesParams(); + expect(deliverParams.mediaMaxBytes).toBe(mediaMaxBytes); + const mediaLocalRoots = deliverParams.mediaLocalRoots as Array | undefined; + expect(mediaLocalRoots?.some((root) => /[\\/]\.openclaw[\\/]workspace-work$/.test(root))).toBe( + true, + ); + expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); + }); + + it("delivers presentation-only tables returned by plugin commands", async () => { + const presentation = { + title: "FY25 outlook", + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage"], + rows: [["Acme", "Won"]], + }, + ], + }; + const { handler } = registerPlugCommand({ result: { presentation } }); + + await handler(createPrivateCommandContext()); + + expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ presentation }); + expect(replyAt(firstDeliverRepliesParams()).text).toBeUndefined(); + }); + + it("delivers Telegram button-only plugin command replies", async () => { + const buttons = [[{ text: "Retry", callback_data: "retry" }]]; + const { handler } = registerPlugCommand({ + result: { channelData: { telegram: { buttons } } }, + }); + + await handler(createPrivateCommandContext()); + + expect(replyAt(firstDeliverRepliesParams())).toEqual({ + channelData: { telegram: { buttons } }, + }); + }); + + it("targets reaction-only plugin replies at the invoking command message", async () => { + const { handler } = registerPlugCommand({ + result: { channelData: { telegram: { reaction: { emoji: "🔥" } } } }, + }); + + await handler(createPrivateCommandContext({ messageId: 321 })); + + const deliveryParams = firstDeliverRepliesParams(); + expect(replyAt(deliveryParams)).toEqual({ + replyToId: "321", + channelData: { telegram: { reaction: { emoji: "🔥" } } }, + }); + expect(deliveryParams.replyToMode).toBe("all"); + }); + + it("uses the empty-response fallback for unrelated metadata-only plugin results", async () => { + const { handler } = registerPlugCommand({ + result: { channelData: { plugin: { traceId: "trace-1" } } }, + }); + + await handler(createPrivateCommandContext()); + + expect(replyAt(firstDeliverRepliesParams())).toEqual({ + text: "No response generated. Please try again.", + }); + }); + + it("replies to unmatched plugin commands in the originating forum topic", async () => { + const { handler, sendMessage } = registerPlugCommand({ acceptsArgs: false }); + + await handler({ + match: "unexpected", + message: { + message_id: 2, + date: Math.floor(Date.now() / 1000), + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + message_thread_id: 77, + from: { id: 200, username: "bob" }, + }, + }); + + const sendMessageCall = firstCall(sendMessage); + expect(sendMessageCall[0]).toBe(-1001234567890); + expect(sendMessageCall[1]).toBe("Command not found."); + expect( + (sendMessageCall[2] as { message_thread_id?: number } | undefined)?.message_thread_id, + ).toBe(77); + }); + + it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { + telegram: + "Running this command now...\n\nI'll edit this message with the final result when it's ready.", + }, + }, + result: { + text: "Command completed successfully", + }, + }); + + await handler( + createPrivateCommandContext({ + match: "now", + }), + ); + + const sendMessageCall = firstCall(sendMessage); + expect(sendMessageCall[0]).toBe(100); + expect(String(sendMessageCall[1])).toContain("Running this command now"); + expect(sendMessageCall[2]).toBeUndefined(); + const editCall = firstCall( + editMessageTelegram as unknown as { mock: { calls: Array> } }, + ); + expect(editCall[0]).toBe(100); + expect(editCall[1]).toBe(999); + expect(String(editCall[2])).toContain("Command completed successfully"); + expect((editCall[3] as { accountId?: string } | undefined)?.accountId).toBe("default"); + expect(deleteMessage).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + const hookParams = firstCallArg( + emitTelegramMessageSentHooks as unknown as { mock: { calls: Array> } }, + ); + expect(hookParams.chatId).toBe("100"); + expect(hookParams.content).toBe("Command completed successfully"); + expect(hookParams.messageId).toBe(999); + expect(hookParams.success).toBe(true); + }); + + it("preserves Telegram buttons when editing a metadata-driven progress placeholder", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Choose an option", + channelData: { + telegram: { + buttons: [[{ text: "Approve", callback_data: "approve" }]], + }, + }, + }, + }); + + await handler(createPrivateCommandContext({ match: "now" })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + const editCall = firstCall( + editMessageTelegram as unknown as { mock: { calls: Array> } }, + ); + expect(editCall[0]).toBe(100); + expect(editCall[1]).toBe(999); + expect(editCall[2]).toBe("Choose an option"); + expect((editCall[3] as { buttons?: unknown } | undefined)?.buttons).toEqual([ + [{ text: "Approve", callback_data: "approve" }], + ]); + expect(deleteMessage).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("delivers reactions after cleaning up a metadata-driven progress placeholder", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Command completed successfully", + channelData: { telegram: { reaction: { emoji: "🔥" } } }, + }, + }); + + await handler(createPrivateCommandContext({ match: "now", messageId: 321 })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + const deliveryParams = firstDeliverRepliesParams(); + expect(deliveryParams.replyToMode).toBe("all"); + expect(replyAt(deliveryParams)).toEqual({ + text: "Command completed successfully", + replyToId: "321", + channelData: { telegram: { reaction: { emoji: "🔥" } } }, + }); + }); + + it("falls back to a normal reply when a metadata-driven progress result is not editable", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "rich output", + mediaUrl: "/tmp/render.png", + }, + }); + + await handler( + createPrivateCommandContext({ + match: "now", + }), + ); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + expect(replyAt(firstDeliverRepliesParams()).mediaUrl).toBe("/tmp/render.png"); + }); + + it("falls back to a normal reply when a progress result has presentation controls", async () => { + const presentation = { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Approve", action: { type: "callback", value: "/approve yes" } }], + }, + ], + }; + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Approval required", + presentation, + }, + }); + + await handler(createPrivateCommandContext({ match: "now" })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ + text: "Approval required", + presentation, + }); + }); + + it("cleans up the progress placeholder before falling back after an edit failure", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Command completed successfully", + }, + }); + editMessageTelegram.mockRejectedValueOnce(new Error("message to edit not found")); + + await handler(createPrivateCommandContext({ match: "now" })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + expect(replyAt(firstDeliverRepliesParams()).text).toBe("Command completed successfully"); + }); + + it("cleans up the progress placeholder when Telegram suppresses a local exec approval reply", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", + channelData: { + execApproval: { + approvalId: "7f423fdc-1111-2222-3333-444444444444", + approvalSlug: "7f423fdc", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }, + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + + await handler(createPrivateCommandContext({ match: "now" })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => { + const { handler } = registerPlugCommand({ + cfg: { + channels: { + telegram: { + silentErrorReplies: true, + }, + }, + }, + result: { + text: "plugin failed", + isError: true, + }, + registerOverrides: { + telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig, + }, + }); + + await handler(createPrivateCommandContext()); + + const deliverParams = firstDeliverRepliesParams(); + expect(deliverParams.silent).toBe(true); + expect(replyAt(deliverParams).isError).toBe(true); + }); + + it("uses rich messages for plugin command replies when enabled", async () => { + const { handler } = registerPlugCommand({ + cfg: { + channels: { + telegram: { + richMessages: true, + }, + }, + }, + registerOverrides: { + telegramCfg: { richMessages: true } as TelegramAccountConfig, + }, + }); + + await handler(createPrivateCommandContext()); + + expect(firstDeliverRepliesParams().richMessages).toBe(true); + }); + + it("forwards topic-scoped binding context to Telegram plugin commands", async () => { + const { handler } = registerPlugCommand(); + + await handler({ + match: "", + message: { + message_id: 2, + date: Math.floor(Date.now() / 1000), + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + message_thread_id: 77, + from: { id: 200, username: "bob" }, + }, + }); + + const commandParams = firstExecutePluginCommandParams(); + expect(commandParams.channel).toBe("telegram"); + expect(commandParams.accountId).toBe("default"); + expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:77"); + expect(commandParams.to).toBe("telegram:-1001234567890"); + expect(commandParams.messageThreadId).toBe(77); + }); + + it("treats Telegram forum #General commands as topic 1 when Telegram omits topic metadata", async () => { + const getChat = vi.fn(async () => ({ id: -1001234567890, type: "supergroup", is_forum: true })); + const { handler } = registerPlugCommand({ + botHarness: createCommandBot({ api: { getChat } }), + }); + + await handler({ + match: "", + message: { + message_id: 2, + date: Math.floor(Date.now() / 1000), + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + }, + from: { id: 200, username: "bob" }, + }, + }); + + expect(getChat).toHaveBeenCalledWith(-1001234567890); + const commandParams = firstExecutePluginCommandParams(); + expect(commandParams.accountId).toBe("default"); + expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:1"); + expect(commandParams.to).toBe("telegram:-1001234567890"); + expect(commandParams.messageThreadId).toBe(1); + }); + + it("forwards direct-message binding context to Telegram plugin commands", async () => { + const { handler } = registerPlugCommand(); + + await handler(createPrivateCommandContext({ chatId: 100, userId: 200 })); + + const commandParams = firstExecutePluginCommandParams(); + expect(commandParams.channel).toBe("telegram"); + expect(commandParams.accountId).toBe("default"); + expect(commandParams.from).toBe("telegram:100"); + expect(commandParams.to).toBe("telegram:100"); + expect(commandParams.messageThreadId).toBeUndefined(); + }); + + it("suppresses the fallback reply when a plugin command returns suppressReply: true", async () => { + const { handler } = registerPlugCommand({ + result: { suppressReply: true }, + }); + + await handler(createPrivateCommandContext()); + + expect(deliverReplies).not.toHaveBeenCalled(); + expect(editMessageTelegram).not.toHaveBeenCalled(); + }); + + it("uses bot topic capability for Telegram plugin command DM topic session keys", async () => { + const { handler } = registerPlugCommand(); + + await handler({ + ...createPrivateCommandContext({ chatId: 100, userId: 200, threadId: 77 }), + me: { has_topics_enabled: true }, + }); + + const commandParams = firstExecutePluginCommandParams(); + expect(commandParams.sessionKey).toBe("agent:main:main:thread:100:77"); + const deliveryParams = firstDeliverRepliesParams(); + expect(deliveryParams.sessionKeyForInternalHooks).toBe("agent:main:main:thread:100:77"); + }); + + it("passes persisted topic session identity to plugin commands", async () => { + pluginSessionMocks.getSessionEntry.mockReturnValue({ + authProfileOverride: "openai:owner@example.com", + sessionId: "sess-topic", + updatedAt: 1, + }); + const { handler } = registerPlugCommand({ + cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, + }); + + await handler( + createTelegramTopicCommandContext({ match: "bind --cwd /tmp/work", threadId: 42 }), + ); + + expect(firstExecutePluginCommandParams()).toEqual( + expect.objectContaining({ + sessionKey: "agent:main:telegram:group:-1001234567890:topic:42", + sessionId: "sess-topic", + messageThreadId: 42, + }), + ); + }); + + it.each([ + { + name: "creates a SQLite marker when the entry has no file", + entry: { sessionId: "sess-main", updatedAt: 1 } satisfies SessionEntry, + }, + { + name: "keeps the canonical SQLite marker", + entry: { + sessionId: "sess-main", + sessionFile: "sqlite:main:sess-main:/tmp/openclaw-sessions.json", + updatedAt: 1, + } satisfies SessionEntry, + }, + { + name: "replaces a stale legacy transcript path", + entry: { + sessionId: "sess-main", + sessionFile: "sess-main.jsonl", + updatedAt: 1, + } satisfies SessionEntry, + }, + ])("$name", async ({ entry }) => { + pluginSessionMocks.getSessionEntry.mockReturnValue(entry); + const { handler } = registerPlugCommand(); + + await handler(createPrivateCommandContext({ match: "status" })); + + expect(firstExecutePluginCommandParams()).toEqual( + expect.objectContaining({ + sessionKey: "agent:main:main", + sessionId: "sess-main", + sessionFile: "sqlite:main:sess-main:/tmp/openclaw-sessions.json", + }), + ); + }); + + it("sends an empty-response fallback when a plugin command returns undefined", async () => { + pluginCommandHandler.mockResolvedValueOnce(undefined as never); + const { handler } = registerPlugCommand(); + + await handler(createPrivateCommandContext({ match: "status" })); + + expect(replyAt(firstDeliverRepliesParams())).toEqual({ + text: "No response generated. Please try again.", + }); + }); +}); diff --git a/extensions/telegram/src/bot-native-command-plugins.ts b/extensions/telegram/src/bot-native-command-plugins.ts new file mode 100644 index 000000000000..592f741d98e2 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-plugins.ts @@ -0,0 +1,316 @@ +// Telegram plugin module implements native plugin command behavior. +import { randomUUID } from "node:crypto"; +import type { Bot, Context } from "grammy"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { PluginCommandNativeCandidate } from "openclaw/plugin-sdk/plugin-command-runtime"; +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; +import { + formatSqliteSessionFileMarker, + getSessionEntry, + resolveStorePath, +} from "openclaw/plugin-sdk/session-store-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { + prepareTelegramCommandDispatch, + type TelegramCommandExecutorParams, +} from "./bot-native-command-dispatch.js"; +import { + buildTelegramRoutingTarget, + buildTelegramGroupFrom, + buildTelegramThreadParams, + extractTelegramForumFlag, + resolveTelegramForumFlag, + resolveTelegramMessageThreadSpec, +} from "./bot/helpers.js"; +import type { TelegramGetChat } from "./bot/types.js"; +import type { TelegramInlineButtons } from "./button-types.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; +import { buildInlineKeyboard } from "./inline-keyboard.js"; +import { recordSentMessage } from "./sent-message-cache.js"; + +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + +type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/plugin-entry").PluginCommandResult; +type TelegramNativeReplyChannelData = { + buttons?: TelegramInlineButtons; + pin?: boolean; + reaction?: { emoji?: unknown }; +}; + +function resolveTelegramNativeReplyChannelData( + result: TelegramNativeReplyPayload, +): TelegramNativeReplyChannelData | undefined { + return result.channelData?.telegram as TelegramNativeReplyChannelData | undefined; +} + +function normalizeTelegramNativeReplyPayload( + result: TelegramNativeReplyPayload | null | undefined, +): TelegramNativeReplyPayload { + return result && typeof result === "object" ? result : {}; +} + +function hasTelegramNativeReplyReaction(result: TelegramNativeReplyPayload): boolean { + const reactionEmoji = resolveTelegramNativeReplyChannelData(result)?.reaction?.emoji; + return typeof reactionEmoji === "string" && reactionEmoji.trim().length > 0; +} + +function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { + const { channelData: _channelData, ...portableContent } = result; + if (hasOutboundReplyContent(portableContent, { trimText: true })) { + return true; + } + const telegramData = resolveTelegramNativeReplyChannelData(result); + return Boolean( + buildInlineKeyboard(telegramData?.buttons) || hasTelegramNativeReplyReaction(result), + ); +} + +function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): boolean { + const telegramData = resolveTelegramNativeReplyChannelData(result); + return Boolean( + typeof result.text === "string" && + result.text.trim() && + !result.mediaUrl && + (!result.mediaUrls || result.mediaUrls.length === 0) && + !result.presentation && + !result.interactive && + !result.btw && + !hasTelegramNativeReplyReaction(result) && + telegramData?.pin !== true, + ); +} + +async function cleanupTelegramProgressPlaceholder(params: { + bot: Bot; + chatId: number; + progressMessageId?: number; + runtime: TelegramCommandExecutorParams["runtime"]; +}): Promise { + if (params.progressMessageId == null) { + return; + } + try { + await withTelegramApiErrorLogging({ + operation: "deleteMessage", + runtime: params.runtime, + fn: () => params.bot.api.deleteMessage(params.chatId, params.progressMessageId!), + }); + } catch { + // Best-effort cleanup before fallback or suppression exits. + } +} + +async function resolveTelegramPluginThreadParams(params: { + msg: NonNullable; + bot: Bot; +}) { + const isGroup = params.msg.chat.type === "group" || params.msg.chat.type === "supergroup"; + const getChat = + typeof params.bot.api.getChat === "function" + ? (params.bot.api.getChat.bind(params.bot.api) as TelegramGetChat) + : undefined; + const isForum = + params.msg.chat.is_direct_messages === true + ? false + : await resolveTelegramForumFlag({ + chatId: params.msg.chat.id, + chatType: params.msg.chat.type, + isGroup, + isForum: extractTelegramForumFlag(params.msg.chat), + isTopicMessage: params.msg.is_topic_message, + getChat, + }); + return buildTelegramThreadParams(resolveTelegramMessageThreadSpec(params.msg, isForum)); +} + +async function resolveTelegramCommandTranscriptContext(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}): Promise<{ sessionId?: string; sessionFile?: string; authProfileId?: string }> { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + return {}; + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const entry = getSessionEntry({ agentId: params.agentId, sessionKey, storePath }); + const sessionId = entry?.sessionId?.trim() || randomUUID(); + const sessionFile = formatSqliteSessionFileMarker({ + agentId: params.agentId, + sessionId, + storePath, + }); + const authProfileId = normalizeOptionalString(entry?.authProfileOverride); + return { sessionId, sessionFile, ...(authProfileId ? { authProfileId } : {}) }; + } catch { + return {}; + } +} + +export async function executeTelegramPluginCommand( + params: TelegramCommandExecutorParams & { + commandName: string; + candidate: PluginCommandNativeCandidate; + }, +): Promise { + const commandBody = `/${params.commandName}${params.rawText ? ` ${params.rawText}` : ""}`; + const pluginCommandDispatch = params.candidate.prepareDispatch(params.rawText); + if (pluginCommandDispatch.kind === "non-plugin") { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: params.runtime, + fn: async () => + await params.bot.api.sendMessage( + params.msg.chat.id, + "Command not found.", + (await resolveTelegramPluginThreadParams(params)) ?? {}, + ), + }); + return; + } + const dispatch = await prepareTelegramCommandDispatch({ + ...params, + requireAuth: params.candidate.requireAuth, + }); + if (!dispatch) { + return; + } + const targetSessionEntry = dispatch.nativeCommandRuntime.getSessionEntry({ + agentId: dispatch.route.agentId, + sessionKey: dispatch.targetSessionKey, + }); + const from = dispatch.isGroup + ? buildTelegramGroupFrom(dispatch.chatId, dispatch.threadSpec.id) + : `telegram:${dispatch.chatId}`; + const to = + dispatch.threadSpec.scope === "direct-messages" + ? buildTelegramRoutingTarget(dispatch.chatId, dispatch.threadSpec) + : `telegram:${dispatch.chatId}`; + const { deliverReplies, emitTelegramMessageSentHooks } = await dispatch.loadDeliveryRuntime(); + let progressMessageId: number | undefined; + if (params.candidate.progressMessage) { + try { + const sent = await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime: dispatch.runtime, + fn: () => + dispatch.bot.api.sendMessage( + dispatch.chatId, + params.candidate.progressMessage!, + buildTelegramThreadParams(dispatch.threadSpec), + ), + }); + const maybeMessageId = (sent as { message_id?: unknown } | undefined)?.message_id; + if (typeof maybeMessageId === "number") { + progressMessageId = maybeMessageId; + } + } catch { + // Fall back to the normal final reply path if the placeholder send fails. + } + } + const transcriptContext = await resolveTelegramCommandTranscriptContext({ + cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, + sessionKey: dispatch.targetSessionKey, + }); + const result = normalizeTelegramNativeReplyPayload( + await pluginCommandDispatch.execute({ + senderId: dispatch.senderId, + channel: "telegram", + isAuthorizedSender: dispatch.commandAuthorized, + senderIsOwner: dispatch.senderIsOwner, + agentId: dispatch.route.agentId, + sessionKey: dispatch.targetSessionKey, + sessionId: transcriptContext.sessionId, + sessionFile: transcriptContext.sessionFile, + authProfileId: transcriptContext.authProfileId ?? targetSessionEntry?.authProfileOverride, + commandBody, + config: dispatch.runtimeCfg, + from, + to, + accountId: dispatch.accountId, + messageThreadId: dispatch.threadSpec.id, + }), + ); + const suppressReply = + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg: dispatch.runtimeCfg, + accountId: dispatch.route.accountId, + payload: result, + }) || result.suppressReply === true; + if (suppressReply) { + await cleanupTelegramProgressPlaceholder({ + bot: dispatch.bot, + chatId: dispatch.chatId, + progressMessageId, + runtime: dispatch.runtime, + }); + return; + } + const hasReaction = hasTelegramNativeReplyReaction(result); + const deliverableResult: TelegramNativeReplyPayload = hasRenderableTelegramNativeReplyPayload( + result, + ) + ? hasReaction && !normalizeOptionalString(result.replyToId) + ? { ...result, replyToId: String(dispatch.msg.message_id) } + : result + : { text: EMPTY_RESPONSE_FALLBACK }; + const progressResultText = + typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0 + ? deliverableResult.text + : null; + const telegramResultData = resolveTelegramNativeReplyChannelData(deliverableResult); + if ( + progressMessageId != null && + dispatch.telegramDeps.editMessageTelegram && + progressResultText && + isEditableTelegramProgressResult(deliverableResult) + ) { + try { + await dispatch.telegramDeps.editMessageTelegram( + dispatch.chatId, + progressMessageId, + progressResultText, + { + cfg: dispatch.runtimeCfg, + accountId: dispatch.route.accountId, + textMode: "markdown", + linkPreview: dispatch.runtimeTelegramCfg.linkPreview, + buttons: telegramResultData?.buttons, + }, + ); + recordSentMessage(dispatch.chatId, progressMessageId, dispatch.runtimeCfg); + emitTelegramMessageSentHooks({ + sessionKeyForInternalHooks: dispatch.targetSessionKey, + chatId: String(dispatch.chatId), + accountId: dispatch.route.accountId, + content: progressResultText, + success: true, + messageId: progressMessageId, + isGroup: dispatch.isGroup, + groupId: dispatch.isGroup ? String(dispatch.chatId) : undefined, + }); + return; + } catch { + // Fall through to cleanup + normal delivered reply if editing fails. + } + } + await cleanupTelegramProgressPlaceholder({ + bot: dispatch.bot, + chatId: dispatch.chatId, + progressMessageId, + runtime: dispatch.runtime, + }); + await deliverReplies({ + replies: [deliverableResult], + ...dispatch.buildDeliveryBaseOptions({ + sessionKeyForInternalHooks: dispatch.targetSessionKey, + policySessionKey: dispatch.targetSessionKey, + }), + ...(hasReaction ? { replyToMode: "all" as const } : {}), + silent: + dispatch.runtimeTelegramCfg.silentErrorReplies === true && deliverableResult.isError === true, + }); +} diff --git a/extensions/telegram/src/bot-native-commands.delivery.runtime.ts b/extensions/telegram/src/bot-native-commands.delivery.runtime.ts index 1e98bc24ec91..fd81399c8569 100644 --- a/extensions/telegram/src/bot-native-commands.delivery.runtime.ts +++ b/extensions/telegram/src/bot-native-commands.delivery.runtime.ts @@ -1,5 +1,4 @@ // Telegram plugin module implements bot native commandselivery behavior. -import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound"; import { deliverReplies, emitTelegramMessageSentHooks } from "./bot/delivery.js"; -export { createChannelMessageReplyPipeline, deliverReplies, emitTelegramMessageSentHooks }; +export { deliverReplies, emitTelegramMessageSentHooks }; diff --git a/extensions/telegram/src/bot-native-commands.runtime.ts b/extensions/telegram/src/bot-native-commands.runtime.ts index a42867b2565d..6eef942b8771 100644 --- a/extensions/telegram/src/bot-native-commands.runtime.ts +++ b/extensions/telegram/src/bot-native-commands.runtime.ts @@ -1,8 +1,5 @@ // Telegram plugin module implements bot native commands behavior. -export { - ensureConfiguredBindingRouteReady, - recordInboundSessionMetaSafe, -} from "openclaw/plugin-sdk/conversation-runtime"; +export { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; export { finalizeInboundContext, diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts deleted file mode 100644 index 0f351d70b58c..000000000000 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ /dev/null @@ -1,2491 +0,0 @@ -import { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound"; -import { - createEmptyPluginRegistry, - withPluginRuntimeRegistryScope, -} from "openclaw/plugin-sdk/channel-test-helpers"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; -import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; -import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; -import { resolveChunkMode } from "openclaw/plugin-sdk/reply-dispatch-runtime"; -import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; -import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; -import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; -// Telegram tests cover bot native commands.session meta plugin behavior. -import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; -import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; -import { - createTelegramGroupCommandContext, - createNativeCommandTestParams, - createTelegramPrivateCommandContext, - createTelegramTopicCommandContext, - type NativeCommandTestParams, -} from "./bot-native-commands.fixture-test-support.js"; -import { runWithTelegramUpdateProcessingFrame } from "./bot-processing-outcome.js"; - -// All mocks scoped to this file only — does not affect bot-native-commands.test.ts - -type ResolveConfiguredBindingRouteFn = - typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute; -type EnsureConfiguredBindingRouteReadyFn = - typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; -type DispatchReplyWithBufferedBlockDispatcherFn = - typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher; -type DispatchReplyWithBufferedBlockDispatcherParams = - Parameters[0]; -type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< - ReturnType ->; -type DispatchChannelInboundTurnFn = - typeof import("openclaw/plugin-sdk/channel-inbound").dispatchChannelInboundTurn; -type ResolveCommandArgMenuFn = - typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgMenu; -type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; -type DeliverRepliesParams = Parameters[0]; -type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog; -type ResolveDefaultModelForAgentFn = - typeof import("openclaw/plugin-sdk/agent-runtime").resolveDefaultModelForAgent; - -const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { - queuedFinal: false, - counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], -}; - -const persistentBindingMocks = vi.hoisted(() => ({ - resolveConfiguredBindingRoute: vi.fn(({ route }) => ({ - bindingResolution: null, - route, - })), - ensureConfiguredBindingRouteReady: vi.fn(async () => ({ - ok: true, - })), -})); -const sessionMocks = vi.hoisted(() => ({ - getSessionEntry: vi.fn(), - loadSessionStore: vi.fn(), - recordSessionMetaFromInbound: vi.fn(), - resolveStorePath: vi.fn(), - updateSessionStoreEntry: vi.fn(), -})); -const commandAuthMocks = vi.hoisted(() => ({ - resolveCommandArgMenu: vi.fn(), -})); -const agentRuntimeMocks = vi.hoisted(() => ({ - loadModelCatalog: vi.fn(async () => [ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - reasoning: true, - }, - ]), - resolveDefaultModelForAgent: vi.fn(), -})); -const pluginRuntimeMocks = vi.hoisted(() => ({ - executePluginCommand: vi.fn(async (_params?: unknown) => ({ text: "ok" })), -})); -const replyMocks = vi.hoisted(() => ({ - dispatchReplyWithBufferedBlockDispatcher: vi.fn( - async () => dispatchReplyResult, - ), -})); -const deliveryMocks = vi.hoisted(() => ({ - deliverReplies: vi.fn(async () => ({ delivered: true })), -})); -const dispatchChannelInboundTurnMock = vi.fn(async (plan) => { - const recordTask = sessionMocks.recordSessionMetaFromInbound({ - storePath: sessionMocks.resolveStorePath(plan.cfg.session?.store, { - agentId: plan.route.agentId, - }), - sessionKey: plan.record?.sessionKey ?? plan.ctxPayload.SessionKey ?? plan.route.sessionKey, - ctx: plan.ctxPayload, - }); - const trackedRecordTask = Promise.resolve(recordTask).catch((error: unknown) => - plan.record?.onRecordError?.(error), - ); - plan.record?.trackSessionMetaTask?.(trackedRecordTask); - await plan.afterRecord?.(); - const deliver = async ( - payload: Parameters< - DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] - >[0], - info: Parameters< - DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] - >[1], - ) => { - const providerInfo = { - ...info, - onPlatformSendDispatch: async () => undefined, - }; - const result = - "deliverWithProviderMessageSending" in plan.delivery - ? await plan.delivery.deliverWithProviderMessageSending(payload, providerInfo) - : await plan.delivery.deliver(payload, info); - await plan.delivery.onDelivered?.(payload, info, result); - return result; - }; - const dispatchResult = await replyMocks.dispatchReplyWithBufferedBlockDispatcher({ - ctx: plan.ctxPayload, - cfg: plan.cfg, - dispatcherOptions: { - ...plan.dispatcherOptions, - deliver, - onError: plan.delivery.onError, - }, - replyOptions: plan.replyOptions, - }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult, - }; -}); -const sessionBindingMocks = vi.hoisted(() => ({ - resolveByConversation: vi.fn< - (ref: unknown) => { bindingId: string; targetSessionKey: string } | null - >(() => null), - touch: vi.fn(), -})); -const conversationStoreMocks = vi.hoisted(() => ({ - readChannelAllowFromStore: vi.fn(async () => []), - upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", created: true })), -})); - -vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/conversation-runtime", - ); - return { - ...actual, - resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute, - resolveRuntimeConversationBindingRoute: ( - params: Parameters[0], - ) => { - const conversation = - "conversation" in params - ? params.conversation - : { - channel: params.channel, - accountId: params.accountId, - conversationId: params.conversationId, - parentConversationId: params.parentConversationId, - }; - const bindingRecord = sessionBindingMocks.resolveByConversation(conversation); - const boundSessionKey = bindingRecord?.targetSessionKey?.trim(); - if (!bindingRecord || !boundSessionKey) { - return { bindingRecord: null, route: params.route }; - } - sessionBindingMocks.touch(bindingRecord.bindingId, undefined); - return { - bindingRecord, - boundSessionKey, - boundAgentId: params.route.agentId, - route: { - ...params.route, - sessionKey: boundSessionKey, - lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session", - matchedBy: "binding.channel", - }, - }; - }, - ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, - recordInboundSessionMetaSafe: vi.fn( - async (params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; - ctx: unknown; - onError?: (error: unknown) => void; - }) => { - const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, { - agentId: params.agentId, - }); - try { - await sessionMocks.recordSessionMetaFromInbound({ - storePath, - sessionKey: params.sessionKey, - ctx: params.ctx, - }); - } catch (error) { - params.onError?.(error); - } - }, - ), - readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore, - upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest, - getSessionBindingService: () => ({ - bind: vi.fn(), - getCapabilities: vi.fn(), - listBySession: vi.fn(), - resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref), - touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at), - unbind: vi.fn(), - }), - }; -}); -vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/session-store-runtime", - ); - return { - ...actual, - getSessionEntry: sessionMocks.getSessionEntry, - loadSessionStore: sessionMocks.loadSessionStore, - resolveStorePath: sessionMocks.resolveStorePath, - updateSessionStoreEntry: sessionMocks.updateSessionStoreEntry, - }; -}); -vi.mock("openclaw/plugin-sdk/command-auth-native", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/command-auth-native", - ); - commandAuthMocks.resolveCommandArgMenu.mockImplementation(actual.resolveCommandArgMenu); - return { - ...actual, - resolveCommandArgMenu: commandAuthMocks.resolveCommandArgMenu, - }; -}); -vi.mock("openclaw/plugin-sdk/agent-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/agent-runtime", - ); - agentRuntimeMocks.resolveDefaultModelForAgent.mockImplementation( - actual.resolveDefaultModelForAgent, - ); - return { - ...actual, - loadPreparedModelCatalog: agentRuntimeMocks.loadModelCatalog, - resolveDefaultModelForAgent: agentRuntimeMocks.resolveDefaultModelForAgent, - }; -}); -vi.mock("./bot-native-commands.runtime.js", () => { - return { - ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, - finalizeInboundContext: vi.fn((ctx: unknown) => ctx), - getAgentScopedMediaLocalRoots, - getSessionEntry: sessionMocks.getSessionEntry, - recordInboundSessionMetaSafe: vi.fn( - async (params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; - ctx: unknown; - onError?: (error: unknown) => void; - }) => { - const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, { - agentId: params.agentId, - }); - try { - await sessionMocks.recordSessionMetaFromInbound({ - storePath, - sessionKey: params.sessionKey, - ctx: params.ctx, - }); - } catch (error) { - params.onError?.(error); - } - }, - ), - resolveChunkMode, - resolveThreadSessionKeys, - dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable< - TelegramNativeCommandDeps["dispatchChannelInboundTurn"] - >, - }; -}); -vi.mock("./bot/delivery.js", () => ({ - deliverReplies: deliveryMocks.deliverReplies, -})); -vi.mock("./bot/delivery.replies.js", () => ({ - deliverReplies: deliveryMocks.deliverReplies, -})); - -let activePluginRegistry: ReturnType; - -type TelegramCommandHandler = (ctx: unknown) => Promise; -type TelegramPluginCommandSpecs = Array<{ - name: string; - description: string; - acceptsArgs?: boolean; -}>; -type TelegramLoginFlow = NonNullable; - -function registerAndResolveStatusHandler(params: { - cfg: OpenClawConfig; - runtimeCfg?: OpenClawConfig; - allowFrom?: string[]; - groupAllowFrom?: string[]; - storeAllowFrom?: string[]; - telegramCfg?: NativeCommandTestParams["telegramCfg"]; - resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; -}): { - handler: TelegramCommandHandler; - sendMessage: ReturnType; -} { - const { - cfg, - runtimeCfg, - allowFrom, - groupAllowFrom, - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - } = params; - return registerAndResolveCommandHandlerBase({ - commandName: "status", - cfg, - runtimeCfg, - allowFrom: allowFrom ?? ["*"], - groupAllowFrom: groupAllowFrom ?? [], - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - }); -} - -function registerAndResolveCommandHandlerBase(params: { - commandName: string; - cfg: OpenClawConfig; - runtimeCfg?: OpenClawConfig; - allowFrom: string[]; - groupAllowFrom: string[]; - storeAllowFrom?: string[]; - telegramCfg?: NativeCommandTestParams["telegramCfg"]; - resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; - pluginCommandSpecs?: TelegramPluginCommandSpecs; - runModelsAuthLoginFlow?: TelegramLoginFlow; -}): { - handler: TelegramCommandHandler; - sendMessage: ReturnType; -} { - const { - commandName, - cfg, - runtimeCfg, - allowFrom, - groupAllowFrom, - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - pluginCommandSpecs, - runModelsAuthLoginFlow, - } = params; - const commandHandlers = new Map(); - const sendMessage = vi.fn().mockResolvedValue(undefined); - const baseRuntimeCfg = runtimeCfg ?? cfg; - const commandRuntimeCfg = baseRuntimeCfg; - const telegramDeps: TelegramNativeCommandDeps = { - getRuntimeConfig: vi.fn(() => commandRuntimeCfg), - readChannelAllowFromStore: vi.fn(async () => storeAllowFrom ?? []), - dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable< - TelegramNativeCommandDeps["dispatchChannelInboundTurn"] - >, - listSkillCommandsForAgents: vi.fn(() => []), - syncTelegramMenuCommands: vi.fn(), - sendMessageTelegram: vi.fn(async (_to, text) => { - await sendMessage(100, text, {}); - return { messageId: "999", chatId: "100" }; - }), - ...(runModelsAuthLoginFlow ? { runModelsAuthLoginFlow } : {}), - }; - withPluginRuntimeRegistryScope(activePluginRegistry, () => { - for (const spec of pluginCommandSpecs ?? []) { - expect( - registerPluginCommand(`test-${spec.name}`, { - ...spec, - requireAuth: true, - handler: pluginRuntimeMocks.executePluginCommand, - }), - ).toEqual({ ok: true }); - } - registerTelegramNativeCommands({ - ...createNativeCommandTestParams({ - bot: { - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage, - }, - command: vi.fn((name: string, cb: TelegramCommandHandler) => { - commandHandlers.set(name, cb); - }), - } as unknown as NativeCommandTestParams["bot"], - cfg, - allowFrom, - groupAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - telegramDeps, - }), - }); - }); - - const handler = commandHandlers.get(commandName); - if (!handler) { - throw new Error(`expected ${commandName} command handler to be registered`); - } - return { handler, sendMessage }; -} - -function registerAndResolveCommandHandler(params: { - commandName: string; - cfg: OpenClawConfig; - allowFrom?: string[]; - groupAllowFrom?: string[]; - storeAllowFrom?: string[]; - telegramCfg?: NativeCommandTestParams["telegramCfg"]; - resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; - pluginCommandSpecs?: TelegramPluginCommandSpecs; - runModelsAuthLoginFlow?: TelegramLoginFlow; -}): { - handler: TelegramCommandHandler; - sendMessage: ReturnType; -} { - const { - commandName, - cfg, - allowFrom, - groupAllowFrom, - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - pluginCommandSpecs, - runModelsAuthLoginFlow, - } = params; - return registerAndResolveCommandHandlerBase({ - commandName, - cfg, - allowFrom: allowFrom ?? [], - groupAllowFrom: groupAllowFrom ?? [], - storeAllowFrom, - telegramCfg, - resolveTelegramGroupConfig, - pluginCommandSpecs, - runModelsAuthLoginFlow, - }); -} - -function createConfiguredAcpTopicBinding(boundSessionKey: string) { - return { - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - } as const; -} - -function createConfiguredBindingRoute( - route: ResolvedAgentRoute, - binding: ReturnType | null, -) { - return { - bindingResolution: binding - ? { - conversation: binding.record.conversation, - compiledBinding: { - channel: "telegram" as const, - binding: { - type: "acp" as const, - agentId: binding.spec.agentId, - match: { - channel: "telegram", - accountId: binding.spec.accountId, - peer: { - kind: "group" as const, - id: binding.spec.conversationId, - }, - }, - acp: { - mode: binding.spec.mode, - }, - }, - bindingConversationId: binding.spec.conversationId, - target: { - conversationId: binding.spec.conversationId, - ...(binding.spec.parentConversationId - ? { parentConversationId: binding.spec.parentConversationId } - : {}), - }, - agentId: binding.spec.agentId, - provider: { - compileConfiguredBinding: () => ({ - conversationId: binding.spec.conversationId, - ...(binding.spec.parentConversationId - ? { parentConversationId: binding.spec.parentConversationId } - : {}), - }), - matchInboundConversation: () => ({ - conversationId: binding.spec.conversationId, - ...(binding.spec.parentConversationId - ? { parentConversationId: binding.spec.parentConversationId } - : {}), - }), - }, - targetFactory: { - driverId: "acp" as const, - materialize: () => ({ - record: binding.record, - statefulTarget: { - kind: "stateful" as const, - driverId: "acp" as const, - sessionKey: binding.record.targetSessionKey, - agentId: binding.spec.agentId, - }, - }), - }, - }, - match: { - conversationId: binding.spec.conversationId, - ...(binding.spec.parentConversationId - ? { parentConversationId: binding.spec.parentConversationId } - : {}), - }, - record: binding.record, - statefulTarget: { - kind: "stateful" as const, - driverId: "acp" as const, - sessionKey: binding.record.targetSessionKey, - agentId: binding.spec.agentId, - }, - } - : null, - ...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}), - route, - }; -} - -function requireValue(value: T | null | undefined, label: string): T { - if (value == null) { - throw new Error(`expected ${label}`); - } - return value; -} - -const requireRecord = createRequireRecord("record", "expected-label-object"); - -function firstMockArg(mockFn: ReturnType, label: string, callIndex = 0): unknown { - const call = mockFn.mock.calls.at(callIndex); - if (!call) { - throw new Error(`expected ${label} call ${callIndex}`); - } - return call.at(0); -} - -function expectRecordFields( - value: unknown, - expected: Record, - label: string, -): Record { - const record = requireRecord(value, label); - for (const [key, expectedValue] of Object.entries(expected)) { - expect(record[key], `${label}.${key}`).toEqual(expectedValue); - } - return record; -} - -function expectSendMessageCall(params: { - sendMessage: ReturnType; - callIndex?: number; - chatId: unknown; - text?: string; - textIncludes?: string; - optionFields?: Record; - requireReplyMarkup?: boolean; - label: string; -}): Record { - const call = requireValue( - params.sendMessage.mock.calls[params.callIndex ?? 0], - `${params.label} sendMessage call`, - ); - expect(call[0]).toBe(params.chatId); - if (params.text !== undefined) { - expect(call[1]).toBe(params.text); - } - if (params.textIncludes !== undefined) { - expect(String(call[1])).toContain(params.textIncludes); - } - const options = params.optionFields - ? expectRecordFields(call[2], params.optionFields, `${params.label} sendMessage options`) - : requireRecord(call[2], `${params.label} sendMessage options`); - if (params.requireReplyMarkup) { - requireRecord(options.reply_markup, `${params.label} reply markup`); - } - return options; -} - -function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled(); - expectSendMessageCall({ - sendMessage, - chatId: -1001234567890, - text: "You are not authorized to use this command.", - optionFields: { message_thread_id: 42 }, - label: "unauthorized /new", - }); -} - -function resetSessionMetaMocks() { - persistentBindingMocks.resolveConfiguredBindingRoute.mockClear(); - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute(route, null), - ); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); - commandAuthMocks.resolveCommandArgMenu.mockClear().mockImplementation(({ command, args }) => { - if (args?.raw || (args?.values && Object.keys(args.values).length > 0)) { - return null; - } - const arg = command.args?.[0]; - if (!arg) { - return null; - } - if (command.key === "think") { - return { - arg, - choices: ["low", "medium", "high"].map((value) => ({ label: value, value })), - }; - } - if (command.key === "fast") { - const choices = ["on", "off", "auto (30 sec)", "default", "status"]; - return { - arg, - choices: choices.map((value) => ({ label: value, value })), - }; - } - return null; - }); - agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - reasoning: true, - }, - ]); - sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined); - sessionMocks.loadSessionStore.mockClear().mockReturnValue({}); - sessionMocks.getSessionEntry.mockImplementation( - ({ storePath, sessionKey }: { storePath: string; sessionKey: string }) => - sessionMocks.loadSessionStore(storePath)[sessionKey], - ); - sessionMocks.updateSessionStoreEntry.mockClear().mockImplementation(async (params) => { - const current = sessionMocks.loadSessionStore(params.storePath)[params.sessionKey]; - if (!current) { - return null; - } - const patch = await params.update({ ...current }); - return patch ? { ...current, ...patch } : current; - }); - sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); - sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); - pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" }); - activePluginRegistry = createEmptyPluginRegistry(); - replyMocks.dispatchReplyWithBufferedBlockDispatcher - .mockClear() - .mockResolvedValue(dispatchReplyResult); - dispatchChannelInboundTurnMock.mockClear(); - sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null); - sessionBindingMocks.touch.mockReset(); - deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true }); -} - -activePluginRegistry = createEmptyPluginRegistry(); -const { registerTelegramNativeCommands } = await import("./bot-native-commands.js"); -await import("./bot-native-commands.runtime.js"); -agentRuntimeMocks.resolveDefaultModelForAgent({ cfg: {}, agentId: "main" }); -resetSessionMetaMocks(); -const warmStatusHandler = registerAndResolveStatusHandler({ cfg: {} }); -await warmStatusHandler.handler(createTelegramPrivateCommandContext()); - -describe("registerTelegramNativeCommands — session metadata", () => { - beforeEach(resetSessionMetaMocks); - - it("calls recordSessionMetaFromInbound after a native slash command", async () => { - const shadowHandler = vi.fn(async () => ({ text: "wrong plugin" })); - activePluginRegistry.commands.push({ - pluginId: "shadow-plugin", - source: "test", - command: { - name: "status", - description: "Shadow status", - channels: ["telegram"], - requireAuth: false, - handler: shadowHandler, - }, - }); - const cfg: OpenClawConfig = {}; - const { handler } = registerAndResolveStatusHandler({ cfg }); - await handler(createTelegramPrivateCommandContext()); - - expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); - expect(shadowHandler).not.toHaveBeenCalled(); - const turnPlan = dispatchChannelInboundTurnMock.mock.calls[0]?.[0]; - expect(turnPlan?.replyOptions?.[Symbol.for("openclaw.pluginCommandDispatch") as never]).toEqual( - { kind: "non-plugin" }, - ); - const call = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string; ctx?: { OriginatingChannel?: string; Provider?: string } }] - > - )[0]?.[0]; - expect(call?.ctx?.OriginatingChannel).toBe("telegram"); - expect(call?.ctx?.Provider).toBe("telegram"); - expect(call?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); - expect(turnPlan?.record?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey); - }); - - it("leaves native-command outcomes to the update middleware owner", async () => { - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - const { result } = await runWithTelegramUpdateProcessingFrame(async () => { - await handler(createTelegramPrivateCommandContext()); - }); - - expect(result).toBeUndefined(); - }); - - it("preserves every argument on native queue command turns", async () => { - const { handler } = registerAndResolveCommandHandler({ - commandName: "queue", - cfg: {}, - allowFrom: ["*"], - }); - - await handler(createTelegramPrivateCommandContext({ match: "Can you diagnose this?" })); - - expect(dispatchChannelInboundTurnMock).toHaveBeenCalledWith( - expect.objectContaining({ - ctxPayload: expect.objectContaining({ - Body: "/queue Can you diagnose this?", - CommandBody: "/queue Can you diagnose this?", - CommandTurn: expect.objectContaining({ - kind: "native", - body: "/queue Can you diagnose this?", - }), - }), - }), - ); - }); - - it("keeps one live config snapshot through native command execution", async () => { - const startupCfg: OpenClawConfig = { session: { store: "/tmp/startup-sessions.json" } }; - const runtimeCfg: OpenClawConfig = { session: { store: "/tmp/runtime-sessions.json" } }; - const { handler } = registerAndResolveStatusHandler({ cfg: startupCfg, runtimeCfg }); - - await handler(createTelegramPrivateCommandContext()); - - const dispatchCall = requireRecord( - firstMockArg( - replyMocks.dispatchReplyWithBufferedBlockDispatcher, - "dispatchReplyWithBufferedBlockDispatcher", - ), - "dispatch call", - ); - expect(dispatchCall.cfg).toBe(runtimeCfg); - }); - - it.each([ - { blockStreamingEnabled: false, expectedDisableBlockStreaming: true }, - { blockStreamingEnabled: true, expectedDisableBlockStreaming: false }, - ])( - "uses nested streaming.block.enabled=$blockStreamingEnabled for native command dispatch", - async ({ blockStreamingEnabled, expectedDisableBlockStreaming }) => { - const cfg = { - channels: { - telegram: { - streaming: { block: { enabled: blockStreamingEnabled } }, - }, - }, - } satisfies OpenClawConfig; - const { handler } = registerAndResolveStatusHandler({ cfg }); - - await handler(createTelegramPrivateCommandContext()); - - const dispatchCall = requireRecord( - firstMockArg( - replyMocks.dispatchReplyWithBufferedBlockDispatcher, - "dispatchReplyWithBufferedBlockDispatcher", - ), - "dispatch call", - ); - expect(dispatchCall.replyOptions).toMatchObject({ - disableBlockStreaming: expectedDisableBlockStreaming, - }); - }, - ); - - it("uses the target session model when building native argument menus", async () => { - const cfg = { - agents: { - defaults: { - thinkingDefault: "low", - models: { - "anthropic/claude-opus-4-7": { - params: { thinking: "xhigh" }, - }, - }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "anthropic", - modelOverride: "claude-opus-4-7", - modelOverrideSource: "user", - thinkingLevel: "high", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.provider === "anthropic", - )?.[0]; - expectRecordFields( - menuCall, - { provider: "anthropic", model: "claude-opus-4-7" }, - "thinking menu call", - ); - expect(sessionMocks.getSessionEntry).toHaveBeenCalledWith({ - storePath: "/tmp/openclaw-sessions.json", - sessionKey: "agent:main:main", - }); - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: high.\nChoose level for /think.", - requireReplyMarkup: true, - label: "thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it.each([ - { sessionRuntime: undefined, expectedRuntime: "codex" }, - { sessionRuntime: "openclaw", expectedRuntime: "openclaw" }, - ])( - "uses the effective $expectedRuntime runtime for native /think menus", - async ({ sessionRuntime, expectedRuntime }) => { - const cfg = { - agents: { - defaults: { - models: { - "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } }, - }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "openai", - modelOverride: "gpt-5.6-luna", - modelOverrideSource: "user", - ...(sessionRuntime ? { agentRuntimeOverride: sessionRuntime } : {}), - updatedAt: 0, - }, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.model === "gpt-5.6-luna", - )?.[0]; - expectRecordFields( - menuCall, - { - provider: "openai", - model: "gpt-5.6-luna", - agentRuntime: expectedRuntime, - }, - "runtime-aware thinking menu call", - ); - }, - ); - - it("resolves /think menu choices against the runtime catalog for live-discovered models", async () => { - const cfg = { - agents: { defaults: { models: { "ollama/*": {} } } }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "ollama", - modelOverride: "glm-5.2:cloud", - modelOverrideSource: "user", - updatedAt: 0, - }, - }); - const runtimeCatalog = [ - { provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true }, - ]; - agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.provider === "ollama", - )?.[0]; - const menuRecord = expectRecordFields( - menuCall, - { provider: "ollama", model: "glm-5.2:cloud" }, - "ollama thinking menu call", - ); - expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled(); - expect(menuRecord.catalog).toEqual(runtimeCatalog); - }); - - it("loads the runtime catalog for /think when no session model override is set", async () => { - const cfg = { - agents: { defaults: { model: "ollama/glm-5.2:cloud", models: { "ollama/*": {} } } }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({}); - const runtimeCatalog = [ - { provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true }, - ]; - agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled(); - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think", - )?.[0]; - const menuRecord = expectRecordFields(menuCall, {}, "default-model thinking menu call"); - expect(menuRecord.provider).toBeUndefined(); - expect(menuRecord.catalog).toEqual(runtimeCatalog); - }); - - it("inherits the parent session model when building DM thread native argument menus", async () => { - const cfg: OpenClawConfig = {}; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "anthropic", - modelOverride: "claude-opus-4-7", - modelOverrideSource: "user", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext({ threadId: 77 })); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.provider === "anthropic", - )?.[0]; - expectRecordFields( - menuCall, - { provider: "anthropic", model: "claude-opus-4-7" }, - "thread thinking menu call", - ); - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Choose level for /think.", - requireReplyMarkup: true, - label: "thread thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses the configured default model instead of temporary auto fallback overrides", async () => { - const cfg = { - agents: { - defaults: { - model: { primary: "openai/gpt-5.5" }, - thinkingDefault: "medium", - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "anthropic", - modelOverride: "claude-opus-4-7", - modelOverrideSource: "auto", - modelProvider: "anthropic", - model: "claude-opus-4-7", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "think" && params.provider === "openai", - )?.[0]; - expectRecordFields( - menuCall, - { provider: "openai", model: "gpt-5.5" }, - "default model thinking menu call", - ); - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: medium.\nChoose level for /think.", - requireReplyMarkup: true, - label: "default model thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses configured model defaults instead of runtime auth metadata for the fast menu", async () => { - const cfg = { - agents: { - defaults: { - model: { primary: "openai/gpt-5.5" }, - models: { - "openai/gpt-5.5": { - params: { fastMode: "auto", fastAutoOnSeconds: 30 }, - }, - }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - modelProvider: "openai-codex", - model: "gpt-5.5", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "fast", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find( - ([params]) => params.command.key === "fast", - )?.[0]; - expectRecordFields(menuCall, { cfg }, "fast menu call"); - expect( - commandAuthMocks.resolveCommandArgMenu.mock.calls.some( - ([params]) => - params.command.key === "fast" && - params.provider === "openai" && - params.model === "gpt-5.5", - ), - ).toBe(true); - const options = expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: - "Current fast mode: auto (30 sec) (default: model).\nOptions: on, off, auto (30 sec), default, status.", - requireReplyMarkup: true, - label: "fast menu", - }); - const replyMarkup = options.reply_markup as - | { inline_keyboard?: Array> } - | undefined; - const labels = (replyMarkup?.inline_keyboard ?? []).flatMap((row) => - row.map((button) => button.text), - ); - expect(labels).toContain("auto (30 sec)"); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses the read-only catalog for Claude CLI thinking menus", async () => { - const cfg = { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-8" }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({}); - agentRuntimeMocks.loadModelCatalog.mockImplementation(async (params) => { - if (!params?.readOnly) { - throw new Error("native /think must not start full model discovery"); - } - return [ - { - provider: "anthropic", - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - reasoning: true, - }, - ]; - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith( - expect.objectContaining({ - config: cfg, - agentDir: expect.any(String), - readOnly: true, - }), - ); - expect(agentRuntimeMocks.loadModelCatalog.mock.calls[0]?.[0]).not.toHaveProperty( - "workspaceDir", - ); - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: off.\nChoose level for /think.", - requireReplyMarkup: true, - label: "Claude CLI thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses target model thinking defaults before global thinking defaults", async () => { - const cfg = { - agents: { - defaults: { - thinkingDefault: "low", - models: { - "anthropic/claude-opus-4-7": { - params: { thinking: "xhigh" }, - }, - }, - }, - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - providerOverride: "anthropic", - modelOverride: "claude-opus-4-7", - modelOverrideSource: "user", - updatedAt: 0, - }, - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: xhigh.\nChoose level for /think.", - requireReplyMarkup: true, - label: "target model thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("uses per-agent thinking defaults before target model and global thinking defaults", async () => { - const cfg = { - agents: { - defaults: { - thinkingDefault: "low", - models: { - "anthropic/claude-opus-4-7": { - params: { thinking: "xhigh" }, - }, - }, - }, - list: [ - { - id: "alpha", - model: { primary: "anthropic/claude-opus-4-7" }, - thinkingDefault: "minimal", - }, - ], - }, - } as OpenClawConfig; - sessionMocks.loadSessionStore.mockReturnValue({}); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "think", - cfg, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext()); - - expectSendMessageCall({ - sendMessage, - chatId: 100, - textIncludes: "Current thinking level: minimal.\nChoose level for /think.", - requireReplyMarkup: true, - label: "agent thinking menu", - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - }); - - it("does not load the session store when a native argument menu is skipped", async () => { - const { handler } = registerAndResolveCommandHandler({ - commandName: "think", - cfg: {}, - allowFrom: ["*"], - }); - await handler(createTelegramPrivateCommandContext({ match: "high" })); - - expect(sessionMocks.loadSessionStore).not.toHaveBeenCalled(); - expect(agentRuntimeMocks.loadModelCatalog).not.toHaveBeenCalled(); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - }); - - it("awaits routed session metadata persistence before command dispatch", async () => { - const deferred = createDeferred(); - sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise); - - const cfg: OpenClawConfig = {}; - const { handler } = registerAndResolveStatusHandler({ cfg }); - const runPromise = handler(createTelegramPrivateCommandContext()); - - await vi.waitFor(() => { - expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); - }); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - - deferred.resolve(); - await runPromise; - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - - const dispatcherOptions = requireRecord( - requireRecord( - firstMockArg( - replyMocks.dispatchReplyWithBufferedBlockDispatcher, - "dispatchReplyWithBufferedBlockDispatcher", - ), - "dispatch reply params", - ).dispatcherOptions, - "dispatcher options", - ); - expect(dispatcherOptions.beforeDeliver).toBeTypeOf("function"); - }); - - it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => { - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( - async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { - await dispatcherOptions.deliver( - { - text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).", - }, - { kind: "final" }, - ); - return dispatchReplyResult; - }, - ); - - const { handler } = registerAndResolveStatusHandler({ - cfg: { - channels: { - telegram: { - execApprovals: { - enabled: true, - approvers: ["12345"], - target: "dm", - }, - }, - }, - }, - }); - await handler(createTelegramPrivateCommandContext()); - - const deliveredCall = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as - | DeliverRepliesParams - | undefined; - const deliveredPayload = deliveredCall?.replies?.[0]; - if (!deliveredPayload) { - throw new Error("expected approval reply payload to be delivered"); - } - expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once"); - expect(deliveredPayload?.["channelData"]).toBeUndefined(); - }); - - it("suppresses local structured exec approval replies for native commands", async () => { - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( - async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { - await dispatcherOptions.deliver( - { - text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", - channelData: { - execApproval: { - approvalId: "7f423fdc-1111-2222-3333-444444444444", - approvalSlug: "7f423fdc", - allowedDecisions: ["allow-once", "allow-always", "deny"], - }, - }, - }, - { kind: "tool" }, - ); - return dispatchReplyResult; - }, - ); - - const { handler } = registerAndResolveStatusHandler({ - cfg: { - channels: { - telegram: { - execApprovals: { - enabled: true, - approvers: ["12345"], - target: "dm", - }, - }, - }, - }, - }); - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("does not emit the empty fallback when reply-payload hooks cancel a native reply", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - await plan.delivery.onDelivered?.( - { text: "cancelled" }, - { kind: "final" }, - { - visibleReplySent: false, - suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, - }, - ); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("does not emit the empty fallback for a message-tool-only native reply", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - sourceReplyDeliveryMode: "message_tool_only", - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("retains the native fallback when message-tool-only delivery also fails", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); - plan.delivery.onError?.(new Error("Telegram final delivery failed"), { - kind: "final", - }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - sourceReplyDeliveryMode: "message_tool_only", - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [{ text: "No response generated. Please try again." }], - }), - ); - }); - - it("emits the fallback when a non-final suppression precedes a final failure", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - await plan.delivery.onDelivered?.( - { text: "cancelled tool reply" }, - { kind: "tool" }, - { - visibleReplySent: false, - suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, - }, - ); - plan.delivery.onError?.(new Error("Telegram final delivery failed"), { - kind: "final", - }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [{ text: "No response generated. Please try again." }], - }), - ); - }); - - it("emits the fallback when a suppressed block reply precedes a final failure", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - await plan.delivery.onDelivered?.( - { text: "cancelled block reply" }, - { kind: "block" }, - { - visibleReplySent: false, - suppression: { reason: "empty_after_reply_payload_sending_hook" }, - }, - ); - plan.delivery.onError?.(new Error("Telegram final delivery failed"), { - kind: "final", - }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - }); - - it("emits the fallback when a final failure precedes a later suppressed final", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.delivery.onError?.(new Error("Telegram final delivery failed"), { - kind: "final", - }); - await plan.delivery.onDelivered?.( - { text: "cancelled final reply" }, - { kind: "final" }, - { - visibleReplySent: false, - suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, - }, - ); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - }); - - it("preserves a suppressed final after a non-final delivery failure", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.delivery.onError?.(new Error("Telegram tool delivery failed"), { - kind: "tool", - }); - await plan.delivery.onDelivered?.( - { text: "cancelled final reply" }, - { kind: "final" }, - { - visibleReplySent: false, - suppression: { reason: "cancelled_by_reply_payload_sending_hook" }, - }, - ); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("does not emit the fallback after a partially delivered final", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.delivery.onError?.( - createChannelPartialDeliveryError(new Error("Telegram final delivery failed"), { - visibleReplySent: true, - }), - { kind: "final" }, - ); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); - }); - - it("retains the empty fallback for a true non-silent metadata-only native reply", async () => { - dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { - plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); - return { - admission: { kind: "dispatch" }, - dispatched: true, - ctxPayload: plan.ctxPayload, - routeSessionKey: plan.route.sessionKey, - dispatchResult: { - queuedFinal: false, - counts: { block: 0, final: 0, tool: 0 }, - }, - }; - }); - const { handler } = registerAndResolveStatusHandler({ cfg: {} }); - - await handler(createTelegramPrivateCommandContext()); - - expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( - expect.objectContaining({ - replies: [{ text: "No response generated. Please try again." }], - }), - ); - }); - - it("sends native command error replies silently when silentErrorReplies is enabled", async () => { - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( - async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { - await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); - return dispatchReplyResult; - }, - ); - - const { handler } = registerAndResolveStatusHandler({ - cfg: { - channels: { - telegram: { - silentErrorReplies: true, - }, - }, - }, - telegramCfg: { silentErrorReplies: true }, - }); - await handler(createTelegramPrivateCommandContext()); - - const deliveredCall = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as - | DeliverRepliesParams - | undefined; - const deliveryParams = requireValue(deliveredCall, "silent error delivery params"); - expect(deliveryParams.silent).toBe(true); - expect(deliveryParams.replies).toHaveLength(1); - expect(deliveryParams.replies[0]?.isError).toBe(true); - }); - - it("routes Telegram native commands through configured ACP topic bindings", async () => { - const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute( - { - ...route, - sessionKey: boundSessionKey, - agentId: "codex", - matchedBy: "binding.channel", - }, - createConfiguredAcpTopicBinding(boundSessionKey), - ), - ); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); - - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [{ ctx?: { CommandTargetSessionKey?: string } }] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); - const sessionMetaCall = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string }] - > - )[0]?.[0]; - expect(sessionMetaCall?.sessionKey).toBe(boundSessionKey); - }); - - it("routes Telegram native commands through topic-specific agent sessions", async () => { - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: "zu" }, - }), - }); - await handler(createTelegramTopicCommandContext()); - - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [{ ctx?: { CommandTargetSessionKey?: string } }] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe( - "agent:zu:telegram:group:-1001234567890:topic:42", - ); - const sessionMetaCall = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string; ctx?: { From?: string; ChatType?: string } }] - > - )[0]?.[0]; - expect(sessionMetaCall?.sessionKey).toBe("agent:zu:telegram:group:-1001234567890:topic:42"); - expect(sessionMetaCall?.ctx?.From).toBe("telegram:group:-1001234567890:topic:42"); - expect(sessionMetaCall?.ctx?.ChatType).toBe("group"); - }); - - it("does not mark paired Telegram DM allowlist entries as native group command owners", async () => { - const { handler, sendMessage } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: [], - groupAllowFrom: [], - storeAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - expectUnauthorizedNewCommandBlocked(sendMessage); - }); - - it("authorizes paired Telegram DMs without marking them as owners", async () => { - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: [], - groupAllowFrom: [], - storeAllowFrom: ["200"], - }); - await handler(createTelegramPrivateCommandContext()); - - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [ - { - ctx?: { - CommandAuthorized?: boolean; - }; - }, - ] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandAuthorized).toBe(true); - expect(dispatchCall?.ctx).not.toHaveProperty("OwnerAllowFrom"); - }); - - it("routes Telegram native commands through bound topic sessions", async () => { - sessionBindingMocks.resolveByConversation.mockReturnValue({ - bindingId: "default:-1001234567890:topic:42", - targetSessionKey: "agent:codex-acp:session-1", - }); - - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - }); - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [{ ctx?: { CommandTargetSessionKey?: string } }] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-1"); - const sessionMetaCall = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string }] - > - )[0]?.[0]; - expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-1"); - expect(sessionBindingMocks.touch).toHaveBeenCalledWith( - "default:-1001234567890:topic:42", - undefined, - ); - }); - - it("routes Telegram native commands through bound top-level group sessions", async () => { - sessionBindingMocks.resolveByConversation.mockReturnValue({ - bindingId: "default:-1001234567890", - targetSessionKey: "agent:codex-acp:session-group", - }); - - const { handler } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramGroupCommandContext()); - - expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890", - }); - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [{ ctx?: { CommandTargetSessionKey?: string; OriginatingTo?: string } }] - > - )[0]?.[0]; - expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-group"); - expect(dispatchCall?.ctx?.OriginatingTo).toBe("telegram:-1001234567890"); - const sessionMetaCall = ( - sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< - [{ sessionKey?: string }] - > - )[0]?.[0]; - expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-group"); - expect(sessionBindingMocks.touch).toHaveBeenCalledWith("default:-1001234567890", undefined); - }); - - it.each(["new", "reset"] as const)( - "preserves the topic-qualified origin target for native /%s in forum topics", - async (commandName) => { - const { handler } = registerAndResolveCommandHandler({ - commandName, - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - const dispatchCall = ( - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< - [ - { - ctx?: { - CommandTargetSessionKey?: string; - MessageThreadId?: number; - OriginatingTo?: string; - }; - }, - ] - > - )[0]?.[0]; - expectRecordFields( - dispatchCall?.ctx, - { - CommandTargetSessionKey: "agent:main:telegram:group:-1001234567890:topic:42", - MessageThreadId: 42, - OriginatingTo: "telegram:-1001234567890:topic:42", - }, - "topic dispatch context", - ); - }, - ); - - it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => { - const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute( - { - ...route, - sessionKey: boundSessionKey, - agentId: "codex", - matchedBy: "binding.channel", - }, - createConfiguredAcpTopicBinding(boundSessionKey), - ), - ); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ - ok: false, - error: "gateway unavailable", - }); - - const { handler, sendMessage } = registerAndResolveStatusHandler({ - cfg: {}, - allowFrom: ["200"], - groupAllowFrom: ["200"], - }); - await handler(createTelegramTopicCommandContext()); - - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expectSendMessageCall({ - sendMessage, - chatId: -1001234567890, - text: "Configured ACP binding is unavailable right now. Please try again.", - optionFields: { message_thread_id: 42 }, - label: "unavailable ACP binding", - }); - }); - - it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => { - const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute( - { - ...route, - sessionKey: boundSessionKey, - agentId: "codex", - matchedBy: "binding.channel", - }, - createConfiguredAcpTopicBinding(boundSessionKey), - ), - ); - persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "new", - cfg: {}, - allowFrom: [], - groupAllowFrom: [], - }); - await handler(createTelegramTopicCommandContext()); - - expectUnauthorizedNewCommandBlocked(sendMessage); - }); - - it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => { - persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) => - createConfiguredBindingRoute(route, null), - ); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "new", - cfg: {}, - allowFrom: [], - groupAllowFrom: [], - }); - await handler(createTelegramTopicCommandContext()); - - expectUnauthorizedNewCommandBlocked(sendMessage); - }); - - it("passes persisted topic session identity to plugin commands", async () => { - sessionMocks.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions/sessions.json"); - sessionMocks.getSessionEntry.mockReturnValue({ - authProfileOverride: "openai:owner@example.com", - sessionId: "sess-topic", - updatedAt: 1, - }); - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:telegram:group:-1001234567890:topic:42": { - authProfileOverride: "openai:owner@example.com", - sessionId: "sess-topic", - updatedAt: 1, - }, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - groupAllowFrom: ["-1001234567890"], - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler( - createTelegramTopicCommandContext({ match: "bind --cwd /tmp/work", threadId: 42 }), - ); - - expectRecordFields( - (pluginRuntimeMocks.executePluginCommand.mock.calls as unknown as Array<[unknown]>)[0]?.[0], - { - sessionKey: "agent:main:telegram:group:-1001234567890:topic:42", - sessionId: "sess-topic", - messageThreadId: 42, - }, - "plugin command params", - ); - }); - - it("moves the target session to the profile returned by Telegram /login codex", async () => { - const finishLogin = createDeferred(); - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - authProfileOverride: "openai:owner@example.com", - sessionId: "sess-main", - updatedAt: 1, - }, - }); - const runModelsAuthLoginFlow = vi.fn(async (opts) => { - await opts.prompter.deviceCode?.({ - title: "OpenAI Codex device code", - code: "ABCD-EFGH", - expiresInMinutes: 15, - message: "URL: https://auth.openai.com/codex/device", - }); - await finishLogin.promise; - return { - providerId: "openai", - methodId: "device-code", - profiles: [ - { profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }, - ], - }; - }); - - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - expect(sessionMocks.updateSessionStoreEntry).not.toHaveBeenCalled(); - finishLogin.resolve(); - - expect(runModelsAuthLoginFlow).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "openai", - method: "device-code", - agent: "main", - }), - ); - expect( - (runModelsAuthLoginFlow.mock.calls[0]?.[0] as { profileId?: string } | undefined)?.profileId, - ).toBeUndefined(); - await vi.waitFor(() => - expect(sessionMocks.updateSessionStoreEntry).toHaveBeenCalledWith({ - sessionKey: "agent:main:main", - storePath: "/tmp/openclaw-sessions.json", - requireWriteSuccess: true, - skipMaintenance: true, - update: expect.any(Function), - }), - ); - const patchUpdate = ( - sessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { - update?: (entry: Record) => Record; - } - )?.update?.({ - authProfileOverride: "openai:owner@example.com", - sessionId: "sess-main", - updatedAt: 1, - }); - expect(patchUpdate).toEqual({ - authProfileOverride: "openai:new-owner@example.com", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: undefined, - }); - await vi.waitFor(() => - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - {}, - ), - ); - }); - - it("moves a session created while Telegram login is pending to the returned profile", async () => { - const finishLogin = createDeferred(); - let sessionStore: Record = {}; - sessionMocks.loadSessionStore.mockImplementation(() => sessionStore); - const runModelsAuthLoginFlow = vi.fn(async (opts) => { - await opts.prompter.deviceCode?.({ - title: "OpenAI Codex device code", - code: "NEW-SESSION", - }); - await finishLogin.promise; - return { - providerId: "openai", - methodId: "device-code", - profiles: [ - { profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }, - ], - }; - }); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - sessionStore = { - "agent:main:main": { - sessionId: "sess-created-during-login", - updatedAt: 2, - }, - }; - finishLogin.resolve(); - - await vi.waitFor(() => expect(sessionMocks.updateSessionStoreEntry).toHaveBeenCalledTimes(1)); - const update = ( - sessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { - update?: (entry: SessionEntry) => Partial | null; - } - )?.update; - expect( - update?.({ - sessionId: "sess-created-during-login", - updatedAt: 2, - }), - ).toEqual({ - authProfileOverride: "openai:new-owner@example.com", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: undefined, - }); - await vi.waitFor(() => - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - {}, - ), - ); - }); - - it("preserves a later user-selected profile on a session created during Telegram login", async () => { - const finishLogin = createDeferred(); - let sessionStore: Record = {}; - sessionMocks.loadSessionStore.mockImplementation(() => sessionStore); - const runModelsAuthLoginFlow = vi.fn(async (opts) => { - await opts.prompter.deviceCode?.({ - title: "OpenAI Codex device code", - code: "LATER-USER-SELECTION", - }); - await finishLogin.promise; - return { - providerId: "openai", - methodId: "device-code", - profiles: [{ profileId: "openai:login-profile", provider: "openai", mode: "oauth" }], - }; - }); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - sessionStore = { - "agent:main:main": { - authProfileOverride: "openai:later-user-profile", - authProfileOverrideSource: "user", - sessionId: "sess-created-during-login", - updatedAt: 2, - }, - }; - finishLogin.resolve(); - - await vi.waitFor(() => - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", - {}, - ), - ); - expect(sessionStore["agent:main:main"]?.authProfileOverride).toBe("openai:later-user-profile"); - expect(sendMessage).not.toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - expect.any(Object), - ); - }); - - it("marks a same-profile Telegram login as user-selected", async () => { - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "auto", - authProfileOverrideCompactionCount: 2, - sessionId: "sess-main", - updatedAt: 1, - }, - }); - const runModelsAuthLoginFlow = vi.fn(async () => ({ - providerId: "openai", - methodId: "device-code", - profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }], - })); - const { handler } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - - const update = ( - sessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as { - update?: (entry: Record) => Record; - } - )?.update; - expect(update).toBeTypeOf("function"); - expect( - update?.({ - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "auto", - authProfileOverrideCompactionCount: 2, - sessionId: "sess-main", - updatedAt: 1, - }), - ).toEqual({ - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: undefined, - }); - expect( - update?.({ - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "user", - sessionId: "sess-main", - updatedAt: 2, - }), - ).toBeNull(); - }); - - it("reports partial success when Telegram cannot persist the returned profile", async () => { - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": { - authProfileOverride: "openai:old-owner@example.com", - sessionId: "sess-main", - updatedAt: 1, - }, - }); - sessionMocks.updateSessionStoreEntry.mockRejectedValueOnce(new Error("write failed")); - const runModelsAuthLoginFlow = vi.fn(async () => ({ - providerId: "openai", - methodId: "device-code", - profiles: [{ profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }], - })); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", - {}, - ); - expect(sendMessage).not.toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - expect.any(Object), - ); - }); - - it("reports partial success when Telegram login returns no OpenAI profile", async () => { - const runModelsAuthLoginFlow = vi.fn(async () => ({ - providerId: "openai", - methodId: "device-code", - profiles: [], - })); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", - {}, - ); - expect(sendMessage).not.toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - expect.any(Object), - ); - }); - - it("revalidates an unchanged Telegram profile after device login", async () => { - const previousEntry = { - authProfileOverride: "openai:owner@example.com", - authProfileOverrideSource: "user", - sessionId: "sess-main", - updatedAt: 1, - }; - sessionMocks.loadSessionStore.mockReturnValue({ - "agent:main:main": previousEntry, - }); - sessionMocks.updateSessionStoreEntry.mockImplementationOnce(async (params) => { - const concurrentEntry = { - ...previousEntry, - authProfileOverride: "openai:concurrent-owner@example.com", - updatedAt: 2, - }; - const patch = await params.update({ ...concurrentEntry }); - return patch ? { ...concurrentEntry, ...patch } : concurrentEntry; - }); - const runModelsAuthLoginFlow = vi.fn(async () => ({ - providerId: "openai", - methodId: "device-code", - profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }], - })); - const { handler, sendMessage } = registerAndResolveCommandHandler({ - commandName: "login", - cfg: { - commands: { native: true, ownerAllowFrom: ["200"] }, - } as OpenClawConfig, - allowFrom: ["200"], - runModelsAuthLoginFlow, - }); - - await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); - - expect(sendMessage).toHaveBeenCalledWith( - 100, - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.", - {}, - ); - expect(sendMessage).not.toHaveBeenCalledWith( - 100, - "Codex login complete. Try your request again now.", - expect.any(Object), - ); - }); - - it("passes session identity to plugin commands when the entry has no file", async () => { - sessionMocks.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions/sessions.json"); - sessionMocks.getSessionEntry.mockReturnValue({ - sessionId: "sess-main", - updatedAt: 1, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler(createTelegramPrivateCommandContext({ match: "status" })); - - expectRecordFields( - (pluginRuntimeMocks.executePluginCommand.mock.calls as unknown as Array<[unknown]>)[0]?.[0], - { - sessionKey: "agent:main:main", - sessionId: "sess-main", - sessionFile: "sqlite:main:sess-main:/tmp/openclaw-sessions/sessions.json", - }, - "plugin command params", - ); - }); - - it("passes SQLite transcript markers to plugin commands without path resolution", async () => { - const storePath = "/tmp/openclaw-sessions/sessions.json"; - const marker = `sqlite:main:sess-main:${storePath}`; - sessionMocks.resolveStorePath.mockReturnValue(storePath); - sessionMocks.getSessionEntry.mockReturnValue({ - sessionId: "sess-main", - sessionFile: marker, - updatedAt: 1, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler(createTelegramPrivateCommandContext({ match: "status" })); - - expectRecordFields( - (pluginRuntimeMocks.executePluginCommand.mock.calls as unknown as Array<[unknown]>)[0]?.[0], - { - sessionKey: "agent:main:main", - sessionId: "sess-main", - sessionFile: marker, - }, - "plugin command params", - ); - }); - - it("replaces stale legacy transcript paths for plugin commands", async () => { - const storePath = "/tmp/openclaw-sessions/sessions.json"; - const marker = `sqlite:main:sess-main:${storePath}`; - sessionMocks.resolveStorePath.mockReturnValue(storePath); - sessionMocks.getSessionEntry.mockReturnValue({ - sessionId: "sess-main", - sessionFile: "sess-main.jsonl", - updatedAt: 1, - }); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler(createTelegramPrivateCommandContext({ match: "status" })); - - expectRecordFields( - (pluginRuntimeMocks.executePluginCommand.mock.calls as unknown as Array<[unknown]>)[0]?.[0], - { - sessionKey: "agent:main:main", - sessionId: "sess-main", - sessionFile: marker, - }, - "plugin command params", - ); - }); - - it("sends an empty-response fallback when a plugin command returns undefined", async () => { - pluginRuntimeMocks.executePluginCommand.mockResolvedValue(undefined as never); - - const { handler } = registerAndResolveCommandHandler({ - commandName: "plugin_meta", - cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - pluginCommandSpecs: [ - { - name: "plugin_meta", - description: "Codex", - acceptsArgs: true, - }, - ] as TelegramPluginCommandSpecs, - }); - await handler(createTelegramPrivateCommandContext({ match: "status" })); - - const deliveryCall = requireValue( - firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as - | DeliverRepliesParams - | undefined, - "empty response delivery params", - ); - expect(deliveryCall.replies).toEqual([{ text: "No response generated. Please try again." }]); - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 89bd3d06ce28..09e997f3331e 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -13,9 +13,6 @@ import { createCommandBot, createNativeCommandTestParams, createPrivateCommandContext, - deliverReplies, - editMessageTelegram, - emitTelegramMessageSentHooks, listSkillCommandsForAgents, resetNativeCommandMenuMocks, waitForRegisteredCommands, @@ -23,19 +20,9 @@ import { import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js"; import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js"; -type CommandBotHarness = ReturnType; type TelegramInlineKeyboardReplyMarkup = { inline_keyboard?: Array>; }; -type PlugCommandHarnessParams = { - botHarness?: CommandBotHarness; - cfg?: OpenClawConfig; - command?: Record; - acceptsArgs?: boolean; - args?: string; - result?: Record; - registerOverrides?: Partial[0]>; -}; const pluginCommandHandler = vi.fn(async (_ctx: Record) => ({ text: "ok" })); @@ -62,35 +49,6 @@ function registerTestPluginCommand(params: { ).toEqual({ ok: true }); } -function primePlugCommand(params: PlugCommandHarnessParams = {}) { - registerTestPluginCommand({ - name: "plug", - description: "Plugin command", - acceptsArgs: params.acceptsArgs ?? true, - command: params.command, - result: params.result, - }); -} - -function registerPlugCommand(params: PlugCommandHarnessParams = {}) { - const botHarness = params.botHarness ?? createCommandBot(); - primePlugCommand(params); - registerTelegramNativeCommands({ - ...createNativeCommandTestParams(params.cfg ?? {}, { - bot: botHarness.bot, - }), - ...params.registerOverrides, - }); - const handler = botHarness.commandHandlers.get("plug"); - if (!handler) { - throw new Error("expected plug command handler to be registered"); - } - return { - ...botHarness, - handler, - }; -} - function collectCallbackData(replyMarkup: TelegramInlineKeyboardReplyMarkup | undefined): string[] { const callbackData: string[] = []; for (const row of replyMarkup?.inline_keyboard ?? []) { @@ -111,39 +69,9 @@ function firstCall(mock: { mock: { calls: Array> } }) { return call; } -function firstCallArg(mock: { mock: { calls: Array> } }, argIndex = 0) { - const arg = firstCall(mock)[argIndex]; - if (!arg || typeof arg !== "object") { - throw new Error(`expected first mock call arg ${argIndex}`); - } - return arg as Record; -} - -function firstDeliverRepliesParams() { - return firstCallArg(deliverReplies as unknown as { mock: { calls: Array> } }); -} - -function firstExecutePluginCommandParams() { - return firstCallArg( - pluginCommandHandler as unknown as { - mock: { calls: Array> }; - }, - ); -} - -function replyAt(params: Record, index = 0) { - const replies = params.replies as Array> | undefined; - const reply = replies?.[index]; - if (!reply) { - throw new Error(`expected reply ${index}`); - } - return reply; -} - resetPluginRuntimeStateForTest(); setActivePluginRegistry(createEmptyPluginRegistry()); -const { registerTelegramNativeCommands, parseTelegramNativeCommandCallbackData } = - await import("./bot-native-commands.js"); +const { registerTelegramNativeCommands } = await import("./bot-native-commands.js"); registerTelegramNativeCommands(createNativeCommandTestParams({})); describe("registerTelegramNativeCommands", () => { @@ -439,476 +367,5 @@ describe("registerTelegramNativeCommands", () => { "tgcmd:/fast status", ]); expect(labels).toEqual(["on", "off", "auto (30 sec)", "default", "status"]); - expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast status")).toBe("/fast status"); - expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast auto")).toBe("/fast auto"); - expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast default")).toBe("/fast default"); - expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull(); - }); - - it("passes agent-scoped media roots for plugin command replies with media", async () => { - const mediaMaxBytes = 50 * 1024 * 1024; - const cfg: OpenClawConfig = { - agents: { - list: [{ id: "main", default: true }, { id: "work" }], - }, - bindings: [{ agentId: "work", match: { channel: "telegram", accountId: "default" } }], - }; - - const { handler, sendMessage } = registerPlugCommand({ - cfg, - result: { - text: "with media", - mediaUrl: "/tmp/workspace-work/render.png", - }, - registerOverrides: { - mediaMaxBytes, - } as Partial[0]>, - }); - - await handler(createPrivateCommandContext()); - - const deliverParams = firstDeliverRepliesParams(); - expect(deliverParams.mediaMaxBytes).toBe(mediaMaxBytes); - const mediaLocalRoots = deliverParams.mediaLocalRoots as Array | undefined; - expect(mediaLocalRoots?.some((root) => /[\\/]\.openclaw[\\/]workspace-work$/.test(root))).toBe( - true, - ); - expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); - }); - - it("delivers presentation-only tables returned by plugin commands", async () => { - const presentation = { - title: "FY25 outlook", - blocks: [ - { - type: "table", - caption: "Pipeline", - headers: ["Account", "Stage"], - rows: [["Acme", "Won"]], - }, - ], - }; - const { handler } = registerPlugCommand({ result: { presentation } }); - - await handler(createPrivateCommandContext()); - - expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ presentation }); - expect(replyAt(firstDeliverRepliesParams()).text).toBeUndefined(); - }); - - it("delivers Telegram button-only plugin command replies", async () => { - const buttons = [[{ text: "Retry", callback_data: "retry" }]]; - const { handler } = registerPlugCommand({ - result: { channelData: { telegram: { buttons } } }, - }); - - await handler(createPrivateCommandContext()); - - expect(replyAt(firstDeliverRepliesParams())).toEqual({ - channelData: { telegram: { buttons } }, - }); - }); - - it("targets reaction-only plugin replies at the invoking command message", async () => { - const { handler } = registerPlugCommand({ - result: { channelData: { telegram: { reaction: { emoji: "🔥" } } } }, - }); - - await handler(createPrivateCommandContext({ messageId: 321 })); - - const deliveryParams = firstDeliverRepliesParams(); - expect(replyAt(deliveryParams)).toEqual({ - replyToId: "321", - channelData: { telegram: { reaction: { emoji: "🔥" } } }, - }); - expect(deliveryParams.replyToMode).toBe("all"); - }); - - it("uses the empty-response fallback for unrelated metadata-only plugin results", async () => { - const { handler } = registerPlugCommand({ - result: { channelData: { plugin: { traceId: "trace-1" } } }, - }); - - await handler(createPrivateCommandContext()); - - expect(replyAt(firstDeliverRepliesParams())).toEqual({ - text: "No response generated. Please try again.", - }); - }); - - it("replies to unmatched plugin commands in the originating forum topic", async () => { - const { handler, sendMessage } = registerPlugCommand({ acceptsArgs: false }); - - await handler({ - match: "unexpected", - message: { - message_id: 2, - date: Math.floor(Date.now() / 1000), - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - message_thread_id: 77, - from: { id: 200, username: "bob" }, - }, - }); - - const sendMessageCall = firstCall(sendMessage); - expect(sendMessageCall[0]).toBe(-1001234567890); - expect(sendMessageCall[1]).toBe("Command not found."); - expect( - (sendMessageCall[2] as { message_thread_id?: number } | undefined)?.message_thread_id, - ).toBe(77); - }); - - it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { - telegram: - "Running this command now...\n\nI'll edit this message with the final result when it's ready.", - }, - }, - result: { - text: "Command completed successfully", - }, - }); - - await handler( - createPrivateCommandContext({ - match: "now", - }), - ); - - const sendMessageCall = firstCall(sendMessage); - expect(sendMessageCall[0]).toBe(100); - expect(String(sendMessageCall[1])).toContain("Running this command now"); - expect(sendMessageCall[2]).toBeUndefined(); - const editCall = firstCall( - editMessageTelegram as unknown as { mock: { calls: Array> } }, - ); - expect(editCall[0]).toBe(100); - expect(editCall[1]).toBe(999); - expect(String(editCall[2])).toContain("Command completed successfully"); - expect((editCall[3] as { accountId?: string } | undefined)?.accountId).toBe("default"); - expect(deleteMessage).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalled(); - const hookParams = firstCallArg( - emitTelegramMessageSentHooks as unknown as { mock: { calls: Array> } }, - ); - expect(hookParams.chatId).toBe("100"); - expect(hookParams.content).toBe("Command completed successfully"); - expect(hookParams.messageId).toBe(999); - expect(hookParams.success).toBe(true); - }); - - it("preserves Telegram buttons when editing a metadata-driven progress placeholder", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Choose an option", - channelData: { - telegram: { - buttons: [[{ text: "Approve", callback_data: "approve" }]], - }, - }, - }, - }); - - await handler(createPrivateCommandContext({ match: "now" })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - const editCall = firstCall( - editMessageTelegram as unknown as { mock: { calls: Array> } }, - ); - expect(editCall[0]).toBe(100); - expect(editCall[1]).toBe(999); - expect(editCall[2]).toBe("Choose an option"); - expect((editCall[3] as { buttons?: unknown } | undefined)?.buttons).toEqual([ - [{ text: "Approve", callback_data: "approve" }], - ]); - expect(deleteMessage).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("delivers reactions after cleaning up a metadata-driven progress placeholder", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Command completed successfully", - channelData: { telegram: { reaction: { emoji: "🔥" } } }, - }, - }); - - await handler(createPrivateCommandContext({ match: "now", messageId: 321 })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - const deliveryParams = firstDeliverRepliesParams(); - expect(deliveryParams.replyToMode).toBe("all"); - expect(replyAt(deliveryParams)).toEqual({ - text: "Command completed successfully", - replyToId: "321", - channelData: { telegram: { reaction: { emoji: "🔥" } } }, - }); - }); - - it("falls back to a normal reply when a metadata-driven progress result is not editable", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "rich output", - mediaUrl: "/tmp/render.png", - }, - }); - - await handler( - createPrivateCommandContext({ - match: "now", - }), - ); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - expect(replyAt(firstDeliverRepliesParams()).mediaUrl).toBe("/tmp/render.png"); - }); - - it("falls back to a normal reply when a progress result has presentation controls", async () => { - const presentation = { - blocks: [ - { - type: "buttons", - buttons: [{ label: "Approve", action: { type: "callback", value: "/approve yes" } }], - }, - ], - }; - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Approval required", - presentation, - }, - }); - - await handler(createPrivateCommandContext({ match: "now" })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ - text: "Approval required", - presentation, - }); - }); - - it("cleans up the progress placeholder before falling back after an edit failure", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Command completed successfully", - }, - }); - editMessageTelegram.mockRejectedValueOnce(new Error("message to edit not found")); - - await handler(createPrivateCommandContext({ match: "now" })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(editMessageTelegram).toHaveBeenCalledTimes(1); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - expect(replyAt(firstDeliverRepliesParams()).text).toBe("Command completed successfully"); - }); - - it("cleans up the progress placeholder when Telegram suppresses a local exec approval reply", async () => { - const { handler, sendMessage, deleteMessage } = registerPlugCommand({ - args: "now", - command: { - nativeProgressMessages: { telegram: "Working on it..." }, - }, - result: { - text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", - channelData: { - execApproval: { - approvalId: "7f423fdc-1111-2222-3333-444444444444", - approvalSlug: "7f423fdc", - allowedDecisions: ["allow-once", "allow-always", "deny"], - }, - }, - }, - cfg: { - channels: { - telegram: { - execApprovals: { - enabled: true, - approvers: ["12345"], - target: "dm", - }, - }, - }, - }, - }); - - await handler(createPrivateCommandContext({ match: "now" })); - - expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); - expect(deleteMessage).toHaveBeenCalledWith(100, 999); - expect(editMessageTelegram).not.toHaveBeenCalled(); - expect(deliverReplies).not.toHaveBeenCalled(); - }); - - it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => { - const { handler } = registerPlugCommand({ - cfg: { - channels: { - telegram: { - silentErrorReplies: true, - }, - }, - }, - result: { - text: "plugin failed", - isError: true, - }, - registerOverrides: { - telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig, - }, - }); - - await handler(createPrivateCommandContext()); - - const deliverParams = firstDeliverRepliesParams(); - expect(deliverParams.silent).toBe(true); - expect(replyAt(deliverParams).isError).toBe(true); - }); - - it("uses rich messages for plugin command replies when enabled", async () => { - const { handler } = registerPlugCommand({ - cfg: { - channels: { - telegram: { - richMessages: true, - }, - }, - }, - registerOverrides: { - telegramCfg: { richMessages: true } as TelegramAccountConfig, - }, - }); - - await handler(createPrivateCommandContext()); - - expect(firstDeliverRepliesParams().richMessages).toBe(true); - }); - - it("forwards topic-scoped binding context to Telegram plugin commands", async () => { - const { handler } = registerPlugCommand(); - - await handler({ - match: "", - message: { - message_id: 2, - date: Math.floor(Date.now() / 1000), - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - message_thread_id: 77, - from: { id: 200, username: "bob" }, - }, - }); - - const commandParams = firstExecutePluginCommandParams(); - expect(commandParams.channel).toBe("telegram"); - expect(commandParams.accountId).toBe("default"); - expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:77"); - expect(commandParams.to).toBe("telegram:-1001234567890"); - expect(commandParams.messageThreadId).toBe(77); - }); - - it("treats Telegram forum #General commands as topic 1 when Telegram omits topic metadata", async () => { - const getChat = vi.fn(async () => ({ id: -1001234567890, type: "supergroup", is_forum: true })); - const { handler } = registerPlugCommand({ - botHarness: createCommandBot({ api: { getChat } }), - }); - - await handler({ - match: "", - message: { - message_id: 2, - date: Math.floor(Date.now() / 1000), - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - }, - from: { id: 200, username: "bob" }, - }, - }); - - expect(getChat).toHaveBeenCalledWith(-1001234567890); - const commandParams = firstExecutePluginCommandParams(); - expect(commandParams.accountId).toBe("default"); - expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:1"); - expect(commandParams.to).toBe("telegram:-1001234567890"); - expect(commandParams.messageThreadId).toBe(1); - }); - - it("forwards direct-message binding context to Telegram plugin commands", async () => { - const { handler } = registerPlugCommand(); - - await handler(createPrivateCommandContext({ chatId: 100, userId: 200 })); - - const commandParams = firstExecutePluginCommandParams(); - expect(commandParams.channel).toBe("telegram"); - expect(commandParams.accountId).toBe("default"); - expect(commandParams.from).toBe("telegram:100"); - expect(commandParams.to).toBe("telegram:100"); - expect(commandParams.messageThreadId).toBeUndefined(); - }); - - it("suppresses the fallback reply when a plugin command returns suppressReply: true", async () => { - const { handler } = registerPlugCommand({ - result: { suppressReply: true }, - }); - - await handler(createPrivateCommandContext()); - - expect(deliverReplies).not.toHaveBeenCalled(); - expect(editMessageTelegram).not.toHaveBeenCalled(); - }); - - it("uses bot topic capability for Telegram plugin command DM topic session keys", async () => { - const { handler } = registerPlugCommand(); - - await handler({ - ...createPrivateCommandContext({ chatId: 100, userId: 200, threadId: 77 }), - me: { has_topics_enabled: true }, - }); - - const commandParams = firstExecutePluginCommandParams(); - expect(commandParams.sessionKey).toBe("agent:main:main:thread:100:77"); - const deliveryParams = firstDeliverRepliesParams(); - expect(deliveryParams.sessionKeyForInternalHooks).toBe("agent:main:main:thread:100:77"); }); }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 3f5dbfd7b861..8fbf7df8a7e3 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1,74 +1,23 @@ -// Telegram plugin module implements bot native commands behavior. -import { randomUUID } from "node:crypto"; +// Telegram plugin module implements native command registration behavior. import type { Bot, Context } from "grammy"; import { - loadPreparedModelCatalog, - resolveAgentConfig, - resolveAgentDir, - resolveDefaultModelForAgent, - resolveThinkingDefaultWithRuntimeCatalog, -} from "openclaw/plugin-sdk/agent-runtime"; -import { - isChannelPartialDeliveryError, - type ChannelInboundTurnPlan, -} from "openclaw/plugin-sdk/channel-inbound"; -import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound"; -import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; -import { - buildCommandTextFromArgs, findCommandByNativeName, - formatFastModeCurrentStatus, - formatCommandArgMenuTitle, listNativeCommandSpecs, listNativeCommandSpecsForConfig, - parseCommandArgs, - resolveEffectiveAgentRuntime, - resolveCommandArgMenu, - resolveFastModeState, - resolveStoredModelOverride, - type CommandArgs, } from "openclaw/plugin-sdk/command-auth-native"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-contracts"; import type { - ReplyToMode, + ChannelGroupPolicy, + OpenClawConfig, TelegramAccountConfig, - TelegramDirectConfig, - TelegramGroupConfig, - TelegramTopicConfig, } from "openclaw/plugin-sdk/config-contracts"; -import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; -import { - createPluginCommandRuntime, - PLUGIN_COMMAND_DISPATCH, - type PluginCommandCatalogDecision, -} from "openclaw/plugin-sdk/plugin-command-runtime"; -import { codexChannelLoginRuntime } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime"; -import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; +import { createPluginCommandRuntime } from "openclaw/plugin-sdk/plugin-command-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { - formatSqliteSessionFileMarker, - getSessionEntry, - resolveStorePath, - type SessionEntry, - updateSessionStoreEntry, -} from "openclaw/plugin-sdk/session-store-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime"; -import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; -import { resolveTelegramAccount } from "./accounts.js"; -import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy } from "./bot-access.js"; -import type { TelegramBotDeps } from "./bot-deps.js"; +import { danger, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { TelegramNativeCommandCallbackDispatcher, TelegramResolvedGroupConfig, } from "./bot-handlers.types.js"; -import { resolveTelegramMessageTurnSettings } from "./bot-message.js"; import { defaultTelegramNativeCommandDeps, type TelegramNativeCommandDeps, @@ -81,536 +30,21 @@ import { } from "./bot-native-command-menu.js"; import type { TelegramUpdateKeyContext } from "./bot-updates.js"; import type { TelegramBotOptions } from "./bot.types.js"; -import { - buildTelegramRoutingTarget, - buildTelegramThreadParams, - buildSenderName, - buildTelegramGroupFrom, - extractTelegramForumFlag, - isTelegramCommandsAllowFromConfigured, - resolveTelegramCommandAuthorization, - resolveTelegramForumFlag, - resolveTelegramGroupAllowFromContext, - resolveTelegramBotHasTopicsEnabled, - resolveTelegramMessageThreadSpec, - resolveTelegramThreadSpec, - shouldUseTelegramDmThreadSession, -} from "./bot/helpers.js"; -import type { TelegramGetChat } from "./bot/types.js"; -import type { TelegramInlineButtons } from "./button-types.js"; import { normalizeTelegramCommandName, resolveTelegramCustomCommands, TELEGRAM_COMMAND_NAME_PATTERN, } from "./command-config.js"; -import { - resolveTelegramConversationBaseSessionKey, - resolveTelegramConversationRoute, -} from "./conversation-route.js"; -import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; -import { - evaluateTelegramGroupBaseAccess, - evaluateTelegramGroupPolicyAccess, -} from "./group-access.js"; -import { - resolveTelegramDirectToolPolicy, - resolveTelegramGroupPromptSettings, -} from "./group-config-helpers.js"; -import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; -import { buildInlineKeyboard } from "./inline-keyboard.js"; -import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; -import { recordSentMessage } from "./sent-message-cache.js"; -import { getTopicName, resolveTopicNameCacheScope } from "./topic-name-cache.js"; -export { parseTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; - -const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; -const NON_PLUGIN_COMMAND_DISPATCH = Object.freeze({ - kind: "non-plugin", -}) satisfies PluginCommandCatalogDecision; -const activeTelegramCodexLoginFlows = codexChannelLoginRuntime.createFlowRegistry(); +const loadTelegramBuiltinCommandExecutor = createLazyRuntimeModule( + () => import("./bot-native-command-builtins.js"), +); +const loadTelegramPluginCommandExecutor = createLazyRuntimeModule( + () => import("./bot-native-command-plugins.js"), +); type TelegramNativeCommandContext = Context & { match?: string }; -type TelegramChunkMode = ReturnType< - typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").resolveChunkMode ->; -type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/plugin-entry").PluginCommandResult; -type TelegramNativeReplyChannelData = { - buttons?: TelegramInlineButtons; - pin?: boolean; - reaction?: { - emoji?: unknown; - }; -}; -type FastModeState = ReturnType; - -type TelegramCommandAuthResult = { - chatId: number; - isGroup: boolean; - isForum: boolean; - resolvedThreadId?: number; - senderId: string; - senderUsername: string; - groupConfig?: TelegramGroupConfig | TelegramDirectConfig; - topicConfig?: TelegramTopicConfig; - commandAuthorized: boolean; - senderIsOwner: boolean; -}; - -type TelegramNativeCommandThreadContext = { - chatId: number; - isGroup: boolean; - isForum: boolean; - threadSpec: ReturnType; - threadParams: ReturnType; -}; - -type TelegramLoginDeviceCode = { - title: string; - code: string; - expiresInMinutes?: number; - message?: string; -}; - -// Telegram's inline-code entity provides the tap-to-copy affordance needed for -// short-lived device codes; plain text and literal backticks do not. -function formatTelegramLoginDeviceCode(params: TelegramLoginDeviceCode): string { - return [ - `${escapeHtml(params.title)}`, - "", - ...(params.message ? [escapeHtml(params.message)] : []), - `Code: ${escapeHtml(params.code)}`, - ...(params.expiresInMinutes - ? [`Code expires in ${params.expiresInMinutes} minutes. Never share it.`] - : []), - ].join("\n"); -} - -function resolveTelegramCodexLoginProviderInput(commandArgs: CommandArgs | undefined): string { - const providerValue = commandArgs?.values?.provider; - return typeof providerValue === "string" && providerValue.trim() - ? providerValue - : (commandArgs?.raw ?? "codex"); -} - -function buildTelegramCodexLoginFlowKey(params: { - accountId: string; - chatId: number; - threadSpec: ReturnType; - agentId: string; - provider: string; -}): string { - const threadKey = - params.threadSpec.id == null - ? params.threadSpec.scope - : `${params.threadSpec.scope}:${params.threadSpec.id}`; - return [ - "telegram", - params.accountId, - String(params.chatId), - threadKey, - params.agentId, - params.provider, - ].join(":"); -} - -type TelegramCommandMenuModelContext = { - provider?: string; - model?: string; - agentRuntime?: string; - thinkingLevel?: string; - fastMode?: SessionEntry["fastMode"]; -}; - -function buildTelegramCommandMenuModelContext(params: { - provider: string; - model: string; - thinkingLevel?: string; - fastMode?: SessionEntry["fastMode"]; -}): TelegramCommandMenuModelContext { - return { - provider: params.provider, - model: params.model, - ...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}), - ...(params.fastMode !== undefined ? { fastMode: params.fastMode } : {}), - }; -} - -const loadTelegramNativeCommandDeliveryRuntime = createLazyRuntimeModule( - () => import("./bot-native-commands.delivery.runtime.js"), -); - -const loadTelegramNativeCommandRuntime = createLazyRuntimeModule( - () => import("./bot-native-commands.runtime.js"), -); - -type TelegramNativeCommandRuntime = Awaited>; - -function resolveTelegramCommandSessionFile(params: { - agentId: string; - sessionId: string; - storePath: string; -}): string { - return formatSqliteSessionFileMarker({ - agentId: params.agentId, - sessionId: params.sessionId, - storePath: params.storePath, - }); -} - -async function resolveTelegramCommandTranscriptContext(params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; - threadId?: string | number; -}): Promise<{ sessionId?: string; sessionFile?: string; authProfileId?: string }> { - const sessionKey = params.sessionKey.trim(); - if (!sessionKey) { - return {}; - } - try { - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const entry = getSessionEntry({ - agentId: params.agentId, - sessionKey, - storePath, - }); - const sessionId = entry?.sessionId?.trim() || randomUUID(); - const sessionFile = resolveTelegramCommandSessionFile({ - agentId: params.agentId, - sessionId, - storePath, - }); - const authProfileId = normalizeOptionalString(entry?.authProfileOverride); - return { - sessionId, - sessionFile, - ...(authProfileId ? { authProfileId } : {}), - }; - } catch { - return {}; - } -} - -function resolveTelegramCommandMenuModelContext(params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; -}): TelegramCommandMenuModelContext { - if (!params.sessionKey.trim()) { - return {}; - } - try { - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const defaultModel = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - }); - const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); - const thinkingLevel = normalizeOptionalString(entry?.thinkingLevel); - const fastMode = entry?.fastMode; - let context: TelegramCommandMenuModelContext; - if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { - context = buildTelegramCommandMenuModelContext({ - provider: defaultModel.provider, - model: defaultModel.model, - ...(thinkingLevel ? { thinkingLevel } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - }); - } else { - const override = resolveStoredModelOverride({ - sessionEntry: entry, - loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), - sessionKey: params.sessionKey, - defaultProvider: defaultModel.provider, - }); - if (override?.model) { - context = buildTelegramCommandMenuModelContext({ - provider: override.provider || defaultModel.provider, - model: override.model, - ...(thinkingLevel ? { thinkingLevel } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - }); - } else { - const provider = - normalizeOptionalString(entry?.providerOverride) ?? - normalizeOptionalString(entry?.modelProvider); - const model = - normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model); - context = { - ...(provider ? { provider } : {}), - ...(model ? { model } : {}), - ...(thinkingLevel ? { thinkingLevel } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - }; - } - } - return { - ...context, - agentRuntime: resolveEffectiveAgentRuntime({ - cfg: params.cfg, - provider: context.provider ?? defaultModel.provider, - modelId: context.model ?? defaultModel.model, - agentId: params.agentId, - sessionKey: params.sessionKey, - sessionEntry: entry, - }), - }; - } catch { - return {}; - } -} - -function resolveTelegramFastCommandModelContext(params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; -}): { - provider?: string; - model?: string; -} { - const defaultModel = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - }); - const fallback = () => ({ - provider: defaultModel.provider, - model: defaultModel.model, - }); - if (!params.sessionKey.trim()) { - return fallback(); - } - try { - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); - if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { - return fallback(); - } - const override = resolveStoredModelOverride({ - sessionEntry: entry, - loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), - sessionKey: params.sessionKey, - defaultProvider: defaultModel.provider, - }); - return { - provider: override?.provider ?? defaultModel.provider, - model: override?.model ?? defaultModel.model, - }; - } catch { - return fallback(); - } -} - -function resolveTelegramFastCommandState(params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; -}): FastModeState { - const defaultModel = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - }); - const fallback = () => - resolveFastModeState({ - cfg: params.cfg, - provider: defaultModel.provider, - model: defaultModel.model, - agentId: params.agentId, - }); - if (!params.sessionKey.trim()) { - return fallback(); - } - try { - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); - const modelContext = resolveTelegramFastCommandModelContext(params); - return resolveFastModeState({ - cfg: params.cfg, - provider: modelContext.provider ?? defaultModel.provider, - model: modelContext.model ?? defaultModel.model, - agentId: params.agentId, - sessionEntry: - entry?.fastMode !== undefined - ? { - fastMode: entry.fastMode, - } - : undefined, - }); - } catch { - return fallback(); - } -} - -async function resolveTelegramThinkMenuCurrentLevel(params: { - cfg: OpenClawConfig; - agentId: string; - provider?: string; - model?: string; - agentRuntime?: string; - thinkingLevel?: string; - catalog: Awaited>; -}): Promise { - const explicit = normalizeOptionalString(params.thinkingLevel); - if (explicit) { - return explicit; - } - const agentThinkingDefault = normalizeOptionalString( - resolveAgentConfig(params.cfg, params.agentId)?.thinkingDefault, - ); - if (agentThinkingDefault) { - return agentThinkingDefault; - } - const defaultModel = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: params.agentId, - }); - return await resolveThinkingDefaultWithRuntimeCatalog({ - cfg: params.cfg, - provider: params.provider ?? defaultModel.provider, - model: params.model ?? defaultModel.model, - agentRuntime: params.agentRuntime, - loadRuntimeCatalog: async () => params.catalog, - }); -} - -function formatTelegramCommandArgMenuTitle(params: { - command: NonNullable>; - menu: NonNullable>; - currentThinkingLevel?: string; - currentFastModeStatus?: string; -}): string { - const title = formatCommandArgMenuTitle({ command: params.command, menu: params.menu }); - if (params.command.key === "think" && params.currentThinkingLevel) { - return `Current thinking level: ${params.currentThinkingLevel}.\n${title}`; - } - if (params.command.key === "fast" && params.currentFastModeStatus) { - const options = params.menu.choices - .map((choice) => choice.label.trim()) - .filter(Boolean) - .join(", "); - return options - ? `${params.currentFastModeStatus}\nOptions: ${options}.` - : params.currentFastModeStatus; - } - return title; -} - -function resolveTelegramFastMenuCurrentStatus(params: { state: FastModeState }): string { - return formatFastModeCurrentStatus({ - mode: params.state.mode, - source: params.state.source, - fastAutoOnSeconds: params.state.fastAutoOnSeconds, - }); -} - -function resolveTelegramNativeReplyChannelData( - result: TelegramNativeReplyPayload, -): TelegramNativeReplyChannelData | undefined { - return result.channelData?.telegram as TelegramNativeReplyChannelData | undefined; -} - -function normalizeTelegramNativeReplyPayload( - result: TelegramNativeReplyPayload | null | undefined, -): TelegramNativeReplyPayload { - return result && typeof result === "object" ? result : {}; -} - -function isSuppressedTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { - return result.suppressReply === true; -} - -function hasTelegramNativeReplyReaction(result: TelegramNativeReplyPayload): boolean { - const reactionEmoji = resolveTelegramNativeReplyChannelData(result)?.reaction?.emoji; - return typeof reactionEmoji === "string" && reactionEmoji.trim().length > 0; -} - -function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { - const { channelData: _channelData, ...portableContent } = result; - if (hasOutboundReplyContent(portableContent, { trimText: true })) { - return true; - } - const telegramData = resolveTelegramNativeReplyChannelData(result); - return Boolean( - buildInlineKeyboard(telegramData?.buttons) || hasTelegramNativeReplyReaction(result), - ); -} - -function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): boolean { - const telegramData = resolveTelegramNativeReplyChannelData(result); - return Boolean( - typeof result.text === "string" && - result.text.trim() && - !result.mediaUrl && - (!result.mediaUrls || result.mediaUrls.length === 0) && - !result.presentation && - !result.interactive && - !result.btw && - !hasTelegramNativeReplyReaction(result) && - telegramData?.pin !== true, - ); -} - -async function cleanupTelegramProgressPlaceholder(params: { - bot: Bot; - chatId: number; - progressMessageId?: number; - runtime: RuntimeEnv; -}): Promise { - const progressMessageId = params.progressMessageId; - if (progressMessageId == null) { - return; - } - try { - await withTelegramApiErrorLogging({ - operation: "deleteMessage", - runtime: params.runtime, - fn: () => params.bot.api.deleteMessage(params.chatId, progressMessageId), - }); - } catch { - // Best-effort cleanup before fallback or suppression exits. - } -} - -async function resolveTelegramNativeCommandThreadContext(params: { - msg: NonNullable; - bot: Bot; -}): Promise { - const { msg, bot } = params; - const chatId = msg.chat.id; - const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; - const getChat = - typeof bot.api.getChat === "function" - ? (bot.api.getChat.bind(bot.api) as TelegramGetChat) - : undefined; - const isForum = - msg.chat.is_direct_messages === true - ? false - : await resolveTelegramForumFlag({ - chatId, - chatType: msg.chat.type, - isGroup, - isForum: extractTelegramForumFlag(msg.chat), - isTopicMessage: msg.is_topic_message, - getChat, - }); - const threadSpec = resolveTelegramMessageThreadSpec(msg, isForum); - return { - chatId, - isGroup, - isForum, - threadSpec, - threadParams: buildTelegramThreadParams(threadSpec), - }; -} - -function resolveTelegramNativeCommandDisableBlockStreaming( - telegramCfg: TelegramAccountConfig, -): boolean | undefined { - const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg); - return typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined; -} - type RegisterTelegramNativeCommandsParams = { bot: Bot; cfg: OpenClawConfig; @@ -634,229 +68,6 @@ type RegisterTelegramNativeCommandsParams = { >; }; -async function resolveTelegramCommandAuth(params: { - msg: NonNullable; - bot: Bot; - cfg: OpenClawConfig; - accountId: string; - telegramCfg: TelegramAccountConfig; - readChannelAllowFromStore: TelegramBotDeps["readChannelAllowFromStore"]; - allowFrom?: Array; - groupAllowFrom?: Array; - resolveGroupPolicy: (chatId: string | number, cfg: OpenClawConfig) => ChannelGroupPolicy; - resolveTelegramGroupConfig: ( - chatId: string | number, - messageThreadId: number | undefined, - cfg: OpenClawConfig, - ) => TelegramResolvedGroupConfig; - requireAuth: boolean; -}): Promise { - const { - msg, - bot, - cfg, - accountId, - telegramCfg, - readChannelAllowFromStore, - allowFrom, - groupAllowFrom, - resolveGroupPolicy, - resolveTelegramGroupConfig, - requireAuth, - } = params; - const { chatId, isGroup, isForum, threadSpec, threadParams } = - await resolveTelegramNativeCommandThreadContext({ msg, bot }); - const senderId = msg.from?.id ? String(msg.from.id) : ""; - const senderUsername = msg.from?.username ?? ""; - // Best-effort pre-context check: if commands.allowFrom already authorizes the - // sender at chat level, skip the pairing-store read so a transient store I/O - // failure cannot block a command this sender is explicitly allowed to run. - // resolvedThreadId is not known yet; the post-context check below is still - // the authoritative decision for topic-scoped command auth. - const commandsAllowFromConfigured = isTelegramCommandsAllowFromConfigured(cfg); - const preContextCommandsAllowFromAccess = commandsAllowFromConfigured - ? resolveTelegramCommandAuthorization({ - cfg, - accountId, - chatId, - isGroup, - senderId, - senderUsername, - }) - : null; - const groupAllowContext = await resolveTelegramGroupAllowFromContext({ - cfg, - chatId, - accountId, - dmPolicy: telegramCfg.dmPolicy, - allowFrom, - senderId, - isGroup, - threadSpec, - groupAllowFrom, - skipPairingStoreRead: Boolean(preContextCommandsAllowFromAccess?.isAuthorizedSender), - readChannelAllowFromStore, - resolveTelegramGroupConfig, - }); - const { - resolvedThreadId, - dmThreadId, - storeAllowFrom, - groupConfig, - topicConfig, - groupAllowOverride, - effectiveGroupAllow, - hasGroupAllowOverride, - } = groupAllowContext; - const effectiveDmPolicy = resolveTelegramEffectiveDmPolicy({ - isGroup, - groupConfig, - dmPolicy: telegramCfg.dmPolicy, - }); - const requireTopic = - !isGroup && groupConfig && "requireTopic" in groupConfig ? groupConfig.requireTopic : undefined; - if (!isGroup && requireTopic === true && dmThreadId == null) { - logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`); - return null; - } - const dmAllowFrom = groupAllowOverride ?? allowFrom; - const commandsAllowFromAccess = commandsAllowFromConfigured - ? resolveTelegramCommandAuthorization({ - cfg, - accountId, - chatId, - isGroup, - resolvedThreadId, - senderId, - senderUsername, - }) - : null; - const ownerAccess = resolveTelegramCommandAuthorization({ - cfg, - accountId, - chatId, - isGroup, - resolvedThreadId, - senderId, - senderUsername, - }); - - const sendAuthMessage = async (text: string) => { - await withTelegramApiErrorLogging({ - operation: "sendMessage", - fn: () => bot.api.sendMessage(chatId, text, threadParams ?? {}), - }); - return null; - }; - const rejectNotAuthorized = async () => { - return await sendAuthMessage("You are not authorized to use this command."); - }; - - const baseAccess = evaluateTelegramGroupBaseAccess({ - isGroup, - groupConfig, - topicConfig, - hasGroupAllowOverride, - effectiveGroupAllow, - senderId, - senderUsername, - enforceAllowOverride: requireAuth, - requireSenderForAllowOverride: true, - }); - if (!baseAccess.allowed) { - if (baseAccess.reason === "group-disabled") { - logVerbose(`Blocked telegram command in group ${chatId} (group disabled)`); - return null; - } - if (baseAccess.reason === "topic-disabled") { - logVerbose( - `Blocked telegram command in topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, - ); - return null; - } - return await rejectNotAuthorized(); - } - - const policyAccess = evaluateTelegramGroupPolicyAccess({ - isGroup, - chatId, - cfg, - telegramCfg, - topicConfig, - groupConfig, - effectiveGroupAllow, - senderId, - senderUsername, - resolveGroupPolicy, - enforcePolicy: true, - enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured, - allowEmptyAllowlistEntries: true, - requireSenderForAllowlistAuthorization: true, - checkChatAllowlist: true, - }); - if (!policyAccess.allowed) { - if (policyAccess.reason === "group-policy-disabled") { - logVerbose("Blocked telegram command (groupPolicy: disabled)"); - return null; - } - if ( - policyAccess.reason === "group-policy-allowlist-no-sender" || - policyAccess.reason === "group-policy-allowlist-unauthorized" - ) { - return await rejectNotAuthorized(); - } - if (policyAccess.reason === "group-chat-not-allowed") { - logVerbose(`Blocked telegram command in group ${chatId} (group not allowed)`); - return null; - } - } - - const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({ - cfg, - allowFrom: dmAllowFrom, - accountId, - senderId, - }); - const dmAllow = normalizeDmAllowFromWithStore({ - allowFrom: expandedDmAllowFrom, - storeAllowFrom: isGroup ? [] : storeAllowFrom, - dmPolicy: effectiveDmPolicy, - }); - const commandAuthorized = commandsAllowFromConfigured - ? Boolean(commandsAllowFromAccess?.isAuthorizedSender) - : ( - await resolveTelegramCommandIngressAuthorization({ - accountId, - cfg, - dmPolicy: effectiveDmPolicy, - isGroup, - chatId, - resolvedThreadId, - senderId, - effectiveDmAllow: dmAllow, - effectiveGroupAllow, - ownerAccess, - eventKind: "native-command", - }) - ).authorized; - if (requireAuth && !commandAuthorized) { - return await rejectNotAuthorized(); - } - - return { - chatId, - isGroup, - isForum, - resolvedThreadId, - senderId, - senderUsername, - groupConfig, - topicConfig, - commandAuthorized, - senderIsOwner: ownerAccess.senderIsOwner, - }; -} - export const registerTelegramNativeCommands = ({ bot, cfg, @@ -883,10 +94,7 @@ export const registerTelegramNativeCommands = ({ } const skillCommands = nativeEnabled && nativeSkillsEnabled && boundRoute - ? telegramDeps.listSkillCommandsForAgents({ - cfg, - agentIds: [boundRoute.agentId], - }) + ? telegramDeps.listSkillCommandsForAgents({ cfg, agentIds: [boundRoute.agentId] }) : []; const pluginCommandRuntime = createPluginCommandRuntime(); const pluginCommandSpecs = pluginCommandRuntime.listNativeCandidates("telegram"); @@ -930,22 +138,17 @@ export const registerTelegramNativeCommands = ({ ); return null; } - const menuCommand: TelegramMenuCommand = { + return { command: normalized, description: command.description, + ...(command.isAlias ? { isAlias: true } : {}), + ...(index >= firstSkillCommandIndex ? { isSkill: true } : {}), + ...(command.descriptionLocalizations + ? { descriptionLocalizations: command.descriptionLocalizations } + : {}), }; - if (command.isAlias) { - menuCommand.isAlias = true; - } - if (index >= firstSkillCommandIndex) { - menuCommand.isSkill = true; - } - if (command.descriptionLocalizations) { - menuCommand.descriptionLocalizations = command.descriptionLocalizations; - } - return menuCommand; }) - .filter((cmd) => cmd !== null); + .filter((command) => command !== null); const customCommandNames = new Set(customCommands.map((command) => command.command)); const fullCommandCatalog = buildCappedTelegramMenuCommands({ allCommands: [ @@ -970,9 +173,6 @@ export const registerTelegramNativeCommands = ({ : loginCommand ? [loginCommand] : []; - const loadFreshRuntimeConfig = (): OpenClawConfig => telegramDeps.getRuntimeConfig(); - const resolveFreshTelegramConfig = (runtimeCfg: OpenClawConfig): TelegramAccountConfig => - resolveTelegramAccount({ cfg: runtimeCfg, accountId }).config; const { commandsToRegister, totalCommands, @@ -1001,8 +201,7 @@ export const registerTelegramNativeCommands = ({ } const syncTelegramMenuCommands = telegramDeps.syncTelegramMenuCommands ?? syncTelegramMenuCommandsRuntime; - // Telegram only limits the setMyCommands payload (menu entries). - // Keep hidden commands callable by registering handlers for the full catalog. + // Telegram only limits menu entries; hidden commands remain callable. syncTelegramMenuCommands({ bot, runtime, @@ -1012,143 +211,21 @@ export const registerTelegramNativeCommands = ({ botToken: opts.token, }); - const resolveCommandRuntimeContext = async (params: { - msg: NonNullable; - runtimeCfg: OpenClawConfig; - isGroup: boolean; - isForum: boolean; - resolvedThreadId?: number; - senderId?: string; - topicAgentId?: string; - }): Promise<{ - chatId: number; - threadSpec: ReturnType; - route: ReturnType["route"]; - mediaLocalRoots: readonly string[] | undefined; - tableMode: ReturnType; - chunkMode: TelegramChunkMode; - } | null> => { - const { msg, runtimeCfg, isGroup, isForum, resolvedThreadId, senderId, topicAgentId } = params; - const chatId = msg.chat.id; - const threadSpec = resolveTelegramMessageThreadSpec(msg, isForum); - const { route, bindingMode } = resolveTelegramConversationRoute({ - cfg: runtimeCfg, - accountId, - chatId, - isGroup, - resolvedThreadId, - replyThreadId: threadSpec.id, - senderId, - topicAgentId, - }); - const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); - if (bindingMode.kind === "configured") { - const ensured = await nativeCommandRuntime.ensureConfiguredBindingRouteReady({ - cfg: runtimeCfg, - bindingResolution: bindingMode.binding, - }); - if (!ensured.ok) { - logVerbose( - `telegram native command: configured ACP binding unavailable for topic ${bindingMode.binding.record.conversation.conversationId}: ${ensured.error}`, - ); - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage( - chatId, - "Configured ACP binding is unavailable right now. Please try again.", - buildTelegramThreadParams(threadSpec) ?? {}, - ), - }); - return null; - } - } - const mediaLocalRoots = nativeCommandRuntime.getAgentScopedMediaLocalRoots( - runtimeCfg, - route.agentId, - ); - const tableMode = resolveMarkdownTableMode({ - cfg: runtimeCfg, - channel: "telegram", - accountId: route.accountId, - supportsBlockTables: true, - }); - const chunkMode = nativeCommandRuntime.resolveChunkMode( - runtimeCfg, - "telegram", - route.accountId, - ); - return { chatId, threadSpec, route, mediaLocalRoots, tableMode, chunkMode }; - }; - const buildCommandDeliveryBaseOptions = (params: { - cfg: OpenClawConfig; - chatId: string | number; - accountId: string; - sessionKeyForInternalHooks?: string; - policySessionKey?: string; - mirrorIsGroup?: boolean; - mirrorGroupId?: string; - mediaLocalRoots?: readonly string[]; - threadSpec: ReturnType; - tableMode: ReturnType; - chunkMode: TelegramChunkMode; - replyToMode: ReplyToMode; - textLimit: number; - linkPreview?: boolean; - richMessages?: boolean; + const buildExecutorParams = (params: { + botUser: Context["me"]; + msg: NonNullable; + rawText: string; }) => ({ - cfg: params.cfg, - chatId: String(params.chatId), - accountId: params.accountId, - sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, - policySessionKey: params.policySessionKey, - mirrorIsGroup: params.mirrorIsGroup, - mirrorGroupId: params.mirrorGroupId, - token: opts.token, - runtime, + ...params, bot, - mediaLocalRoots: params.mediaLocalRoots, + runtime, + accountId, mediaMaxBytes, - replyToMode: params.replyToMode, - textLimit: params.textLimit, - thread: params.threadSpec, - tableMode: params.tableMode, - chunkMode: params.chunkMode, - linkPreview: params.linkPreview, - richMessages: params.richMessages, + resolveGroupPolicy, + resolveTelegramGroupConfig, + telegramDeps, + opts, }); - const resolveCommandTargetSessionKey = (params: { - runtimeCfg: OpenClawConfig; - route: ReturnType["route"]; - chatId: number; - isGroup: boolean; - senderId?: string; - threadSpec: ReturnType; - botHasTopicsEnabled?: boolean; - resolveThreadSessionKeys: TelegramNativeCommandRuntime["resolveThreadSessionKeys"]; - }): string => { - const baseSessionKey = resolveTelegramConversationBaseSessionKey({ - cfg: params.runtimeCfg, - route: params.route, - chatId: params.chatId, - isGroup: params.isGroup, - senderId: params.senderId, - }); - const dmThreadId = params.threadSpec.scope === "dm" ? params.threadSpec.id : undefined; - const threadKeys = - shouldUseTelegramDmThreadSession({ - dmThreadId, - botHasTopicsEnabled: params.botHasTopicsEnabled, - }) && dmThreadId != null - ? params.resolveThreadSessionKeys({ - baseSessionKey, - threadId: `${params.chatId}:${dmThreadId}`, - }) - : null; - return threadKeys?.sessionKey ?? baseSessionKey; - }; - let handleLoginCallback: | (( botUser: Context["me"], @@ -1156,918 +233,57 @@ export const registerTelegramNativeCommands = ({ rawText: string, ) => Promise) | undefined; - if (nativeCommandsToHandle.length > 0 || pluginCatalog.selectedCommands.length > 0) { - for (const command of nativeCommandsToHandle) { - const normalizedCommandName = normalizeTelegramCommandName(command.name); - const commandDefinition = findCommandByNativeName(command.name, "telegram"); - const handleNativeCommand = async ( - botUser: Context["me"], - msg: NonNullable, - rawText: string, - ): Promise => { - const runtimeCfg = loadFreshRuntimeConfig(); - const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg); - const turnSettings = resolveTelegramMessageTurnSettings({ - accountId, - cfg: runtimeCfg, - telegramCfg: runtimeTelegramCfg, - opts, - }); - const auth = await resolveTelegramCommandAuth({ - msg, - bot, - cfg: runtimeCfg, - accountId, - telegramCfg: runtimeTelegramCfg, - readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, - allowFrom: turnSettings.allowFrom, - groupAllowFrom: turnSettings.groupAllowFrom, - resolveGroupPolicy, - resolveTelegramGroupConfig, - requireAuth: true, - }); - if (!auth) { - return false; - } - const { - chatId, - isGroup, - isForum, - resolvedThreadId, - senderId, - senderUsername, - groupConfig, - topicConfig, - commandAuthorized, - senderIsOwner, - } = auth; - const runtimeContext = await resolveCommandRuntimeContext({ - msg, - runtimeCfg, - isGroup, - isForum, - resolvedThreadId, - senderId, - topicAgentId: topicConfig?.agentId, - }); - if (!runtimeContext) { - return false; - } - const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext; - const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; - const originatingTo = buildTelegramRoutingTarget(chatId, threadSpec); - const commandArgs = commandDefinition - ? parseCommandArgs(commandDefinition, rawText) - : rawText - ? ({ raw: rawText } satisfies CommandArgs) - : undefined; - const prompt = commandDefinition - ? buildCommandTextFromArgs(commandDefinition, commandArgs) - : rawText - ? `/${command.name} ${rawText}` - : `/${command.name}`; - - if (commandDefinition?.key === "login") { - const sendLoginMessage = async (text: string) => { - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => bot.api.sendMessage(chatId, text, threadParams), - }); - }; - const sendLoginDeviceCode = async (params: TelegramLoginDeviceCode) => { - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage(chatId, formatTelegramLoginDeviceCode(params), { - ...threadParams, - parse_mode: "HTML", - }), - }); - }; - const sendLoginResultMessage = async (text: string) => { - await telegramDeps.sendMessageTelegram( - buildTelegramRoutingTarget(chatId, threadSpec), - text, - { - cfg: runtimeCfg, - token: opts.token, - accountId: route.accountId, - }, - ); - }; - if ( - !senderIsOwner || - !codexChannelLoginRuntime.hasConfiguredCommandOwnerAllowlist(runtimeCfg) - ) { - await sendLoginMessage( - "Only a configured OpenClaw owner can start Codex login from Telegram.", - ); - return false; - } - if (isGroup) { - await sendLoginMessage( - "For safety, Codex login codes are only sent in a private chat with this bot. DM this bot `/login codex` to pair Codex.", - ); - return true; - } - const loginProvider = codexChannelLoginRuntime.resolveProvider( - resolveTelegramCodexLoginProviderInput(commandArgs), - ); - if (!loginProvider) { - await sendLoginMessage("Unsupported login provider. Use `/login codex`."); - return false; - } - const flowKey = buildTelegramCodexLoginFlowKey({ - accountId: route.accountId, - chatId, - threadSpec, - agentId: route.agentId, - provider: loginProvider, - }); - const reservation = codexChannelLoginRuntime.reserveFlow({ - flows: activeTelegramCodexLoginFlows, - flowKey, - }); - if (reservation.status === "active") { - await sendLoginMessage( - "A Codex login code is already active for this Telegram chat. Complete it, or wait for it to expire before requesting a new one.", - ); - return true; - } - const flowSignal = opts.accountAbortSignal - ? AbortSignal.any([reservation.record.signal, opts.accountAbortSignal]) - : reservation.record.signal; - const deviceCodeDelivered = createDeferred(); - let deviceCodeWasDelivered = false; - // Device-code delivery releases Telegram's serialized chat lane. The - // reservation and account signal still own polling through completion. - const completion = (async () => { - const sessionSwitchFailedMessage = - "Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually."; - let terminalMessage: string; - const loginFlow = - telegramDeps.runModelsAuthLoginFlow ?? - defaultTelegramNativeCommandDeps.runModelsAuthLoginFlow; - try { - if (!loginFlow) { - throw new Error("Codex login flow is unavailable."); - } - const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); - const targetSessionKey = resolveCommandTargetSessionKey({ - runtimeCfg, - route, - chatId, - isGroup, - senderId, - threadSpec, - botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(botUser), - resolveThreadSessionKeys: nativeCommandRuntime.resolveThreadSessionKeys, - }); - const targetSessionEntryAtStart = nativeCommandRuntime.getSessionEntry({ - agentId: route.agentId, - sessionKey: targetSessionKey, - }); - const loginResult = await codexChannelLoginRuntime.runDeviceLoginFlow({ - runLoginFlow: loginFlow, - provider: loginProvider, - agentId: route.agentId, - config: runtimeCfg, - runtime, - signal: flowSignal, - sendMessage: sendLoginMessage, - sendDeviceCode: async (deviceCode) => { - flowSignal.throwIfAborted(); - await sendLoginDeviceCode(deviceCode); - flowSignal.throwIfAborted(); - deviceCodeWasDelivered = true; - deviceCodeDelivered.resolve(); - }, - unsupportedPromptMessage: - "Telegram /login supports only fixed Codex device-code auth.", - }); - flowSignal.throwIfAborted(); - const nextProfileId = loginResult.profiles.find( - (profile) => profile.provider === loginProvider, - )?.profileId; - terminalMessage = "Codex login complete. Try your request again now."; - if (!nextProfileId) { - terminalMessage = sessionSwitchFailedMessage; - } else { - const storePath = resolveStorePath(runtimeCfg.session?.store, { - agentId: route.agentId, - }); - let entryObserved = false; - let adoptionAllowed = false; - try { - const persisted = await updateSessionStoreEntry({ - sessionKey: targetSessionKey, - storePath, - requireWriteSuccess: true, - skipMaintenance: true, - update: (entry) => { - entryObserved = true; - const source = - entry.authProfileOverrideSource ?? - (typeof entry.authProfileOverrideCompactionCount === "number" - ? "auto" - : entry.authProfileOverride - ? "user" - : undefined); - if ( - flowSignal.aborted || - (targetSessionEntryAtStart - ? entry.sessionId !== targetSessionEntryAtStart.sessionId || - entry.authProfileOverride !== - targetSessionEntryAtStart.authProfileOverride || - entry.authProfileOverrideSource !== - targetSessionEntryAtStart.authProfileOverrideSource || - entry.authProfileOverrideCompactionCount !== - targetSessionEntryAtStart.authProfileOverrideCompactionCount - : source === "user" && entry.authProfileOverride !== nextProfileId) - ) { - return null; - } - adoptionAllowed = true; - return entry.authProfileOverride !== nextProfileId || - entry.authProfileOverrideSource !== "user" || - entry.authProfileOverrideCompactionCount !== undefined - ? { - authProfileOverride: nextProfileId, - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: undefined, - } - : null; - }, - }); - flowSignal.throwIfAborted(); - if ( - entryObserved && - (!adoptionAllowed || - !persisted || - persisted.authProfileOverride !== nextProfileId || - persisted.authProfileOverrideSource !== "user" || - persisted.authProfileOverrideCompactionCount !== undefined) - ) { - terminalMessage = sessionSwitchFailedMessage; - } - } catch (error) { - flowSignal.throwIfAborted(); - runtime.error?.( - danger( - `telegram /login codex completed but failed to update session auth profile: ${String( - error, - )}`, - ), - ); - terminalMessage = sessionSwitchFailedMessage; - } - } - } catch (error) { - if (flowSignal.aborted) { - return; - } - runtime.error?.(danger(`telegram /login codex failed: ${String(error)}`)); - terminalMessage = - "Codex login did not complete. Send `/login codex` to request a new code."; - } - if (flowSignal.aborted) { - return; - } - try { - await sendLoginResultMessage(terminalMessage); - } catch (error) { - runtime.error?.( - danger(`telegram /login codex result notification failed: ${String(error)}`), - ); - } - })().finally(() => { - codexChannelLoginRuntime.releaseFlow({ - flows: activeTelegramCodexLoginFlows, - flowKey, - record: reservation.record, - }); - }); - await Promise.race([deviceCodeDelivered.promise, completion]); - return deviceCodeWasDelivered; - } - - let cachedTargetSessionKey: string | undefined; - let cachedNativeCommandRuntime: - | Awaited> - | undefined; - const resolveNativeCommandRuntime = async () => { - cachedNativeCommandRuntime ??= await loadTelegramNativeCommandRuntime(); - return cachedNativeCommandRuntime; - }; - const resolveTargetSessionKey = async (): Promise => { - if (cachedTargetSessionKey) { - return cachedTargetSessionKey; - } - cachedTargetSessionKey = resolveCommandTargetSessionKey({ - runtimeCfg, - route, - chatId, - isGroup, - senderId, - threadSpec, - botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(botUser), - resolveThreadSessionKeys: (await resolveNativeCommandRuntime()) - .resolveThreadSessionKeys, - }); - return cachedTargetSessionKey; - }; - const menuNeedsModelContext = - commandDefinition?.argsMenu && - !(commandArgs?.raw && !commandArgs.values) && - commandDefinition.args?.some( - (arg) => typeof arg.choices === "function" && commandArgs?.values?.[arg.name] == null, - ); - const targetSessionKeyForMenu = - commandDefinition && menuNeedsModelContext ? await resolveTargetSessionKey() : ""; - const fastCommandState = - commandDefinition?.key === "fast" && menuNeedsModelContext - ? resolveTelegramFastCommandState({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKeyForMenu, - }) - : undefined; - const fastMenuModelContext = - commandDefinition?.key === "fast" && menuNeedsModelContext - ? resolveTelegramFastCommandModelContext({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKeyForMenu, - }) - : undefined; - const menuModelContext = - commandDefinition && menuNeedsModelContext - ? (fastMenuModelContext ?? - resolveTelegramCommandMenuModelContext({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKeyForMenu, - })) - : {}; - // Native /think must not wait on provider discovery; persisted rows retain its metadata. - const menuModelCatalog = - commandDefinition?.key === "think" && menuNeedsModelContext - ? await loadPreparedModelCatalog({ - config: runtimeCfg, - agentId: route.agentId, - agentDir: resolveAgentDir(runtimeCfg, route.agentId), - readOnly: true, - }) - : undefined; - const menu = commandDefinition - ? resolveCommandArgMenu({ - command: commandDefinition, - args: commandArgs, - cfg: runtimeCfg, - ...menuModelContext, - ...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}), - }) - : null; - if (menu && commandDefinition) { - const title = formatTelegramCommandArgMenuTitle({ - command: commandDefinition, - menu, - currentThinkingLevel: - commandDefinition.key === "think" - ? await resolveTelegramThinkMenuCurrentLevel({ - cfg: runtimeCfg, - agentId: route.agentId, - ...menuModelContext, - catalog: menuModelCatalog ?? [], - }) - : undefined, - currentFastModeStatus: - commandDefinition.key === "fast" - ? resolveTelegramFastMenuCurrentStatus({ - state: - fastCommandState ?? - resolveTelegramFastCommandState({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKeyForMenu, - }), - }) - : undefined, - }); - const rows: Array> = []; - for (let i = 0; i < menu.choices.length; i += 2) { - const slice = menu.choices.slice(i, i + 2); - rows.push( - slice.map((choice) => { - const args: CommandArgs = { - values: { [menu.arg.name]: choice.value }, - }; - return { - text: choice.label, - callback_data: buildTelegramNativeCommandCallbackData( - buildCommandTextFromArgs(commandDefinition, args), - ), - }; - }), - ); - } - const replyMarkup = buildInlineKeyboard(rows); - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage(chatId, title, { - ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - ...threadParams, - }), - }); - return false; - } - const nativeCommandRuntime = await resolveNativeCommandRuntime(); - const sessionKey = await resolveTargetSessionKey(); - const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({ - groupConfig, - topicConfig, - }); - const { sessionKey: commandSessionKey, commandTargetSessionKey } = - resolveNativeCommandSessionTargets({ - agentId: route.agentId, - sessionPrefix: "telegram:slash", - userId: String(senderId || chatId), - targetSessionKey: sessionKey, - }); - const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ - cfg: runtimeCfg, - chatId, - accountId: route.accountId, - sessionKeyForInternalHooks: commandSessionKey, - policySessionKey: commandTargetSessionKey, - mirrorIsGroup: isGroup, - mirrorGroupId: isGroup ? String(chatId) : undefined, - mediaLocalRoots, - threadSpec, - tableMode, - chunkMode, - replyToMode: turnSettings.replyToMode, - textLimit: turnSettings.textLimit, - linkPreview: runtimeTelegramCfg.linkPreview, - richMessages: runtimeTelegramCfg.richMessages, - }); - let topicName: string | undefined; - if (isForum && resolvedThreadId != null) { - try { - const storePath = resolveStorePath(runtimeCfg.session?.store, { - agentId: route.accountId, - }); - const scope = resolveTopicNameCacheScope(storePath); - topicName = await getTopicName(chatId, resolvedThreadId, scope); - } catch { - // best-effort: topic name is supplementary metadata - } - } - const conversationLabel = isGroup - ? msg.chat.title - ? `${msg.chat.title} id:${chatId}` - : `group:${chatId}` - : (buildSenderName(msg) ?? String(senderId || chatId)); - const ctxPayload = nativeCommandRuntime.finalizeInboundContext({ - Body: prompt, - BodyForAgent: prompt, - RawBody: prompt, - CommandBody: prompt, - CommandArgs: commandArgs, - From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, - To: `slash:${senderId || chatId}`, - ChatType: isGroup ? "group" : "direct", - ConversationToolPolicy: isGroup - ? undefined - : resolveTelegramDirectToolPolicy({ - directConfig: groupConfig, - senderId, - senderName: buildSenderName(msg), - senderUsername, - }), - ConversationLabel: conversationLabel, - GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, - GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined, - SenderName: buildSenderName(msg), - SenderId: senderId || undefined, - SenderUsername: senderUsername || undefined, - Surface: "telegram", - Provider: "telegram", - MessageSid: String(msg.message_id), - Timestamp: msg.date ? msg.date * 1000 : undefined, - WasMentioned: true, - CommandAuthorized: commandAuthorized, - CommandTurn: { - kind: "native" as const, - source: "native" as const, - authorized: commandAuthorized, - body: prompt, - }, - CommandSource: "native" as const, - SessionKey: commandSessionKey, - AccountId: route.accountId, - CommandTargetSessionKey: commandTargetSessionKey, - MessageThreadId: threadSpec.id, - IsForum: isForum, - TopicName: isForum && topicName ? topicName : undefined, - // Originating context for sub-agent announce routing - OriginatingChannel: "telegram" as const, - OriginatingTo: originatingTo, - }); - const disableBlockStreaming = - resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg); - const deliveryState = { - delivered: false, - skippedNonSilent: 0, - failedNonSilent: 0, - }; - let finalReplyOutcome: "accepted" | "failed" | "suppressed" | undefined; - - const { deliverReplies } = await loadTelegramNativeCommandDeliveryRuntime(); - let recordSessionMetaTask: Promise | undefined; - - const turnPlan: ChannelInboundTurnPlan<"provider_message_sending"> = { - cfg: runtimeCfg, - channel: "telegram", - accountId: route.accountId, - route: { - agentId: route.agentId, - sessionKey: commandSessionKey, - }, - ctxPayload, - record: { - sessionKey: commandTargetSessionKey, - trackSessionMetaTask: (task) => { - recordSessionMetaTask = task; - }, - onRecordError: (err) => - runtime.error?.( - danger(`telegram slash: failed updating session meta: ${String(err)}`), - ), - }, - // Native commands historically persisted target metadata before dispatch. - // Preserve that ordering while the shared recorder owns the write. - afterRecord: async () => { - await recordSessionMetaTask; - }, - replyPipeline: {}, - dispatcherOptions: { - beforeDeliver: async (payload) => payload, - onSkip: (_payload, info) => { - if (info.reason !== "silent") { - deliveryState.skippedNonSilent += 1; - } - }, - }, - delivery: { - deliverWithProviderMessageSending: async (payload, info) => { - if ( - shouldSuppressLocalTelegramExecApprovalPrompt({ - cfg: runtimeCfg, - accountId: route.accountId, - payload, - }) - ) { - deliveryState.delivered = true; - return { - visibleReplySent: false, - suppression: { reason: "no_visible_result" }, - }; - } - const targetedPayload = payload.replyToId - ? payload - : { ...payload, replyToId: String(msg.message_id) }; - const result = await deliverReplies({ - // Bind custody so a lost response on the native-command path is - // recorded as ambiguous instead of silently unaccounted. - replies: [ - info.bindPendingFinalDelivery - ? info.bindPendingFinalDelivery(targetedPayload) - : targetedPayload, - ], - ...deliveryBaseOptions, - silent: runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true, - onPlatformSendDispatch: info.onPlatformSendDispatch, - }); - if (result.delivered) { - deliveryState.delivered = true; - } - return result.delivered - ? { visibleReplySent: true } - : { - visibleReplySent: false, - suppression: { reason: "no_visible_result" as const }, - }; - }, - onDelivered: (_payload, info, result) => { - const reason = result?.suppression?.reason; - if (info.kind === "final" && result?.visibleReplySent) { - finalReplyOutcome = "accepted"; - } - if ( - info.kind === "final" && - finalReplyOutcome !== "failed" && - (reason === "cancelled_by_reply_payload_sending_hook" || - reason === "empty_after_reply_payload_sending_hook") - ) { - finalReplyOutcome = "suppressed"; - } - }, - onError: (err, info) => { - deliveryState.failedNonSilent += 1; - const partialDelivery = isChannelPartialDeliveryError(err); - if (partialDelivery) { - deliveryState.delivered = true; - logVerbose("telegram slash reply partially delivered before failure"); - } - if (info.kind === "final") { - // A failed final outweighs any earlier suppression until a final delivers. - finalReplyOutcome = partialDelivery ? "accepted" : "failed"; - } - runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); - }, - }, - replyOptions: { - skillFilter, - disableBlockStreaming, - [PLUGIN_COMMAND_DISPATCH]: NON_PLUGIN_COMMAND_DISPATCH, - }, - }; - const turnResult = await ( - telegramDeps.dispatchChannelInboundTurn ?? - defaultTelegramNativeCommandDeps.dispatchChannelInboundTurn - )(turnPlan); - if ( - !deliveryState.delivered && - finalReplyOutcome !== "suppressed" && - (deliveryState.skippedNonSilent > 0 || deliveryState.failedNonSilent > 0) && - (!turnResult.dispatched || - turnResult.dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" || - deliveryState.failedNonSilent > 0) - ) { - await deliverReplies({ - replies: [{ text: EMPTY_RESPONSE_FALLBACK }], - ...deliveryBaseOptions, - }); - } - return false; - }; - if (nativeEnabled) { - bot.command(normalizedCommandName, async (ctx) => { - if (shouldSkipUpdate(ctx)) { - return; - } - const msg = ctx.message; - if (!msg) { - return; - } - await handleNativeCommand( - ctx.me, - msg, - typeof ctx.match === "string" ? ctx.match.trim() : "", - ); - }); - } - if (commandDefinition?.key === "login") { - handleLoginCallback = handleNativeCommand; - } - } - - for (const pluginCommand of pluginCatalog.selectedCommands) { - bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { - const msg = ctx.message; - if (!msg) { + for (const command of nativeCommandsToHandle) { + const normalizedCommandName = normalizeTelegramCommandName(command.name); + const handleNativeCommand = async ( + botUser: Context["me"], + msg: NonNullable, + rawText: string, + ): Promise => { + const { executeTelegramBuiltinCommand } = await loadTelegramBuiltinCommandExecutor(); + return await executeTelegramBuiltinCommand({ + ...buildExecutorParams({ botUser, msg, rawText }), + commandName: command.name, + }); + }; + if (nativeEnabled) { + bot.command(normalizedCommandName, async (ctx) => { + if (shouldSkipUpdate(ctx) || !ctx.message) { return; } - if (shouldSkipUpdate(ctx)) { - return; - } - const chatId = msg.chat.id; - const runtimeCfg = loadFreshRuntimeConfig(); - const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg); - const turnSettings = resolveTelegramMessageTurnSettings({ - accountId, - cfg: runtimeCfg, - telegramCfg: runtimeTelegramCfg, - opts, - }); - const { threadParams } = await resolveTelegramNativeCommandThreadContext({ msg, bot }); - const rawText = ctx.match?.trim() ?? ""; - const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`; - const candidate = pluginCommand.spec; - const pluginCommandDispatch = candidate.prepareDispatch(rawText); - if (pluginCommandDispatch.kind === "non-plugin") { - await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => bot.api.sendMessage(chatId, "Command not found.", threadParams ?? {}), - }); - return; - } - const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); - const auth = await resolveTelegramCommandAuth({ - msg, - bot, - cfg: runtimeCfg, - accountId, - telegramCfg: runtimeTelegramCfg, - readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore, - allowFrom: turnSettings.allowFrom, - groupAllowFrom: turnSettings.groupAllowFrom, - resolveGroupPolicy, - resolveTelegramGroupConfig, - requireAuth: candidate.requireAuth, - }); - if (!auth) { - return; - } - const { senderId, commandAuthorized, senderIsOwner, isGroup, isForum, resolvedThreadId } = - auth; - const runtimeContext = await resolveCommandRuntimeContext({ - msg, - runtimeCfg, - isGroup, - isForum, - resolvedThreadId, - senderId, - topicAgentId: auth.topicConfig?.agentId, - }); - if (!runtimeContext) { - return; - } - const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext; - const targetSessionKey = resolveCommandTargetSessionKey({ - runtimeCfg, - route, - chatId, - isGroup, - senderId, - threadSpec, - botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(ctx.me), - resolveThreadSessionKeys: nativeCommandRuntime.resolveThreadSessionKeys, - }); - const targetSessionEntry = nativeCommandRuntime.getSessionEntry({ - agentId: route.agentId, - sessionKey: targetSessionKey, - }); - const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ - cfg: runtimeCfg, - chatId, - accountId: route.accountId, - sessionKeyForInternalHooks: targetSessionKey, - policySessionKey: targetSessionKey, - mirrorIsGroup: isGroup, - mirrorGroupId: isGroup ? String(chatId) : undefined, - mediaLocalRoots, - threadSpec, - tableMode, - chunkMode, - replyToMode: turnSettings.replyToMode, - textLimit: turnSettings.textLimit, - linkPreview: runtimeTelegramCfg.linkPreview, - richMessages: runtimeTelegramCfg.richMessages, - }); - const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`; - const to = - threadSpec.scope === "direct-messages" - ? buildTelegramRoutingTarget(chatId, threadSpec) - : `telegram:${chatId}`; - const { deliverReplies, emitTelegramMessageSentHooks } = - await loadTelegramNativeCommandDeliveryRuntime(); - let progressMessageId: number | undefined; - const progressPlaceholder = candidate.progressMessage; - - if (progressPlaceholder) { - try { - const sent = await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage( - chatId, - progressPlaceholder, - buildTelegramThreadParams(threadSpec), - ), - }); - const maybeMessageId = (sent as { message_id?: unknown } | undefined)?.message_id; - if (typeof maybeMessageId === "number") { - progressMessageId = maybeMessageId; - } - } catch { - // Fall back to the normal final reply path if the placeholder send fails. - } - } - - const transcriptContext = await resolveTelegramCommandTranscriptContext({ - cfg: runtimeCfg, - agentId: route.agentId, - sessionKey: targetSessionKey, - threadId: threadSpec.id, - }); - - const result = normalizeTelegramNativeReplyPayload( - await pluginCommandDispatch.execute({ - senderId, - channel: "telegram", - isAuthorizedSender: commandAuthorized, - senderIsOwner, - agentId: route.agentId, - sessionKey: targetSessionKey, - sessionId: transcriptContext.sessionId, - sessionFile: transcriptContext.sessionFile, - authProfileId: - transcriptContext.authProfileId ?? targetSessionEntry?.authProfileOverride, - commandBody, - config: runtimeCfg, - from, - to, - accountId, - messageThreadId: threadSpec.id, - }), + await handleNativeCommand( + ctx.me, + ctx.message, + typeof ctx.match === "string" ? ctx.match.trim() : "", ); - - const suppressTelegramNativeReply = - shouldSuppressLocalTelegramExecApprovalPrompt({ - cfg: runtimeCfg, - accountId: route.accountId, - payload: result, - }) || isSuppressedTelegramNativeReplyPayload(result); - if (suppressTelegramNativeReply) { - await cleanupTelegramProgressPlaceholder({ - bot, - chatId, - progressMessageId, - runtime, - }); - return; - } - - const hasReaction = hasTelegramNativeReplyReaction(result); - const deliverableResult: TelegramNativeReplyPayload = - hasRenderableTelegramNativeReplyPayload(result) - ? hasReaction && !normalizeOptionalString(result.replyToId) - ? { ...result, replyToId: String(msg.message_id) } - : result - : { text: EMPTY_RESPONSE_FALLBACK }; - const progressResultText = - typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0 - ? deliverableResult.text - : null; - const telegramResultData = resolveTelegramNativeReplyChannelData(deliverableResult); - if ( - progressMessageId != null && - telegramDeps.editMessageTelegram && - progressResultText && - isEditableTelegramProgressResult(deliverableResult) - ) { - try { - await telegramDeps.editMessageTelegram(chatId, progressMessageId, progressResultText, { - cfg: runtimeCfg, - accountId: route.accountId, - textMode: "markdown", - linkPreview: runtimeTelegramCfg.linkPreview, - buttons: telegramResultData?.buttons, - }); - recordSentMessage(chatId, progressMessageId, runtimeCfg); - emitTelegramMessageSentHooks({ - sessionKeyForInternalHooks: targetSessionKey, - chatId: String(chatId), - accountId: route.accountId, - content: progressResultText, - success: true, - messageId: progressMessageId, - isGroup, - groupId: isGroup ? String(chatId) : undefined, - }); - return; - } catch { - // Fall through to cleanup + normal delivered reply if editing fails. - } - } - await cleanupTelegramProgressPlaceholder({ - bot, - chatId, - progressMessageId, - runtime, - }); - await deliverReplies({ - replies: [deliverableResult], - ...deliveryBaseOptions, - ...(hasReaction ? { replyToMode: "all" as const } : {}), - silent: - runtimeTelegramCfg.silentErrorReplies === true && deliverableResult.isError === true, - }); }); } - if (pluginCatalog.selectedCommands.length > 0) { - pluginCommandRuntime.retainNativeCatalog("telegram"); + if (findCommandByNativeName(command.name, "telegram")?.key === "login") { + handleLoginCallback = handleNativeCommand; } } + for (const pluginCommand of pluginCatalog.selectedCommands) { + bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { + if (shouldSkipUpdate(ctx) || !ctx.message) { + return; + } + const { executeTelegramPluginCommand } = await loadTelegramPluginCommandExecutor(); + await executeTelegramPluginCommand({ + ...buildExecutorParams({ + botUser: ctx.me, + msg: ctx.message, + rawText: ctx.match?.trim() ?? "", + }), + commandName: pluginCommand.command, + candidate: pluginCommand.spec, + }); + }); + } + if (pluginCatalog.selectedCommands.length > 0) { + pluginCommandRuntime.retainNativeCatalog("telegram"); + } + if (!handleLoginCallback) { return undefined; } @@ -2087,19 +303,20 @@ export const registerTelegramNativeCommands = ({ if (!callbackMessage || callbackMessage.date <= 0) { return { handled: true, clearButtons: false }; } - const chat = callbackMessage.chat; - if (chat.type === "channel") { + if (callbackMessage.chat.type === "channel") { return { handled: true, clearButtons: false }; } const rawText = separatorIndex === -1 ? "" : commandBody.slice(separatorIndex + 1).trim(); - const message = { - ...callbackMessage, - chat, - from: callbackQuery.from, - text: commandText, - }; - const clearButtons = await handleLoginCallback(botUser, message, rawText); + const clearButtons = await handleLoginCallback( + botUser, + { + ...callbackMessage, + chat: callbackMessage.chat, + from: callbackQuery.from, + text: commandText, + }, + rawText, + ); return { handled: true, clearButtons }; }; }; -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index da796726467b..ad4762da6b5b 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -9,12 +9,17 @@ import { buildAgentSessionKey, deriveLastRoutePolicy, resolveAgentRoute, + resolveThreadSessionKeys, } from "openclaw/plugin-sdk/routing"; import { buildAgentMainSessionKey, sanitizeAgentId } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDefaultTelegramAccountId } from "./accounts.js"; -import { buildTelegramGroupPeerId, buildTelegramParentPeer } from "./bot/helpers.js"; +import { + buildTelegramGroupPeerId, + buildTelegramParentPeer, + shouldUseTelegramDmThreadSession, +} from "./bot/helpers.js"; import { resolveTelegramDirectPeerId, resolveTelegramNamedAccountBaseSessionKey, @@ -162,3 +167,26 @@ export function resolveTelegramConversationBaseSessionKey( params, ); } + +export function resolveTelegramTargetSession(params: { + cfg: OpenClawConfig; + route: TelegramResolvedRoute; + chatId: number | string; + isGroup: boolean; + senderId?: string | number | null; + dmThreadId?: number; + botHasTopicsEnabled?: boolean; +}): string { + const baseSessionKey = resolveTelegramConversationBaseSessionKey(params); + const threadKeys = + shouldUseTelegramDmThreadSession({ + dmThreadId: params.dmThreadId, + botHasTopicsEnabled: params.botHasTopicsEnabled, + }) && params.dmThreadId != null + ? resolveThreadSessionKeys({ + baseSessionKey, + threadId: `${params.chatId}:${params.dmThreadId}`, + }) + : null; + return threadKeys?.sessionKey ?? baseSessionKey; +} diff --git a/extensions/telegram/src/native-command-callback-data.test.ts b/extensions/telegram/src/native-command-callback-data.test.ts new file mode 100644 index 000000000000..253582f0a8c2 --- /dev/null +++ b/extensions/telegram/src/native-command-callback-data.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { parseTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; + +describe("parseTelegramNativeCommandCallbackData", () => { + it("preserves prefixed native commands and rejects malformed command bodies", () => { + expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast status")).toBe("/fast status"); + expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast auto")).toBe("/fast auto"); + expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast default")).toBe("/fast default"); + expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull(); + }); +});