dsh-im-ops/test/channels/telegram/telegram.test.mjs
2026-08-16 14:47:16 +08:00

274 lines
9.5 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
TelegramConfigStore,
deriveTelegramBotIdentity,
} from '../../../src/channels/telegram/config-store.mjs';
import { TelegramController } from '../../../src/channels/telegram/telegram-controller.mjs';
import {
TelegramApi,
inspectTelegramToken,
validTelegramToken,
} from '../../../src/channels/telegram/telegram-api.mjs';
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
import {
TelegramRuntime,
normalizeTelegramUpdate,
} 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),
};
}
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 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 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() {},
};
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');
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));
await controller.reconnectBot(identity.botId);
assert.equal(runtimes.length, 2);
await controller.deleteBot(identity.botId);
assert.equal(credentialStore.values.has(identity.tokenRef), false);
assert.equal(controller.status().totals.configured, 0);
});
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
const calls = [];
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 () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
deleteBot: async () => ({ bots: [], totals: { configured: 0, 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');
});
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);
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');
});
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
const sent = [];
const updates = [];
const bot = {
sendText: async (_target, text) => 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 bridge = new TelegramHarnessBridge({ bot, harness, state: memoryState() });
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: {},
});
assert.equal(askCount, 1);
assert.deepEqual(updates, ['正在使用搜索…', '处理中']);
assert.deepEqual(sent, ['完成']);
});
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: '' }),
getUpdates: async ({ offset, timeout, signal }) => {
calls.push({ offset, timeout });
if (timeout === 0) return [];
return new Promise((resolve, reject) => signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true }));
},
};
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');
await runtime.stop();
assert.equal(runtime.status.ready, false);
assert.deepEqual(calls[0], { offset: -1, timeout: 0 });
await rm(directory, { recursive: true, force: true });
});