Skip to content

Commit c4e9bb3

Browse files
akramcodezobviyus
andauthored
fix: sanitize native command names for Telegram API (openclaw#19257)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: b608be3 Co-authored-by: akramcodez <179671552+akramcodez@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus
1 parent 20a5612 commit c4e9bb3

File tree

7 files changed

+71
-22
lines changed

7 files changed

+71
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
6262
- 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.
6363
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
6464
- 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.
65+
- 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.
6566
- 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.
6667
- Telegram: ignore `<media:...>` placeholder lines when extracting `MEDIA:` tool-result paths, preventing false local-file reads and dropped replies. (#18510) Thanks @yinghaosang.
6768
- 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.

src/config/config.telegram-custom-commands.test.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe("telegram custom commands schema", () => {
2121
]);
2222
});
2323

24-
it("rejects custom commands with invalid names", () => {
24+
it("normalizes hyphens in custom command names", () => {
2525
const res = OpenClawSchema.safeParse({
2626
channels: {
2727
telegram: {
@@ -30,17 +30,13 @@ describe("telegram custom commands schema", () => {
3030
},
3131
});
3232

33-
expect(res.success).toBe(false);
34-
if (res.success) {
33+
expect(res.success).toBe(true);
34+
if (!res.success) {
3535
return;
3636
}
3737

38-
expect(
39-
res.error.issues.some(
40-
(issue) =>
41-
issue.path.join(".") === "channels.telegram.customCommands.0.command" &&
42-
issue.message.includes("invalid"),
43-
),
44-
).toBe(true);
38+
expect(res.data.channels?.telegram?.customCommands).toEqual([
39+
{ command: "bad_name", description: "Override status" },
40+
]);
4541
});
4642
});

src/config/telegram-custom-commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function normalizeTelegramCommandName(value: string): string {
1717
return "";
1818
}
1919
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
20-
return withoutSlash.trim().toLowerCase();
20+
return withoutSlash.trim().toLowerCase().replace(/-/g, "_");
2121
}
2222

2323
export function normalizeTelegramCommandDescription(value: string): string {

src/telegram/bot-native-command-menu.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,7 @@ export function syncTelegramMenuCommands(params: {
100100
});
101101
};
102102

103-
void sync().catch(() => {});
103+
void sync().catch((err) => {
104+
runtime.error?.(`Telegram command sync failed: ${String(err)}`);
105+
});
104106
}

src/telegram/bot-native-commands.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,37 @@ describe("registerTelegramNativeCommands", () => {
149149
);
150150
});
151151

152+
it("normalizes hyphenated native command names for Telegram registration", async () => {
153+
const setMyCommands = vi.fn().mockResolvedValue(undefined);
154+
const command = vi.fn();
155+
156+
registerTelegramNativeCommands({
157+
...buildParams({}),
158+
bot: {
159+
api: {
160+
setMyCommands,
161+
sendMessage: vi.fn().mockResolvedValue(undefined),
162+
},
163+
command,
164+
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
165+
});
166+
167+
await vi.waitFor(() => {
168+
expect(setMyCommands).toHaveBeenCalled();
169+
});
170+
171+
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
172+
command: string;
173+
description: string;
174+
}>;
175+
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
176+
expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false);
177+
178+
const registeredHandlers = command.mock.calls.map(([name]) => name);
179+
expect(registeredHandlers).toContain("export_session");
180+
expect(registeredHandlers).not.toContain("export-session");
181+
});
182+
152183
it("passes agent-scoped media roots for plugin command replies with media", async () => {
153184
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
154185
const sendMessage = vi.fn().mockResolvedValue(undefined);

src/telegram/bot-native-commands.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
1717
import type { OpenClawConfig } from "../config/config.js";
1818
import type { ChannelGroupPolicy } from "../config/group-policy.js";
1919
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
20-
import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js";
20+
import {
21+
normalizeTelegramCommandName,
22+
resolveTelegramCustomCommands,
23+
TELEGRAM_COMMAND_NAME_PATTERN,
24+
} from "../config/telegram-custom-commands.js";
2125
import type {
2226
ReplyToMode,
2327
TelegramAccountConfig,
@@ -310,7 +314,7 @@ export const registerTelegramNativeCommands = ({
310314
})
311315
: [];
312316
const reservedCommands = new Set(
313-
listNativeCommandSpecs().map((command) => command.name.toLowerCase()),
317+
listNativeCommandSpecs().map((command) => normalizeTelegramCommandName(command.name)),
314318
);
315319
for (const command of skillCommands) {
316320
reservedCommands.add(command.name.toLowerCase());
@@ -326,7 +330,7 @@ export const registerTelegramNativeCommands = ({
326330
const pluginCommandSpecs = getPluginCommandSpecs();
327331
const existingCommands = new Set(
328332
[
329-
...nativeCommands.map((command) => command.name),
333+
...nativeCommands.map((command) => normalizeTelegramCommandName(command.name)),
330334
...customCommands.map((command) => command.command),
331335
].map((command) => command.toLowerCase()),
332336
);
@@ -338,10 +342,23 @@ export const registerTelegramNativeCommands = ({
338342
runtime.error?.(danger(issue));
339343
}
340344
const allCommandsFull: Array<{ command: string; description: string }> = [
341-
...nativeCommands.map((command) => ({
342-
command: command.name,
343-
description: command.description,
344-
})),
345+
...nativeCommands
346+
.map((command) => {
347+
const normalized = normalizeTelegramCommandName(command.name);
348+
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
349+
runtime.error?.(
350+
danger(
351+
`Native command "${command.name}" is invalid for Telegram (resolved to "${normalized}"). Skipping.`,
352+
),
353+
);
354+
return null;
355+
}
356+
return {
357+
command: normalized,
358+
description: command.description,
359+
};
360+
})
361+
.filter((cmd): cmd is { command: string; description: string } => cmd !== null),
345362
...(nativeEnabled ? pluginCatalog.commands : []),
346363
...customCommands,
347364
];
@@ -419,7 +436,8 @@ export const registerTelegramNativeCommands = ({
419436
logVerbose("telegram: bot.command unavailable; skipping native handlers");
420437
} else {
421438
for (const command of nativeCommands) {
422-
bot.command(command.name, async (ctx: TelegramNativeCommandContext) => {
439+
const normalizedCommandName = normalizeTelegramCommandName(command.name);
440+
bot.command(normalizedCommandName, async (ctx: TelegramNativeCommandContext) => {
423441
const msg = ctx.message;
424442
if (!msg) {
425443
return;

src/telegram/bot.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
listNativeCommandSpecs,
66
listNativeCommandSpecsForConfig,
77
} from "../auto-reply/commands-registry.js";
8+
import { normalizeTelegramCommandName } from "../config/telegram-custom-commands.js";
89
import {
910
answerCallbackQuerySpy,
1011
commandSpy,
@@ -72,7 +73,7 @@ describe("createTelegramBot", () => {
7273
}>;
7374
const skillCommands = resolveSkillCommands(config);
7475
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
75-
command: command.name,
76+
command: normalizeTelegramCommandName(command.name),
7677
description: command.description,
7778
}));
7879
expect(registered.slice(0, native.length)).toEqual(native);
@@ -113,7 +114,7 @@ describe("createTelegramBot", () => {
113114
}>;
114115
const skillCommands = resolveSkillCommands(config);
115116
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
116-
command: command.name,
117+
command: normalizeTelegramCommandName(command.name),
117118
description: command.description,
118119
}));
119120
const nativeStatus = native.find((command) => command.command === "status");

0 commit comments

Comments
 (0)