Merge pull request #860 from nachoiacovino/feat/telegram-custom-commands
feat(telegram): support custom commands in config
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
|
||||
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` 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)
|
||||
|
||||
43
src/config/config.telegram-custom-commands.test.ts
Normal file
43
src/config/config.telegram-custom-commands.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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").',
|
||||
|
||||
93
src/config/telegram-custom-commands.ts
Normal file
93
src/config/telegram-custom-commands.ts
Normal file
@@ -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<string>;
|
||||
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<string>();
|
||||
const checkReserved = params.checkReserved !== false;
|
||||
const checkDuplicates = params.checkDuplicates !== false;
|
||||
const seen = new Set<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user