Merge pull request #32 from bitxeno/feat/telegram-command-menu

This commit is contained in:
xmanrui 2026-08-22 00:43:01 +08:00
commit 0cd3d706c3
3 changed files with 155 additions and 3 deletions

View file

@ -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}`;

View file

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

View file

@ -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();