fix: add telegram custom commands (#860) (thanks @nachoiacovino)

Co-authored-by: Nacho Iacovino <50103937+nachoiacovino@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-16 08:20:48 +00:00
parent cd409e5667
commit 929666a8c8
10 changed files with 338 additions and 7 deletions

View 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);
});
});

View File

@@ -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 (05).",
"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").',

View 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 };
}

View File

@@ -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;
/**

View File

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