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: 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).
|
- 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: 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.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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
|
## Limits
|
||||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
- 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.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.
|
- `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.
|
- 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.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.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`).
|
- `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)
|
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||||
replyToMode: "first", // off | first | all
|
replyToMode: "first", // off | first | all
|
||||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
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",
|
"talk.apiKey": "Talk API Key",
|
||||||
"channels.whatsapp": "WhatsApp",
|
"channels.whatsapp": "WhatsApp",
|
||||||
"channels.telegram": "Telegram",
|
"channels.telegram": "Telegram",
|
||||||
|
"channels.telegram.customCommands": "Telegram Custom Commands",
|
||||||
"channels.discord": "Discord",
|
"channels.discord": "Discord",
|
||||||
"channels.slack": "Slack",
|
"channels.slack": "Slack",
|
||||||
"channels.signal": "Signal",
|
"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").',
|
"channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',
|
||||||
"session.agentToAgent.maxPingPongTurns":
|
"session.agentToAgent.maxPingPongTurns":
|
||||||
"Max reply-back turns between requester and target (0–5).",
|
"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.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
|
||||||
"messages.ackReactionScope":
|
"messages.ackReactionScope":
|
||||||
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
|
'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;
|
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 = {
|
export type TelegramAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -21,6 +29,8 @@ export type TelegramAccountConfig = {
|
|||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
/** Override native command registration for Telegram (bool or "auto"). */
|
/** Override native command registration for Telegram (bool or "auto"). */
|
||||||
commands?: ProviderCommandsConfig;
|
commands?: ProviderCommandsConfig;
|
||||||
|
/** Custom commands to register in Telegram's command menu (merged with native). */
|
||||||
|
customCommands?: TelegramCustomCommand[];
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
configWrites?: boolean;
|
configWrites?: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
RetryConfigSchema,
|
RetryConfigSchema,
|
||||||
requireOpenAllowFrom,
|
requireOpenAllowFrom,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
|
import {
|
||||||
|
normalizeTelegramCommandDescription,
|
||||||
|
normalizeTelegramCommandName,
|
||||||
|
resolveTelegramCustomCommands,
|
||||||
|
} from "./telegram-custom-commands.js";
|
||||||
|
|
||||||
export const TelegramTopicSchema = z.object({
|
export const TelegramTopicSchema = z.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
@@ -31,11 +36,36 @@ export const TelegramGroupSchema = z.object({
|
|||||||
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
|
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({
|
export const TelegramAccountSchemaBase = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
|
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional(),
|
||||||
@@ -80,6 +110,7 @@ export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((valu
|
|||||||
message:
|
message:
|
||||||
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
||||||
});
|
});
|
||||||
|
validateTelegramCustomCommands(value, ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
||||||
@@ -93,6 +124,7 @@ export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
|||||||
message:
|
message:
|
||||||
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
||||||
});
|
});
|
||||||
|
validateTelegramCustomCommands(value, ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DiscordDmSchema = z
|
export const DiscordDmSchema = z
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
|||||||
import {
|
import {
|
||||||
buildCommandTextFromArgs,
|
buildCommandTextFromArgs,
|
||||||
findCommandByNativeName,
|
findCommandByNativeName,
|
||||||
|
listNativeCommandSpecs,
|
||||||
listNativeCommandSpecsForConfig,
|
listNativeCommandSpecsForConfig,
|
||||||
parseCommandArgs,
|
parseCommandArgs,
|
||||||
resolveCommandArgMenu,
|
resolveCommandArgMenu,
|
||||||
} from "../auto-reply/commands-registry.js";
|
} from "../auto-reply/commands-registry.js";
|
||||||
import type { CommandArgs } 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 { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||||
import { danger, logVerbose } from "../globals.js";
|
import { danger, logVerbose } from "../globals.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
@@ -42,7 +44,26 @@ export const registerTelegramNativeCommands = ({
|
|||||||
opts,
|
opts,
|
||||||
}) => {
|
}) => {
|
||||||
const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg) : [];
|
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 {
|
const api = bot.api as unknown as {
|
||||||
setMyCommands?: (
|
setMyCommands?: (
|
||||||
commands: Array<{ command: string; description: string }>,
|
commands: Array<{ command: string; description: string }>,
|
||||||
@@ -50,12 +71,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
};
|
};
|
||||||
if (typeof api.setMyCommands === "function") {
|
if (typeof api.setMyCommands === "function") {
|
||||||
api
|
api
|
||||||
.setMyCommands(
|
.setMyCommands(allCommands)
|
||||||
nativeCommands.map((command) => ({
|
|
||||||
command: command.name,
|
|
||||||
description: command.description,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`));
|
runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||||
import * as replyModule from "../auto-reply/reply.js";
|
import * as replyModule from "../auto-reply/reply.js";
|
||||||
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
|
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
|
||||||
@@ -164,6 +168,107 @@ describe("createTelegramBot", () => {
|
|||||||
expect(useSpy).toHaveBeenCalledWith("throttler");
|
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", () => {
|
it("forces native fetch only under Bun", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const originalBun = (globalThis as { Bun?: unknown }).Bun;
|
const originalBun = (globalThis as { Bun?: unknown }).Bun;
|
||||||
|
|||||||
Reference in New Issue
Block a user