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: 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 `<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.

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({
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" },
]);
});
});

View File

@@ -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 {

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 () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
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 { 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;

View File

@@ -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");