From 75366cdade62fd76aebef080589d97f71d892aa0 Mon Sep 17 00:00:00 2001 From: Naveen Chatlapalli Date: Thu, 29 Jan 2026 23:59:08 -0600 Subject: [PATCH 1/2] fix(telegram): skip native commands in regular message handler When native commands are enabled (default), the regular message handler now skips messages that match registered native commands (e.g., /new, /commands). This prevents duplicate processing where both the native command handler (bot.command()) and the regular message handler (bot.on("message")) would process the same command. Previously, /new sent to a secondary Telegram agent would reset both that agent's session AND the default agent's session because Grammy triggers both handlers for the same message. The native handler correctly targeted the bound agent via CommandTargetSessionKey, but the regular handler processed it as a normal message. Fixes #4385 Co-Authored-By: Claude Opus 4.5 --- src/telegram/bot-handlers.ts | 36 ++ ...native-commands-in-regular-handler.test.ts | 329 ++++++++++++++++++ src/telegram/bot.ts | 18 + 3 files changed, 383 insertions(+) create mode 100644 src/telegram/bot.skips-native-commands-in-regular-handler.test.ts 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; From 9296643307bec0bddfbcd97e212a407825ed028b Mon Sep 17 00:00:00 2001 From: Naveen Chatlapalli Date: Fri, 30 Jan 2026 09:42:40 -0600 Subject: [PATCH 2/2] fix(telegram): preserve control command processing in message handler Skip native commands in the message handler only if they are NOT control commands. Control commands like /status need to be processed by the message handler for proper reply generation. --- src/telegram/bot-handlers.ts | 13 ++++++------- src/telegram/bot.ts | 3 +-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 2d675465f..26dc37b61 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -459,18 +459,18 @@ 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) { + // Skip native commands in DMs - they will be handled by bot.command() handlers. + // In groups, we still process commands through the message handler for access + // control validation (groupPolicy, groupAllowFrom, etc.) before reply generation. + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + if (!isGroup && 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`); + logVerbose(`telegram: skipping native command /${commandName} in DM handler`); return; } } @@ -478,7 +478,6 @@ export const registerTelegramHandlers = ({ } 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; const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; const resolvedThreadId = resolveTelegramForumThreadId({ diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index d0dce67a4..f73686aed 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -256,8 +256,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { // This prevents native commands (e.g., /new) from being processed twice. const nativeCommandNames = new Set(); if (nativeEnabled) { - const skillCommands = - nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; + const skillCommands = nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; const nativeSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "telegram",