mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue