diff --git a/src/channels/telegram/telegram-api.mjs b/src/channels/telegram/telegram-api.mjs index cfe9343..cd6b8c2 100644 --- a/src/channels/telegram/telegram-api.mjs +++ b/src/channels/telegram/telegram-api.mjs @@ -16,6 +16,18 @@ export function validTelegramToken(value) { return typeof value === 'string' && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim()); } +const TELEGRAM_COMMAND_NAME = /^[a-z0-9_]{1,32}$/; + +function validBotCommand(value) { + return Boolean(value) + && typeof value === 'object' && !Array.isArray(value) + && typeof value.command === 'string' && TELEGRAM_COMMAND_NAME.test(value.command) + && typeof value.description === 'string' && value.description.length >= 1 + && value.description.length <= 256; +} + +export const COMMANDS_MENU_BUTTON = Object.freeze({ type: 'commands' }); + export class TelegramApi { #token; #fetch; @@ -113,6 +125,25 @@ export class TelegramApi { }, { signal }); } + async setMyCommands({ commands, scope, languageCode, signal } = {}) { + if (!Array.isArray(commands) || commands.length === 0 + || commands.some((command) => !validBotCommand(command))) { + throw new TypeError('Telegram bot commands are invalid'); + } + return this.#call('setMyCommands', { + commands, + ...(scope ? { scope } : {}), + ...(cleanString(languageCode) ? { language_code: cleanString(languageCode) } : {}), + }, { signal }); + } + + async setChatMenuButton({ menuButton = COMMANDS_MENU_BUTTON, signal } = {}) { + if (!menuButton || typeof menuButton !== 'object' || Array.isArray(menuButton)) { + throw new TypeError('Telegram menu button is invalid'); + } + return this.#call('setChatMenuButton', { menu_button: menuButton }, { signal }); + } + async #call(method, payload, { signal, timeoutMs = 15_000 } = {}) { const url = new URL(this.#baseUrl); url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`; diff --git a/src/channels/telegram/telegram-runtime.mjs b/src/channels/telegram/telegram-runtime.mjs index 043a3dd..f7d694d 100644 --- a/src/channels/telegram/telegram-runtime.mjs +++ b/src/channels/telegram/telegram-runtime.mjs @@ -1,11 +1,26 @@ import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs'; -import { TelegramApi } from './telegram-api.mjs'; +import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs'; import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs'; import { TELEGRAM_ACCESS_MODES, normalizeTelegramAccessPolicy, } from './config-store.mjs'; +export const TELEGRAM_COMMAND_MENU = Object.freeze([ + { command: 'new', description: '开启一个全新会话' }, + { command: 'compact', description: '压缩当前会话的较早上下文' }, + { command: 'workspace', description: '切换工作区' }, + { command: 'workspacelist', description: '列出工作区绝对路径' }, + { command: 'sessionlist', description: '列出会话 ID 和标题' }, + { command: 'session', description: '将当前聊天绑定到指定会话' }, + { command: 'models', description: '按序号列出所有可用模型' }, + { command: 'model', description: '查看或切换当前会话模型' }, + { command: 'stop', description: '停止当前任务' }, + { command: 'steer', description: '纠偏当前任务' }, + { command: 'status', description: '检查连接状态' }, + { command: 'help', description: '显示帮助' }, +]); + function escaped(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -290,6 +305,15 @@ export class TelegramRuntime { error.code = 'webhook-configured'; throw error; } + try { + await api.setMyCommands({ commands: TELEGRAM_COMMAND_MENU, signal: controller.signal }); + await api.setChatMenuButton({ menuButton: COMMANDS_MENU_BUTTON, signal: controller.signal }); + } catch (error) { + this.#logger.warn?.( + `[dsh-im:telegram] bot ${this.#config.botId} command menu setup failed:`, + error, + ); + } const client = new TelegramBotClient({ api, signal: controller.signal }); this.#bridge = new TelegramHarnessBridge({ bot: client, diff --git a/test/channels/telegram/telegram.test.mjs b/test/channels/telegram/telegram.test.mjs index cb63af9..0ddf9f3 100644 --- a/test/channels/telegram/telegram.test.mjs +++ b/test/channels/telegram/telegram.test.mjs @@ -13,12 +13,14 @@ import { import { TelegramController } from '../../../src/channels/telegram/telegram-controller.mjs'; import { TelegramApi, + COMMANDS_MENU_BUTTON, inspectTelegramToken, validTelegramToken, } from '../../../src/channels/telegram/telegram-api.mjs'; import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs'; import { TelegramRuntime, + TELEGRAM_COMMAND_MENU, normalizeTelegramUpdate, telegramInboundAllowed, } from '../../../src/channels/telegram/telegram-runtime.mjs'; @@ -154,6 +156,43 @@ test('Telegram API resolves and downloads files without exposing arbitrary paths }); }); +test('Telegram API registers the command menu and commands-type menu button', async () => { + const calls = []; + const api = new TelegramApi({ + token: TOKEN, + fetchImpl: async (url, options) => { + calls.push({ url, body: JSON.parse(options.body) }); + return jsonResponse({ ok: true, result: true }); + }, + }); + await api.setMyCommands({ commands: TELEGRAM_COMMAND_MENU }); + await api.setChatMenuButton(); + assert.equal(calls.length, 2); + assert.match(calls[0].url.pathname, /setMyCommands$/); + assert.deepEqual(calls[0].body, { commands: TELEGRAM_COMMAND_MENU }); + assert.match(calls[1].url.pathname, /setChatMenuButton$/); + assert.deepEqual(calls[1].body, { menu_button: COMMANDS_MENU_BUTTON }); + + const scopeCall = await api.setMyCommands({ + commands: [{ command: 'help', description: '帮助' }], + scope: { type: 'chat', chat_id: 88 }, + languageCode: 'zh', + }); + assert.equal(scopeCall, true); + assert.deepEqual(calls[2].body.commands, [{ command: 'help', description: '帮助' }]); + assert.deepEqual(calls[2].body.scope, { type: 'chat', chat_id: 88 }); + assert.equal(calls[2].body.language_code, 'zh'); + + await assert.rejects(() => api.setMyCommands({ commands: [] }), /commands are invalid/); + await assert.rejects(() => api.setMyCommands({ + commands: [{ command: 'Bad-Name', description: '非法命令名' }], + }), /commands are invalid/); + await assert.rejects(() => api.setMyCommands({ + commands: [{ command: 'help' }], + }), /commands are invalid/); + await assert.rejects(() => api.setChatMenuButton({ menuButton: 'commands' }), /menu button is invalid/); +}); + test('Telegram config and controller store only a credential reference in bot data', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-')); t.after(() => rm(directory, { recursive: true, force: true })); @@ -749,8 +788,16 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol const fakeApi = { getMe: async () => ({ id: 123456789, is_bot: true }), getWebhookInfo: async () => ({ url: '' }), + setMyCommands: async ({ commands }) => { + calls.push({ method: 'setMyCommands', commands }); + return true; + }, + setChatMenuButton: async ({ menuButton }) => { + calls.push({ method: 'setChatMenuButton', menuButton }); + return true; + }, getUpdates: async ({ offset, timeout, signal }) => { - calls.push({ offset, timeout }); + calls.push({ method: 'getUpdates', offset, timeout }); if (timeout === 0) return []; return new Promise((resolve, reject) => signal.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')); @@ -773,10 +820,60 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol assert.equal(runtime.status.connectionState, 'connected'); await runtime.stop(); assert.equal(runtime.status.ready, false); - assert.deepEqual(calls[0], { offset: -1, timeout: 0 }); + assert.deepEqual(calls[0], { method: 'setMyCommands', commands: TELEGRAM_COMMAND_MENU }); + assert.deepEqual(calls[1], { method: 'setChatMenuButton', menuButton: COMMANDS_MENU_BUTTON }); + assert.deepEqual(calls[2], { method: 'getUpdates', offset: -1, timeout: 0 }); await rm(directory, { recursive: true, force: true }); }); +test('Telegram runtime still starts when the command menu setup fails', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-menu-failure-')); + const state = await new TelegramStateStore(join(directory, 'state.json')).load(); + const warnings = []; + let delivered = false; + const fakeApi = { + getMe: async () => ({ id: 123456789, is_bot: true }), + getWebhookInfo: async () => ({ url: '' }), + setMyCommands: async () => { + throw new Error('telegram-502 Bad Gateway'); + }, + setChatMenuButton: async () => { + throw new Error('telegram-502 Bad Gateway'); + }, + getUpdates: async ({ timeout, signal }) => { + if (timeout === 0) return []; + if (!delivered) { + delivered = true; + return []; + } + return new Promise((resolve, reject) => signal.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }, { once: true })); + }, + }; + const runtime = new TelegramRuntime({ + config: { + botId: 'telegram_menu_failure', + platformId: '123456789', + username: 'HarnessBot', + }, + token: TOKEN, + harness: { ensureRunning: async () => true }, + state, + createApi: () => fakeApi, + logger: { warn: (message) => warnings.push(message), error() {} }, + }); + try { + await runtime.start(); + assert.equal(runtime.status.ready, true); + assert.equal(runtime.status.connectionState, 'connected'); + assert.match(warnings.at(-1), /command menu setup failed/); + } finally { + await runtime.stop(); + await rm(directory, { recursive: true, force: true }); + } +}); + test('Telegram runtime enforces the selected bot private allowlist', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-allowlist-runtime-')); const state = await new TelegramStateStore(join(directory, 'state.json')).load();