feat: add stable proactive delivery

This commit is contained in:
xmanrui 2026-08-30 11:24:53 +08:00
parent 410cb822d7
commit 31792d7bc8
88 changed files with 6427 additions and 488 deletions

View file

@ -153,6 +153,70 @@ test('session text replies include populated mention targets and omit empty ment
for (const body of sent) assert.equal(body.text.content, '回答');
});
test('DingTalk sends proactive text through stable user and group robot endpoints', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url: url.toString(), options });
if (url.pathname.endsWith('/oauth2/accessToken')) {
return jsonResponse({ accessToken: 'proactive-access-token', expireIn: 7_200 });
}
return jsonResponse({ processQueryKey: `query-${calls.length}` });
};
const api = createDingtalkApi({ fetchImpl });
await api.sendRobotText({
clientId: 'ding-proactive-client',
clientSecret: 'host-only-secret',
target: { type: 'user', robotCode: 'ding-proactive-client', userId: 'staff-one' },
text: ' 用户主动消息\n',
});
await api.sendRobotText({
clientId: 'ding-proactive-client',
clientSecret: 'host-only-secret',
target: {
type: 'group',
robotCode: 'ding-proactive-client',
openConversationId: 'cid-one',
},
text: '群主动消息',
});
assert.equal(calls[1].url, `${DINGTALK_API_BASE_URL}v1.0/robot/oToMessages/batchSend`);
assert.equal(calls[2].url, `${DINGTALK_API_BASE_URL}v1.0/robot/groupMessages/send`);
assert.equal(calls[1].options.headers['x-acs-dingtalk-access-token'], 'proactive-access-token');
assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'proactive-access-token');
assert.deepEqual(JSON.parse(calls[1].options.body), {
robotCode: 'ding-proactive-client',
msgKey: 'sampleText',
msgParam: JSON.stringify({ content: ' 用户主动消息\n' }),
userIds: ['staff-one'],
});
assert.deepEqual(JSON.parse(calls[2].options.body), {
robotCode: 'ding-proactive-client',
msgKey: 'sampleText',
msgParam: JSON.stringify({ content: '群主动消息' }),
openConversationId: 'cid-one',
});
assert.doesNotMatch(JSON.stringify(calls.slice(1)), /sessionWebhook|sendBySession/);
});
test('DingTalk exposes a stable rejection code for rejected proactive text', async () => {
const api = createDingtalkApi({
fetchImpl: async (url) => url.pathname.endsWith('/oauth2/accessToken')
? jsonResponse({ accessToken: 'rejected-text-token', expireIn: 7_200 })
: jsonResponse({ code: 'Forbidden.AccessDenied' }),
});
await assert.rejects(() => api.sendRobotText({
clientId: 'ding-rejected-text-client',
clientSecret: 'host-only-secret',
target: { type: 'user', robotCode: 'ding-rejected-text-client', userId: 'staff-one' },
text: '主动投递',
}), (error) => {
assert.equal(error.code, 'send-rejected');
assert.equal(error.providerCode, 'Forbidden.AccessDenied');
return true;
});
});
test('DingTalk adds, recalls, and replaces its native status reactions', async () => {
const calls = [];
const fetchImpl = async (url, options) => {

View file

@ -57,9 +57,11 @@ function configFixture(initial = [], events = []) {
function runtimeFactory({ events = [], pendingByClient = new Map(), failStart = false } = {}) {
const runtimes = [];
const connectionTests = [];
const proactiveSends = [];
return {
runtimes,
connectionTests,
proactiveSends,
createRuntime: async ({ botId, config, clientSecret }) => {
events.push(['create', botId]);
let ready = false;
@ -96,6 +98,10 @@ function runtimeFactory({ events = [], pendingByClient = new Map(), failStart =
async sendConnectionTest(text) {
connectionTests.push({ botId, text });
},
async sendProactiveText(...args) {
proactiveSends.push({ botId, args });
return { sent: true };
},
};
runtimes.push(runtime);
return runtime;
@ -154,6 +160,14 @@ test('successful QR poll stores secret then config then starts runtime without p
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
assert.match(runtimes.connectionTests[0].text, /钉钉机器人(ding••••vate)/);
const target = { kind: 'user', route: { userId: 'staff-one' } };
assert.deepEqual(await controller.sendProactiveText(completed.botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(runtimes.proactiveSends, [{
botId: completed.botId,
args: [target, '主动投递', {}],
}]);
await controller.close();
});

View file

@ -48,6 +48,8 @@ function stateFixture() {
test('runtime sends a DingTalk connection test only through the remembered private webhook', async () => {
const state = stateFixture();
const sends = [];
const proactiveSends = [];
let proactiveFailure = null;
const client = {
connected: true,
socket: { readyState: 1 },
@ -61,7 +63,13 @@ test('runtime sends a DingTalk connection test only through the remembered priva
clientSecret: 'host-secret',
harness: { ensureRunning: async () => true },
state,
api: { sendText: async (request) => sends.push(request) },
api: {
sendText: async (request) => sends.push(request),
sendRobotText: async (request) => {
proactiveSends.push(request);
if (proactiveFailure) throw proactiveFailure;
},
},
streamFactory: async () => ({ client, topic: 'robot-topic' }),
});
@ -78,6 +86,23 @@ test('runtime sends a DingTalk connection test only through the remembered priva
assert.equal(sends[0].clientSecret, 'host-secret');
assert.equal(sends[0].sessionWebhook, 'https://oapi.dingtalk.com/robot/reply?ticket=inbound-private');
assert.equal(sends[0].text, '连接测试');
assert.deepEqual(await runtime.sendProactiveText({
kind: 'group',
route: { openConversationId: 'cid-proactive' },
}, '主动投递'), { sent: true });
assert.equal(proactiveSends.length, 1);
assert.deepEqual(proactiveSends[0].target, {
type: 'group',
robotCode: 'ding-client',
openConversationId: 'cid-proactive',
});
assert.equal(proactiveSends[0].text, '主动投递');
assert.equal('sessionWebhook' in proactiveSends[0], false);
proactiveFailure = Object.assign(new Error('provider detail'), { code: 'send-rejected' });
await assert.rejects(() => runtime.sendProactiveText({
kind: 'user',
route: { userId: 'staff-one' },
}, '失败投递'), { code: 'target-rejected' });
await runtime.stop();
});

View file

@ -392,6 +392,7 @@ test('Discord controller persists a credential reference and exposes only masked
const configPath = join(directory, 'config.json');
const configStore = await new DiscordConfigStore(configPath).load();
const credentialStore = credentials();
const proactiveSends = [];
const controller = new DiscordController({
credentials: credentialStore,
configStore,
@ -409,6 +410,10 @@ test('Discord controller persists a credential reference and exposes only masked
},
async start() {},
async stop() {},
async sendProactiveText(...args) {
proactiveSends.push(args);
return { sent: true };
},
}),
});
const status = await controller.bindCredentials({ token: TOKEN });
@ -426,6 +431,11 @@ test('Discord controller persists a credential reference and exposes only masked
const identity = deriveDiscordBotIdentity('1234567890123456789');
assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN);
assert.doesNotMatch(await readFile(configPath, 'utf8'), new RegExp(TOKEN.replaceAll('.', '\\.')));
const target = { kind: 'channel', route: { channelId: '222222222222222222' } };
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);
});
@ -1608,6 +1618,7 @@ test('Discord runtime identifies on Gateway v10 and becomes ready', async () =>
const abortMark = deferred();
let abortMarkStarted = false;
const errors = [];
const proactiveCalls = [];
const runtime = new DiscordRuntime({
config: {
botId: 'discord_test',
@ -1632,6 +1643,10 @@ test('Discord runtime identifies on Gateway v10 and becomes ready', async () =>
createApi: () => ({
getCurrentUser: async () => ({ id: '1234567890123456789', bot: true }),
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
createMessage: async (request) => {
proactiveCalls.push(request);
return { id: '111111111111111900' };
},
}),
createWebSocket: () => {
socket = new FakeSocket();
@ -1648,6 +1663,14 @@ test('Discord runtime identifies on Gateway v10 and becomes ready', async () =>
});
await runtime.start();
assert.equal(runtime.status.ready, true);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'channel',
route: { channelId: '222222222222222900' },
}, 'proactive-test'), { providerMessageIds: ['111111111111111900'] });
assert.equal(proactiveCalls.length, 1);
assert.equal(proactiveCalls[0].channelId, '222222222222222900');
assert.equal(proactiveCalls[0].content, 'proactive-test');
assert.equal(proactiveCalls[0].replyToMessageId, undefined);
const identify = socket.sent.find((packet) => packet.op === 2);
assert.equal(identify.d.token, TOKEN);
assert.equal(identify.d.intents, 37_377);

View file

@ -176,6 +176,19 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
},
}]);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'group',
route: { chatId: 'oc_proactive_group' },
}, '主动投递'), { sent: true });
assert.deepEqual(FakeClient.sent[1], {
params: { receive_id_type: 'chat_id' },
data: {
receive_id: 'oc_proactive_group',
msg_type: 'text',
content: JSON.stringify({ text: '主动投递' }),
},
});
const stopped = await runtime.stop();
assert.equal(stopped.ready, false);
assert.equal(stopped.feishuLongConnectionState, 'idle');

View file

@ -107,6 +107,7 @@ function fixture({
starts: 0,
stops: 0,
sentTests: [],
proactiveSends: [],
probes: [],
responseModes: [],
repair,
@ -127,6 +128,10 @@ function fixture({
runtime.sentTests.push(text);
return { sent: true };
},
async sendProactiveText(...args) {
runtime.proactiveSends.push(args);
return { sent: true };
},
setGroupResponseMode(mode) {
runtime.responseModes.push(mode);
runtime.config.groupResponseMode = mode;
@ -793,6 +798,11 @@ test('connection test uses the selected bot runtime and shared message copy', as
assert.deepEqual(fx.runtimes.get(healthy.id)[0].sentTests, [
'✅ DeepSeek Harness 连接测试成功\n这条消息由「IM机器人」设置页中的“机器人 healthy(cli_heal••••7890)”机器人卡片发出。',
]);
const target = { kind: 'group', route: { chatId: 'oc_target' } };
assert.deepEqual(await fx.controller.sendProactiveText(healthy.id, target, '主动投递'), {
sent: true,
});
assert.deepEqual(fx.runtimes.get(healthy.id)[0].proactiveSends, [[target, '主动投递', {}]]);
await fx.controller.close();
});

View file

@ -97,6 +97,7 @@ test('QQ controller delegates the shared connection-test message to the selected
botId: 'qq_bot', appId: 'app-id', secretRef: 'qq.secret', ownerUserOpenid: 'owner-openid',
};
const sent = [];
const proactiveSends = [];
const controller = new QqController({
qrAuth: { start() { return () => {}; } },
credentials: {
@ -116,6 +117,10 @@ test('QQ controller delegates the shared connection-test message to the selected
start: async () => {},
stop: async () => {},
sendConnectionTest: async (text) => sent.push(text),
sendProactiveText: async (...args) => {
proactiveSends.push(args);
return { sent: true };
},
}),
});
@ -124,6 +129,11 @@ test('QQ controller delegates the shared connection-test message to the selected
assert.deepEqual(sent, [
connectionTestMessage(`QQ 机器人(${maskQqAppId(config.appId)})`),
]);
const target = { kind: 'user', route: { userOpenId: 'user-openid' } };
assert.deepEqual(await controller.sendProactiveText(config.botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, '主动投递', {}]]);
await controller.close();
});

View file

@ -98,6 +98,14 @@ test('QQ runtime sends a proactive connection test to the explicit owner fallbac
target: { scope: 'c2c', targetId: 'owner-openid' },
text: 'connection-test',
}]);
await runtime.sendProactiveText({
kind: 'group',
route: { groupOpenId: 'group-openid' },
}, 'proactive-test');
assert.deepEqual(bot.sent[1], {
target: { scope: 'group', targetId: 'group-openid' },
text: 'proactive-test',
});
await runtime.stop();
});

View file

@ -709,6 +709,7 @@ test('Slack controller stores two protected credential references and exposes ne
const configStore = await new SlackConfigStore(configPath).load();
const credentialStore = credentials();
const connectionTests = [];
const proactiveSends = [];
const controller = new SlackController({
credentials: credentialStore,
configStore,
@ -729,6 +730,10 @@ test('Slack controller stores two protected credential references and exposes ne
async start() {},
async stop() {},
async sendConnectionTest(text) { connectionTests.push(text); },
async sendProactiveText(...args) {
proactiveSends.push(args);
return { sent: true };
},
}),
});
const status = await controller.bindCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN });
@ -744,6 +749,11 @@ test('Slack controller stores two protected credential references and exposes ne
await controller.sendConnectionTest(identity.botId);
assert.match(connectionTests[0], /DeepSeek Harness/);
assert.match(connectionTests[0], /T1234••• · U1234•••/);
const target = { kind: 'conversation', route: { channelId: 'C12345678' } };
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.botTokenRef), false);
assert.equal(credentialStore.values.has(identity.appTokenRef), false);
@ -1048,6 +1058,7 @@ test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready
const abortMark = deferred();
let abortMarkStarted = false;
const errors = [];
const proactiveCalls = [];
const runtime = new SlackRuntime({
config: {
botId: 'slack_test',
@ -1073,6 +1084,10 @@ test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready
createApi: () => ({
authTest: async () => ({ team_id: 'T12345678', user_id: 'U12345678' }),
openConnection: async () => ({ url: 'wss://wss-primary.slack.com/link/?ticket=test' }),
postMessage: async (request) => {
proactiveCalls.push(request);
return { ts: '1700000000.900' };
},
}),
createWebSocket: () => {
socket = new FakeSocket();
@ -1091,6 +1106,14 @@ test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready
});
await runtime.start();
assert.equal(runtime.status.ready, true);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'thread',
route: { channelId: 'C12345678', threadTs: '1700000000.100' },
}, 'proactive-test'), { providerMessageIds: ['1700000000.900'] });
assert.equal(proactiveCalls.length, 1);
assert.equal(proactiveCalls[0].channelId, 'C12345678');
assert.equal(proactiveCalls[0].threadTs, '1700000000.100');
assert.equal(proactiveCalls[0].text, 'proactive-test');
socket.emit('message', {
data: JSON.stringify({
envelope_id: 'env-1',

View file

@ -630,6 +630,7 @@ test('Telegram config and controller store only a credential reference in bot da
const credentialStore = credentials();
const runtimes = [];
const connectionTests = [];
const proactiveSends = [];
const controller = new TelegramController({
credentials: credentialStore,
configStore,
@ -647,6 +648,10 @@ test('Telegram config and controller store only a credential reference in bot da
async start() {},
async stop() {},
async sendConnectionTest(text) { connectionTests.push(text); },
async sendProactiveText(...args) {
proactiveSends.push(args);
return { sent: true };
},
};
runtimes.push(runtime);
return runtime;
@ -673,6 +678,11 @@ test('Telegram config and controller store only a credential reference in bot da
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);
@ -1352,6 +1362,10 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true }));
},
sendMessage: async (request) => {
calls.push({ method: 'sendMessage', ...request });
return { message_id: 900 };
},
};
const runtime = new TelegramRuntime({
config: {
@ -1367,6 +1381,15 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol
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 });

View file

@ -57,6 +57,7 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
const configs = [];
let runtimeArgs;
let connectionTestText;
const proactiveSends = [];
const controller = new WecomController({
qrAuth: { start: async () => ({}), poll: async () => ({ status: 'waiting' }) },
credentials: {
@ -77,6 +78,10 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
start: async () => {}, stop: async () => {},
sendConnectionTest: async (text) => { connectionTestText = text; },
sendProactiveText: async (...args) => {
proactiveSends.push(args);
return { sent: true };
},
};
},
});
@ -88,6 +93,11 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
await controller.sendConnectionTest(status.bots[0].botId);
assert.match(connectionTestText, /DeepSeek Harness 连接测试成功/);
assert.match(connectionTestText, /企业微信机器人(remote••••nual)/);
const target = { kind: 'group', route: { chatId: 'group-target' } };
assert.deepEqual(await controller.sendProactiveText(status.bots[0].botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, '主动投递', {}]]);
await controller.close();
});

View file

@ -42,6 +42,14 @@ test('Enterprise WeChat runtime sends a connection test only to the remembered p
chatId: 'member-private',
body: { msgtype: 'markdown', markdown: { content: '测试' } },
}]);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'group',
route: { chatId: 'group-target' },
}, '主动投递'), { sent: true });
assert.deepEqual(client.sent[1], {
chatId: 'group-target',
body: { msgtype: 'markdown', markdown: { content: '主动投递' } },
});
await runtime.stop();
});

View file

@ -50,6 +50,7 @@ function configFixture() {
function runtimeFactory({ failStart = false, startError, lastMessageError = null } = {}) {
const runtimes = [];
const connectionTests = [];
const proactiveSends = [];
const createRuntime = async ({ config, token }) => {
let ready = false;
const runtime = {
@ -71,11 +72,15 @@ function runtimeFactory({ failStart = false, startError, lastMessageError = null
},
async stop() { ready = false; },
async sendConnectionTest(text) { connectionTests.push({ botId: config.botId, text }); },
async sendProactiveText(target, text, options) {
proactiveSends.push({ botId: config.botId, target, text, options });
return { sent: true };
},
};
runtimes.push(runtime);
return runtime;
};
return { runtimes, connectionTests, createRuntime };
return { runtimes, connectionTests, proactiveSends, createRuntime };
}
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
@ -139,6 +144,16 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
assert.match(runtimes.connectionTests[0].text, /微信机器人(accoun••••\.bot)/);
const target = { kind: 'user', route: { toUserId: 'target-user' } };
assert.deepEqual(await controller.sendProactiveText(completed.botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(runtimes.proactiveSends[0], {
botId: completed.botId,
target,
text: '主动投递',
options: {},
});
await controller.deleteBot(completed.botId);
assert.equal(credentials.values.size, 0);

View file

@ -165,6 +165,14 @@ test('runtime sends a connection test to the bound Weixin owner without reply co
assert.equal(sends[0].text, '连接测试');
assert.equal(sends[0].contextToken, undefined);
assert.equal(sends[0].runId, undefined);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'user',
route: { toUserId: 'target-user' },
}, '主动投递'), { sent: true });
assert.equal(sends[1].toUserId, 'target-user');
assert.equal(sends[1].text, '主动投递');
assert.equal(sends[1].contextToken, undefined);
assert.equal(sends[1].runId, undefined);
await runtime.stop();
});

View file

@ -1341,7 +1341,14 @@ test('WhatsApp runtime sends a connection test to self and suppresses its outbou
await runtime.start();
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
assert.deepEqual(sent, [[ACCOUNT_JID, { text: '连接测试' }]]);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'group',
route: { jid: '120363000000000000@g.us' },
}, '主动投递'), { providerMessageIds: ['connection-test-1'] });
assert.deepEqual(sent, [
[ACCOUNT_JID, { text: '连接测试' }],
['120363000000000000@g.us', { text: '主动投递' }],
]);
await callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: 'connection-test-1', fromMe: true },
message: { conversation: '连接测试' },
@ -1355,6 +1362,7 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
const config = await configStore.save(linkedConfig());
const sent = [];
const proactiveSends = [];
const controller = new WhatsappController({
configStore,
authPath: (name) => join(root, 'auth', name),
@ -1371,6 +1379,10 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
sent.push(text);
return { sent: true };
},
sendProactiveText: async (...args) => {
proactiveSends.push(args);
return { sent: true };
},
}),
});
t.after(() => controller.close());
@ -1380,6 +1392,11 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
assert.deepEqual(sent, [
'✅ DeepSeek Harness 连接测试成功\n这条消息由「IM机器人」设置页中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
]);
const target = { kind: 'user', route: { jid: '16505550199@s.whatsapp.net' } };
assert.deepEqual(await controller.sendProactiveText(config.botId, target, 'proactive-test'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]);
});
test('WhatsApp reconnect RPC sends tests only for the connected target and keeps failures non-fatal', async () => {

View file

@ -0,0 +1,653 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import {
DELIVERY_CHANNEL_DEFINITIONS,
DELIVERY_ENDPOINTS,
DELIVERY_RPC_CHANNEL,
DeliveryTargetSettingsPage,
} from '../plugin-src/client/delivery-settings.js';
import { en, setImTranslator } from '../plugin-src/client/i18n.js';
import { BotCard as FeishuBotCard } from '../plugin-src/client/channels/feishu/index.js';
import { AccountCard as WeixinAccountCard } from '../plugin-src/client/channels/weixin/index.js';
import { AccountCard as DingtalkAccountCard } from '../plugin-src/client/channels/dingtalk/index.js';
import { AccountCard as WecomAccountCard } from '../plugin-src/client/channels/wecom/index.js';
import { AccountCard as QqAccountCard } from '../plugin-src/client/channels/qq/index.js';
import { SlackAccountCard } from '../plugin-src/client/channels/slack/index.js';
import { TelegramAccountCard } from '../plugin-src/client/channels/telegram/index.js';
import { DiscordAccountCard } from '../plugin-src/client/channels/discord/index.js';
import { WhatsappAccountCard } from '../plugin-src/client/channels/whatsapp/index.js';
import { IMSettingsTab } from '../plugin-src/client/index.js';
const { act, create } = TestRenderer;
function flush() {
return new Promise((resolve) => setImmediate(resolve));
}
function textOf(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textOf).join('');
return textOf(node?.props?.children ?? '');
}
function button(root, label) {
return root.findAllByType('button').find((entry) => textOf(entry) === label);
}
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function mount(t, props) {
let renderer;
await act(async () => {
renderer = create(React.createElement(DeliveryTargetSettingsPage, props));
await flush();
});
t.after(async () => {
await act(async () => {
renderer.unmount();
await flush();
});
});
return renderer;
}
const connectedAccount = Object.freeze({
botId: 'bot_feishu_01',
botName: '通知机器人',
connected: true,
});
test('delivery settings define only the nine supported IM channel routes', () => {
assert.equal(DELIVERY_RPC_CHANNEL, '/dsh-im-delivery');
assert.deepEqual(Object.keys(DELIVERY_CHANNEL_DEFINITIONS), [
'weixin', 'feishu', 'dingtalk', 'wecom', 'qq',
'slack', 'telegram', 'discord', 'whatsapp',
]);
assert.deepEqual(
DELIVERY_CHANNEL_DEFINITIONS.feishu.fields.user.map((field) => field.key),
['openId'],
);
assert.deepEqual(
DELIVERY_CHANNEL_DEFINITIONS.dingtalk.fields.group.map((field) => field.key),
['openConversationId'],
);
assert.deepEqual(
DELIVERY_CHANNEL_DEFINITIONS.telegram.fields.topic.map((field) => field.key),
['chatId', 'messageThreadId'],
);
assert.equal('office' in DELIVERY_CHANNEL_DEFINITIONS, false);
});
test('all nine robot cards add one accessible settings gear beside existing content', () => {
const account = {
botId: 'bot_card_01',
connected: true,
state: 'connected',
bot: {
name: '机器人',
idMasked: 'id••01',
username: 'robot',
appIdMasked: 'app••01',
accountIdMasked: 'account••01',
clientIdMasked: 'client••01',
},
health: { lastCheckedAt: null, summary: '运行正常' },
workspace: null,
};
const cards = [
['feishu', FeishuBotCard, { connection: account }],
['weixin', WeixinAccountCard, { account }],
['dingtalk', DingtalkAccountCard, { account }],
['wecom', WecomAccountCard, { account }],
['qq', QqAccountCard, { account }],
['slack', SlackAccountCard, { account }],
['telegram', TelegramAccountCard, { account }],
['discord', DiscordAccountCard, { account }],
['whatsapp', WhatsappAccountCard, { account }],
];
for (const [channel, Card, props] of cards) {
const markup = renderToStaticMarkup(React.createElement(Card, props));
assert.equal((markup.match(/aria-label="机器人设置"/g) ?? []).length, 1);
assert.match(markup, /class="dim-botCardTools"/);
assert.match(markup, new RegExp(`data-delivery-channel="${channel}"`));
assert.match(markup, /role="tooltip"[^>]*>机器人设置</);
}
});
test('the card gear opens a bot-scoped page in the current channel panel and returns in place', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = {
setInterval() { return 1; },
clearInterval() {},
setTimeout() { return 1; },
clearTimeout() {},
requestAnimationFrame(callback) { callback(); return 1; },
cancelAnimationFrame() {},
};
const bot = {
botId: 'wx_stable_bot',
connected: true,
configured: true,
state: 'connected',
bot: { name: '微信通知助手', accountIdMasked: 'wx••01' },
health: { status: 'healthy', summary: '微信连接正常', lastCheckedAt: Date.now() },
};
const deliveryCalls = [];
const renderer = await (async () => {
let mounted;
await act(async () => {
mounted = create(React.createElement(IMSettingsTab, {
browserLocation: { href: 'http://localhost:9527/settings' },
weixinRpcCall: async (endpoint) => {
assert.equal(endpoint, 'connection.status');
return { ok: true, value: { revision: 1, bots: [bot] } };
},
deliveryRpcCall: async (endpoint, payload) => {
deliveryCalls.push({ endpoint, payload });
return { ok: true, value: { targets: [] } };
},
updateRpcCall: async () => ({
ok: true,
value: { runningVersion: '4.0.1', canInstall: false },
}),
}));
await flush();
});
return mounted;
})();
t.after(async () => {
await act(async () => { renderer.unmount(); await flush(); });
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const card = renderer.root.findByProps({ 'data-bot-id': 'wx_stable_bot' });
await act(async () => {
card.findByProps({ 'aria-label': '机器人设置' }).props.onClick();
await flush();
});
const page = renderer.root.findByProps({ className: 'dim-deliveryPage' });
assert.match(textOf(page), /微信通知助手/);
assert.equal(
page.findByProps({ className: 'dim-deliveryHeader' }).findAllByType('span').length,
2,
);
const docsLink = page.findByProps({ className: 'dim-deliveryDocsLink' });
assert.equal(textOf(docsLink), '使用文档↗');
assert.equal(
docsLink.props.href,
'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.md',
);
assert.equal(docsLink.props.target, '_blank');
assert.equal(docsLink.props.rel, 'noopener noreferrer');
assert.equal(page.findByType('code').props.children, 'wx_stable_bot');
assert.deepEqual(deliveryCalls, [{
endpoint: DELIVERY_ENDPOINTS.list,
payload: { botId: 'wx_stable_bot' },
}]);
await act(async () => {
button(page, '← 返回机器人列表').props.onClick();
await flush();
});
assert.ok(renderer.root.findByProps({ 'data-bot-id': 'wx_stable_bot' }));
assert.equal(renderer.root.findByProps({ id: 'dim-tab-weixin' }).props['aria-selected'], true);
});
test('new target waits for saved targets before generating aliases or checking duplicates', async (t) => {
const pending = deferred();
const renderer = await mount(t, {
channel: 'feishu',
account: connectedAccount,
rpcCall: async (endpoint) => {
assert.equal(endpoint, DELIVERY_ENDPOINTS.list);
return pending.promise;
},
onBack() {},
});
assert.equal(button(renderer.root, '新建目标').props.disabled, true);
await act(async () => {
pending.resolve({ ok: true, value: { targets: [] } });
await flush();
});
assert.equal(button(renderer.root, '新建目标').props.disabled, false);
});
test('each saved target tests only its own botId and targetId and keeps row-local feedback', async (t) => {
const pending = deferred();
const calls = [];
const targets = [
{ targetId: 'daily-report', name: '日报群', kind: 'group', route: { chatId: 'oc_daily' } },
{ targetId: 'oncall', name: '值班同学', kind: 'user', route: { openId: 'ou_oncall' } },
];
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === DELIVERY_ENDPOINTS.list) return { ok: true, value: { targets } };
if (endpoint === DELIVERY_ENDPOINTS.test) return pending.promise;
throw new Error(`unexpected endpoint ${endpoint}`);
};
const renderer = await mount(t, {
channel: 'feishu', account: connectedAccount, rpcCall, onBack() {},
});
const first = renderer.root.findByProps({ 'data-target-id': 'daily-report' });
const second = renderer.root.findByProps({ 'data-target-id': 'oncall' });
await act(async () => {
first.findByProps({ 'aria-label': '测试投递目标' }).props.onClick();
await flush();
});
assert.ok(first.findAllByType('button').some((entry) => textOf(entry) === '测试中…'));
assert.ok(second.findAllByType('button').some((entry) => textOf(entry) === '测试'));
assert.deepEqual(calls.at(-1), {
endpoint: DELIVERY_ENDPOINTS.test,
payload: { botId: 'bot_feishu_01', targetId: 'daily-report' },
});
pending.resolve({ ok: true, value: { sent: true } });
await act(async () => { await flush(); });
assert.match(textOf(first), /测试消息已发送,请到目标会话确认。/);
assert.doesNotMatch(textOf(second), /测试消息已发送/);
await act(async () => {
renderer.update(React.createElement(DeliveryTargetSettingsPage, {
channel: 'feishu',
account: { ...connectedAccount, connected: false },
rpcCall,
onBack() {},
}));
await flush();
});
for (const row of renderer.root.findAllByProps({ className: 'dim-targetRow' })) {
assert.equal(row.findByProps({ 'aria-label': '测试投递目标' }).props.disabled, true);
}
});
test('new target defaults to recent conversations and selection creates an editable unsaved draft', async (t) => {
const calls = [];
let targets = [
{ targetId: 'user-1', name: '值班同学', kind: 'user', route: { openId: 'ou_existing_secret_123456' } },
];
const suggestions = [
{ kind: 'user', route: { openId: 'ou_existing_secret_123456' } },
{ kind: 'user', route: { openId: 'ou_new_secret_123456' } },
{ kind: 'group', route: { chatId: 'oc_group_secret_123456' } },
];
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === DELIVERY_ENDPOINTS.list) {
return { ok: true, value: { targets: structuredClone(targets) } };
}
if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
return { ok: true, value: { suggestions: structuredClone(suggestions) } };
}
if (endpoint === DELIVERY_ENDPOINTS.test) {
return { ok: true, value: { sent: true } };
}
if (endpoint === DELIVERY_ENDPOINTS.create) {
targets = [...targets, structuredClone(payload.target)];
return { ok: true, value: structuredClone(payload.target) };
}
throw new Error(`unexpected endpoint ${endpoint}`);
};
const renderer = await mount(t, {
channel: 'feishu', account: connectedAccount, rpcCall, onBack() {},
});
await act(async () => {
button(renderer.root, '新建目标').props.onClick();
await flush();
});
assert.deepEqual(calls.at(-1), {
endpoint: DELIVERY_ENDPOINTS.listSuggestions,
payload: { botId: 'bot_feishu_01' },
});
const picker = renderer.root.findByProps({ 'aria-label': '从已聊过的会话选择' });
const suggestionSelect = picker.findByProps({ name: 'suggestion' });
const options = suggestionSelect.findAllByType('option');
assert.equal(options.length, 4);
const existing = options.find((option) => textOf(option).includes('ou_ex'));
assert.equal(existing.props.disabled, true);
assert.match(textOf(existing), /已添加/);
assert.doesNotMatch(textOf(picker), /ou_existing_secret_123456|ou_new_secret_123456|oc_group_secret_123456/);
const groupIndex = options.find((option) => textOf(option).includes('oc_gr')).props.value;
await act(async () => { suggestionSelect.props.onChange({ target: { value: groupIndex } }); });
let form = renderer.root.findByType('form');
const groupTargetId = form.findByProps({ name: 'targetId' }).props.value;
assert.match(groupTargetId, /^tgt_[0-9a-f]{16}$/);
assert.equal(form.findByProps({ name: 'targetId' }).props.readOnly, false);
assert.equal(form.findByProps({ name: 'kind' }).props.value, 'group');
assert.equal(form.findByProps({ name: 'chatId' }).props.value, 'oc_group_secret_123456');
assert.match(form.findByProps({ name: 'name' }).props.value, /^群聊 · oc_gr/);
assert.equal(calls.some((call) => call.endpoint === DELIVERY_ENDPOINTS.create), false);
await act(async () => {
form.findByProps({ name: 'chatId' }).props.onChange({ target: { value: 'oc_unsaved_current' } });
});
form = renderer.root.findByType('form');
assert.ok(button(form, '测试'));
await act(async () => {
button(form, '测试').props.onClick();
await flush();
});
assert.deepEqual(calls.at(-1), {
endpoint: DELIVERY_ENDPOINTS.test,
payload: {
botId: 'bot_feishu_01',
target: { kind: 'group', route: { chatId: 'oc_unsaved_current' } },
},
});
assert.equal(calls.some((call) => (
call.endpoint === DELIVERY_ENDPOINTS.create || call.endpoint === DELIVERY_ENDPOINTS.update
)), false);
assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/);
await act(async () => { button(renderer.root, '取消').props.onClick(); });
const nextSelect = renderer.root.findByProps({ name: 'suggestion' });
const userIndex = nextSelect.findAllByType('option')
.find((option) => textOf(option).includes('ou_ne')).props.value;
await act(async () => { nextSelect.props.onChange({ target: { value: userIndex } }); });
form = renderer.root.findByType('form');
const userTargetId = form.findByProps({ name: 'targetId' }).props.value;
assert.match(userTargetId, /^tgt_[0-9a-f]{16}$/);
assert.notEqual(userTargetId, groupTargetId);
assert.match(form.findByProps({ name: 'name' }).props.value, /^私聊 · ou_ne/);
assert.equal(form.findByProps({ name: 'kind' }).props.value, 'user');
assert.equal(form.findByProps({ name: 'openId' }).props.value, 'ou_new_secret_123456');
const fallbackName = form.findByProps({ name: 'name' }).props.value;
await act(async () => {
form.findByProps({ name: 'targetId' }).props.onChange({ target: { value: 'project-chat' } });
});
await act(async () => {
await form.props.onSubmit({ preventDefault() {} });
await flush();
});
assert.deepEqual(calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.create), {
endpoint: DELIVERY_ENDPOINTS.create,
payload: {
botId: 'bot_feishu_01',
target: {
targetId: 'project-chat',
name: fallbackName,
kind: 'user',
route: { openId: 'ou_new_secret_123456' },
},
},
});
});
test('recent conversation picker explains the empty state and refreshes on demand', async (t) => {
const calls = [];
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === DELIVERY_ENDPOINTS.list) return { ok: true, value: { targets: [] } };
if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
return { ok: true, value: { suggestions: [] } };
}
throw new Error(`unexpected endpoint ${endpoint}`);
};
const renderer = await mount(t, {
channel: 'telegram', account: connectedAccount, rpcCall, onBack() {},
});
await act(async () => {
button(renderer.root, '新建目标').props.onClick();
await flush();
});
assert.match(
textOf(renderer.root.findByProps({ className: 'dim-targetSuggestions' })),
/先在对应平台与机器人聊一条消息,再刷新。/,
);
assert.ok(button(renderer.root, '手动填写(高级)'));
await act(async () => {
button(renderer.root, '刷新').props.onClick();
await flush();
});
assert.equal(calls.filter((call) => call.endpoint === DELIVERY_ENDPOINTS.listSuggestions).length, 2);
});
test('recent conversation names remain platform data in the English UI', async (t) => {
setImTranslator((key) => en[key] ?? key);
t.after(() => setImTranslator(null));
const rpcCall = async (endpoint) => {
if (endpoint === DELIVERY_ENDPOINTS.list) return { ok: true, value: { targets: [] } };
if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
return {
ok: true,
value: {
suggestions: [{
id: 'group', name: '飞书项目群', kind: 'group', route: { chatId: 'oc_secret_123456' },
}],
},
};
}
throw new Error(`unexpected endpoint ${endpoint}`);
};
const renderer = await mount(t, {
channel: 'feishu', account: connectedAccount, rpcCall, onBack() {},
});
await act(async () => {
button(renderer.root, 'New target').props.onClick();
await flush();
});
const select = renderer.root.findByProps({ name: 'suggestion' });
assert.match(textOf(select), /飞书项目群/);
assert.doesNotMatch(textOf(select), /Feishu项目群/);
assert.match(
textOf(renderer.root.findByProps({ className: 'dim-targetSuggestions' })),
/Choose from conversations/,
);
const docsLink = renderer.root.findByProps({ className: 'dim-deliveryDocsLink' });
assert.equal(textOf(docsLink), 'User guide↗');
assert.equal(
docsLink.props.href,
'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md',
);
});
test('target create, edit, copy, and delete use the minimal RPC payloads', async (t) => {
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const copied = [];
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { clipboard: { async writeText(value) { copied.push(value); } } },
});
t.after(() => {
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator);
else delete globalThis.navigator;
});
const calls = [];
let targets = [];
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === DELIVERY_ENDPOINTS.list) {
return { ok: true, value: { targets: structuredClone(targets) } };
}
if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
return { ok: true, value: { suggestions: [] } };
}
if (endpoint === DELIVERY_ENDPOINTS.test) {
return { ok: true, value: { sent: true } };
}
if (endpoint === DELIVERY_ENDPOINTS.create) {
targets = [...targets, structuredClone(payload.target)];
return { ok: true, value: structuredClone(payload.target) };
}
if (endpoint === DELIVERY_ENDPOINTS.update) {
targets = targets.map((target) => target.targetId === payload.targetId
? { targetId: payload.targetId, ...structuredClone(payload.target) }
: target);
return { ok: true, value: targets[0] };
}
if (endpoint === DELIVERY_ENDPOINTS.delete) {
targets = targets.filter((target) => target.targetId !== payload.targetId);
return { ok: true, value: { deleted: true } };
}
throw new Error(`unexpected endpoint ${endpoint}`);
};
const renderer = await mount(t, {
channel: 'telegram', account: connectedAccount, rpcCall, onBack() {},
});
await act(async () => {
button(renderer.root, '复制').props.onClick();
await flush();
});
assert.equal(copied.at(-1), 'bot_feishu_01');
await act(async () => {
button(renderer.root, '新建目标').props.onClick();
await flush();
});
assert.ok(renderer.root.findByProps({ 'aria-label': '从已聊过的会话选择' }));
await act(async () => { button(renderer.root, '手动填写(高级)').props.onClick(); });
let createForm = renderer.root.findByType('form');
assert.ok(button(createForm, '测试'));
assert.equal(button(createForm, '测试').props.disabled, true);
assert.match(
renderer.root.findByProps({ name: 'targetId' }).props.value,
/^tgt_[0-9a-f]{16}$/,
);
await act(async () => {
renderer.root.findByProps({ name: 'targetId' }).props.onChange({ target: { value: 'Ops:Topic@A' } });
renderer.root.findByProps({ name: 'name' }).props.onChange({ target: { value: '告警话题' } });
renderer.root.findByProps({ name: 'kind' }).props.onChange({ target: { value: 'topic' } });
});
await act(async () => {
renderer.root.findByProps({ name: 'chatId' }).props.onChange({ target: { value: '-100123' } });
renderer.root.findByProps({ name: 'messageThreadId' }).props.onChange({ target: { value: '42' } });
});
createForm = renderer.root.findByType('form');
assert.equal(createForm.findByProps({ name: 'targetId' }).props.pattern, '[A-Za-z0-9._:@-]{1,128}');
assert.equal(createForm.findByProps({ name: 'targetId' }).props.maxLength, 128);
assert.equal(button(createForm, '测试').props.disabled, false);
await act(async () => {
button(createForm, '测试').props.onClick();
await flush();
});
assert.deepEqual(calls.at(-1), {
endpoint: DELIVERY_ENDPOINTS.test,
payload: {
botId: 'bot_feishu_01',
target: {
kind: 'topic',
route: { chatId: '-100123', messageThreadId: 42 },
},
},
});
assert.equal(calls.some((call) => (
call.endpoint === DELIVERY_ENDPOINTS.create || call.endpoint === DELIVERY_ENDPOINTS.update
)), false);
assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/);
await act(async () => {
await createForm.props.onSubmit({ preventDefault() {} });
await flush();
});
assert.deepEqual(calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.create), {
endpoint: DELIVERY_ENDPOINTS.create,
payload: {
botId: 'bot_feishu_01',
target: {
targetId: 'Ops:Topic@A',
name: '告警话题',
kind: 'topic',
route: { chatId: '-100123', messageThreadId: 42 },
},
},
});
const row = () => renderer.root.findByProps({ 'data-target-id': 'Ops:Topic@A' });
assert.match(textOf(row()), /告警话题/);
assert.match(textOf(row()), /targetId: Ops:Topic@A/);
assert.doesNotMatch(textOf(row()), /-100123/);
const typeBadges = row().findByProps({ className: 'dim-targetTitle' }).findAllByType('span');
assert.equal(typeBadges.length, 1);
assert.equal(textOf(typeBadges[0]), '话题');
await act(async () => {
button(row(), '复制调用参数').props.onClick();
await flush();
});
assert.equal(copied.at(-1), '{"botId":"bot_feishu_01","targetId":"Ops:Topic@A"}');
await act(async () => { button(row(), '编辑').props.onClick(); });
let editForm = renderer.root.findByType('form');
assert.equal(editForm.findByProps({ name: 'targetId' }).props.readOnly, true);
assert.ok(button(editForm, '测试'));
await act(async () => {
editForm.findByProps({ name: 'chatId' }).props.onChange({ target: { value: '-100999' } });
editForm.findByProps({ name: 'messageThreadId' }).props.onChange({ target: { value: '77' } });
});
editForm = renderer.root.findByType('form');
await act(async () => {
button(editForm, '测试').props.onClick();
await flush();
});
assert.deepEqual(calls.at(-1), {
endpoint: DELIVERY_ENDPOINTS.test,
payload: {
botId: 'bot_feishu_01',
target: {
kind: 'topic',
route: { chatId: '-100999', messageThreadId: 77 },
},
},
});
assert.equal(calls.filter((call) => call.endpoint === DELIVERY_ENDPOINTS.update).length, 0);
assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/);
await act(async () => {
editForm.findByProps({ name: 'chatId' }).props.onChange({ target: { value: '-100123' } });
editForm.findByProps({ name: 'messageThreadId' }).props.onChange({ target: { value: '42' } });
editForm.findByProps({ name: 'name' }).props.onChange({ target: { value: '夜间告警' } });
});
editForm = renderer.root.findByType('form');
await act(async () => {
await editForm.props.onSubmit({ preventDefault() {} });
await flush();
});
const updateCall = calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.update);
assert.deepEqual(updateCall.payload, {
botId: 'bot_feishu_01',
targetId: 'Ops:Topic@A',
target: {
name: '夜间告警',
kind: 'topic',
route: { chatId: '-100123', messageThreadId: 42 },
},
});
assert.equal('targetId' in updateCall.payload.target, false);
await act(async () => { button(row(), '删除').props.onClick(); });
assert.match(textOf(row().findByProps({ role: 'alertdialog' })), /unknown-target/);
await act(async () => {
button(row(), '确认删除').props.onClick();
await flush();
});
assert.deepEqual(calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.delete), {
endpoint: DELIVERY_ENDPOINTS.delete,
payload: { botId: 'bot_feishu_01', targetId: 'Ops:Topic@A' },
});
assert.match(
textOf(renderer.root.findByProps({ className: 'dim-deliveryState dim-deliveryEmpty' })),
/尚未配置投递目标/,
);
});

View file

@ -0,0 +1,293 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { DingtalkStateStore } from '../src/channels/dingtalk/state-store.mjs';
import { DiscordStateStore } from '../src/channels/discord/state-store.mjs';
import { StateStore as FeishuStateStore } from '../src/channels/feishu/state-store.mjs';
import { QqStateStore } from '../src/channels/qq/state-store.mjs';
import { SlackStateStore } from '../src/channels/slack/state-store.mjs';
import { TelegramStateStore } from '../src/channels/telegram/state-store.mjs';
import { WecomStateStore } from '../src/channels/wecom/state-store.mjs';
import { WeixinStateStore } from '../src/channels/weixin/state-store.mjs';
import { WhatsappStateStore } from '../src/channels/whatsapp/state-store.mjs';
import {
createDeliveryAdapter,
normalizeDeliveryTarget,
} from '../plugin-src/host/delivery-adapter.mjs';
import { deliverySuggestionsFromSessions } from '../plugin-src/host/delivery-suggestions.mjs';
const TARGETS = {
weixin: { targetId: 'daily', kind: 'user', route: { toUserId: 'wx-user' } },
feishu: { targetId: 'daily', kind: 'group', route: { chatId: 'oc_group' } },
dingtalk: {
targetId: 'daily', kind: 'group', route: { openConversationId: 'cid_group' },
},
wecom: { targetId: 'daily', kind: 'user', route: { chatId: 'wecom-user' } },
qq: { targetId: 'daily', kind: 'group', route: { groupOpenId: 'qq-group' } },
slack: {
targetId: 'daily', kind: 'thread', route: { channelId: 'C123', threadTs: '123.456' },
},
telegram: {
targetId: 'daily', kind: 'topic', route: { chatId: '-100123', messageThreadId: 42 },
},
discord: { targetId: 'daily', kind: 'channel', route: { channelId: '123456789' } },
whatsapp: {
targetId: 'daily', kind: 'group', route: { jid: '123456789-120363000000000000@g.us' },
},
};
const STATE_STORES = {
weixin: WeixinStateStore,
feishu: FeishuStateStore,
dingtalk: DingtalkStateStore,
wecom: WecomStateStore,
qq: QqStateStore,
slack: SlackStateStore,
telegram: TelegramStateStore,
discord: DiscordStateStore,
whatsapp: WhatsappStateStore,
};
const SESSION_SUGGESTIONS = {
weixin: {
sessions: { 'p2p:wx-user': 'session-weixin', 'group:ignored': 'session-ignored' },
suggestions: [{ kind: 'user', route: { toUserId: 'wx-user' } }],
},
feishu: {
sessions: {
'p2p:ou_user': 'session-user',
'p2p:legacy-user-id': 'session-without-open-id',
'group:oc_group': 'session-group',
},
suggestions: [
{ kind: 'user', route: { openId: 'ou_user' } },
{ kind: 'group', route: { chatId: 'oc_group' } },
],
},
dingtalk: {
sessions: { 'p2p:staff-one': 'session-user', 'group:cid_group': 'session-group' },
suggestions: [
{ kind: 'user', route: { userId: 'staff-one' } },
{ kind: 'group', route: { openConversationId: 'cid_group' } },
],
},
wecom: {
sessions: { 'direct:member-one': 'session-user', 'group:wr_group': 'session-group' },
suggestions: [
{ kind: 'user', route: { chatId: 'member-one' } },
{ kind: 'group', route: { chatId: 'wr_group' } },
],
},
qq: {
sessions: { 'c2c:user-openid': 'session-user', 'group:group-openid': 'session-group' },
suggestions: [
{ kind: 'user', route: { userOpenId: 'user-openid' } },
{ kind: 'group', route: { groupOpenId: 'group-openid' } },
],
},
slack: {
sessions: {
'direct:D123456': 'session-direct',
'group:C123456:1712345678.123456': 'session-thread',
},
suggestions: [
{ kind: 'conversation', route: { channelId: 'D123456' } },
{ kind: 'thread', route: { channelId: 'C123456', threadTs: '1712345678.123456' } },
],
},
telegram: {
sessions: {
'direct:88': 'session-direct',
'group:88': 'session-duplicate-chat',
'group:-1001234567890:42': 'session-topic',
},
suggestions: [
{ kind: 'chat', route: { chatId: '88' } },
{ kind: 'topic', route: { chatId: '-1001234567890', messageThreadId: 42 } },
],
},
discord: {
sessions: {
'direct:123456789012345678': 'session-direct',
'group:123456789012345678': 'session-duplicate-channel',
'group:223456789012345678': 'session-thread',
},
suggestions: [
{ kind: 'channel', route: { channelId: '123456789012345678' } },
{ kind: 'channel', route: { channelId: '223456789012345678' } },
],
},
whatsapp: {
sessions: {
'direct:16505550123@s.whatsapp.net': 'session-user',
'group:120363000000000000@g.us': 'session-group',
'group:123456789-120363000000000001@g.us': 'session-legacy-group',
},
suggestions: [
{ kind: 'user', route: { jid: '16505550123@s.whatsapp.net' } },
{ kind: 'group', route: { jid: '120363000000000000@g.us' } },
{ kind: 'group', route: { jid: '123456789-120363000000000001@g.us' } },
],
},
};
test('all nine delivery adapters validate and forward one stable target', async () => {
for (const [channel, target] of Object.entries(TARGETS)) {
const calls = [];
const workspaces = {
has: (botId) => botId === `bot-${channel}`,
listDeliveryTargets: () => [target],
createDeliveryTarget: async (...args) => calls.push(['create', ...args]),
updateDeliveryTarget: async (...args) => calls.push(['update', ...args]),
deleteDeliveryTarget: async (...args) => calls.push(['delete', ...args]),
};
const coreController = {
async sendProactiveText(...args) { calls.push(['send', ...args]); },
};
const { sessions, suggestions } = SESSION_SUGGESTIONS[channel];
const adapter = createDeliveryAdapter({
channel,
workspaces,
coreController,
stateFor: async () => ({ snapshot: () => ({ sessions }) }),
});
assert.equal(adapter.channel, channel);
assert.equal(adapter.ownsBot(`bot-${channel}`), true);
assert.equal(adapter.ownsBot('bot-other'), false);
assert.deepEqual(adapter.listTargets(`bot-${channel}`), [target]);
assert.deepEqual(await adapter.listSuggestions(`bot-${channel}`), suggestions);
assert.deepEqual(
await adapter.sendText(`bot-${channel}`, target, 'hello', { signal: undefined }),
{ sent: true },
);
assert.deepEqual(calls[0], [
'send', `bot-${channel}`, target, 'hello', { signal: undefined },
]);
}
});
test('delivery adapter delegates target CRUD to the existing workspace store', async () => {
const calls = [];
const workspaces = {
has: () => true,
listDeliveryTargets: () => [],
createDeliveryTarget: async (...args) => calls.push(['create', ...args]),
updateDeliveryTarget: async (...args) => calls.push(['update', ...args]),
deleteDeliveryTarget: async (...args) => calls.push(['delete', ...args]),
};
const adapter = createDeliveryAdapter({
channel: 'feishu',
workspaces,
coreController: { sendProactiveText() {} },
stateFor: async () => ({ snapshot: () => ({ sessions: {} }) }),
});
const target = {
targetId: 'ops', name: ' Ops ', kind: 'user', route: { openId: 'ou_ops' },
};
await adapter.createTarget('bot-one', target);
await adapter.updateTarget('bot-one', 'ops', {
name: 'On call', kind: 'group', route: { chatId: 'oc_oncall' },
});
await adapter.deleteTarget('bot-one', 'ops');
assert.deepEqual(calls, [
['create', 'bot-one', {
targetId: 'ops', name: 'Ops', kind: 'user', route: { openId: 'ou_ops' },
}],
['update', 'bot-one', 'ops', {
name: 'On call', kind: 'group', route: { chatId: 'oc_oncall' },
}],
['delete', 'bot-one', 'ops'],
]);
});
test('suggestion parser strictly filters malformed keys, de-duplicates routes, and never leaks state values', () => {
const sessions = {
'direct:88': 'secret-session-one',
'group:88': { sessionId: 'secret-session-two', replyToMessageId: 7 },
'group:-100:42': 'secret-topic-session',
'direct:88:7': 'transient-reply-key',
'group:-100:0': 'invalid-topic',
'group:not-a-number': 'invalid-chat',
'other:88': 'wrong-prefix',
};
const suggestions = deliverySuggestionsFromSessions('telegram', sessions);
assert.deepEqual(suggestions, [
{ kind: 'chat', route: { chatId: '88' } },
{ kind: 'topic', route: { chatId: '-100', messageThreadId: 42 } },
]);
assert.deepEqual(
suggestions.map((suggestion) => Object.keys(suggestion)),
[['kind', 'route'], ['kind', 'route']],
);
assert.doesNotMatch(JSON.stringify(suggestions), /session|reply|secret/i);
assert.deepEqual(deliverySuggestionsFromSessions('telegram', null), []);
assert.deepEqual(deliverySuggestionsFromSessions('unknown', sessions), []);
});
test('all nine adapters list suggestions from reloaded bot state while no runtime is connected', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-delivery-suggestions-'));
t.after(() => rm(directory, { recursive: true, force: true }));
for (const [channel, StateStore] of Object.entries(STATE_STORES)) {
const path = join(directory, `${channel}.json`);
const [conversationKey] = Object.keys(SESSION_SUGGESTIONS[channel].sessions);
await (await new StateStore(path).load()).setSession(conversationKey, `session-${channel}`);
const reloaded = await new StateStore(path).load();
const adapter = createDeliveryAdapter({
channel,
workspaces: {
has: () => true,
listDeliveryTargets: () => [],
createDeliveryTarget() {},
updateDeliveryTarget() {},
deleteDeliveryTarget() {},
},
coreController: {},
stateFor: async () => reloaded,
});
assert.deepEqual(
await adapter.listSuggestions(`bot-${channel}`),
SESSION_SUGGESTIONS[channel].suggestions.slice(0, 1),
channel,
);
}
});
test('every channel rejects unknown or transient route fields', () => {
for (const [channel, target] of Object.entries(TARGETS)) {
assert.throws(
() => normalizeDeliveryTarget(channel, {
...target,
route: { ...target.route, sessionId: 'unstable' },
}),
(error) => error?.code === 'invalid-target',
channel,
);
}
});
test('channel-specific route kinds stay strict', () => {
assert.throws(
() => normalizeDeliveryTarget('weixin', {
targetId: 'group', kind: 'group', route: { toUserId: 'wx-user' },
}),
(error) => error?.code === 'invalid-target',
);
assert.throws(
() => normalizeDeliveryTarget('telegram', {
targetId: 'topic', kind: 'topic', route: { chatId: '-100', messageThreadId: '42' },
}),
(error) => error?.code === 'invalid-target',
);
assert.throws(
() => normalizeDeliveryTarget('whatsapp', {
targetId: 'broadcast', kind: 'group', route: { jid: 'status@broadcast' },
}),
(error) => error?.code === 'invalid-target',
);
});

131
test/delivery-rpc.test.mjs Normal file
View file

@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DELIVERY_RPC_CHANNEL,
DELIVERY_TEST_MESSAGE,
createDeliveryRpcHandler,
installDeliveryRpc,
} from '../plugin-src/host/delivery-rpc.mjs';
function serviceFixture() {
const calls = [];
const service = {};
for (const method of [
'send',
'listTargets',
'listSuggestions',
'createTarget',
'updateTarget',
'deleteTarget',
]) {
service[method] = async (...args) => {
calls.push([method, ...args]);
return { method };
};
}
return { service, calls };
}
test('delivery RPC forwards all endpoints and both target.test payloads to the shared service', async () => {
const { service, calls } = serviceFixture();
const handle = createDeliveryRpcHandler(service);
const signal = new AbortController().signal;
const target = {
targetId: 'daily-report',
name: 'Daily report',
kind: 'group',
route: { chatId: 'chat-one' },
};
const replacement = { kind: 'group', route: { chatId: 'chat-two' } };
const draft = { kind: 'group', route: { chatId: 'chat-draft' } };
for (const [endpoint, payload] of [
['message.send', { botId: 'bot_one', targetId: 'daily-report', text: 'hello' }],
['target.list', { botId: 'bot_one' }],
['target.suggestion.list', { botId: 'bot_one' }],
['target.create', { botId: 'bot_one', target }],
['target.update', { botId: 'bot_one', targetId: 'daily-report', target: replacement }],
['target.delete', { botId: 'bot_one', targetId: 'daily-report' }],
['target.test', { botId: 'bot_one', targetId: 'daily-report' }],
['target.test', { botId: 'bot_one', target: draft }],
]) {
assert.equal((await handle(endpoint, payload, signal)).ok, true);
}
assert.deepEqual(calls, [
['send', 'bot_one', 'daily-report', 'hello', { signal }],
['listTargets', 'bot_one'],
['listSuggestions', 'bot_one'],
['createTarget', 'bot_one', target],
['updateTarget', 'bot_one', 'daily-report', replacement],
['deleteTarget', 'bot_one', 'daily-report'],
['send', 'bot_one', 'daily-report', DELIVERY_TEST_MESSAGE, { signal }],
['send', 'bot_one', draft, DELIVERY_TEST_MESSAGE, { signal }],
]);
});
test('delivery RPC rejects unknown, missing, and extra fields before calling the service', async () => {
const { service, calls } = serviceFixture();
const handle = createDeliveryRpcHandler(service);
for (const [endpoint, payload] of [
['unknown', {}],
['message.send', { botId: 'bot_one', targetId: 'target', text: 'hello', sessionId: 'no' }],
['message.send', { botId: 'bot_one', targetId: 'target', text: ' ' }],
['target.list', { botId: '../bot' }],
['target.suggestion.list', { botId: 'bot_one', sessionId: 'no' }],
['target.create', {
botId: 'bot_one',
target: { targetId: 'bad target', kind: 'group', route: { chatId: 'one' } },
}],
['target.update', {
botId: 'bot_one',
targetId: 'old',
target: { targetId: 'new', kind: 'group', route: { chatId: 'one' } },
}],
['target.delete', { botId: 'bot_one', targetId: 'target', confirm: true }],
['target.test', ['bot_one', 'target']],
['target.test', { botId: 'bot_one', targetId: 'target', target: {
kind: 'group', route: { chatId: 'one' },
} }],
['target.test', { botId: 'bot_one', target: {
targetId: 'target', kind: 'group', route: { chatId: 'one' },
} }],
['target.test', { botId: 'bot_one', target: {
name: 'Draft', kind: 'group', route: { chatId: 'one' },
} }],
['target.test', { botId: 'bot_one', target: { kind: 'group' } }],
]) {
assert.equal((await handle(endpoint, payload)).error.code, 'bad-request');
}
assert.deepEqual(calls, []);
});
test('delivery RPC returns only stable public errors and handles pre-cancelled calls', async () => {
const { service } = serviceFixture();
service.listTargets = async () => {
throw new Error('token=secret /Users/private/workspaces.json');
};
const handle = createDeliveryRpcHandler(service);
assert.deepEqual(await handle('target.list', { botId: 'bot_one' }), {
ok: false,
error: { code: 'delivery-failed', message: 'delivery-failed', details: {} },
});
const abort = new AbortController();
abort.abort();
assert.deepEqual(await handle('target.list', { botId: 'bot_one' }, abort.signal), {
ok: false,
error: { code: 'cancelled', message: 'cancelled', details: {} },
});
});
test('delivery RPC uses its own channel and the configured management authority', () => {
const { service } = serviceFixture();
const calls = [];
installDeliveryRpc({
connection: { rpc: { handle: (...args) => calls.push(args) } },
}, service, { authority: 'trusted-host' });
assert.equal(calls[0][0], DELIVERY_RPC_CHANNEL);
assert.equal(typeof calls[0][1], 'function');
assert.deepEqual(calls[0][2], { authority: 'trusted-host' });
});

View file

@ -0,0 +1,226 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDeliveryAdapter } from '../plugin-src/host/delivery-adapter.mjs';
import { createDeliveryService } from '../plugin-src/host/delivery-service.mjs';
function memoryAdapter({ channel = 'telegram', botId = 'bot_one' } = {}) {
const targets = new Map();
const sends = [];
return {
channel,
sends,
ownsBot: (candidate) => candidate === botId,
listTargets: () => [...targets.values()].map((target) => structuredClone(target)),
listSuggestions: () => [{ kind: 'chat', route: { chatId: '123' } }],
async createTarget(_botId, target) {
if (targets.has(target.targetId)) {
const error = new Error('duplicate');
error.code = 'target-conflict';
throw error;
}
targets.set(target.targetId, structuredClone(target));
return structuredClone(target);
},
async updateTarget(_botId, targetId, replacement) {
if (!targets.has(targetId)) {
const error = new Error('missing');
error.code = 'unknown-target';
throw error;
}
const target = { targetId, ...structuredClone(replacement) };
targets.set(targetId, target);
return structuredClone(target);
},
async deleteTarget(_botId, targetId) {
if (!targets.delete(targetId)) {
const error = new Error('missing');
error.code = 'unknown-target';
throw error;
}
},
async sendText(...args) { sends.push(args); },
};
}
test('DeliveryService shares target CRUD and sending through one adapter', async () => {
const service = createDeliveryService();
const adapter = memoryAdapter();
service.registerAdapter(adapter);
const target = {
targetId: 'daily-report',
name: 'Daily report',
kind: 'chat',
route: { chatId: 123 },
};
assert.deepEqual(await service.createTarget('bot_one', target), target);
assert.deepEqual(await service.listTargets('bot_one'), {
botId: 'bot_one',
channel: 'telegram',
targets: [target],
});
assert.deepEqual(await service.listSuggestions('bot_one'), {
botId: 'bot_one',
channel: 'telegram',
suggestions: [{ kind: 'chat', route: { chatId: '123' } }],
});
assert.deepEqual(await service.updateTarget('bot_one', 'daily-report', {
name: 'New target',
kind: 'chat',
route: { chatId: 456 },
}), {
targetId: 'daily-report',
name: 'New target',
kind: 'chat',
route: { chatId: 456 },
});
const signal = new AbortController().signal;
assert.deepEqual(
await service.send('bot_one', 'daily-report', 'keep original whitespace\n', { signal }),
{ sent: true },
);
assert.deepEqual(adapter.sends, [[
'bot_one',
{
targetId: 'daily-report',
name: 'New target',
kind: 'chat',
route: { chatId: 456 },
},
'keep original whitespace\n',
{ signal },
]]);
assert.deepEqual(await service.deleteTarget('bot_one', 'daily-report'), { deleted: true });
await assert.rejects(service.send('bot_one', 'daily-report', 'missing'), { code: 'unknown-target' });
});
test('DeliveryService validates public ids, text, cancellation, and unknown bots', async () => {
const service = createDeliveryService();
service.registerAdapter(memoryAdapter());
await assert.rejects(service.listTargets('../bot'), { code: 'bad-request' });
await assert.rejects(service.listSuggestions('../bot'), { code: 'bad-request' });
await assert.rejects(service.listTargets('bot_missing'), { code: 'unknown-bot' });
await assert.rejects(service.listSuggestions('bot_missing'), { code: 'unknown-bot' });
await assert.rejects(service.send('bot_one', 'bad target', 'hello'), { code: 'bad-request' });
await assert.rejects(service.send('bot_one', 'target', ' '), { code: 'bad-request' });
const abort = new AbortController();
abort.abort();
await assert.rejects(service.send('bot_one', 'target', 'hello', { signal: abort.signal }), {
code: 'cancelled',
});
await assert.rejects(
service.updateTarget('bot_one', 'target', {
targetId: 'renamed', kind: 'chat', route: { chatId: 1 },
}),
{ code: 'bad-request' },
);
});
test('DeliveryService tests a validated draft route without reading or persisting targets', async () => {
const service = createDeliveryService();
const workspaceCalls = [];
const sends = [];
service.registerAdapter(createDeliveryAdapter({
channel: 'telegram',
workspaces: {
has: (botId) => botId === 'bot_one',
listDeliveryTargets: (...args) => workspaceCalls.push(['list', ...args]),
createDeliveryTarget: (...args) => workspaceCalls.push(['create', ...args]),
updateDeliveryTarget: (...args) => workspaceCalls.push(['update', ...args]),
deleteDeliveryTarget: (...args) => workspaceCalls.push(['delete', ...args]),
},
coreController: {
async sendProactiveText(...args) { sends.push(args); },
},
stateFor: async () => ({ snapshot: () => ({ sessions: {} }) }),
}));
const signal = new AbortController().signal;
const draft = {
kind: 'topic',
route: { chatId: '-1001234567890', messageThreadId: 42 },
};
assert.deepEqual(
await service.send('bot_one', draft, 'draft test', { signal }),
{ sent: true },
);
assert.deepEqual(workspaceCalls, []);
assert.deepEqual(sends, [[
'bot_one',
{ targetId: '__test__', ...draft },
'draft test',
{ signal },
]]);
assert.deepEqual(draft, {
kind: 'topic',
route: { chatId: '-1001234567890', messageThreadId: 42 },
});
await assert.rejects(
service.send('bot_one', { ...draft, targetId: 'must-not-participate' }, 'draft test'),
{ code: 'bad-request' },
);
await assert.rejects(
service.send('bot_one', { ...draft, name: 'must-not-participate' }, 'draft test'),
{ code: 'bad-request' },
);
await assert.rejects(
service.send('bot_one', {
kind: 'topic', route: { chatId: '-1001234567890', messageThreadId: '42' },
}, 'draft test'),
{ code: 'invalid-target' },
);
assert.deepEqual(workspaceCalls, []);
assert.equal(sends.length, 1);
});
test('DeliveryService revalidates suggestions before exposing adapter output', async () => {
const service = createDeliveryService();
const adapter = memoryAdapter();
service.registerAdapter(adapter);
adapter.listSuggestions = () => [{
kind: 'chat',
route: { chatId: '123' },
sessionId: 'secret-session',
}];
await assert.rejects(service.listSuggestions('bot_one'), { code: 'invalid-target' });
adapter.listSuggestions = () => [{
kind: 'chat',
route: { chatId: '123', replyTarget: 'secret-reply' },
}];
await assert.rejects(service.listSuggestions('bot_one'), { code: 'invalid-target' });
});
test('DeliveryService maps an adapter abort to cancelled', async () => {
const service = createDeliveryService();
const adapter = memoryAdapter();
adapter.sendText = async () => {
const error = new Error('aborted downstream');
error.name = 'AbortError';
throw error;
};
service.registerAdapter(adapter);
await service.createTarget('bot_one', {
targetId: 'target', kind: 'chat', route: { chatId: 1 },
});
await assert.rejects(service.send('bot_one', 'target', 'hello'), { code: 'cancelled' });
});
test('DeliveryService adapter replacement has stale-safe unregister semantics', async () => {
const service = createDeliveryService();
const first = memoryAdapter();
const second = memoryAdapter();
const unregisterFirst = service.registerAdapter(first);
const unregisterSecond = service.registerAdapter(second);
assert.equal(unregisterFirst(), false);
await service.createTarget('bot_one', {
targetId: 'current', kind: 'chat', route: { chatId: 1 },
});
assert.equal((await service.listTargets('bot_one')).targets.length, 1);
assert.equal(unregisterSecond(), true);
await assert.rejects(service.listTargets('bot_one'), { code: 'unknown-bot' });
});

View file

@ -1,11 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Context } from '@deepseek-ai/cordis';
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
test('Host composes nine IM channels and the AI Office connector inside one plugin context', async () => {
const calls = [];
const deliveryService = { marker: 'shared-delivery-service' };
const plugin = createImHostPlugin({
createDeliveryService: () => deliveryService,
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
applyWeixin: async (ctx, config) => calls.push(['weixin', ctx, config]),
applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]),
@ -41,19 +45,109 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
'typertGateway',
]);
assert.deepEqual(calls, [
['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' }],
['slack', ctx, { ...config.slack, rpcAuthority: 'trusted-host' }],
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host', deliveryService }],
['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host', deliveryService }],
['dingtalk', ctx, { ...config.dingtalk, rpcAuthority: 'trusted-host', deliveryService }],
['wecom', ctx, { ...config.wecom, rpcAuthority: 'trusted-host', deliveryService }],
['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host', deliveryService }],
['slack', ctx, { ...config.slack, rpcAuthority: 'trusted-host', deliveryService }],
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host', deliveryService }],
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host', deliveryService }],
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host', deliveryService }],
['office', ctx, { ...config.office, rpcAuthority: 'trusted-host' }],
]);
});
test('Host provides #65 and installs #84 with the same delivery service', async () => {
const sent = [];
const deliveryService = {
async send(...args) { sent.push(args); return { sent: true }; },
async listTargets(botId) {
return { botId, channel: 'telegram', targets: [{ targetId: 'target' }] };
},
};
const provided = [];
const rpc = [];
const channelServices = [];
const internals = Object.fromEntries(CHANNELS.map(([channel, applyName]) => [
applyName,
async (_ctx, config) => {
if (channel !== 'office') channelServices.push(config.deliveryService);
},
]));
Object.assign(internals, {
createDeliveryService: () => deliveryService,
installUpdateRpc: () => {},
installDeliveryRpc: (...args) => rpc.push(args),
});
const ctx = {
connection: { rpc: {} },
provide: (...args) => provided.push(args),
};
await createImHostPlugin(internals).apply(ctx, { rpcAuthority: 'trusted-host' });
assert.equal(provided[0][0], 'dshIm');
assert.equal(rpc[0][1], deliveryService);
assert.deepEqual(rpc[0][2], { authority: 'trusted-host' });
assert.ok(channelServices.every((service) => service === deliveryService));
assert.deepEqual(await provided[0][1].listTargets('bot_one'), [{ targetId: 'target' }]);
assert.deepEqual(await provided[0][1].send('bot_one', 'target', 'hello'), { sent: true });
assert.deepEqual(sent, [['bot_one', 'target', 'hello', undefined]]);
});
test('#65 activates a real Cordis consumer without crossing the Connection RPC', async (t) => {
const ctx = new Context();
const rpcCalls = [];
ctx.provide('connection', {
rpc: {
handle: () => async () => {},
call: (...args) => rpcCalls.push(args),
},
});
ctx.provide('credentials', {});
ctx.provide('typertGateway', { stream() {} });
ctx.provide('sessionController', {});
ctx.provide('workspaceController', {});
const sent = [];
const deliveryService = {
async send(...args) {
sent.push(args);
return { sent: true };
},
async listTargets() { return { targets: [] }; },
};
const internals = Object.fromEntries(CHANNELS.map(([, applyName]) => [
applyName,
async () => {},
]));
Object.assign(internals, {
createDeliveryService: () => deliveryService,
installUpdateRpc: () => async () => {},
installDeliveryRpc: () => async () => {},
});
const host = ctx.plugin(createImHostPlugin(internals));
t.after(() => host.dispose());
await host.await();
let result;
const consumer = ctx.plugin({
name: 'dsh-im-delivery-consumer-test',
inject: ['dshIm'],
async apply(consumerCtx) {
result = await consumerCtx.dshIm.send('bot_one', 'daily-report', '测试消息');
},
});
t.after(() => consumer.dispose());
await consumer.await();
assert.deepEqual(result, { sent: true });
assert.deepEqual(sent, [['bot_one', 'daily-report', '测试消息', undefined]]);
assert.equal(rpcCalls.length, 0);
});
test('Host waits for apiProxy on legacy Harness and Controllers on modern Harness', async () => {
for (const [modern, expected] of [
[false, ['apiProxy']],

View file

@ -56,6 +56,7 @@ test('Host update initialization failure leaves all channel activations availabl
const channels = ['Feishu', 'Weixin', 'Dingtalk', 'Wecom', 'Qq', 'Slack', 'Telegram', 'Discord', 'Whatsapp', 'Office'];
const internals = Object.fromEntries(channels.map((channel) => [`apply${channel}`, async () => calls.push(channel)]));
internals.installUpdateRpc = () => { throw new Error('updater unavailable'); };
internals.installDeliveryRpc = () => {};
const errors = [];
await createImHostPlugin(internals).apply({
connection: { rpc: {} }, logger: { error: (...args) => errors.push(args) },

View file

@ -100,6 +100,78 @@ test('BotWorkspaceStore persists the creation default and keeps bots isolated',
assert.equal(reloaded.workspaceFor('bot_two'), defaultWorkspace);
});
test('BotWorkspaceStore migrates v1 on the first delivery target and persists target CRUD', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
await writeFile(path, `${JSON.stringify({
version: 1,
workspaces: { bot_delivery: defaultWorkspace },
})}\n`);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.deepEqual(store.listDeliveryTargets('bot_delivery'), []);
const input = {
targetId: 'daily-report',
name: ' 每日汇报群 ',
kind: 'group',
route: { chatId: 'chat-one' },
};
assert.deepEqual(await store.createDeliveryTarget('bot_delivery', input), {
targetId: 'daily-report',
name: '每日汇报群',
kind: 'group',
route: { chatId: 'chat-one' },
});
input.route.chatId = 'mutated-outside-store';
await store.updateDeliveryTarget('bot_delivery', 'daily-report', {
kind: 'group',
route: { chatId: 'chat-two' },
});
const saved = JSON.parse(await readFile(path, 'utf8'));
assert.equal(saved.version, 2);
assert.deepEqual(saved.deliveryTargets.bot_delivery['daily-report'], {
kind: 'group',
route: { chatId: 'chat-two' },
});
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.deepEqual(reloaded.deliveryTargetFor('bot_delivery', 'daily-report'), {
targetId: 'daily-report',
kind: 'group',
route: { chatId: 'chat-two' },
});
assert.equal(await reloaded.deleteDeliveryTarget('bot_delivery', 'daily-report'), true);
assert.deepEqual(reloaded.listDeliveryTargets('bot_delivery'), []);
assert.equal(JSON.parse(await readFile(path, 'utf8')).version, 2);
});
test('BotWorkspaceStore keeps delivery targets bot-scoped and removes them with the bot', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([store.ensure('bot_one'), store.ensure('bot_two')]);
const target = {
targetId: 'same-target',
kind: 'user',
route: { userId: 'user-one' },
};
await store.createDeliveryTarget('bot_one', target);
await store.createDeliveryTarget('bot_two', {
...target,
route: { userId: 'user-two' },
});
await assert.rejects(store.createDeliveryTarget('bot_one', target), { code: 'target-conflict' });
await assert.rejects(
store.updateDeliveryTarget('bot_one', 'missing', { kind: 'user', route: { userId: 'x' } }),
{ code: 'unknown-target' },
);
await store.remove('bot_one');
assert.throws(() => store.listDeliveryTargets('bot_one'), { code: 'unknown-bot' });
assert.equal(store.deliveryTargetFor('bot_two', 'same-target').route.userId, 'user-two');
const saved = JSON.parse(await readFile(path, 'utf8'));
assert.equal(saved.deliveryTargets.bot_one, undefined);
assert.equal(saved.deliveryTargets.bot_two['same-target'].route.userId, 'user-two');
});
test('BotWorkspaceStore uses process.cwd() when a bot has no configured workspace', async (t) => {
const { root } = await fixture(t);
const store = await new BotWorkspaceStore(join(root, 'cwd-workspaces.json')).load();