dsh-im-ops/test/delivery-adapter.test.mjs
2026-08-30 11:24:53 +08:00

293 lines
11 KiB
JavaScript

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