mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
Surface chat/access-list contacts as selectable targets with clearer nicknames, and keep conversation peers usable for ops schedulers. Co-authored-by: Cursor <cursoragent@cursor.com>
344 lines
12 KiB
JavaScript
344 lines
12 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, enrichWhatsappDeliverySuggestions } 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('whatsapp suggestions prefer nickname+phone and include access-grant groups', () => {
|
|
const sessions = {
|
|
'direct:910123456789012@lid': 'session-lid-only',
|
|
'direct:8613800000001@s.whatsapp.net': 'session-phone',
|
|
'group:120363000000000001@g.us': 'session-group',
|
|
};
|
|
const fromSessions = deliverySuggestionsFromSessions('whatsapp', sessions);
|
|
const enriched = enrichWhatsappDeliverySuggestions(fromSessions, {
|
|
contacts: [
|
|
{ phone: '8613800000001', lids: ['910123456789012'], pushName: '张三' },
|
|
{ phone: '8613800000002', lids: [], pushName: '李四' },
|
|
{ lids: ['999888777666555'], pushName: '只有LID' },
|
|
],
|
|
groups: {
|
|
'120363000000000001@g.us': { title: '运维告警群', admins: [], members: [] },
|
|
'120363000000000099@g.us': { title: '未聊过的群', admins: [], members: [] },
|
|
},
|
|
});
|
|
assert.deepEqual(enriched, [
|
|
{
|
|
kind: 'group',
|
|
route: { jid: '120363000000000099@g.us' },
|
|
name: '未聊过的群',
|
|
},
|
|
{
|
|
kind: 'group',
|
|
route: { jid: '120363000000000001@g.us' },
|
|
name: '运维告警群',
|
|
},
|
|
{
|
|
kind: 'user',
|
|
route: { jid: '8613800000002@s.whatsapp.net' },
|
|
name: '李四 · 8613800000002',
|
|
},
|
|
{
|
|
kind: 'user',
|
|
route: { jid: '8613800000001@s.whatsapp.net' },
|
|
name: '张三 · 8613800000001',
|
|
},
|
|
{
|
|
kind: 'user',
|
|
route: { jid: '999888777666555@lid' },
|
|
name: '只有LID',
|
|
},
|
|
]);
|
|
assert.equal(
|
|
enriched.some((row) => row.route.jid.endsWith('@lid') && row.route.jid.startsWith('910')),
|
|
false,
|
|
);
|
|
});
|
|
|
|
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',
|
|
);
|
|
});
|