mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
Add Telegram and Discord IM channels
This commit is contained in:
parent
7123fce47a
commit
cb74e1c4c7
66 changed files with 8579 additions and 1910 deletions
42
test/channels/discord/client-ui.test.mjs
Normal file
42
test/channels/discord/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import {
|
||||
DiscordAccountCard,
|
||||
DiscordSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/discord/index.js';
|
||||
|
||||
test('Discord settings exposes a Bot Token action without a fake QR action', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(DiscordSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: { bots: [] } }),
|
||||
}));
|
||||
assert.match(markup, /aria-label="使用 Bot Token 接入 Discord 机器人"/);
|
||||
assert.match(markup, />手动接入</);
|
||||
assert.doesNotMatch(markup, /扫码接入机器人|dim-scanButton/);
|
||||
});
|
||||
|
||||
test('Discord account card matches the unified compact card layout', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(DiscordAccountCard, {
|
||||
account: {
|
||||
botId: 'discord_test',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: 'Harness Bot', username: 'HarnessBot', idMasked: '123•••' },
|
||||
health: { summary: 'Discord Gateway 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="discord"/);
|
||||
assert.match(markup, /@HarnessBot/);
|
||||
assert.match(markup, />Gateway 长连接</);
|
||||
assert.match(markup, />检查连接</);
|
||||
assert.match(markup, />移除接入</);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary/);
|
||||
});
|
||||
272
test/channels/discord/discord.test.mjs
Normal file
272
test/channels/discord/discord.test.mjs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
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 {
|
||||
DiscordConfigStore,
|
||||
deriveDiscordBotIdentity,
|
||||
} from '../../../src/channels/discord/config-store.mjs';
|
||||
import { DiscordController } from '../../../src/channels/discord/discord-controller.mjs';
|
||||
import {
|
||||
DiscordApi,
|
||||
inspectDiscordToken,
|
||||
validDiscordToken,
|
||||
} from '../../../src/channels/discord/discord-api.mjs';
|
||||
import {
|
||||
DiscordRuntime,
|
||||
normalizeDiscordMessage,
|
||||
} from '../../../src/channels/discord/discord-runtime.mjs';
|
||||
import {
|
||||
DISCORD_ENDPOINTS,
|
||||
createDiscordRpcHandler,
|
||||
} from '../../../plugin-src/host/channels/discord/rpc.mjs';
|
||||
|
||||
const TOKEN = 'MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.ABCD.abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
function jsonResponse(body, status = 200, headers = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
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); },
|
||||
};
|
||||
}
|
||||
|
||||
test('Discord API authenticates with a Bot header and validates the current bot', async () => {
|
||||
assert.equal(validDiscordToken(TOKEN), true);
|
||||
assert.equal(validDiscordToken('not-a-token'), false);
|
||||
const calls = [];
|
||||
const bot = await inspectDiscordToken(TOKEN, {
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return jsonResponse({
|
||||
id: '1234567890123456789',
|
||||
bot: true,
|
||||
username: 'HarnessBot',
|
||||
global_name: 'Harness Discord',
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.deepEqual(bot, {
|
||||
platformId: '1234567890123456789',
|
||||
name: 'Harness Discord',
|
||||
username: 'HarnessBot',
|
||||
});
|
||||
assert.equal(calls[0].options.headers.authorization, `Bot ${TOKEN}`);
|
||||
assert.match(calls[0].url.pathname, /users\/@me$/);
|
||||
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse({ message: '401: Unauthorized' }, 401),
|
||||
});
|
||||
await assert.rejects(() => api.getCurrentUser(), (error) => {
|
||||
assert.equal(error.code, 'discord-401');
|
||||
assert.doesNotMatch(error.message, new RegExp(TOKEN.replaceAll('.', '\\.')));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Discord API retries one rate-limited message request', async () => {
|
||||
let attempts = 0;
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => {
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? jsonResponse({ message: 'rate limited', retry_after: 0.001 }, 429)
|
||||
: jsonResponse({ id: '987654321012345678', content: 'hello' });
|
||||
},
|
||||
});
|
||||
const message = await api.createMessage({
|
||||
channelId: '123456789012345678',
|
||||
content: 'hello',
|
||||
});
|
||||
assert.equal(message.id, '987654321012345678');
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test('Discord controller persists a credential reference and exposes only masked identity', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-discord-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const configPath = join(directory, 'config.json');
|
||||
const configStore = await new DiscordConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const controller = new DiscordController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
inspectToken: async () => ({
|
||||
platformId: '1234567890123456789',
|
||||
name: 'Harness Discord',
|
||||
username: 'HarnessBot',
|
||||
}),
|
||||
createRuntime: async () => ({
|
||||
status: {
|
||||
ready: true,
|
||||
connectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
lastCheckedAt: 20,
|
||||
},
|
||||
async start() {},
|
||||
async stop() {},
|
||||
}),
|
||||
});
|
||||
const status = await controller.bindCredentials({ token: TOKEN });
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(status.bots[0].bot.name, 'Harness Discord');
|
||||
const identity = deriveDiscordBotIdentity('1234567890123456789');
|
||||
assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN);
|
||||
assert.doesNotMatch(await readFile(configPath, 'utf8'), new RegExp(TOKEN.replaceAll('.', '\\.')));
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.tokenRef), false);
|
||||
});
|
||||
|
||||
test('Discord RPC rejects extra credential fields and removes token internals', async () => {
|
||||
const controller = {
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
bindCredentials: async () => ({
|
||||
bots: [{
|
||||
botId: 'discord_123',
|
||||
token: TOKEN,
|
||||
tokenRef: 'DSH_DISCORD_BOT_TOKEN_ABC',
|
||||
bot: { name: 'Discord机器人', 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 = createDiscordRpcHandler(controller);
|
||||
const result = await handler(DISCORD_ENDPOINTS.bindCredentials, { token: TOKEN });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.bots[0].token, undefined);
|
||||
assert.equal(result.value.bots[0].tokenRef, undefined);
|
||||
const rejected = await handler(DISCORD_ENDPOINTS.bindCredentials, { token: TOKEN, appId: 'x' });
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('Discord normalizes DMs and only addressed server messages', () => {
|
||||
const direct = normalizeDiscordMessage({
|
||||
id: '111111111111111111',
|
||||
channel_id: '222222222222222222',
|
||||
author: { id: '333333333333333333', bot: false },
|
||||
content: 'hello',
|
||||
}, '1234567890123456789');
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
|
||||
const group = normalizeDiscordMessage({
|
||||
id: '111111111111111112',
|
||||
channel_id: '222222222222222223',
|
||||
guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333334', bot: false },
|
||||
mentions: [{ id: '1234567890123456789' }],
|
||||
content: '<@1234567890123456789> run this',
|
||||
}, '1234567890123456789');
|
||||
assert.equal(group.kind, 'group');
|
||||
assert.equal(group.addressed, true);
|
||||
assert.equal(group.content, 'run this');
|
||||
|
||||
const unmentionedReply = normalizeDiscordMessage({
|
||||
id: '111111111111111113',
|
||||
channel_id: '222222222222222223',
|
||||
guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333334', bot: false },
|
||||
mentions: [],
|
||||
referenced_message: { author: { id: '1234567890123456789', bot: true } },
|
||||
content: '',
|
||||
}, '1234567890123456789');
|
||||
assert.equal(unmentionedReply.addressed, false);
|
||||
});
|
||||
|
||||
class FakeSocket {
|
||||
#listeners = new Map();
|
||||
sent = [];
|
||||
readyState = 1;
|
||||
|
||||
addEventListener(name, listener) {
|
||||
const listeners = this.#listeners.get(name) ?? [];
|
||||
listeners.push(listener);
|
||||
this.#listeners.set(name, listeners);
|
||||
}
|
||||
|
||||
send(value) {
|
||||
const packet = JSON.parse(value);
|
||||
this.sent.push(packet);
|
||||
if (packet.op === 2) {
|
||||
queueMicrotask(() => this.emit('message', {
|
||||
data: JSON.stringify({
|
||||
op: 0,
|
||||
t: 'READY',
|
||||
s: 1,
|
||||
d: {
|
||||
session_id: 'session',
|
||||
resume_gateway_url: 'wss://gateway.discord.gg',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
close(code = 1000) {
|
||||
if (this.readyState >= 2) return;
|
||||
this.readyState = 3;
|
||||
this.emit('close', { code });
|
||||
}
|
||||
|
||||
emit(name, event) {
|
||||
for (const listener of this.#listeners.get(name) ?? []) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
test('Discord runtime identifies on Gateway v10 and becomes ready', async () => {
|
||||
let socket;
|
||||
const runtime = new DiscordRuntime({
|
||||
config: {
|
||||
botId: 'discord_test',
|
||||
platformId: '1234567890123456789',
|
||||
name: 'Harness Discord',
|
||||
},
|
||||
token: TOKEN,
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {
|
||||
sessionFor: () => null,
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
},
|
||||
createApi: () => ({
|
||||
getCurrentUser: async () => ({ id: '1234567890123456789', bot: true }),
|
||||
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
|
||||
}),
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.emit('message', {
|
||||
data: JSON.stringify({ op: 10, d: { heartbeat_interval: 45_000 } }),
|
||||
}));
|
||||
return socket;
|
||||
},
|
||||
random: () => 0.5,
|
||||
});
|
||||
await runtime.start();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
const identify = socket.sent.find((packet) => packet.op === 2);
|
||||
assert.equal(identify.d.token, TOKEN);
|
||||
assert.equal(identify.d.intents, 4_609);
|
||||
assert.equal(identify.d.properties.browser, 'dsh-im');
|
||||
await runtime.stop();
|
||||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
42
test/channels/telegram/client-ui.test.mjs
Normal file
42
test/channels/telegram/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import {
|
||||
TelegramAccountCard,
|
||||
TelegramSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/telegram/index.js';
|
||||
|
||||
test('Telegram settings exposes a Bot Token action without a fake QR action', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(TelegramSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: { bots: [] } }),
|
||||
}));
|
||||
assert.match(markup, /aria-label="使用 Bot Token 接入 Telegram 机器人"/);
|
||||
assert.match(markup, />手动接入</);
|
||||
assert.doesNotMatch(markup, /扫码接入机器人|dim-scanButton/);
|
||||
});
|
||||
|
||||
test('Telegram account card matches the unified compact card layout', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(TelegramAccountCard, {
|
||||
account: {
|
||||
botId: 'telegram_test',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: 'Harness Bot', username: 'harness_bot', idMasked: '123•••' },
|
||||
health: { summary: 'Telegram Bot API 长轮询运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="telegram"/);
|
||||
assert.match(markup, /@harness_bot/);
|
||||
assert.match(markup, />Bot API 长轮询</);
|
||||
assert.match(markup, />检查连接</);
|
||||
assert.match(markup, />移除接入</);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary/);
|
||||
});
|
||||
274
test/channels/telegram/telegram.test.mjs
Normal file
274
test/channels/telegram/telegram.test.mjs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
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 });
|
||||
});
|
||||
|
|
@ -57,6 +57,21 @@ test('Host plugin registers the Weixin RPC channel as loopback-only', async () =
|
|||
assert.deepEqual(registration.options, { authority: 'loopback' });
|
||||
});
|
||||
|
||||
test('Host plugin opts the Weixin RPC channel into trusted Host authorities', async () => {
|
||||
let registration;
|
||||
const ctx = {
|
||||
connection: { rpc: { handle: (channel, handler, options) => {
|
||||
registration = { channel, handler, options };
|
||||
return async () => {};
|
||||
} } },
|
||||
};
|
||||
await apply(ctx, {
|
||||
controller: controllerFixture(),
|
||||
rpcAuthority: 'trusted-host',
|
||||
});
|
||||
assert.deepEqual(registration.options, { authority: 'trusted-host' });
|
||||
});
|
||||
|
||||
test('RPC returns QR data and verification states without exposing secret-shaped fields', async () => {
|
||||
const controller = controllerFixture();
|
||||
const handler = createWeixinRpcHandler(controller, {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const QQ_SOURCE_URL = new URL(
|
|||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders five compact logo channel tabs without enable switches', async () => {
|
||||
test('IM settings renders seven compact logo channel tabs without enable switches', async () => {
|
||||
const styles = await readFile(STYLES_URL, 'utf8');
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -73,23 +73,29 @@ test('IM settings renders five compact logo channel tabs without enable switches
|
|||
dingtalkRpcCall: async () => ({ ok: true, value: {} }),
|
||||
wecomRpcCall: async () => ({ ok: true, value: {} }),
|
||||
qqRpcCall: async () => ({ ok: true, value: {} }),
|
||||
telegramRpcCall: async () => ({ ok: true, value: {} }),
|
||||
discordRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /IM机器人/);
|
||||
assert.match(markup, /通过扫码把机器人接入 DeepSeek Harness/);
|
||||
assert.match(markup, /把机器人接入 DeepSeek Harness/);
|
||||
assert.doesNotMatch(markup, /\d+ 个渠道|dim-channelCount/);
|
||||
assert.match(markup, />微信</);
|
||||
assert.match(markup, />飞书</);
|
||||
assert.match(markup, />钉钉</);
|
||||
assert.match(markup, />企业微信</);
|
||||
assert.match(markup, />QQ</);
|
||||
assert.match(markup, />Telegram</);
|
||||
assert.match(markup, />Discord</);
|
||||
assert.match(markup, /dim-logoWeixin/);
|
||||
assert.match(markup, /dim-logoFeishu/);
|
||||
assert.match(markup, /dim-logoDingtalk/);
|
||||
assert.match(markup, /dim-logoWecom/);
|
||||
assert.match(markup, /dim-logoQq/);
|
||||
assert.match(markup, /dim-logoTelegram/);
|
||||
assert.match(markup, /dim-logoDiscord/);
|
||||
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 5);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 7);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import test from 'node:test';
|
|||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside one plugin context', async () => {
|
||||
test('Host composes all seven IM channels inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
|
|
@ -11,14 +11,19 @@ test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside o
|
|||
applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]),
|
||||
applyWecom: async (ctx, config) => calls.push(['wecom', ctx, config]),
|
||||
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
|
||||
applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]),
|
||||
applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]),
|
||||
});
|
||||
const ctx = { marker: 'shared-context' };
|
||||
const config = {
|
||||
rpcAuthority: 'trusted-host',
|
||||
feishu: { domain: 'feishu' },
|
||||
weixin: { timeout: 30 },
|
||||
dingtalk: { replyTimeoutMs: 60_000 },
|
||||
wecom: { replyTimeoutMs: 60_000 },
|
||||
qq: { replyTimeoutMs: 60_000 },
|
||||
telegram: { replyTimeoutMs: 60_000 },
|
||||
discord: { replyTimeoutMs: 60_000 },
|
||||
};
|
||||
|
||||
await plugin.apply(ctx, config);
|
||||
|
|
@ -26,11 +31,13 @@ test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside o
|
|||
assert.equal(name, 'dsh-im-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer']);
|
||||
assert.deepEqual(calls, [
|
||||
['feishu', ctx, config.feishu],
|
||||
['weixin', ctx, config.weixin],
|
||||
['dingtalk', ctx, config.dingtalk],
|
||||
['wecom', ctx, config.wecom],
|
||||
['qq', ctx, config.qq],
|
||||
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host' }],
|
||||
['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host' }],
|
||||
['dingtalk', ctx, { ...config.dingtalk, rpcAuthority: 'trusted-host' }],
|
||||
['wecom', ctx, { ...config.wecom, rpcAuthority: 'trusted-host' }],
|
||||
['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host' }],
|
||||
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
|
||||
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
17
test/rpc-authority.test.mjs
Normal file
17
test/rpc-authority.test.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveRpcAuthority } from '../plugin-src/host/rpc-authority.mjs';
|
||||
|
||||
test('IM management RPC authority defaults to loopback and accepts explicit trusted hosts', () => {
|
||||
assert.equal(resolveRpcAuthority(), 'loopback');
|
||||
assert.equal(resolveRpcAuthority('loopback'), 'loopback');
|
||||
assert.equal(resolveRpcAuthority('trusted-host'), 'trusted-host');
|
||||
});
|
||||
|
||||
test('IM management RPC authority rejects unknown policy values', () => {
|
||||
assert.throws(
|
||||
() => resolveRpcAuthority('public'),
|
||||
/rpcAuthority must be "loopback" or "trusted-host"/,
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue