mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 13:10:44 +08:00
Improve WhatsApp delivery suggestions and peer labels (ops.30).
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>
This commit is contained in:
parent
e84630565c
commit
356538ac18
19 changed files with 827 additions and 324 deletions
|
|
@ -83,6 +83,55 @@ test('resolveChannelPeerFromBinding builds DM and group labels from access grant
|
|||
assert.equal(groupShared.label, 'Ops Room');
|
||||
});
|
||||
|
||||
test('resolveChannelPeerFromBinding exposes agentPreset from grant or bot default', () => {
|
||||
const grant = {
|
||||
version: 1,
|
||||
globalAdmins: ['8618111111111'],
|
||||
directMembers: [
|
||||
{ phone: '8618222222222', canExecuteCommands: true, agentPreset: 'ops-dm' },
|
||||
],
|
||||
groups: {
|
||||
'120363@g.us': {
|
||||
title: 'Ops Room',
|
||||
agentPreset: 'ops-group',
|
||||
admins: [],
|
||||
members: [],
|
||||
},
|
||||
},
|
||||
contacts: [{
|
||||
phone: '8618222222222',
|
||||
lids: ['91010910658657@lid'],
|
||||
pushName: 'Alice',
|
||||
}],
|
||||
};
|
||||
const direct = resolveChannelPeerFromBinding({
|
||||
channel: 'whatsapp',
|
||||
botId: 'bot-1',
|
||||
conversationKey: 'direct:8618222222222@s.whatsapp.net',
|
||||
grant,
|
||||
botAgentPreset: 'bot-default',
|
||||
});
|
||||
assert.equal(direct.agentPreset, 'ops-dm');
|
||||
|
||||
const group = resolveChannelPeerFromBinding({
|
||||
channel: 'whatsapp',
|
||||
botId: 'bot-1',
|
||||
conversationKey: 'group:120363@g.us',
|
||||
grant,
|
||||
botAgentPreset: 'bot-default',
|
||||
});
|
||||
assert.equal(group.agentPreset, 'ops-group');
|
||||
|
||||
const fallback = resolveChannelPeerFromBinding({
|
||||
channel: 'whatsapp',
|
||||
botId: 'bot-1',
|
||||
conversationKey: 'direct:8618999999999@s.whatsapp.net',
|
||||
grant,
|
||||
botAgentPreset: 'bot-default',
|
||||
});
|
||||
assert.equal(fallback.agentPreset, 'bot-default');
|
||||
});
|
||||
|
||||
test('resolveChannelPeerFromBinding never falls back to raw LID when contact is incomplete', () => {
|
||||
const grant = {
|
||||
contacts: [{ lids: ['91010910658657@lid'], pushName: 'Bob' }],
|
||||
|
|
@ -105,7 +154,9 @@ test('resolveChannelPeerFromBinding never falls back to raw LID when contact is
|
|||
conversationKey: 'direct:111222333444@lid',
|
||||
grant,
|
||||
});
|
||||
assert.equal(unknown, null);
|
||||
assert.equal(unknown?.kind, 'direct');
|
||||
assert.equal(unknown?.label, 'WhatsApp 私聊');
|
||||
assert.doesNotMatch(unknown.label, /@lid|111222333444/);
|
||||
|
||||
const groupOnlyLid = resolveChannelPeerFromBinding({
|
||||
channel: 'whatsapp',
|
||||
|
|
@ -143,3 +194,31 @@ test('ConversationStateStore.keyForSession reverses session bindings', async ()
|
|||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ConversationStateStore keeps channel provenance after /new-style clearSession', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-session-origin-'));
|
||||
try {
|
||||
const path = join(root, 'state.json');
|
||||
const store = await new ConversationStateStore(path).load();
|
||||
await store.setSession('direct:8618222222222@s.whatsapp.net', 'session-old');
|
||||
assert.equal(store.isLiveSession('session-old'), true);
|
||||
|
||||
await store.clearSession('direct:8618222222222@s.whatsapp.net');
|
||||
assert.equal(store.sessionFor('direct:8618222222222@s.whatsapp.net'), null);
|
||||
assert.equal(store.isLiveSession('session-old'), false);
|
||||
// Unbound Harness session must still resolve for DSH channel chip / cron peer.
|
||||
assert.equal(store.keyForSession('session-old'), 'direct:8618222222222@s.whatsapp.net');
|
||||
|
||||
await store.setSession('direct:8618222222222@s.whatsapp.net', 'session-new');
|
||||
assert.equal(store.keyForSession('session-new'), 'direct:8618222222222@s.whatsapp.net');
|
||||
assert.equal(store.keyForSession('session-old'), 'direct:8618222222222@s.whatsapp.net');
|
||||
assert.equal(store.isLiveSession('session-new'), true);
|
||||
assert.equal(store.isLiveSession('session-old'), false);
|
||||
|
||||
const reloaded = await new ConversationStateStore(path).load();
|
||||
assert.equal(reloaded.keyForSession('session-old'), 'direct:8618222222222@s.whatsapp.net');
|
||||
assert.equal(reloaded.keyForSession('session-new'), 'direct:8618222222222@s.whatsapp.net');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -632,16 +632,15 @@ test('new target defaults to recent conversations and selection creates an edita
|
|||
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'));
|
||||
const suggestionItems = picker.findAllByProps({ className: 'dim-targetSuggestionItem' });
|
||||
assert.equal(suggestionItems.length, 3);
|
||||
const existing = suggestionItems.find((item) => textOf(item).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 } }); });
|
||||
const groupItem = suggestionItems.find((item) => textOf(item).includes('oc_gr') && !item.props.disabled);
|
||||
await act(async () => { groupItem.props.onClick(); });
|
||||
let form = renderer.root.findByType('form');
|
||||
const groupTargetId = form.findByProps({ name: 'targetId' }).props.value;
|
||||
assert.match(groupTargetId, /^tgt_[0-9a-f]{16}$/);
|
||||
|
|
@ -673,10 +672,9 @@ test('new target defaults to recent conversations and selection creates an edita
|
|||
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 } }); });
|
||||
const nextItems = renderer.root.findAllByProps({ className: 'dim-targetSuggestionItem' });
|
||||
const userItem = nextItems.find((item) => textOf(item).includes('ou_ne') && !item.props.disabled);
|
||||
await act(async () => { userItem.props.onClick(); });
|
||||
form = renderer.root.findByType('form');
|
||||
const userTargetId = form.findByProps({ name: 'targetId' }).props.value;
|
||||
assert.match(userTargetId, /^tgt_[0-9a-f]{16}$/);
|
||||
|
|
@ -727,7 +725,7 @@ test('recent conversation picker explains the empty state and refreshes on deman
|
|||
});
|
||||
assert.match(
|
||||
textOf(renderer.root.findByProps({ className: 'dim-targetSuggestions' })),
|
||||
/先在对应平台与机器人聊一条消息,再刷新。/,
|
||||
/先在对应平台与机器人聊一条消息/,
|
||||
);
|
||||
assert.ok(button(renderer.root, '手动填写(高级)'));
|
||||
await act(async () => {
|
||||
|
|
@ -762,11 +760,11 @@ test('recent conversation names remain platform data in the English UI', 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项目群/);
|
||||
const picker = renderer.root.findByProps({ className: 'dim-targetSuggestions' });
|
||||
assert.match(textOf(picker), /飞书项目群/);
|
||||
assert.doesNotMatch(textOf(picker), /Feishu项目群/);
|
||||
assert.match(
|
||||
textOf(renderer.root.findByProps({ className: 'dim-targetSuggestions' })),
|
||||
textOf(picker),
|
||||
/Choose from conversations/,
|
||||
);
|
||||
const docsLink = renderer.root.findByProps({ className: 'dim-deliveryDocsLink' });
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
createDeliveryAdapter,
|
||||
normalizeDeliveryTarget,
|
||||
} from '../plugin-src/host/delivery-adapter.mjs';
|
||||
import { deliverySuggestionsFromSessions } from '../plugin-src/host/delivery-suggestions.mjs';
|
||||
import { deliverySuggestionsFromSessions, enrichWhatsappDeliverySuggestions } from '../plugin-src/host/delivery-suggestions.mjs';
|
||||
|
||||
const TARGETS = {
|
||||
weixin: { targetId: 'daily', kind: 'user', route: { toUserId: 'wx-user' } },
|
||||
|
|
@ -206,6 +206,57 @@ test('delivery adapter delegates target CRUD to the existing workspace store', a
|
|||
]);
|
||||
});
|
||||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -96,6 +96,22 @@ test('DeliveryService shares target CRUD and sending through one adapter', async
|
|||
await assert.rejects(service.send('bot_one', 'daily-report', 'missing'), { code: 'unknown-target' });
|
||||
});
|
||||
|
||||
test('DeliveryService forwards WhatsApp mentions to the adapter', async () => {
|
||||
const service = createDeliveryService();
|
||||
const adapter = memoryAdapter({ channel: 'whatsapp' });
|
||||
service.registerAdapter(adapter);
|
||||
await service.createTarget('bot_one', {
|
||||
targetId: 'ops-group',
|
||||
name: 'Ops',
|
||||
kind: 'group',
|
||||
route: { jid: '120363@g.us' },
|
||||
});
|
||||
const mentions = ['8613800000000@s.whatsapp.net'];
|
||||
await service.send('bot_one', 'ops-group', '@8613800000000 hello', { mentions });
|
||||
assert.equal(adapter.sends.length, 1);
|
||||
assert.deepEqual(adapter.sends[0][3], { signal: undefined, mentions });
|
||||
});
|
||||
|
||||
test('DeliveryService validates public ids, text, cancellation, and unknown bots', async () => {
|
||||
const service = createDeliveryService();
|
||||
service.registerAdapter(memoryAdapter());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue