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:
oliver 2026-09-06 02:31:42 +08:00
parent e84630565c
commit 356538ac18
19 changed files with 827 additions and 324 deletions

View file

@ -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 });
}
});

View file

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

View file

@ -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',

View file

@ -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());