From c4e9bb3b99dbc90b5964fc810c00a598e8c0a628 Mon Sep 17 00:00:00 2001 From: Sk Akram Date: Tue, 17 Feb 2026 23:20:36 +0530 Subject: [PATCH] fix: sanitize native command names for Telegram API (#19257) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: b608be348888505b23bb4b2f1c8c42058a28e64e Co-authored-by: akramcodez <179671552+akramcodez@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + .../config.telegram-custom-commands.test.ts | 16 ++++----- src/config/telegram-custom-commands.ts | 2 +- src/telegram/bot-native-command-menu.ts | 4 ++- src/telegram/bot-native-commands.test.ts | 31 +++++++++++++++++ src/telegram/bot-native-commands.ts | 34 ++++++++++++++----- src/telegram/bot.test.ts | 5 +-- 7 files changed, 71 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e264a336..56c971afd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Telegram: debounce the first draft-stream preview update (30-char threshold) and finalize short responses by editing the stop-time preview message, improving first push notifications and avoiding duplicate final sends. (#18148) Thanks @Marvae. - Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk. - Telegram: keep `streamMode: "partial"` draft previews in a single message across assistant-message/reasoning boundaries, preventing duplicate preview bubbles during partial-mode tool-call turns. (#18956) Thanks @obviyus. +- Telegram: normalize native command names for Telegram menu registration (`-` -> `_`) to avoid `BOT_COMMAND_INVALID` command-menu wipeouts, and log failed command syncs instead of silently swallowing them. (#19257) Thanks @akramcodez. - Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus. - Telegram: ignore `` placeholder lines when extracting `MEDIA:` tool-result paths, preventing false local-file reads and dropped replies. (#18510) Thanks @yinghaosang. - Telegram: skip retries when inbound media `getFile` fails with Telegram's 20MB limit and continue processing message text, avoiding dropped messages for oversized attachments. (#18531) Thanks @brandonwise. diff --git a/src/config/config.telegram-custom-commands.test.ts b/src/config/config.telegram-custom-commands.test.ts index 8b6a8a4b60..27ff045022 100644 --- a/src/config/config.telegram-custom-commands.test.ts +++ b/src/config/config.telegram-custom-commands.test.ts @@ -21,7 +21,7 @@ describe("telegram custom commands schema", () => { ]); }); - it("rejects custom commands with invalid names", () => { + it("normalizes hyphens in custom command names", () => { const res = OpenClawSchema.safeParse({ channels: { telegram: { @@ -30,17 +30,13 @@ describe("telegram custom commands schema", () => { }, }); - expect(res.success).toBe(false); - if (res.success) { + expect(res.success).toBe(true); + if (!res.success) { return; } - expect( - res.error.issues.some( - (issue) => - issue.path.join(".") === "channels.telegram.customCommands.0.command" && - issue.message.includes("invalid"), - ), - ).toBe(true); + expect(res.data.channels?.telegram?.customCommands).toEqual([ + { command: "bad_name", description: "Override status" }, + ]); }); }); diff --git a/src/config/telegram-custom-commands.ts b/src/config/telegram-custom-commands.ts index 579707b591..e7c316791d 100644 --- a/src/config/telegram-custom-commands.ts +++ b/src/config/telegram-custom-commands.ts @@ -17,7 +17,7 @@ export function normalizeTelegramCommandName(value: string): string { return ""; } const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed; - return withoutSlash.trim().toLowerCase(); + return withoutSlash.trim().toLowerCase().replace(/-/g, "_"); } export function normalizeTelegramCommandDescription(value: string): string { diff --git a/src/telegram/bot-native-command-menu.ts b/src/telegram/bot-native-command-menu.ts index 25e0b420c1..5528fd06ff 100644 --- a/src/telegram/bot-native-command-menu.ts +++ b/src/telegram/bot-native-command-menu.ts @@ -100,5 +100,7 @@ export function syncTelegramMenuCommands(params: { }); }; - void sync().catch(() => {}); + void sync().catch((err) => { + runtime.error?.(`Telegram command sync failed: ${String(err)}`); + }); } diff --git a/src/telegram/bot-native-commands.test.ts b/src/telegram/bot-native-commands.test.ts index 24cf113492..0c89a63ff9 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/src/telegram/bot-native-commands.test.ts @@ -149,6 +149,37 @@ describe("registerTelegramNativeCommands", () => { ); }); + it("normalizes hyphenated native command names for Telegram registration", async () => { + const setMyCommands = vi.fn().mockResolvedValue(undefined); + const command = vi.fn(); + + registerTelegramNativeCommands({ + ...buildParams({}), + bot: { + api: { + setMyCommands, + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command, + } as unknown as Parameters[0]["bot"], + }); + + await vi.waitFor(() => { + expect(setMyCommands).toHaveBeenCalled(); + }); + + const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{ + command: string; + description: string; + }>; + expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true); + expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false); + + const registeredHandlers = command.mock.calls.map(([name]) => name); + expect(registeredHandlers).toContain("export_session"); + expect(registeredHandlers).not.toContain("export-session"); + }); + it("passes agent-scoped media roots for plugin command replies with media", async () => { const commandHandlers = new Map Promise>(); const sendMessage = vi.fn().mockResolvedValue(undefined); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 6c6a5cfc39..1448d6c818 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -17,7 +17,11 @@ import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; -import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; +import { + normalizeTelegramCommandName, + resolveTelegramCustomCommands, + TELEGRAM_COMMAND_NAME_PATTERN, +} from "../config/telegram-custom-commands.js"; import type { ReplyToMode, TelegramAccountConfig, @@ -310,7 +314,7 @@ export const registerTelegramNativeCommands = ({ }) : []; const reservedCommands = new Set( - listNativeCommandSpecs().map((command) => command.name.toLowerCase()), + listNativeCommandSpecs().map((command) => normalizeTelegramCommandName(command.name)), ); for (const command of skillCommands) { reservedCommands.add(command.name.toLowerCase()); @@ -326,7 +330,7 @@ export const registerTelegramNativeCommands = ({ const pluginCommandSpecs = getPluginCommandSpecs(); const existingCommands = new Set( [ - ...nativeCommands.map((command) => command.name), + ...nativeCommands.map((command) => normalizeTelegramCommandName(command.name)), ...customCommands.map((command) => command.command), ].map((command) => command.toLowerCase()), ); @@ -338,10 +342,23 @@ export const registerTelegramNativeCommands = ({ runtime.error?.(danger(issue)); } const allCommandsFull: Array<{ command: string; description: string }> = [ - ...nativeCommands.map((command) => ({ - command: command.name, - description: command.description, - })), + ...nativeCommands + .map((command) => { + const normalized = normalizeTelegramCommandName(command.name); + if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { + runtime.error?.( + danger( + `Native command "${command.name}" is invalid for Telegram (resolved to "${normalized}"). Skipping.`, + ), + ); + return null; + } + return { + command: normalized, + description: command.description, + }; + }) + .filter((cmd): cmd is { command: string; description: string } => cmd !== null), ...(nativeEnabled ? pluginCatalog.commands : []), ...customCommands, ]; @@ -419,7 +436,8 @@ export const registerTelegramNativeCommands = ({ logVerbose("telegram: bot.command unavailable; skipping native handlers"); } else { for (const command of nativeCommands) { - bot.command(command.name, async (ctx: TelegramNativeCommandContext) => { + const normalizedCommandName = normalizeTelegramCommandName(command.name); + bot.command(normalizedCommandName, async (ctx: TelegramNativeCommandContext) => { const msg = ctx.message; if (!msg) { return; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index e8c2e39e0d..6c37766198 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -5,6 +5,7 @@ import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; +import { normalizeTelegramCommandName } from "../config/telegram-custom-commands.js"; import { answerCallbackQuerySpy, commandSpy, @@ -72,7 +73,7 @@ describe("createTelegramBot", () => { }>; const skillCommands = resolveSkillCommands(config); const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ - command: command.name, + command: normalizeTelegramCommandName(command.name), description: command.description, })); expect(registered.slice(0, native.length)).toEqual(native); @@ -113,7 +114,7 @@ describe("createTelegramBot", () => { }>; const skillCommands = resolveSkillCommands(config); const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ - command: command.name, + command: normalizeTelegramCommandName(command.name), description: command.description, })); const nativeStatus = native.find((command) => command.command === "status");