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

@ -20,8 +20,8 @@ const CHANNEL_LABELS = Object.freeze({
});
/**
* Session-header chip: channel peer for IM-bound conversations.
* Renders nothing when the current session is not bound to a channel chat.
* Session-header chip: channel peer for IM conversations (live or formerly bound).
* Renders nothing when the session has no channel provenance.
* @param {{
* sessionId: string,
* resolveChannelPeer?: (sessionId: string, signal?: AbortSignal) => Promise<object|null>,
@ -67,7 +67,7 @@ export function ChannelPeerLabel({ sessionId, resolveChannelPeer }) {
/**
* Resolve the channel peer for a session via WhatsApp (ops-first).
* Returns null when the session is not bound to a WhatsApp conversation.
* Works for live bindings and sessions unbound by `/new` (provenance retained).
* @param {(endpoint: string, payload: object, signal?: AbortSignal) => Promise<unknown>} whatsappRpcCall
* @param {string} sessionId
* @param {AbortSignal} [signal]

View file

@ -325,7 +325,7 @@ function TargetForm({
value: name,
disabled: testing,
maxLength: 80,
placeholder: '例如 每日汇报群',
placeholder: definition.label === 'WhatsApp' ? '例如 张三 · 13800138000' : '例如 每日汇报群',
onChange: (event) => setName(event.target.value),
})),
h('label', { className: 'dim-targetField' },
@ -386,13 +386,12 @@ function TargetForm({
}
function suggestionOptionLabel(definition, suggestion, added) {
const name = suggestionDisplayName(suggestion);
const name = suggestionDisplayName(suggestion) || suggestionFallbackName(definition, suggestion);
const type = localizeText(kindLabel(definition, suggestion.kind));
const route = fieldsFor(definition, suggestion.kind)
.map((field) => maskRouteValue(suggestion.route?.[field.key]))
.filter(Boolean)
.join(' · ');
return [name, type, route, added ? localizeText('已添加') : null]
const firstField = fieldsFor(definition, suggestion.kind)[0];
const route = firstField ? String(suggestion.route?.[firstField.key] ?? '').trim() : '';
const showRoute = route && !name.includes(route) && !name.includes(route.split('@')[0] || '');
return [name, type, showRoute ? maskRouteValue(route) : null, added ? localizeText('已添加') : null]
.filter(Boolean)
.join(' · ');
}
@ -409,12 +408,15 @@ function TargetSuggestionPicker({
onCancel,
}) {
const configured = new Set(targets.map((target) => routeIdentity(definition, target)).filter(Boolean));
const channelHint = definition.label === 'WhatsApp'
? '会列出已聊过的私聊/群聊,以及访问名单里记住的联系人与群(显示为昵称 · 电话 / 群名)。'
: '选择后会自动填写目标信息和调用别名,确认后再保存。';
return h('section', { className: 'dim-targetSuggestions', 'aria-label': '从已聊过的会话选择' },
h('div', { className: 'dim-targetSuggestionHeading' },
h('div', null,
h('h3', null, '从已聊过的会话选择'),
h('p', null, '选择后会自动填写目标信息和调用别名,确认后再保存。')),
h('p', null, channelHint)),
h(DeliveryButton, {
onClick: () => void onRefresh(),
disabled: phase === 'loading',
@ -428,28 +430,33 @@ function TargetSuggestionPicker({
: suggestions.length === 0
? h('div', { className: 'dim-targetSuggestionState' },
h('strong', null, '还没有可选择的会话'),
h('p', null, '先在对应平台与机器人聊一条消息,再刷新。'))
: h('label', { className: 'dim-targetSuggestionField' },
h('span', null, '已聊会话'),
h('select', {
name: 'suggestion',
value: '',
onChange: (event) => {
if (event.target.value === '') return;
const suggestion = suggestions[Number(event.target.value)];
if (suggestion) onSelect(suggestion);
},
},
h('option', { value: '', disabled: true }, '从会话选择targetID'),
h('p', null, '先在对应平台与机器人聊一条消息(私聊或群聊),或先在「访问设置」里出现过联系人/群,再刷新。'))
: h('ul', { className: 'dim-targetSuggestionList', role: 'listbox', 'aria-label': '可选投递会话' },
suggestions.map((suggestion, index) => {
const identity = routeIdentity(definition, suggestion);
const added = configured.has(identity);
return React.createElement('option', {
key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}`,
value: String(index),
disabled: added,
}, suggestionOptionLabel(definition, suggestion, added));
}))),
return h('li', { key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}` },
h('button', {
type: 'button',
className: 'dim-targetSuggestionItem',
role: 'option',
'aria-disabled': added ? 'true' : undefined,
disabled: added,
onClick: () => { if (!added) onSelect(suggestion); },
},
// Platform display names must stay raw (bypass h() i18n phrase rewrite).
React.createElement('strong', null,
suggestionDisplayName(suggestion)
|| suggestionFallbackName(definition, suggestion)),
h('span', null, [
localizeText(kindLabel(definition, suggestion.kind)),
added ? localizeText('已添加') : null,
].filter(Boolean).join(' · ')),
React.createElement('code', null, fieldsFor(definition, suggestion.kind)
.map((field) => maskRouteValue(suggestion.route?.[field.key]))
.filter(Boolean)
.join(' · '))));
})),
h('div', { className: 'dim-targetFormActions' },
h(DeliveryButton, { onClick: onCancel }, '取消'),
h(DeliveryButton, { onClick: onManual }, '手动填写(高级)')));

View file

@ -903,12 +903,16 @@ const EN = Object.freeze({
'已聊会话': 'Conversation',
'从会话选择targetID': 'Select a targetID from conversations',
'选择后会自动填写目标信息和调用别名,确认后再保存。': 'Selecting a conversation fills the target details and call alias. Review them before saving.',
'会列出已聊过的私聊/群聊,以及访问名单里记住的联系人与群(显示为昵称 · 电话 / 群名)。': 'Lists known DMs and groups, plus remembered access-list contacts and groups (shown as nickname · phone / group name).',
'可选投递会话': 'Available delivery conversations',
'例如 张三 · 13800138000': 'For example, Alice · 13800138000',
'正在刷新…': 'Refreshing…',
'刷新': 'Refresh',
'正在读取已聊会话…': 'Loading known conversations…',
'无法读取已聊会话,请稍后重试。': 'Could not load known conversations. Try again later.',
'还没有可选择的会话': 'No conversations to choose from',
'先在对应平台与机器人聊一条消息,再刷新。': 'Send the bot a message on the platform, then refresh.',
'先在对应平台与机器人聊一条消息(私聊或群聊),或先在「访问设置」里出现过联系人/群,再刷新。': 'Chat with the bot first (DM or group), or wait until contacts/groups appear under Access settings, then refresh.',
'已添加': 'Added',
'手动填写(高级)': 'Enter manually (advanced)',
'测试消息已发送,请到目标会话确认。': 'Test message sent. Confirm it in the target conversation.',

View file

@ -425,6 +425,13 @@ body[data-ds-dark-theme] .dim-page select option, body[data-ds-dark-theme] .dim-
.dim-targetSuggestionField { min-width: 0; display: grid; gap: 5px; margin-top: 12px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
.dim-targetSuggestionField select { width: 100%; min-width: 0; height: 38px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; }
.dim-targetSuggestionField select:focus { outline: 2px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 28%, transparent); border-color: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-targetSuggestionList { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; max-height: 280px; overflow: auto; }
.dim-targetSuggestionItem { width: 100%; box-sizing: border-box; display: grid; gap: 2px; text-align: left; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, #fff); color: inherit; font: inherit; cursor: pointer; }
.dim-targetSuggestionItem:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-targetSuggestionItem:disabled { opacity: 0.55; cursor: not-allowed; }
.dim-targetSuggestionItem strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; line-height: 20px; font-weight: 600; }
.dim-targetSuggestionItem span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
.dim-targetSuggestionItem code { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 16px; word-break: break-all; }
.dim-targetForm { margin-top: 14px; padding: 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.dim-targetFormHeading h3 { margin: 0; font-size: 14px; line-height: 21px; }
.dim-targetFormHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }

View file

@ -1,4 +1,7 @@
import { deliverySuggestionsFromSessions } from './delivery-suggestions.mjs';
import {
deliverySuggestionsFromSessions,
enrichWhatsappDeliverySuggestions,
} from './delivery-suggestions.mjs';
const CHANNELS = new Set([
'weixin',
@ -145,12 +148,25 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
channel,
ownsBot: (botId) => workspaces.has(botId),
listTargets: (botId) => workspaces.listDeliveryTargets(botId),
listAllTargets: () => {
if (typeof workspaces.listAllDeliveryTargets !== 'function') return [];
return workspaces.listAllDeliveryTargets().map((row) => ({
...row,
channel,
}));
},
async listSuggestions(botId) {
const state = await stateFor(botId);
if (!state || typeof state.snapshot !== 'function') {
throw new TypeError('delivery suggestion state cannot be inspected');
}
const suggestions = deliverySuggestionsFromSessions(channel, state.snapshot()?.sessions);
let suggestions = deliverySuggestionsFromSessions(channel, state.snapshot()?.sessions);
if (channel === 'whatsapp' && typeof workspaces.accessGrantFor === 'function') {
suggestions = enrichWhatsappDeliverySuggestions(
suggestions,
workspaces.accessGrantFor(botId),
);
}
return suggestions.map((suggestion) => normalizeDeliveryTarget(
channel,
suggestion,

View file

@ -118,6 +118,46 @@ export class DeliveryService {
}
}
/**
* List every saved delivery target across registered channel adapters.
* @returns {Promise<Array<{ botId: string, targetId: string, name: string, kind: string, channel: string }>>}
*/
async listCatalog() {
const rows = [];
const seen = new Set();
for (const { adapter } of this.#adapters.values()) {
if (typeof adapter.listAllTargets !== 'function') continue;
let list;
try {
list = await adapter.listAllTargets();
} catch {
continue;
}
if (!Array.isArray(list)) continue;
for (const row of list) {
const botId = typeof row?.botId === 'string' ? row.botId.trim() : '';
const targetId = typeof row?.targetId === 'string' ? row.targetId.trim() : '';
if (!botId || !targetId) continue;
const key = `${botId}\0${targetId}`;
if (seen.has(key)) continue;
seen.add(key);
rows.push({
botId,
targetId,
name: typeof row.name === 'string' && row.name.trim() ? row.name.trim() : targetId,
kind: typeof row.kind === 'string' ? row.kind : '',
channel: typeof row.channel === 'string' ? row.channel : adapter.channel,
});
}
}
rows.sort((left, right) => (
left.channel.localeCompare(right.channel)
|| left.name.localeCompare(right.name)
|| left.targetId.localeCompare(right.targetId)
));
return rows;
}
async listSuggestions(botId) {
const id = botIdOf(botId);
const adapter = await this.#adapterFor(id);
@ -173,7 +213,7 @@ export class DeliveryService {
}
}
async send(botId, targetIdOrDraft, text, { signal } = {}) {
async send(botId, targetIdOrDraft, text, { signal, mentions } = {}) {
const id = botIdOf(botId);
const targetKey = typeof targetIdOrDraft === 'string'
? targetIdOf(targetIdOrDraft)
@ -195,7 +235,13 @@ export class DeliveryService {
if (!target) throw deliveryError('unknown-target', 'Unknown target');
}
cancellation(signal);
await adapter.sendText(id, target, text, { signal });
const mentionList = Array.isArray(mentions)
? mentions.filter((jid) => typeof jid === 'string' && jid.includes('@'))
: [];
await adapter.sendText(id, target, text, {
signal,
...mentionList.length > 0 ? { mentions: mentionList } : {},
});
return { sent: true };
} catch (error) {
if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {

View file

@ -133,3 +133,136 @@ export function deliverySuggestionsFromSessions(channel, sessions) {
}
return suggestions;
}
function clipName(value) {
if (typeof value !== 'string') return '';
const text = value.trim().replace(/\s+/g, ' ');
return text ? text.slice(0, 80) : '';
}
/** Prefer「昵称 · 电话」so ops pickers can tell people apart. */
export function whatsappUserDisplayName(pushName, phone) {
const nick = clipName(pushName);
const number = typeof phone === 'string' ? phone.trim() : '';
if (nick && number) return clipName(`${nick} · ${number}`);
if (nick) return nick;
if (number) return number;
return '';
}
function whatsappGroupDisplayName(title, jid) {
const name = clipName(title);
if (name) return name;
const raw = typeof jid === 'string' ? jid.trim() : '';
if (!raw) return '';
const local = raw.includes('@') ? raw.slice(0, raw.indexOf('@')) : raw;
if (local.length <= 10) return clipName(`群 · ${local}`);
return clipName(`群 · ${local.slice(0, 6)}…${local.slice(-4)}`);
}
function suggestionKey(suggestion) {
return `${suggestion?.kind ?? ''}\0${suggestion?.route?.jid ?? ''}`;
}
/**
* Merge WhatsApp session routes with access-grant contacts/groups so the
* delivery picker shows nickname+phone and known groups, not only opaque JIDs.
*
* @param {Array<{ kind: string, route: { jid: string }, name?: string }>} sessionSuggestions
* @param {object|null|undefined} grant - WhatsApp accessGrant document
* @returns {Array<{ kind: string, route: { jid: string }, name?: string }>}
*/
export function enrichWhatsappDeliverySuggestions(sessionSuggestions, grant) {
const byKey = new Map();
const lidToPhone = new Map();
const contacts = Array.isArray(grant?.contacts) ? grant.contacts : [];
for (const contact of contacts) {
const phone = typeof contact?.phone === 'string' ? contact.phone.trim() : '';
if (!phone) continue;
for (const lid of Array.isArray(contact.lids) ? contact.lids : []) {
const id = typeof lid === 'string' ? lid.trim() : '';
if (id) lidToPhone.set(id, phone);
}
}
const put = (suggestion) => {
if (!suggestion?.kind || !suggestion?.route?.jid) return;
const key = suggestionKey(suggestion);
const previous = byKey.get(key);
if (!previous) {
byKey.set(key, suggestion);
return;
}
if (!previous.name && suggestion.name) byKey.set(key, { ...previous, name: suggestion.name });
else if (previous.name && suggestion.name && suggestion.name.length > previous.name.length) {
byKey.set(key, { ...previous, name: suggestion.name });
}
};
for (const raw of Array.isArray(sessionSuggestions) ? sessionSuggestions : []) {
if (raw?.kind === 'user' && typeof raw?.route?.jid === 'string') {
const jid = raw.route.jid;
const lidMatch = /^(\d{5,32})@lid$/.exec(jid);
if (lidMatch) {
const phone = lidToPhone.get(lidMatch[1]);
if (phone) {
put({
kind: 'user',
route: { jid: `${phone}@s.whatsapp.net` },
...(raw.name ? { name: clipName(raw.name) } : {}),
});
continue;
}
}
}
put({
kind: raw.kind,
route: { jid: raw.route.jid },
...(raw.name ? { name: clipName(raw.name) } : {}),
});
}
for (const contact of contacts) {
const phone = typeof contact?.phone === 'string' ? contact.phone.trim() : '';
const lids = Array.isArray(contact?.lids)
? contact.lids.map((lid) => String(lid ?? '').trim()).filter(Boolean)
: [];
const name = whatsappUserDisplayName(contact?.pushName, phone);
if (phone) {
put({
kind: 'user',
route: { jid: `${phone}@s.whatsapp.net` },
...(name ? { name } : {}),
});
continue;
}
for (const lid of lids.slice(0, 1)) {
put({
kind: 'user',
route: { jid: `${lid}@lid` },
...(name ? { name } : {}),
});
}
}
const groups = grant?.groups && typeof grant.groups === 'object' && !Array.isArray(grant.groups)
? grant.groups
: {};
for (const [groupJid, entry] of Object.entries(groups)) {
if (typeof groupJid !== 'string' || !/^\d{5,32}(?:-\d{1,32})?@g\.us$/.test(groupJid)) continue;
const name = whatsappGroupDisplayName(entry?.title, groupJid);
put({
kind: 'group',
route: { jid: groupJid },
...(name ? { name } : {}),
});
}
const rows = [...byKey.values()];
rows.sort((left, right) => (
String(left.kind).localeCompare(String(right.kind))
|| String(left.name || '').localeCompare(String(right.name || ''), 'zh')
|| String(left.route.jid).localeCompare(String(right.route.jid))
));
return rows;
}

View file

@ -69,6 +69,8 @@ export function createImHostPlugin(internals = {}) {
deliveryService.send(botId, targetId, text, options)
),
listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
listDeliveryCatalog: async () => deliveryService.listCatalog(),
createTarget: (botId, target) => deliveryService.createTarget(botId, target),
/**
* Resolve the IM channel peer for a Harness session (botId + conversationKey).
* Used by ops schedulers to default proactive delivery back to the caller.