mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
feat(telegram): register command menu and menu button on bot start
Add setMyCommands and setChatMenuButton to the Telegram Bot API client and register a 12-command menu with a commands-type menu button when a bot runtime connects. Menu setup failures are logged as warnings and do not block startup. Co-authored-by: xmanrui <841206367@qq.com>
This commit is contained in:
parent
7fbb459e00
commit
60e00bbca9
4 changed files with 284 additions and 132 deletions
258
lib/index.js
258
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue