diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 477b98280..2d675465f 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -37,6 +37,24 @@ export const registerTelegramHandlers = ({ shouldSkipUpdate, processMessage, logger, + nativeEnabled, + nativeCommandNames, +}: { + cfg: unknown; + accountId: string; + bot: unknown; + opts: unknown; + runtime: unknown; + mediaMaxBytes: number; + telegramCfg: unknown; + groupAllowFrom: unknown; + resolveGroupPolicy: unknown; + resolveTelegramGroupConfig: unknown; + shouldSkipUpdate: unknown; + processMessage: unknown; + logger: unknown; + nativeEnabled?: boolean; + nativeCommandNames?: Set; }) => { const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000; const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS = 1500; @@ -441,6 +459,24 @@ export const registerTelegramHandlers = ({ if (!msg) return; if (shouldSkipUpdate(ctx)) return; + // Skip messages that will be handled by native command handlers. + // Native commands (e.g., /new) are processed by bot.command() handlers first. + // Without this check, the regular message handler would also process them, + // potentially causing duplicate session resets or targeting wrong agents. + if (nativeEnabled && nativeCommandNames && nativeCommandNames.size > 0) { + const rawText = (msg.text ?? "").trim(); + if (rawText.startsWith("/")) { + const commandMatch = rawText.match(/^\/([a-z0-9_]+)/i); + if (commandMatch) { + const commandName = commandMatch[1].toLowerCase(); + if (nativeCommandNames.has(commandName)) { + logVerbose(`telegram: skipping native command /${commandName} in regular handler`); + return; + } + } + } + } + const chatId = msg.chat.id; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; diff --git a/src/telegram/bot.skips-native-commands-in-regular-handler.test.ts b/src/telegram/bot.skips-native-commands-in-regular-handler.test.ts new file mode 100644 index 000000000..0e9ae5875 --- /dev/null +++ b/src/telegram/bot.skips-native-commands-in-regular-handler.test.ts @@ -0,0 +1,329 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/openclaw-telegram-${Math.random().toString(16).slice(2)}.json`, +})); + +const { loadWebMedia } = vi.hoisted(() => ({ + loadWebMedia: vi.fn(), +})); + +vi.mock("../web/media.js", () => ({ + loadWebMedia, +})); + +const { loadConfig } = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), +})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig, + }; +}); + +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + +const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ + readTelegramAllowFromStore: vi.fn(async () => [] as string[]), + upsertTelegramPairingRequest: vi.fn(async () => ({ + code: "PAIRCODE", + created: true, + })), +})); + +vi.mock("./pairing-store.js", () => ({ + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +})); + +const useSpy = vi.fn(); +const middlewareUseSpy = vi.fn(); +const onSpy = vi.fn(); +const stopSpy = vi.fn(); +const commandSpy = vi.fn(); +const botCtorSpy = vi.fn(); +const answerCallbackQuerySpy = vi.fn(async () => undefined); +const sendChatActionSpy = vi.fn(); +const setMessageReactionSpy = vi.fn(async () => undefined); +const setMyCommandsSpy = vi.fn(async () => undefined); +const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); +const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); +const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); +type ApiStub = { + config: { use: (arg: unknown) => void }; + answerCallbackQuery: typeof answerCallbackQuerySpy; + sendChatAction: typeof sendChatActionSpy; + setMessageReaction: typeof setMessageReactionSpy; + setMyCommands: typeof setMyCommandsSpy; + sendMessage: typeof sendMessageSpy; + sendAnimation: typeof sendAnimationSpy; + sendPhoto: typeof sendPhotoSpy; +}; +const apiStub: ApiStub = { + config: { use: useSpy }, + answerCallbackQuery: answerCallbackQuerySpy, + sendChatAction: sendChatActionSpy, + setMessageReaction: setMessageReactionSpy, + setMyCommands: setMyCommandsSpy, + sendMessage: sendMessageSpy, + sendAnimation: sendAnimationSpy, + sendPhoto: sendPhotoSpy, +}; + +vi.mock("grammy", () => ({ + Bot: class { + api = apiStub; + use = middlewareUseSpy; + on = onSpy; + stop = stopSpy; + command = commandSpy; + catch = vi.fn(); + constructor( + public token: string, + public options?: { client?: { fetch?: typeof fetch } }, + ) { + botCtorSpy(token, options); + } + }, + InputFile: class {}, + webhookCallback: vi.fn(), +})); + +const sequentializeMiddleware = vi.fn(); +const sequentializeSpy = vi.fn(() => sequentializeMiddleware); +let _sequentializeKey: ((ctx: unknown) => string) | undefined; +vi.mock("@grammyjs/runner", () => ({ + sequentialize: (keyFn: (ctx: unknown) => string) => { + _sequentializeKey = keyFn; + return sequentializeSpy(); + }, +})); + +const throttlerSpy = vi.fn(() => "throttler"); + +vi.mock("@grammyjs/transformer-throttler", () => ({ + apiThrottler: () => throttlerSpy(), +})); + +vi.mock("../auto-reply/reply.js", () => { + const replySpy = vi.fn(async (_ctx, opts) => { + await opts?.onReplyStart?.(); + return undefined; + }); + return { getReplyFromConfig: replySpy, __replySpy: replySpy }; +}); + +let replyModule: typeof import("../auto-reply/reply.js"); + +const getOnHandler = (event: string) => { + const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; + if (!handler) throw new Error(`Missing handler for event: ${event}`); + return handler as (ctx: Record) => Promise; +}; + +describe("createTelegramBot - native command handling", () => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); + resetInboundDedupe(); + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + loadWebMedia.mockReset(); + sendAnimationSpy.mockReset(); + sendPhotoSpy.mockReset(); + setMessageReactionSpy.mockReset(); + answerCallbackQuerySpy.mockReset(); + setMyCommandsSpy.mockReset(); + middlewareUseSpy.mockReset(); + sequentializeSpy.mockReset(); + botCtorSpy.mockReset(); + onSpy.mockReset(); + commandSpy.mockReset(); + _sequentializeKey = undefined; + }); + + it("skips native commands in regular message handler when native commands are enabled", async () => { + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + // Native commands are enabled by default, so /new should be skipped in regular handler + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + // Send a /new command message + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "/new", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Regular handler should NOT process /new when native commands are enabled + // because it will be handled by the native command handler + expect(replySpy).toHaveBeenCalledTimes(0); + }); + + it("processes regular messages that are not native commands", async () => { + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + // Send a regular message (not a native command) + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "hello world", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Regular handler should process normal messages + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("processes text commands (like /foo) that are not native commands", async () => { + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + // Send a slash command that is NOT a registered native command + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "/customcommand", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Regular handler should process commands that aren't native + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("skips /commands native command in regular message handler", async () => { + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + // Send a /commands message (another native command) + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "/commands", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Regular handler should NOT process /commands when native commands are enabled + expect(replySpy).toHaveBeenCalledTimes(0); + }); + + it("processes native commands in regular handler when native commands are disabled", async () => { + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + // Disable native commands + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + commands: { native: false }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + // Send a /new command message + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "/new", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // When native commands are disabled, regular handler should process them + expect(replySpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index fb0940d3b..d0dce67a4 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -6,6 +6,8 @@ import { Bot, webhookCallback } from "grammy"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isControlCommandMessage } from "../auto-reply/command-detection.js"; import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { listNativeCommandSpecsForConfig } from "../auto-reply/commands-registry.js"; +import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; import { isNativeCommandsExplicitlyDisabled, @@ -250,6 +252,20 @@ export function createTelegramBot(opts: TelegramBotOptions) { providerSetting: telegramCfg.commands?.native, globalSetting: cfg.commands?.native, }); + // Build set of native command names for deduplication in regular message handler. + // This prevents native commands (e.g., /new) from being processed twice. + const nativeCommandNames = new Set(); + if (nativeEnabled) { + const skillCommands = + nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; + const nativeSpecs = listNativeCommandSpecsForConfig(cfg, { + skillCommands, + provider: "telegram", + }); + for (const spec of nativeSpecs) { + nativeCommandNames.add(spec.name.toLowerCase()); + } + } const useAccessGroups = cfg.commands?.useAccessGroups !== false; const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024; @@ -465,6 +481,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { shouldSkipUpdate, processMessage, logger, + nativeEnabled, + nativeCommandNames, }); return bot;