From 929666a8c8d585ee134f975ccf1429cf3bb9af1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 08:20:48 +0000 Subject: [PATCH] fix: add telegram custom commands (#860) (thanks @nachoiacovino) Co-authored-by: Nacho Iacovino <50103937+nachoiacovino@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/channels/telegram.md | 23 ++++ docs/gateway/configuration.md | 5 + .../config.telegram-custom-commands.test.ts | 43 +++++++ src/config/schema.ts | 3 + src/config/telegram-custom-commands.ts | 93 ++++++++++++++++ src/config/types.telegram.ts | 10 ++ src/config/zod-schema.providers-core.ts | 32 ++++++ src/telegram/bot-native-commands.ts | 30 +++-- src/telegram/bot.test.ts | 105 ++++++++++++++++++ 10 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 src/config/config.telegram-custom-commands.test.ts create mode 100644 src/config/telegram-custom-commands.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a48109545..93064e66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer. - Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page). - Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi. +- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino. - Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c. - Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow. - Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 6cfa62e6b..a437d933a 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -101,6 +101,29 @@ group messages, so use admin if you need full visibility. - Raw HTML from models is escaped to avoid Telegram parse errors. - If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text. +## Commands (native + custom) +Clawdbot registers native commands (like `/status`, `/reset`, `/model`) with Telegram’s bot menu on startup. +You can add custom commands to the menu via config: + +```json5 +{ + channels: { + telegram: { + customCommands: [ + { command: "backup", description: "Git backup" }, + { command: "generate", description: "Create an image" } + ] + } + } +} +``` + +Notes: +- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere. +- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars). +- Custom commands **cannot override native commands**. Conflicts are ignored and logged. +- If `commands.native` is disabled, only custom commands are registered (or cleared if none). + ## Limits - Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000). - Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index bc86799c7..feecff35c 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -871,6 +871,7 @@ Notes: - `commands.text: false` disables parsing chat messages for commands. - `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported channels stay text-only. - Set `commands.native: true|false` to force all, or override per channel with `channels.discord.commands.native`, `channels.telegram.commands.native`, `channels.slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app. +- `channels.telegram.customCommands` adds extra Telegram bot menu entries. Names are normalized; conflicts with native commands are ignored. - `commands.bash: true` enables `! ` to run host shell commands (`/bash ` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.`. - `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! ` requests are rejected (one at a time). - `commands.config: true` enables `/config` (reads/writes `clawdbot.json`). @@ -929,6 +930,10 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w } } }, + customCommands: [ + { command: "backup", description: "Git backup" }, + { command: "generate", description: "Create an image" } + ], historyLimit: 50, // include last N group messages as context (0 disables) replyToMode: "first", // off | first | all streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming) diff --git a/src/config/config.telegram-custom-commands.test.ts b/src/config/config.telegram-custom-commands.test.ts new file mode 100644 index 000000000..61129a108 --- /dev/null +++ b/src/config/config.telegram-custom-commands.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { ClawdbotSchema } from "./zod-schema.js"; + +describe("telegram custom commands schema", () => { + it("normalizes custom commands", () => { + const res = ClawdbotSchema.safeParse({ + channels: { + telegram: { + customCommands: [{ command: "/Backup", description: " Git backup " }], + }, + }, + }); + + expect(res.success).toBe(true); + if (!res.success) return; + + expect(res.data.channels?.telegram?.customCommands).toEqual([ + { command: "backup", description: "Git backup" }, + ]); + }); + + it("rejects custom commands with invalid names", () => { + const res = ClawdbotSchema.safeParse({ + channels: { + telegram: { + customCommands: [{ command: "Bad-Name", description: "Override status" }], + }, + }, + }); + + expect(res.success).toBe(false); + if (res.success) return; + + expect( + res.error.issues.some( + (issue) => + issue.path.join(".") === "channels.telegram.customCommands.0.command" && + issue.message.includes("invalid"), + ), + ).toBe(true); + }); +}); diff --git a/src/config/schema.ts b/src/config/schema.ts index 065409a2c..66742d9fa 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -175,6 +175,7 @@ const FIELD_LABELS: Record = { "talk.apiKey": "Talk API Key", "channels.whatsapp": "WhatsApp", "channels.telegram": "Telegram", + "channels.telegram.customCommands": "Telegram Custom Commands", "channels.discord": "Discord", "channels.slack": "Slack", "channels.signal": "Signal", @@ -348,6 +349,8 @@ const FIELD_HELP: Record = { "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', "session.agentToAgent.maxPingPongTurns": "Max reply-back turns between requester and target (0–5).", + "channels.telegram.customCommands": + "Additional Telegram bot menu commands (merged with native; conflicts ignored).", "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", "messages.ackReactionScope": 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', diff --git a/src/config/telegram-custom-commands.ts b/src/config/telegram-custom-commands.ts new file mode 100644 index 000000000..381cc1e55 --- /dev/null +++ b/src/config/telegram-custom-commands.ts @@ -0,0 +1,93 @@ +export const TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/; + +export type TelegramCustomCommandInput = { + command?: string | null; + description?: string | null; +}; + +export type TelegramCustomCommandIssue = { + index: number; + field: "command" | "description"; + message: string; +}; + +export function normalizeTelegramCommandName(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed; + return withoutSlash.trim().toLowerCase(); +} + +export function normalizeTelegramCommandDescription(value: string): string { + return value.trim(); +} + +export function resolveTelegramCustomCommands(params: { + commands?: TelegramCustomCommandInput[] | null; + reservedCommands?: Set; + checkReserved?: boolean; + checkDuplicates?: boolean; +}): { + commands: Array<{ command: string; description: string }>; + issues: TelegramCustomCommandIssue[]; +} { + const entries = Array.isArray(params.commands) ? params.commands : []; + const reserved = params.reservedCommands ?? new Set(); + const checkReserved = params.checkReserved !== false; + const checkDuplicates = params.checkDuplicates !== false; + const seen = new Set(); + const resolved: Array<{ command: string; description: string }> = []; + const issues: TelegramCustomCommandIssue[] = []; + + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const normalized = normalizeTelegramCommandName(String(entry?.command ?? "")); + if (!normalized) { + issues.push({ + index, + field: "command", + message: "Telegram custom command is missing a command name.", + }); + continue; + } + if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { + issues.push({ + index, + field: "command", + message: `Telegram custom command "/${normalized}" is invalid (use a-z, 0-9, underscore; max 32 chars).`, + }); + continue; + } + if (checkReserved && reserved.has(normalized)) { + issues.push({ + index, + field: "command", + message: `Telegram custom command "/${normalized}" conflicts with a native command.`, + }); + continue; + } + if (checkDuplicates && seen.has(normalized)) { + issues.push({ + index, + field: "command", + message: `Telegram custom command "/${normalized}" is duplicated.`, + }); + continue; + } + const description = normalizeTelegramCommandDescription(String(entry?.description ?? "")); + if (!description) { + issues.push({ + index, + field: "description", + message: `Telegram custom command "/${normalized}" is missing a description.`, + }); + continue; + } + if (checkDuplicates) { + seen.add(normalized); + } + resolved.push({ command: normalized, description }); + } + + return { commands: resolved, issues }; +} diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 29ad277dd..69f273706 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -14,6 +14,14 @@ export type TelegramActionConfig = { deleteMessage?: boolean; }; +/** Custom command definition for Telegram bot menu. */ +export type TelegramCustomCommand = { + /** Command name (without leading /). */ + command: string; + /** Description shown in Telegram command menu. */ + description: string; +}; + export type TelegramAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -21,6 +29,8 @@ export type TelegramAccountConfig = { capabilities?: string[]; /** Override native command registration for Telegram (bool or "auto"). */ commands?: ProviderCommandsConfig; + /** Custom commands to register in Telegram's command menu (merged with native). */ + customCommands?: TelegramCustomCommand[]; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 18851d557..f61e425d6 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -13,6 +13,11 @@ import { RetryConfigSchema, requireOpenAllowFrom, } from "./zod-schema.core.js"; +import { + normalizeTelegramCommandDescription, + normalizeTelegramCommandName, + resolveTelegramCustomCommands, +} from "./telegram-custom-commands.js"; export const TelegramTopicSchema = z.object({ requireMention: z.boolean().optional(), @@ -31,11 +36,36 @@ export const TelegramGroupSchema = z.object({ topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), }); +const TelegramCustomCommandSchema = z.object({ + command: z.string().transform(normalizeTelegramCommandName), + description: z.string().transform(normalizeTelegramCommandDescription), +}); + +const validateTelegramCustomCommands = ( + value: { customCommands?: Array<{ command?: string; description?: string }> }, + ctx: z.RefinementCtx, +) => { + if (!value.customCommands || value.customCommands.length === 0) return; + const { issues } = resolveTelegramCustomCommands({ + commands: value.customCommands, + checkReserved: false, + checkDuplicates: false, + }); + for (const issue of issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customCommands", issue.index, issue.field], + message: issue.message, + }); + } +}; + export const TelegramAccountSchemaBase = z.object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), commands: ProviderCommandsSchema, + customCommands: z.array(TelegramCustomCommandSchema).optional(), configWrites: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), botToken: z.string().optional(), @@ -80,6 +110,7 @@ export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((valu message: 'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"', }); + validateTelegramCustomCommands(value, ctx); }); export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ @@ -93,6 +124,7 @@ export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ message: 'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"', }); + validateTelegramCustomCommands(value, ctx); }); export const DiscordDmSchema = z diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 948011d7a..525c5f5cd 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -4,11 +4,13 @@ import { resolveEffectiveMessagesConfig } from "../agents/identity.js"; import { buildCommandTextFromArgs, findCommandByNativeName, + listNativeCommandSpecs, listNativeCommandSpecsForConfig, parseCommandArgs, resolveCommandArgMenu, } from "../auto-reply/commands-registry.js"; import type { CommandArgs } from "../auto-reply/commands-registry.js"; +import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { danger, logVerbose } from "../globals.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; @@ -42,7 +44,26 @@ export const registerTelegramNativeCommands = ({ opts, }) => { const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg) : []; - if (nativeCommands.length > 0) { + const reservedCommands = new Set( + listNativeCommandSpecs().map((command) => command.name.toLowerCase()), + ); + const customResolution = resolveTelegramCustomCommands({ + commands: telegramCfg.customCommands, + reservedCommands, + }); + for (const issue of customResolution.issues) { + runtime.error?.(danger(issue.message)); + } + const customCommands = customResolution.commands; + const allCommands: Array<{ command: string; description: string }> = [ + ...nativeCommands.map((command) => ({ + command: command.name, + description: command.description, + })), + ...customCommands, + ]; + + if (allCommands.length > 0) { const api = bot.api as unknown as { setMyCommands?: ( commands: Array<{ command: string; description: string }>, @@ -50,12 +71,7 @@ export const registerTelegramNativeCommands = ({ }; if (typeof api.setMyCommands === "function") { api - .setMyCommands( - nativeCommands.map((command) => ({ - command: command.name, - description: command.description, - })), - ) + .setMyCommands(allCommands) .catch((err) => { runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`)); }); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index e05c1129b..7461d2c5a 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -2,6 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + listNativeCommandSpecs, + listNativeCommandSpecsForConfig, +} from "../auto-reply/commands-registry.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import * as replyModule from "../auto-reply/reply.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; @@ -164,6 +168,107 @@ describe("createTelegramBot", () => { expect(useSpy).toHaveBeenCalledWith("throttler"); }); + it("merges custom commands with native commands", () => { + const config = { + channels: { + telegram: { + customCommands: [ + { command: "custom_backup", description: "Git backup" }, + { command: "/Custom_Generate", description: "Create an image" }, + ], + }, + }, + }; + loadConfig.mockReturnValue(config); + + createTelegramBot({ token: "tok" }); + + const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ + command: string; + description: string; + }>; + const native = listNativeCommandSpecsForConfig(config).map((command) => ({ + command: command.name, + description: command.description, + })); + expect(registered.slice(0, native.length)).toEqual(native); + expect(registered.slice(native.length)).toEqual([ + { command: "custom_backup", description: "Git backup" }, + { command: "custom_generate", description: "Create an image" }, + ]); + }); + + it("ignores custom commands that collide with native commands", () => { + const errorSpy = vi.fn(); + const config = { + channels: { + telegram: { + customCommands: [ + { command: "status", description: "Custom status" }, + { command: "custom_backup", description: "Git backup" }, + ], + }, + }, + }; + loadConfig.mockReturnValue(config); + + createTelegramBot({ + token: "tok", + runtime: { + log: vi.fn(), + error: errorSpy, + exit: ((code: number) => { + throw new Error(`exit ${code}`); + }) as (code: number) => never, + }, + }); + + const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ + command: string; + description: string; + }>; + const native = listNativeCommandSpecsForConfig(config).map((command) => ({ + command: command.name, + description: command.description, + })); + const nativeStatus = native.find((command) => command.command === "status"); + expect(nativeStatus).toBeDefined(); + expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" }); + expect(registered).not.toContainEqual({ command: "status", description: "Custom status" }); + expect(registered.filter((command) => command.command === "status")).toEqual([ + nativeStatus, + ]); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("registers custom commands when native commands are disabled", () => { + const config = { + commands: { native: false }, + channels: { + telegram: { + customCommands: [ + { command: "custom_backup", description: "Git backup" }, + { command: "custom_generate", description: "Create an image" }, + ], + }, + }, + }; + loadConfig.mockReturnValue(config); + + createTelegramBot({ token: "tok" }); + + const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ + command: string; + description: string; + }>; + expect(registered).toEqual([ + { command: "custom_backup", description: "Git backup" }, + { command: "custom_generate", description: "Create an image" }, + ]); + const reserved = listNativeCommandSpecs().map((command) => command.name); + expect(registered.some((command) => reserved.includes(command.command))).toBe(false); + }); + it("forces native fetch only under Bun", () => { const originalFetch = globalThis.fetch; const originalBun = (globalThis as { Bun?: unknown }).Bun;