Add Telegram and Discord IM channels

This commit is contained in:
xmanrui 2026-08-16 14:47:16 +08:00
parent 7123fce47a
commit cb74e1c4c7
66 changed files with 8579 additions and 1910 deletions

View 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/);
});

View 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);
});

View 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/);
});

View 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 });
});

View file

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

View file

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

View file

@ -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' }],
]);
});

View 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"/,
);
});