mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 00:32:43 +08:00
feat: add stable proactive delivery
This commit is contained in:
parent
410cb822d7
commit
31792d7bc8
88 changed files with 6427 additions and 488 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
653
test/client-delivery-settings.test.mjs
Normal file
653
test/client-delivery-settings.test.mjs
Normal 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' })),
|
||||
/尚未配置投递目标/,
|
||||
);
|
||||
});
|
||||
293
test/delivery-adapter.test.mjs
Normal file
293
test/delivery-adapter.test.mjs
Normal 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
131
test/delivery-rpc.test.mjs
Normal 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' });
|
||||
});
|
||||
226
test/delivery-service.test.mjs
Normal file
226
test/delivery-service.test.mjs
Normal 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' });
|
||||
});
|
||||
|
|
@ -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']],
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue