fix: sanitize native command names for Telegram API (#19257)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: b608be3488
Co-authored-by: akramcodez <179671552+akramcodez@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
Sk Akram
2026-02-17 23:20:36 +05:30
committed by GitHub
parent 20a561224c
commit c4e9bb3b99
7 changed files with 71 additions and 22 deletions

View File

@@ -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: 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: 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: 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: 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 `<media:...>` placeholder lines when extracting `MEDIA:` tool-result paths, preventing false local-file reads and dropped replies. (#18510) Thanks @yinghaosang. - Telegram: ignore `<media:...>` 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. - 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.

View File

@@ -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({ const res = OpenClawSchema.safeParse({
channels: { channels: {
telegram: { telegram: {
@@ -30,17 +30,13 @@ describe("telegram custom commands schema", () => {
}, },
}); });
expect(res.success).toBe(false); expect(res.success).toBe(true);
if (res.success) { if (!res.success) {
return; return;
} }
expect( expect(res.data.channels?.telegram?.customCommands).toEqual([
res.error.issues.some( { command: "bad_name", description: "Override status" },
(issue) => ]);
issue.path.join(".") === "channels.telegram.customCommands.0.command" &&
issue.message.includes("invalid"),
),
).toBe(true);
}); });
}); });

View File

@@ -17,7 +17,7 @@ export function normalizeTelegramCommandName(value: string): string {
return ""; return "";
} }
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed; const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
return withoutSlash.trim().toLowerCase(); return withoutSlash.trim().toLowerCase().replace(/-/g, "_");
} }
export function normalizeTelegramCommandDescription(value: string): string { export function normalizeTelegramCommandDescription(value: string): string {

View File

@@ -100,5 +100,7 @@ export function syncTelegramMenuCommands(params: {
}); });
}; };
void sync().catch(() => {}); void sync().catch((err) => {
runtime.error?.(`Telegram command sync failed: ${String(err)}`);
});
} }

View File

@@ -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<typeof registerTelegramNativeCommands>[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 () => { it("passes agent-scoped media roots for plugin command replies with media", async () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>(); const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const sendMessage = vi.fn().mockResolvedValue(undefined); const sendMessage = vi.fn().mockResolvedValue(undefined);

View File

@@ -17,7 +17,11 @@ import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.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 { import type {
ReplyToMode, ReplyToMode,
TelegramAccountConfig, TelegramAccountConfig,
@@ -310,7 +314,7 @@ export const registerTelegramNativeCommands = ({
}) })
: []; : [];
const reservedCommands = new Set( const reservedCommands = new Set(
listNativeCommandSpecs().map((command) => command.name.toLowerCase()), listNativeCommandSpecs().map((command) => normalizeTelegramCommandName(command.name)),
); );
for (const command of skillCommands) { for (const command of skillCommands) {
reservedCommands.add(command.name.toLowerCase()); reservedCommands.add(command.name.toLowerCase());
@@ -326,7 +330,7 @@ export const registerTelegramNativeCommands = ({
const pluginCommandSpecs = getPluginCommandSpecs(); const pluginCommandSpecs = getPluginCommandSpecs();
const existingCommands = new Set( const existingCommands = new Set(
[ [
...nativeCommands.map((command) => command.name), ...nativeCommands.map((command) => normalizeTelegramCommandName(command.name)),
...customCommands.map((command) => command.command), ...customCommands.map((command) => command.command),
].map((command) => command.toLowerCase()), ].map((command) => command.toLowerCase()),
); );
@@ -338,10 +342,23 @@ export const registerTelegramNativeCommands = ({
runtime.error?.(danger(issue)); runtime.error?.(danger(issue));
} }
const allCommandsFull: Array<{ command: string; description: string }> = [ const allCommandsFull: Array<{ command: string; description: string }> = [
...nativeCommands.map((command) => ({ ...nativeCommands
command: command.name, .map((command) => {
description: command.description, 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 : []), ...(nativeEnabled ? pluginCatalog.commands : []),
...customCommands, ...customCommands,
]; ];
@@ -419,7 +436,8 @@ export const registerTelegramNativeCommands = ({
logVerbose("telegram: bot.command unavailable; skipping native handlers"); logVerbose("telegram: bot.command unavailable; skipping native handlers");
} else { } else {
for (const command of nativeCommands) { 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; const msg = ctx.message;
if (!msg) { if (!msg) {
return; return;

View File

@@ -5,6 +5,7 @@ import {
listNativeCommandSpecs, listNativeCommandSpecs,
listNativeCommandSpecsForConfig, listNativeCommandSpecsForConfig,
} from "../auto-reply/commands-registry.js"; } from "../auto-reply/commands-registry.js";
import { normalizeTelegramCommandName } from "../config/telegram-custom-commands.js";
import { import {
answerCallbackQuerySpy, answerCallbackQuerySpy,
commandSpy, commandSpy,
@@ -72,7 +73,7 @@ describe("createTelegramBot", () => {
}>; }>;
const skillCommands = resolveSkillCommands(config); const skillCommands = resolveSkillCommands(config);
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
command: command.name, command: normalizeTelegramCommandName(command.name),
description: command.description, description: command.description,
})); }));
expect(registered.slice(0, native.length)).toEqual(native); expect(registered.slice(0, native.length)).toEqual(native);
@@ -113,7 +114,7 @@ describe("createTelegramBot", () => {
}>; }>;
const skillCommands = resolveSkillCommands(config); const skillCommands = resolveSkillCommands(config);
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
command: command.name, command: normalizeTelegramCommandName(command.name),
description: command.description, description: command.description,
})); }));
const nativeStatus = native.find((command) => command.command === "status"); const nativeStatus = native.find((command) => command.command === "status");