import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { OUTBOUND_ARTIFACT_TOOL, OutboundArtifactRegistry, createOutboundArtifactTool, } from '../../../src/channels/shared/semantic/artifact.mjs'; import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs'; import { TELEGRAM_ACCESS_MODES, TelegramConfigStore, deriveTelegramBotIdentity, normalizeTelegramAccessPolicy, } from '../../../src/channels/telegram/config-store.mjs'; 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 { TelegramBotClient, TelegramRuntime, TELEGRAM_COMMAND_MENU, normalizeTelegramUpdate, telegramCommandMenu, telegramInboundAllowed, } from '../../../src/channels/telegram/telegram-runtime.mjs'; import { TelegramStateStore } from '../../../src/channels/telegram/state-store.mjs'; import { TELEGRAM_ENDPOINTS, createTelegramRpcHandler, } from '../../../plugin-src/host/channels/telegram/rpc.mjs'; const TOKEN = '123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef123456'; function jsonResponse(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' }, }); } function credentials() { const values = new Map(); return { values, async resolve(ref) { return values.has(ref) ? { value: values.get(ref), source: 'test' } : undefined; }, async set(ref, value) { values.set(ref, value); }, async unset(ref) { values.delete(ref); }, }; } function memoryState() { const sessions = new Map(); const seen = new Set(); return { sessionFor: (key) => sessions.get(key) ?? null, setSession: async (key, value) => sessions.set(key, value), clearSession: async (key) => sessions.delete(key), hasSeen: (id) => seen.has(id), markSeen: async (id) => seen.add(id), }; } function deferred() { let resolve; let reject; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } async function bounded(promise, message, timeoutMs = 1_000) { let timer; try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); }), ]); } finally { clearTimeout(timer); } } async function committedTelegramArtifact(t, { suffix, fileName, content, }) { const workspace = await mkdtemp(join(tmpdir(), `dsh-im-telegram-artifact-${suffix}-`)); t.after(() => rm(workspace, { recursive: true, force: true })); const sessionId = `session-telegram-artifact-${suffix}`; let nextId = 0; const registry = new OutboundArtifactRegistry({ uuid: () => `${suffix}-${++nextId}`, }); t.after(() => registry.clear()); const agent = { session: { header: { id: sessionId, cwd: workspace }, events: [ { type: 'turn/start', data: { turn: 1 } }, { type: 'user/message', data: { turn: 1, source: { rpcId: `rpc-${suffix}` } } }, ], }, }; await writeFile(join(workspace, fileName), content); const tool = createOutboundArtifactTool({ registry }); const execution = { name: OUTBOUND_ARTIFACT_TOOL, callId: `call-${suffix}`, rootCallId: `call-${suffix}`, token: Symbol(`call-${suffix}`), agent, }; await tool.definition.execute({ path: fileName }, execution); tool.onResult(execution, { isError: false }); return registry.take(sessionId, 1)[0]; } test('Telegram API validates a Bot Token without exposing it in requests or errors', async () => { assert.equal(validTelegramToken(TOKEN), true); assert.equal(validTelegramToken('short'), false); const calls = []; const bot = await inspectTelegramToken(TOKEN, { fetchImpl: async (url, options) => { calls.push({ url, options }); return jsonResponse({ ok: true, result: { id: 123456789, is_bot: true, first_name: 'Harness', username: 'HarnessBot', } }); }, }); assert.deepEqual(bot, { platformId: '123456789', name: 'Harness', username: 'HarnessBot', }); assert.equal(calls.length, 1); assert.equal(calls[0].url.protocol, 'https:'); assert.equal(calls[0].url.hostname, 'api.telegram.org'); assert.match(calls[0].url.pathname, /^\/bot/); assert.match(calls[0].url.pathname, /getMe$/); assert.equal(calls[0].options.method, 'POST'); const api = new TelegramApi({ token: TOKEN, fetchImpl: async () => jsonResponse({ ok: false, error_code: 401, description: 'Unauthorized' }, 401), }); await assert.rejects(() => api.getMe(), (error) => { assert.equal(error.code, 'telegram-401'); assert.doesNotMatch(error.message, new RegExp(TOKEN)); return true; }); }); test('Telegram API resolves and downloads files without exposing arbitrary paths', async () => { const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const calls = []; const api = new TelegramApi({ token: TOKEN, fetchImpl: async (url, options) => { calls.push({ url, options }); if (url.pathname.endsWith('/getFile')) { return jsonResponse({ ok: true, result: { file_path: 'photos/file_1.png' } }); } return new Response(png, { status: 200, headers: { 'content-length': String(png.length) } }); }, }); assert.deepEqual(await api.downloadFile({ fileId: 'AgAC_test-file', maxBytes: 100 }), png); const streamed = await api.downloadFileStream({ fileId: 'AgAC_test-file' }); const streamedChunks = []; for await (const chunk of streamed.stream) streamedChunks.push(Buffer.from(chunk)); assert.deepEqual(Buffer.concat(streamedChunks), png); assert.equal(calls.length, 4); assert.equal(calls[0].options.method, 'POST'); assert.equal(calls[1].options.method, 'GET'); assert.equal(calls[1].url.hostname, 'api.telegram.org'); assert.match(calls[1].url.pathname, /\/file\/bot.+\/photos\/file_1\.png$/); assert.equal(calls[3].options.signal, undefined); const unsafeApi = new TelegramApi({ token: TOKEN, fetchImpl: async () => jsonResponse({ ok: true, result: { file_path: '../secret' } }), }); await assert.rejects(() => unsafeApi.downloadFile({ fileId: 'AgAC_test-file' }), (error) => { assert.match(error.message, /invalid file path/); assert.doesNotMatch(error.message, new RegExp(TOKEN.replaceAll(':', '\\:'))); return true; }); }); 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.deepEqual( TELEGRAM_COMMAND_MENU.filter(({ command }) => ['presetlist', 'presets', 'preset'].includes(command)), [ { command: 'presetlist', description: '列出可用 Agent Preset' }, { command: 'presets', description: '列出可用 Agent Preset' }, { command: 'preset', description: '查看或设置新会话 Agent Preset' }, ], ); 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 command menu follows the host language at runtime', () => { assert.equal(TELEGRAM_COMMAND_MENU[0].description, '开启一个全新会话'); assert.equal(TELEGRAM_COMMAND_MENU.some(({ command }) => command === 'version'), true); setImHostLanguage('en'); try { assert.equal(telegramCommandMenu()[0].description, 'Start a brand-new Session'); } finally { setImHostLanguage('zh'); } assert.deepEqual(telegramCommandMenu(), TELEGRAM_COMMAND_MENU); }); test('Telegram API preserves the legacy plain send and edit payloads', async () => { const calls = []; const api = new TelegramApi({ token: TOKEN, fetchImpl: async (url, options) => { calls.push({ method: url.pathname.split('/').at(-1), body: JSON.parse(options.body) }); return jsonResponse({ ok: true, result: { message_id: 501 } }); }, }); await api.sendMessage({ chatId: -100123, text: 'legacy send', replyToMessageId: 44, messageThreadId: 55, }); await api.editMessageText({ chatId: -100123, messageId: 501, text: 'legacy edit', }); assert.deepEqual(calls, [{ method: 'sendMessage', body: { chat_id: -100123, text: 'legacy send', link_preview_options: { is_disabled: true }, reply_parameters: { message_id: 44, allow_sending_without_reply: true }, message_thread_id: 55, }, }, { method: 'editMessageText', body: { chat_id: -100123, message_id: 501, text: 'legacy edit', link_preview_options: { is_disabled: true }, }, }]); assert.equal(Object.hasOwn(calls[0].body, 'parse_mode'), false); assert.equal(Object.hasOwn(calls[1].body, 'parse_mode'), false); }); test('Telegram API and bot client set and clear one reaction on the source message', async () => { const calls = []; const requestAbort = new AbortController(); const api = new TelegramApi({ token: TOKEN, fetchImpl: async (url, options) => { calls.push({ method: url.pathname.split('/').at(-1), body: JSON.parse(options.body), signal: options.signal, }); return jsonResponse({ ok: true, result: true }); }, }); await api.setMessageReaction({ chatId: -100123, messageId: 44, emoji: ' 👀 ', signal: requestAbort.signal, timeoutMs: 5_000, }); await api.setMessageReaction({ chatId: -100123, messageId: 44, signal: requestAbort.signal, timeoutMs: 5_000, }); assert.deepEqual(calls.map(({ method, body }) => ({ method, body })), [{ method: 'setMessageReaction', body: { chat_id: -100123, message_id: 44, reaction: [{ type: 'emoji', emoji: '👀' }], }, }, { method: 'setMessageReaction', body: { chat_id: -100123, message_id: 44, reaction: [] }, }]); requestAbort.abort(); assert.equal(calls.every(({ signal }) => signal.aborted), true); const adapterCalls = []; const runtimeAbort = new AbortController(); const sidecarAbort = new AbortController(); const client = new TelegramBotClient({ api: { setMessageReaction: async (payload) => adapterCalls.push(payload), }, signal: runtimeAbort.signal, }); const target = { chatId: -100123, messageId: 45 }; assert.equal(await client.addReaction(target, '👍', { signal: sidecarAbort.signal }), '👍'); await client.removeReaction(target, '👍', { signal: sidecarAbort.signal }); assert.deepEqual(adapterCalls.map(({ chatId, messageId, emoji }) => ({ chatId, messageId, emoji, })), [{ chatId: -100123, messageId: 45, emoji: '👍' }, { chatId: -100123, messageId: 45, emoji: undefined, }]); sidecarAbort.abort(); assert.equal(adapterCalls.every(({ signal }) => signal.aborted), true); }); test('Telegram plain delivery keeps the 4000 boundary, reply, topic, and content', async () => { const calls = []; let nextMessageId = 600; const client = new TelegramBotClient({ api: { sendMessage: async (payload) => { calls.push(payload); return { message_id: nextMessageId++ }; }, }, }); const target = { chatId: -100123, replyToMessageId: 44, messageThreadId: 55 }; const exact = `\`\`\`js\n${'a'.repeat(3990)}\n\`\`\``; assert.equal(exact.length, 4000); await client.sendText(target, exact); assert.equal(calls.length, 1); assert.equal(calls[0].text, exact); calls.length = 0; const long = `${'中'.repeat(3998)}😀Z`; assert.equal(long.length, 4001); const receipt = await client.sendText(target, long); assert.equal(calls.length, 2); assert.equal(calls.map((call) => call.text).join(''), long); assert.equal(calls[0].replyToMessageId, 44); assert.equal(calls[1].replyToMessageId, undefined); assert.deepEqual(calls.map((call) => call.messageThreadId), [55, 55]); assert.deepEqual(receipt.providerMessageIds, ['601', '602']); }); test('Telegram API uploads a result file as a native document in the same topic and reply chain', async () => { let request; const api = new TelegramApi({ token: TOKEN, fetchImpl: async (url, options) => { request = { url, options }; return jsonResponse({ ok: true, result: { message_id: 901 } }); }, }); const result = await api.sendDocument({ chatId: -100123, replyToMessageId: 44, messageThreadId: 55, file: { fileName: 'result.txt', mediaType: 'text/plain', bytes: Buffer.from('telegram-result'), }, }); assert.equal(result.message_id, 901); assert.match(request.url.pathname, /sendDocument$/); assert.equal(request.options.method, 'POST'); assert.equal(request.options.headers, undefined); assert.ok(request.options.body instanceof FormData); assert.equal(request.options.body.get('chat_id'), '-100123'); assert.equal(request.options.body.get('message_thread_id'), '55'); assert.deepEqual(JSON.parse(request.options.body.get('reply_parameters')), { message_id: 44, allow_sending_without_reply: true, }); const document = request.options.body.get('document'); assert.equal(document.name, 'result.txt'); assert.equal(document.type, 'text/plain'); assert.equal(Buffer.from(await document.arrayBuffer()).toString(), 'telegram-result'); }); test('Telegram API uploads a result image as a native photo in the same topic and reply chain', async () => { let request; const api = new TelegramApi({ token: TOKEN, fetchImpl: async (url, options) => { request = { url, options }; return jsonResponse({ ok: true, result: { message_id: 902 } }); }, }); const result = await api.sendPhoto({ chatId: -100123, replyToMessageId: 44, messageThreadId: 55, file: { fileName: 'result.png', mediaType: 'image/png', bytes: Buffer.from('telegram-image'), }, }); assert.equal(result.message_id, 902); assert.match(request.url.pathname, /sendPhoto$/); assert.equal(request.options.method, 'POST'); assert.equal(request.options.headers, undefined); assert.ok(request.options.body instanceof FormData); assert.equal(request.options.body.get('chat_id'), '-100123'); assert.equal(request.options.body.get('message_thread_id'), '55'); assert.deepEqual(JSON.parse(request.options.body.get('reply_parameters')), { message_id: 44, allow_sending_without_reply: true, }); const photo = request.options.body.get('photo'); assert.equal(photo.name, 'result.png'); assert.equal(photo.type, 'image/png'); assert.equal(Buffer.from(await photo.arrayBuffer()).toString(), 'telegram-image'); }); test('Telegram bot client routes images through sendPhoto with the existing reply context', async () => { let request; const controller = new AbortController(); const client = new TelegramBotClient({ api: { sendPhoto: async (value) => { request = value; return { message_id: 903 }; }, }, signal: controller.signal, }); const file = { fileName: 'result.png', mediaType: 'image/png', bytes: Buffer.from('telegram-image'), }; assert.deepEqual(await client.sendImage({ chatId: -100456, replyToMessageId: 66, messageThreadId: 77, }, file), { message_id: 903 }); assert.equal(request.chatId, -100456); assert.equal(request.file, file); assert.equal(request.replyToMessageId, 66); assert.equal(request.messageThreadId, 77); assert.equal(request.signal, controller.signal); }); test('Telegram photo delivery reuses stable artifact error mapping', async () => { const rejectedApi = new TelegramApi({ token: TOKEN, fetchImpl: async () => jsonResponse({ ok: false, error_code: 400, description: 'Bad Request: unsupported photo', }, 400), }); await assert.rejects(() => rejectedApi.sendPhoto({ chatId: 123, file: { fileName: 'result.webp', bytes: Buffer.from('result') }, }), (error) => error.code === 'artifact-provider-rejected' && error.providerCode === 400 && error.status === 400); const uncertainApi = new TelegramApi({ token: TOKEN, fetchImpl: async () => new Response('not-json', { status: 200 }), }); await assert.rejects(() => uncertainApi.sendPhoto({ chatId: 123, file: { fileName: 'result.png', bytes: Buffer.from('result') }, }), (error) => error.code === 'artifact-delivery-uncertain'); }); test('Telegram document errors retain provider details and use stable artifact reasons', async () => { const cases = [{ body: { ok: false, error_code: 403, description: 'Forbidden: bot was blocked' }, status: 403, code: 'artifact-permission-required', }, { body: { ok: false, error_code: 400, description: 'Bad Request: file is too big' }, status: 400, code: 'artifact-too-large', }, { body: { ok: false, error_code: 429, description: 'Too Many Requests', parameters: { retry_after: 7 }, }, status: 429, code: 'artifact-rate-limited', retryAfter: 7, }, { body: { ok: false, error_code: 400, description: 'Bad Request: unsupported document' }, status: 400, code: 'artifact-provider-rejected', }, { body: { ok: false, error_code: 500, description: 'Internal Server Error' }, status: 500, code: 'artifact-delivery-uncertain', }]; for (const entry of cases) { const api = new TelegramApi({ token: TOKEN, fetchImpl: async () => jsonResponse(entry.body, entry.status), }); await assert.rejects(() => api.sendDocument({ chatId: 123, file: { fileName: 'result.bin', bytes: Buffer.from('result') }, }), (error) => { assert.equal(error.code, entry.code); assert.equal(error.providerCode, entry.body.error_code); assert.equal(error.status, entry.status); assert.equal(error.retry_after, entry.retryAfter); assert.equal(error.retryAfter, entry.retryAfter); return true; }); } }); test('Telegram document delivery marks post-dispatch failures uncertain but preserves caller aborts', async () => { for (const fetchImpl of [ async () => { throw new TypeError('socket reset'); }, async () => new Response('not-json', { status: 200 }), ]) { const api = new TelegramApi({ token: TOKEN, fetchImpl }); await assert.rejects(() => api.sendDocument({ chatId: 123, file: { fileName: 'result.bin', bytes: Buffer.from('result') }, }), (error) => error.code === 'artifact-delivery-uncertain'); } const timeoutApi = new TelegramApi({ token: TOKEN, fileUploadTimeoutMs: 10, fetchImpl: async (_url, { signal }) => new Promise((resolve, reject) => { if (signal.aborted) reject(signal.reason); else signal.addEventListener('abort', () => reject(signal.reason), { once: true }); }), }); await assert.rejects(() => timeoutApi.sendDocument({ chatId: 123, file: { fileName: 'result.bin', bytes: Buffer.from('result') }, }), (error) => error.code === 'artifact-delivery-uncertain' && error.cause?.name === 'TimeoutError'); const caller = new AbortController(); const reason = new DOMException('caller stopped', 'AbortError'); caller.abort(reason); let calls = 0; const cancelledApi = new TelegramApi({ token: TOKEN, fetchImpl: async () => { calls += 1; return jsonResponse({ ok: true, result: {} }); }, }); await assert.rejects(() => cancelledApi.sendDocument({ chatId: 123, file: { fileName: 'result.bin', bytes: Buffer.from('result') }, signal: caller.signal, }), (error) => error === reason && error.code !== 'artifact-delivery-uncertain'); assert.equal(calls, 0); }); 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 })); const configPath = join(directory, 'config.json'); const configStore = await new TelegramConfigStore(configPath).load(); const credentialStore = credentials(); const runtimes = []; const connectionTests = []; const proactiveSends = []; const controller = new TelegramController({ credentials: credentialStore, configStore, inspectToken: async () => ({ platformId: '123456789', name: 'Harness Telegram', username: 'harness_bot', }), createRuntime: async () => { const runtime = { status: { ready: true, connectionState: 'connected', harnessReachable: true, lastCheckedAt: 10, }, async start() {}, async stop() {}, async sendConnectionTest(text) { connectionTests.push(text); }, async sendProactiveText(...args) { proactiveSends.push(args); return { sent: true }; }, }; runtimes.push(runtime); return runtime; }, }); const status = await controller.bindCredentials({ token: TOKEN }); assert.equal(status.totals.connected, 1); assert.equal(status.bots[0].bot.name, 'Harness Telegram'); assert.equal(status.bots[0].bot.username, 'harness_bot'); assert.deepEqual(status.bots[0].accessPolicy, { accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [], }); const identity = deriveTelegramBotIdentity('123456789'); assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN); const persisted = await readFile(configPath, 'utf8'); assert.doesNotMatch(persisted, new RegExp(TOKEN)); assert.match(persisted, new RegExp(identity.tokenRef)); assert.doesNotMatch(persisted, /accessMode|allowedUsers/); await controller.reconnectBot(identity.botId); assert.equal(runtimes.length, 2); await controller.sendConnectionTest(identity.botId); assert.match(connectionTests[0], /Harness Telegram/); assert.match(connectionTests[0], /123•••/); const target = { kind: 'chat', route: { chatId: '123456' } }; assert.deepEqual(await controller.sendProactiveText(identity.botId, target, 'proactive-test'), { sent: true, }); assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]); await controller.deleteBot(identity.botId); assert.equal(credentialStore.values.has(identity.tokenRef), false); assert.equal(controller.status().totals.configured, 0); }); test('Telegram loads legacy bots without an access policy as compatible mode', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-legacy-')); t.after(() => rm(directory, { recursive: true, force: true })); const configPath = join(directory, 'config.json'); const identity = deriveTelegramBotIdentity('123456789'); await writeFile(configPath, `${JSON.stringify({ version: 1, bots: [{ ...identity, platformId: '123456789', name: 'Legacy Telegram', username: 'legacy_bot', createdAt: '2026-01-01T00:00:00.000Z', connectedAt: '2026-01-01T00:00:00.000Z', }], }, null, 2)}\n`); const store = await new TelegramConfigStore(configPath).load(); const saved = store.get(identity.botId); assert.equal(saved.accessMode, undefined); assert.equal(saved.allowedUsers, undefined); assert.deepEqual(normalizeTelegramAccessPolicy(saved), { accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [], }); }); test('Telegram access policy persists per bot, switches freely, and restarts only that bot', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-policy-')); t.after(() => rm(directory, { recursive: true, force: true })); const configPath = join(directory, 'config.json'); const configStore = await new TelegramConfigStore(configPath).load(); const runtimeRecords = []; let inspected = 0; const controller = new TelegramController({ credentials: credentials(), configStore, inspectToken: async () => { inspected += 1; return { platformId: inspected === 1 ? '111111111' : inspected === 2 ? '222222222' : '111111111', name: inspected === 2 ? 'Bot B' : 'Bot A', username: inspected === 2 ? 'bot_b' : 'bot_a', }; }, createRuntime: async ({ botId, config }) => { const record = { botId, config: structuredClone(config), starts: 0, stops: 0 }; runtimeRecords.push(record); return { status: { ready: true, connectionState: 'connected', harnessReachable: true, lastCheckedAt: 10, }, async start() { record.starts += 1; }, async stop() { record.stops += 1; }, }; }, }); await controller.bindCredentials({ token: TOKEN }); await controller.bindCredentials({ token: '222222222:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef123456' }); const botA = deriveTelegramBotIdentity('111111111').botId; const botB = deriveTelegramBotIdentity('222222222').botId; assert.deepEqual(controller.status().bots.map((bot) => bot.accessPolicy), [ { accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [] }, { accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [] }, ]); await controller.setAccessPolicy(botA, { accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['6087707998', '1202499116'], }); assert.equal(runtimeRecords.filter((record) => record.botId === botA).length, 2); assert.equal(runtimeRecords.filter((record) => record.botId === botB).length, 1); assert.equal(runtimeRecords.find((record) => record.botId === botA).stops, 1); assert.equal(runtimeRecords.find((record) => record.botId === botB).stops, 0); assert.deepEqual(controller.status().bots.find((bot) => bot.botId === botA).accessPolicy, { accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['6087707998', '1202499116'], }); assert.deepEqual(controller.status().bots.find((bot) => bot.botId === botB).accessPolicy, { accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [], }); await controller.setAccessPolicy(botA, { accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: ['6087707998', '1202499116'], }); assert.equal(configStore.get(botA).accessMode, TELEGRAM_ACCESS_MODES.compatible); assert.deepEqual(configStore.get(botA).allowedUsers, ['6087707998', '1202499116']); await controller.setAccessPolicy(botA, { accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['6087707998', '1202499116'], }); assert.deepEqual(controller.status().bots.find((bot) => bot.botId === botA).accessPolicy, { accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['6087707998', '1202499116'], }); await controller.bindCredentials({ token: TOKEN }); assert.deepEqual(configStore.get(botA).allowedUsers, ['6087707998', '1202499116']); assert.equal(configStore.get(botA).accessMode, TELEGRAM_ACCESS_MODES.privateAllowlist); const reloaded = await new TelegramConfigStore(configPath).load(); assert.deepEqual(reloaded.get(botA).allowedUsers, ['6087707998', '1202499116']); assert.equal(reloaded.get(botB).accessMode, undefined); await controller.close(); }); test('Telegram access policy rejects invalid modes and user IDs', () => { assert.throws(() => normalizeTelegramAccessPolicy({ accessMode: 'allow-everything', allowedUsers: [], }), /accessMode/); assert.throws(() => normalizeTelegramAccessPolicy({ accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['0', '-1001', '@username'], }), /invalid Telegram User ID/); }); test('Telegram policy update is serialized with deletion and cannot restore a deleted bot', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-policy-delete-')); t.after(() => rm(directory, { recursive: true, force: true })); const configStore = await new TelegramConfigStore(join(directory, 'config.json')).load(); const credentialStore = credentials(); const unsetStarted = deferred(); const releaseUnset = deferred(); const unset = credentialStore.unset; credentialStore.unset = async (ref) => { unsetStarted.resolve(); await releaseUnset.promise; return unset(ref); }; const controller = new TelegramController({ credentials: credentialStore, configStore, inspectToken: async () => ({ platformId: '123456789', name: 'Harness Telegram', username: 'harness_bot', }), createRuntime: async () => ({ status: { ready: true, connectionState: 'connected', harnessReachable: true }, async start() {}, async stop() {}, }), }); await controller.bindCredentials({ token: TOKEN }); const botId = deriveTelegramBotIdentity('123456789').botId; const deletion = controller.deleteBot(botId); await unsetStarted.promise; const policyUpdate = controller.setAccessPolicy(botId, { accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['6087707998'], }); releaseUnset.resolve(); await deletion; await assert.rejects(policyUpdate, /Unknown Telegram bot/); assert.equal(configStore.get(botId), null); assert.equal(credentialStore.values.size, 0); assert.equal(controller.status().totals.configured, 0); }); test('Telegram queued policy update cannot persist after controller close begins', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-policy-close-')); t.after(() => rm(directory, { recursive: true, force: true })); const configStore = await new TelegramConfigStore(join(directory, 'config.json')).load(); const reconnectStarted = deferred(); const releaseReconnect = deferred(); let runtimeCount = 0; const controller = new TelegramController({ credentials: credentials(), configStore, inspectToken: async () => ({ platformId: '123456789', name: 'Harness Telegram', username: 'harness_bot', }), createRuntime: async () => { runtimeCount += 1; const current = runtimeCount; return { status: { ready: true, connectionState: 'connected', harnessReachable: true }, async start() { if (current === 2) { reconnectStarted.resolve(); await releaseReconnect.promise; } }, async stop() {}, }; }, }); await controller.bindCredentials({ token: TOKEN }); const botId = deriveTelegramBotIdentity('123456789').botId; const reconnect = controller.reconnectBot(botId); await reconnectStarted.promise; const policyUpdate = controller.setAccessPolicy(botId, { accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['6087707998'], }); const rejectedPolicy = assert.rejects(policyUpdate, /controller is closed/); const closing = controller.close(); releaseReconnect.resolve(); await reconnect; await rejectedPolicy; await closing; assert.equal(configStore.get(botId).accessMode, undefined); assert.equal(configStore.get(botId).allowedUsers, undefined); }); test('Telegram RPC accepts only token binding and strips credential internals', async () => { const calls = []; const connectionTests = []; const controller = { status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }), bindCredentials: async (payload) => { calls.push(payload); return { bots: [{ botId: 'telegram_123', tokenRef: 'DSH_TELEGRAM_BOT_TOKEN_ABC', token: TOKEN, bot: { name: 'Telegram机器人', idMasked: '123•••' }, }], totals: { configured: 1, connected: 0 }, }; }, reconnectBot: async (botId) => ({ bots: [{ botId, connected: true }], totals: { configured: 1, connected: 1 }, }), sendConnectionTest: async (botId) => { connectionTests.push(botId); }, deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }), setAccessPolicy: async (botId, policy) => { calls.push({ botId, policy }); return { bots: [{ botId, accessPolicy: policy }], totals: { configured: 1, connected: 0 }, }; }, }; const handler = createTelegramRpcHandler(controller); const result = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN }); assert.equal(result.ok, true); assert.deepEqual(calls, [{ token: TOKEN }]); assert.equal(result.value.bots[0].token, undefined); assert.equal(result.value.bots[0].tokenRef, undefined); const rejected = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN, extra: true }); assert.equal(rejected.ok, false); assert.equal(rejected.error.code, 'bad-request'); const legacyReconnect = await handler(TELEGRAM_ENDPOINTS.reconnectBot, { botId: 'telegram_123', }); assert.equal(legacyReconnect.ok, true); assert.equal('testMessage' in legacyReconnect.value, false); assert.deepEqual(connectionTests, []); const tested = await handler(TELEGRAM_ENDPOINTS.reconnectBot, { botId: 'telegram_123', sendTest: true, }); assert.equal(tested.ok, true); assert.deepEqual(tested.value.testMessage, { sent: true }); assert.deepEqual(connectionTests, ['telegram_123']); controller.sendConnectionTest = async () => { const error = new Error('No explicit recipient'); error.code = 'test-target-unavailable'; throw error; }; const missingTarget = await handler(TELEGRAM_ENDPOINTS.reconnectBot, { botId: 'telegram_123', sendTest: true, }); assert.equal(missingTarget.ok, true); assert.deepEqual(missingTarget.value.testMessage, { sent: false, code: 'test-target-unavailable', }); const access = await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, { botId: 'telegram_123', accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['6087707998', '6087707998'], }); assert.equal(access.ok, true); assert.deepEqual(calls.at(-1), { botId: 'telegram_123', policy: { accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['6087707998'], }, }); assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, { botId: 'telegram_123', accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['@username'], })).error.code, 'bad-request'); assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, { botId: 'telegram_123', accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [], extra: true, })).error.code, 'bad-request'); }); test('shared token RPC never sends a connection test after reconnect is cancelled', async () => { let resolveReconnect; let sendCalls = 0; const reconnect = new Promise((resolve) => { resolveReconnect = resolve; }); const controller = { status: async () => ({ bots: [] }), bindCredentials: async () => ({ bots: [] }), reconnectBot: async () => reconnect, sendConnectionTest: async () => { sendCalls += 1; }, deleteBot: async () => ({ bots: [] }), setAccessPolicy: async () => ({ bots: [] }), }; const abort = new AbortController(); const result = createTelegramRpcHandler(controller)(TELEGRAM_ENDPOINTS.reconnectBot, { botId: 'telegram_123', sendTest: true, }, abort.signal); abort.abort(); resolveReconnect({ bots: [{ botId: 'telegram_123', connected: true }] }); assert.deepEqual(await result, { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' }, }); assert.equal(sendCalls, 0); }); test('Telegram normalizes private messages and requires an explicit group address', () => { const privateMessage = normalizeTelegramUpdate({ update_id: 10, message: { message_id: 4, chat: { id: 88, type: 'private' }, from: { id: 42, is_bot: false }, text: 'hello', }, }, { botId: '123456789', username: 'HarnessBot' }); assert.equal(privateMessage.kind, 'direct'); assert.equal(privateMessage.addressed, true); assert.equal(privateMessage.replyTarget.chatType, 'private'); assert.deepEqual(privateMessage.reactionTarget, { chatId: 88, messageId: 4 }); assert.deepEqual(privateMessage.connectionTestTarget, { chatId: 88, messageThreadId: undefined }); const groupMessage = normalizeTelegramUpdate({ update_id: 11, message: { message_id: 5, chat: { id: -1001, type: 'supergroup' }, from: { id: 43, is_bot: false }, text: '@HarnessBot run this', entities: [{ type: 'mention', offset: 0, length: 11 }], }, }, { botId: '123456789', username: 'HarnessBot' }); assert.equal(groupMessage.kind, 'group'); assert.equal(groupMessage.addressed, true); assert.equal(groupMessage.content, 'run this'); assert.equal(groupMessage.replyTarget.chatType, 'supergroup'); const topicOne = normalizeTelegramUpdate({ update_id: 12, message: { message_id: 6, message_thread_id: 100, chat: { id: -1001, type: 'supergroup' }, from: { id: 43, is_bot: false }, text: '@HarnessBot first topic', entities: [{ type: 'mention', offset: 0, length: 11 }], }, }, { botId: '123456789', username: 'HarnessBot' }); const topicTwo = normalizeTelegramUpdate({ update_id: 13, message: { message_id: 7, message_thread_id: 200, chat: { id: -1001, type: 'supergroup' }, from: { id: 43, is_bot: false }, text: '@HarnessBot second topic', entities: [{ type: 'mention', offset: 0, length: 11 }], }, }, { botId: '123456789', username: 'HarnessBot' }); assert.equal(topicOne.conversationId, '-1001:100'); assert.equal(topicTwo.conversationId, '-1001:200'); assert.notEqual(topicOne.conversationId, topicTwo.conversationId); assert.equal(topicOne.replyTarget.messageThreadId, 100); assert.equal(topicTwo.replyTarget.messageThreadId, 200); assert.deepEqual(topicOne.reactionTarget, { chatId: -1001, messageId: 6 }); assert.deepEqual(topicTwo.reactionTarget, { chatId: -1001, messageId: 7 }); }); test('Telegram compatible mode preserves old routing and private allowlist mode restricts inbound messages', () => { const allowed = new Set(['6087707998', '1202499116']); assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }), true); assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }), true); const policy = { accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedPrivateUserIds: allowed, }; assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }, policy), false); assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, policy), true); assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }, policy), false); assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, { ...policy, allowedPrivateUserIds: new Set(), }), false); }); test('Telegram keeps native photos and image documents as images and exposes ordinary documents as files', async () => { const loads = []; const groupPhoto = normalizeTelegramUpdate({ update_id: 20, message: { message_id: 10, chat: { id: -1001, type: 'supergroup' }, from: { id: 43, is_bot: false }, caption: '@HarnessBot 看看这张图', caption_entities: [{ type: 'mention', offset: 0, length: 11 }], photo: [ { file_id: 'small', file_unique_id: 'photo', width: 90, height: 90, file_size: 500 }, { file_id: 'large', file_unique_id: 'photo', width: 1280, height: 720, file_size: 2_000 }, ], }, }, { botId: '123456789', username: 'HarnessBot', loadFile: async (fileId, options) => { loads.push({ fileId, options }); return Buffer.from('image'); }, }); assert.equal(groupPhoto.addressed, true); assert.equal(groupPhoto.content, '看看这张图'); assert.equal(groupPhoto.images.length, 1); assert.equal(groupPhoto.images[0].size, 2_000); await groupPhoto.images[0].load({ maxBytes: 5_000 }); assert.equal(loads[0].fileId, 'large'); const document = normalizeTelegramUpdate({ update_id: 21, message: { message_id: 11, chat: { id: 88, type: 'private' }, from: { id: 42, is_bot: false }, document: { file_id: 'png-document', file_name: 'diagram.png', mime_type: 'image/png', file_size: 3_000, }, }, }, { botId: '123456789', username: 'HarnessBot', loadFile: async (fileId, options) => { loads.push({ fileId, options }); return Buffer.from('document'); }, }); assert.equal(document.images[0].name, 'diagram.png'); assert.equal(document.images[0].mediaType, 'image/png'); assert.equal(document.images[0].size, 3_000); assert.deepEqual(document.files, []); await document.images[0].load({ maxBytes: 5_000 }); assert.deepEqual(loads[1], { fileId: 'png-document', options: { maxBytes: 5_000 }, }); const documentWithoutMime = normalizeTelegramUpdate({ update_id: 23, message: { message_id: 13, chat: { id: 88, type: 'private' }, from: { id: 42, is_bot: false }, document: { file_id: 'webp-document', file_name: 'diagram.webp', file_size: 2_000 }, }, }, { botId: '123456789', username: 'HarnessBot' }); assert.equal(documentWithoutMime.images[0].name, 'diagram.webp'); assert.equal(documentWithoutMime.images[0].mediaType, 'image/webp'); assert.deepEqual(documentWithoutMime.files, []); const fileLoads = []; const pdf = normalizeTelegramUpdate({ update_id: 22, message: { message_id: 12, chat: { id: 88, type: 'private' }, from: { id: 42, is_bot: false }, document: { file_id: 'pdf-document', file_name: 'file.pdf', mime_type: 'application/pdf' }, }, }, { botId: '123456789', username: 'HarnessBot', loadFileStream: async (fileId, options) => { fileLoads.push({ fileId, options }); return { stream: (async function* content() { yield Buffer.from('pdf'); }()) }; }, }); assert.deepEqual(pdf.images, []); assert.equal(pdf.files[0].name, 'file.pdf'); assert.equal(pdf.files[0].mediaType, 'application/pdf'); const controller = new AbortController(); const loadedPdf = await pdf.files[0].load({ signal: controller.signal }); const pdfChunks = []; for await (const chunk of loadedPdf.stream) pdfChunks.push(Buffer.from(chunk)); assert.equal(Buffer.concat(pdfChunks).toString(), 'pdf'); assert.deepEqual(fileLoads, [{ fileId: 'pdf-document', options: { signal: controller.signal }, }]); }); test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => { const sent = []; const sentTargets = []; const updates = []; const bot = { sendText: async (target, text) => { sentTargets.push(target); sent.push(text); }, sendTyping: async () => {}, openStream: async () => ({ update: async (text) => updates.push(text), finish: async (text) => sent.push(text), }), }; let askCount = 0; const harness = { ensureRunning: async () => true, sessionExists: async () => true, createSession: async () => 'session-1', ask: async (_session, _text, options) => { askCount += 1; await options.onUpdate({ type: 'tool', name: '搜索' }); await options.onUpdate({ type: 'text', text: '处理中' }); return '完成'; }, }; const state = memoryState(); const bridge = new TelegramHarnessBridge({ bot, harness, state }); await bridge.accept({ messageId: '1', senderId: 'u1', kind: 'group', conversationId: 'g1', content: 'ignored', addressed: false, replyTarget: {}, }); assert.equal(askCount, 0); await bridge.accept({ messageId: '2', senderId: 'u1', kind: 'direct', conversationId: 'u1', content: 'hello', addressed: true, replyTarget: { chatId: 88, replyToMessageId: 7 }, connectionTestTarget: { chatId: 88 }, }); assert.equal(askCount, 1); assert.deepEqual(updates, ['正在使用搜索…', '处理中']); assert.deepEqual(sent, ['完成']); await bridge.sendConnectionTest('card test'); assert.equal(sent.at(-1), 'card test'); assert.deepEqual(sentTargets.at(-1), { chatId: 88 }); const reconnectedBridge = new TelegramHarnessBridge({ bot, harness, state }); await reconnectedBridge.sendConnectionTest('after reconnect'); assert.equal(sent.at(-1), 'after reconnect'); assert.deepEqual(sentTargets.at(-1), { chatId: 88 }); }); test('Telegram bridge routes outbound image artifacts natively with safe file fallback', async (t) => { const scenarios = [{ name: 'native image', suffix: 'native-image', fileName: 'result.png', content: Buffer.from([1, 2, 3]), expectedCalls: ['image:result.png:image/png'], expectedOutcome: 'sent', }, { name: 'ordinary file', suffix: 'ordinary-file', fileName: 'result.txt', content: 'ordinary file', expectedCalls: ['file:result.txt:text/plain'], expectedOutcome: 'sent', }, { name: 'definitive image rejection', suffix: 'image-fallback', fileName: 'result.webp', content: Buffer.from([4, 5, 6]), imageErrorCode: 'artifact-provider-rejected', expectedCalls: ['image:result.webp:image/webp', 'file:result.webp:image/webp'], expectedOutcome: 'sent', }, { name: 'uncertain image result', suffix: 'image-uncertain', fileName: 'result.gif', content: Buffer.from([7, 8, 9]), imageErrorCode: 'artifact-delivery-uncertain', expectedCalls: ['image:result.gif:image/gif'], expectedOutcome: 'unknown', }]; for (const scenario of scenarios) { const artifact = await committedTelegramArtifact(t, scenario); const calls = []; const bot = { sendText: async () => ({ message_id: `text-${scenario.suffix}` }), sendImage: async (_target, file) => { calls.push(`image:${file.fileName}:${file.mediaType}`); if (scenario.imageErrorCode) { const error = new Error(scenario.imageErrorCode); error.code = scenario.imageErrorCode; throw error; } return { message_id: `image-${scenario.suffix}` }; }, sendFile: async (_target, file) => { calls.push(`file:${file.fileName}:${file.mediaType}`); return { message_id: `file-${scenario.suffix}` }; }, }; const bridge = new TelegramHarnessBridge({ bot, state: memoryState(), logger: { warn() {}, error() {} }, harness: { createSession: async () => `session-${scenario.suffix}`, ask: async (_sessionId, _text, options) => { await options.onArtifact(artifact); return '图片已生成。'; }, }, }); const receipt = await bridge.accept({ messageId: `telegram-artifact-${scenario.suffix}`, senderId: '42', kind: 'direct', conversationId: `telegram-artifact-${scenario.suffix}`, content: '生成结果', addressed: true, replyTarget: { chatId: 88, replyToMessageId: 7, messageThreadId: 9 }, }); assert.deepEqual(calls, scenario.expectedCalls, scenario.name); assert.equal(receipt.artifacts[0].outcome, scenario.expectedOutcome, scenario.name); } }); test('Telegram runtime validates webhook state and starts a cancellable long poll', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-runtime-')); const state = await new TelegramStateStore(join(directory, 'state.json')).load(); const calls = []; 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({ method: 'getUpdates', offset, timeout }); if (timeout === 0) return []; return new Promise((resolve, reject) => signal.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')); }, { once: true })); }, sendMessage: async (request) => { calls.push({ method: 'sendMessage', ...request }); return { message_id: 900 }; }, }; const runtime = new TelegramRuntime({ config: { botId: 'telegram_test', platformId: '123456789', username: 'HarnessBot', }, token: TOKEN, harness: { ensureRunning: async () => true }, state, createApi: () => fakeApi, }); await runtime.start(); assert.equal(runtime.status.ready, true); assert.equal(runtime.status.connectionState, 'connected'); assert.deepEqual(await runtime.sendProactiveText({ kind: 'topic', route: { chatId: '-1001234567890', messageThreadId: 42 }, }, 'proactive-test'), { providerMessageIds: ['900'] }); const proactiveCall = calls.find(({ method }) => method === 'sendMessage'); assert.equal(proactiveCall.chatId, -1001234567890); assert.equal(proactiveCall.messageThreadId, 42); assert.equal(proactiveCall.replyToMessageId, undefined); assert.equal(proactiveCall.text, 'proactive-test'); await runtime.stop(); assert.equal(runtime.status.ready, false); 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(); const asked = []; let delivered = false; let nextMessageId = 500; const updates = [ { update_id: 0, message: { message_id: 100, chat: { id: -1001, type: 'group' }, from: { id: 7, is_bot: false }, text: '@HarnessBot group', entities: [{ type: 'mention', offset: 0, length: 11 }], }, }, { update_id: 1, message: { message_id: 101, chat: { id: 7, type: 'private' }, from: { id: 7, is_bot: false }, text: 'allowed direct', }, }, { update_id: 2, message: { message_id: 102, chat: { id: 8, type: 'private' }, from: { id: 8, is_bot: false }, text: 'denied direct', }, }, ]; const fakeApi = { getMe: async () => ({ id: 123456789, is_bot: true }), getWebhookInfo: async () => ({ url: '' }), setMyCommands: async () => true, setChatMenuButton: async () => true, getUpdates: async ({ timeout, signal }) => { if (timeout === 0) return []; if (!delivered) { delivered = true; return updates; } return new Promise((resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }); }); }, sendChatAction: async () => true, sendRichMessageDraft: async () => true, sendRichMessage: async () => ({ message_id: nextMessageId++ }), sendMessage: async () => ({ message_id: nextMessageId++ }), editMessageText: async () => true, }; const runtime = new TelegramRuntime({ config: { botId: 'telegram_allowlist', platformId: '123456789', username: 'HarnessBot', accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist, allowedUsers: ['7'], }, token: TOKEN, harness: { ensureRunning: async () => true, createSession: async () => 'session-allowlist', ask: async (_sessionId, text) => { asked.push(text); return 'done'; }, }, state, createApi: () => fakeApi, }); try { await runtime.start(); await bounded((async () => { while (state.cursor() !== 3 || asked.length !== 1) { await new Promise((resolve) => setTimeout(resolve, 5)); } })(), 'Telegram safe-mode updates were not processed'); assert.deepEqual(asked, ['allowed direct']); assert.equal(runtime.status.messagesRejected, 2); } finally { await runtime.stop(); await rm(directory, { recursive: true, force: true }); } }); for (const scenario of [ { name: 'two private messages', kinds: ['private', 'private'], before: [true, true], after: [false, false] }, { name: 'mixed private/group messages', kinds: ['private', 'supergroup'], before: [false, true], after: [true, false] }, { name: 'disabled private messages before enabling', kinds: ['private', 'private'], before: [false, false], after: [true, true] }, ]) { test(`Telegram received poll batch retains its settings across cursor writes: ${scenario.name}`, async () => { const firstCursorStarted = deferred(); const releaseFirstCursor = deferred(); const allPrompts = deferred(); const seen = new Set(); const asked = []; const pollOffsets = []; const cursorWrites = []; let cursor = 10; let settingsReads = 0; let nextMessageId = 500; const configFor = ([directEnabled, groupEnabled], guidance) => ({ group: { enabled: groupEnabled, fields: ['channel', 'botId'], guidance }, direct: { enabled: directEnabled, fields: ['channel', 'botId'], guidance }, }); let config = configFor(scenario.before, 'before cursor write'); const update = (id, kind) => ({ update_id: id, message: { message_id: id + 100, chat: { id: kind === 'private' ? 42 : -1001, type: kind }, from: { id: 7, is_bot: false, first_name: 'Ada' }, text: kind === 'private' ? `message ${id}` : `@HarnessBot message ${id}`, entities: kind === 'private' ? [] : [{ type: 'mention', offset: 0, length: 11 }], }, }); const firstBatch = scenario.kinds.map((kind, index) => update(10 + index, kind)); const nextBatch = ['private', 'supergroup'].map((kind, index) => update(12 + index, kind)); const runtime = new TelegramRuntime({ config: { botId: 'telegram_poll', platformId: '123456789', username: 'HarnessBot' }, token: TOKEN, contextEnhancement: { botId: 'telegram_poll', getSettings: () => { settingsReads += 1; return config; }, }, state: { cursor: () => cursor, setCursor: async (value) => { cursorWrites.push(value); if (value === 11) { firstCursorStarted.resolve(); await releaseFirstCursor.promise; } cursor = value; }, hasSeen: (id) => seen.has(id), markSeen: async (id) => seen.add(id), sessionFor: () => 'session-existing', }, harness: { ensureRunning: async () => true, sessionExists: async () => true, ask: async (_sessionId, text) => { asked.push(text); if (asked.length === 4) allPrompts.resolve(); return 'done'; }, }, createApi: () => ({ getMe: async () => ({ id: 123456789, is_bot: true }), getWebhookInfo: async () => ({ url: '' }), setMyCommands: async () => true, setChatMenuButton: async () => true, getUpdates: async ({ offset, signal }) => { pollOffsets.push(offset); if (offset === 10) return firstBatch; if (offset === 12) return nextBatch; assert.equal(offset, 14); return new Promise((_, reject) => { if (signal.aborted) return reject(signal.reason); signal.addEventListener('abort', () => reject(signal.reason), { once: true }); }); }, setMessageReaction: async () => true, sendChatAction: async () => true, sendRichMessageDraft: async () => true, sendRichMessage: async () => ({ message_id: nextMessageId++ }), sendMessage: async () => ({ message_id: nextMessageId++ }), editMessageText: async () => true, }), logger: { warn() {}, error() {} }, }); try { await runtime.start(); await bounded(firstCursorStarted.promise, 'first cursor write was not reached', 5_000); assert.equal(settingsReads, 2, 'every received update captures its settings before the first cursor await'); config = configFor(scenario.after, 'after cursor write'); releaseFirstCursor.resolve(); await bounded(allPrompts.promise, 'both poll batches did not reach Harness', 5_000); for (const [index, event] of [...firstBatch, ...nextBatch].entries()) { const expectedText = `message ${event.update_id}`; const content = asked.find((text) => text.endsWith(expectedText)); assert.ok(content, `Missing prompt ${event.update_id}`); const switches = index < 2 ? scenario.before : scenario.after; const enabled = switches[event.message.chat.type === 'private' ? 0 : 1]; if (enabled) { assert.match(content, index < 2 ? /before cursor write/ : /after cursor write/); assert.deepEqual(JSON.parse(/^(.*?)<\/dsh_im_source>/su.exec(content)[1]), { channel: 'telegram', botId: 'telegram_poll', }); } else { assert.equal(content, expectedText); } } assert.equal(settingsReads, 4, 'Bridge reuses the snapshot instead of reading settings again'); assert.deepEqual(pollOffsets, [10, 12, 14]); assert.deepEqual(cursorWrites, [11, 12, 13, 14]); assert.deepEqual([...seen].sort(), ['10', '11', '12', '13']); } finally { releaseFirstCursor.resolve(); await runtime.stop(); } }); } test('Telegram runtime keeps polling while a Harness question waits for its answer', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-interaction-')); const state = await new TelegramStateStore(join(directory, 'state.json')).load(); const questionSent = deferred(); const secondPollStarted = deferred(); const answerSubmitted = deferred(); const releaseTurn = deferred(); const finalReplySent = deferred(); const pollOffsets = []; const asked = []; let answerUpdateDelivered = false; let originalTurnEnded = false; let nextOutboundMessageId = 500; const promptUpdate = { update_id: 10, message: { message_id: 100, chat: { id: 42, type: 'private' }, from: { id: 7, is_bot: false }, text: '请先询问测试环境', }, }; const answerUpdate = { update_id: 11, message: { message_id: 101, chat: { id: 42, type: 'private' }, from: { id: 7, is_bot: false }, text: '2', }, }; const fakeApi = { getMe: async () => ({ id: 123456789, is_bot: true }), getWebhookInfo: async () => ({ url: '' }), getUpdates: async ({ offset, timeout, signal }) => { pollOffsets.push(offset); if (timeout === 0) return []; if (offset === 0) return [promptUpdate]; if (offset === 11) { secondPollStarted.resolve(originalTurnEnded); await questionSent.promise; answerUpdateDelivered = true; return [answerUpdate]; } assert.equal(offset, 12); return new Promise((resolve, reject) => { if (signal.aborted) { reject(signal.reason); return; } signal.addEventListener('abort', () => reject(signal.reason), { once: true }); }); }, sendChatAction: async () => true, sendMessage: async ({ text }) => { const messageId = nextOutboundMessageId; nextOutboundMessageId += 1; if (text.includes('请选择测试环境')) questionSent.resolve(); return { message_id: messageId }; }, sendRichMessageDraft: async () => true, sendRichMessage: async ({ richMessage }) => { if (richMessage.markdown === '已选择生产环境') finalReplySent.resolve(); const messageId = nextOutboundMessageId; nextOutboundMessageId += 1; return { message_id: messageId }; }, editMessageText: async ({ text }) => { if (text === '已选择生产环境') finalReplySent.resolve(); return true; }, }; const harness = { ensureRunning: async () => true, createSession: async () => 'session-runtime-interaction', ask: async (sessionId, text, options) => { asked.push({ sessionId, text }); if (text !== '请先询问测试环境') return '不应将答案当成新 prompt'; await options.onInteraction({ kind: 'question', interactionId: 'telegram-runtime-question', rpcId: 'telegram-runtime-question', sessionId, payload: { type: 'question/requested', sessionId, questions: [{ id: 'environment', question: '请选择测试环境', options: [{ label: '测试环境' }, { label: '生产环境' }], }], }, respond: async (result) => { assert.equal(answerUpdateDelivered, true); assert.equal(originalTurnEnded, false); answerSubmitted.resolve(result); return { accepted: true }; }, }); await Promise.race([ answerSubmitted.promise, new Promise((_, reject) => { options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true, }); }), ]); await releaseTurn.promise; originalTurnEnded = true; return '已选择生产环境'; }, }; const runtime = new TelegramRuntime({ config: { botId: 'telegram_interaction', platformId: '123456789', username: 'HarnessBot', }, token: TOKEN, harness, state, createApi: () => fakeApi, logger: { error() {}, warn() {} }, allowedPrivateUserIds: ['7'], }); try { await runtime.start(); assert.equal(await bounded( secondPollStarted.promise, 'poller did not request the answer update while the first turn was active', ), false); const submitted = await bounded( answerSubmitted.promise, 'the Telegram answer was not submitted through the interaction fast path', ); assert.deepEqual(submitted, { ok: true, value: { sessionId: 'session-runtime-interaction', answer: { answers: [{ id: 'environment', selected: ['生产环境'] }], }, }, }); assert.equal(originalTurnEnded, false); assert.deepEqual(asked, [{ sessionId: 'session-runtime-interaction', text: '请先询问测试环境', }]); await bounded((async () => { while (state.cursor() !== 12) { await new Promise((resolve) => setTimeout(resolve, 5)); } })(), 'Telegram cursor did not advance past the answer update'); assert.deepEqual(pollOffsets.slice(0, 4), [-1, 0, 11, 12]); assert.equal(state.hasSeen('10'), true); assert.equal(state.hasSeen('11'), true); releaseTurn.resolve(); await bounded(finalReplySent.promise, 'the original Harness turn did not finish'); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(originalTurnEnded, true); assert.deepEqual(asked, [{ sessionId: 'session-runtime-interaction', text: '请先询问测试环境', }]); } finally { releaseTurn.resolve(); await runtime.stop(); await rm(directory, { recursive: true, force: true }); } });