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

@ -422,6 +422,30 @@ export class BotWorkspaceStore {
.sort((left, right) => left.targetId.localeCompare(right.targetId));
}
/**
* Flat catalog of every saved delivery target across bots still present
* in this workspace store (used by ops schedulers for picker UIs).
*/
listAllDeliveryTargets() {
const rows = [];
for (const botId of Object.keys(this.#deliveryTargets)) {
if (!this.has(botId)) continue;
for (const target of this.listDeliveryTargets(botId)) {
rows.push({
botId,
targetId: target.targetId,
name: target.name || target.targetId,
kind: target.kind,
});
}
}
rows.sort((left, right) => (
left.botId.localeCompare(right.botId)
|| left.targetId.localeCompare(right.targetId)
));
return rows;
}
deliveryTargetFor(botId, targetId) {
const id = botIdOf(botId);
const targetKey = targetIdOf(targetId);
@ -1705,6 +1729,7 @@ export function createWorkspaceAwareController(controller, {
botId,
conversationKey,
grant: workspaces.accessGrantFor(botId),
botAgentPreset: workspaces.agentPresetFor(botId),
});
}
return null;

View file

@ -1,4 +1,4 @@
import { normalizeAccessPhone, phoneFromWhatsappJid } from './access-grant.mjs';
import { normalizeAccessPhone, phoneFromWhatsappJid, resolveAccessAgentPreset } from './access-grant.mjs';
/**
* Parse a durable conversation binding key into chat identity fields.
@ -102,6 +102,7 @@ export function formatChannelPeerLabel(...parts) {
* botId: string,
* conversationKey: string,
* grant?: object | null,
* botAgentPreset?: string | null,
* }} input
* @returns {object | null}
*/
@ -114,12 +115,26 @@ export function resolveChannelPeerFromBinding(input) {
const parsed = parseConversationKey(conversationKey);
if (!parsed) return null;
const grant = input.grant && typeof input.grant === 'object' ? input.grant : null;
const botAgentPreset = typeof input.botAgentPreset === 'string' ? input.botAgentPreset.trim() : '';
const withPreset = (peer) => {
const fromGrant = resolveAccessAgentPreset(grant, {
kind: peer.kind === 'group' ? 'group' : 'direct',
groupJid: peer.kind === 'group' ? peer.conversationId : undefined,
conversationId: peer.conversationId,
phone: peer.phone,
senderId: peer.senderId || peer.phone,
});
const agentPreset = fromGrant || botAgentPreset || null;
return Object.freeze({ ...peer, agentPreset });
};
if (parsed.kind === 'direct') {
const { phone, pushName } = resolveContactIdentity(grant, parsed.conversationId);
const label = formatChannelPeerLabel(pushName, phone);
if (!label) return null;
return Object.freeze({
// Ops schedulers need a peer even when the contact directory is incomplete;
// keep the label human-facing (never raw LID / JID).
const label = formatChannelPeerLabel(pushName, phone) || 'WhatsApp 私聊';
return withPreset({
channel,
botId,
kind: 'direct',
@ -135,7 +150,7 @@ export function resolveChannelPeerFromBinding(input) {
const title = groupTitle(grant, parsed.conversationId) || '未命名群';
if (!parsed.senderId) {
return Object.freeze({
return withPreset({
channel,
botId,
kind: 'group',
@ -151,7 +166,7 @@ export function resolveChannelPeerFromBinding(input) {
const { phone, pushName } = resolveContactIdentity(grant, parsed.senderId);
const label = formatChannelPeerLabel(title, pushName, phone) || title;
return Object.freeze({
return withPreset({
channel,
botId,
kind: 'group',

View file

@ -1,7 +1,56 @@
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], cursor: null });
const EMPTY_STATE = Object.freeze({
version: 1,
sessions: {},
sessionOrigins: {},
seenMessageIds: [],
cursor: null,
});
/** Cap unbound+bound provenance so bot state files stay bounded. */
const MAX_SESSION_ORIGINS = 2_000;
/**
* @param {unknown} value
* @returns {Record<string, string>}
*/
function normalizeOrigins(value) {
const origins = Object.create(null);
if (!value || typeof value !== 'object' || Array.isArray(value)) return origins;
for (const [sessionId, conversationKey] of Object.entries(value)) {
if (typeof sessionId !== 'string' || !sessionId) continue;
if (typeof conversationKey !== 'string' || !conversationKey) continue;
origins[sessionId] = conversationKey;
}
return origins;
}
/**
* Seed provenance from live bindings so older state files gain reverse maps.
* @param {Record<string, string>} sessions
* @param {Record<string, string>} origins
*/
function mergeLiveIntoOrigins(sessions, origins) {
for (const [conversationKey, sessionId] of Object.entries(sessions)) {
if (typeof conversationKey === 'string' && conversationKey
&& typeof sessionId === 'string' && sessionId) {
origins[sessionId] = conversationKey;
}
}
}
/**
* Drop oldest provenance entries when over the cap (insertion order).
* @param {Record<string, string>} origins
*/
function pruneOrigins(origins) {
const ids = Object.keys(origins);
if (ids.length <= MAX_SESSION_ORIGINS) return;
const drop = ids.length - MAX_SESSION_ORIGINS;
for (let i = 0; i < drop; i += 1) delete origins[ids[i]];
}
function normalizeState(value) {
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
@ -13,9 +62,13 @@ function normalizeState(value) {
}
}
}
const sessionOrigins = normalizeOrigins(value.sessionOrigins);
mergeLiveIntoOrigins(sessions, sessionOrigins);
pruneOrigins(sessionOrigins);
return {
version: 1,
sessions,
sessionOrigins,
seenMessageIds: Array.isArray(value.seenMessageIds)
? value.seenMessageIds.filter((id) => typeof id === 'string' && id).slice(-1_000)
: [],
@ -49,6 +102,8 @@ export class ConversationStateStore {
/**
* Reverse-map a Harness session id to its conversation binding key.
* Live bindings win; unbound sessions still resolve via sessionOrigins
* so DSH can show channel provenance after `/new`.
* @param {string} sessionId
* @returns {string|null}
*/
@ -57,20 +112,40 @@ export class ConversationStateStore {
for (const [key, bound] of Object.entries(this.#state.sessions)) {
if (bound === sessionId) return key;
}
return null;
const former = this.#state.sessionOrigins?.[sessionId];
return typeof former === 'string' && former ? former : null;
}
/**
* Whether this session id is the live binding for its conversation key.
* @param {string} sessionId
* @returns {boolean}
*/
isLiveSession(sessionId) {
if (typeof sessionId !== 'string' || !sessionId) return false;
for (const bound of Object.values(this.#state.sessions)) {
if (bound === sessionId) return true;
}
return false;
}
async setSession(key, sessionId) {
this.#state.sessions[key] = sessionId;
if (typeof sessionId === 'string' && sessionId) {
this.#state.sessionOrigins[sessionId] = key;
pruneOrigins(this.#state.sessionOrigins);
}
await this.#persist();
}
async clearSession(key) {
// Keep sessionOrigins so unbound Harness sessions still show channel peer.
delete this.#state.sessions[key];
await this.#persist();
}
async clearSessions() {
// Workspace switches drop live chat→session maps only; provenance stays.
this.#state.sessions = {};
await this.#persist();
}

View file

@ -461,9 +461,12 @@ export class WhatsappBotClient {
this.#mediaUploadTimeoutMs = mediaUploadTimeoutMs;
}
async sendText(target, text) {
async sendText(target, text, { mentions } = {}) {
await this.#stopTyping(target.jid);
const providerMessageIds = [];
const mentionList = Array.isArray(mentions)
? mentions.filter((jid) => typeof jid === 'string' && jid.includes('@'))
: [];
for (const [index, chunk] of splitMessageText(text, 4_000).entries()) {
const messageId = randomBytes(10).toString('hex').toUpperCase();
const options = {
@ -478,9 +481,13 @@ export class WhatsappBotClient {
} else {
this.#outboundIds.remember(messageId);
}
const content = {
text: chunk,
...(index === 0 && mentionList.length > 0 ? { mentions: mentionList } : {}),
};
const result = await this.#socket.sendMessage(
target.jid,
{ text: chunk },
content,
options,
);
this.#outboundIds.remember(result?.key?.id);
@ -915,7 +922,7 @@ export class WhatsappRuntime {
return grant;
}
async sendProactiveText(target, text, { signal } = {}) {
async sendProactiveText(target, text, { signal, mentions } = {}) {
const jid = typeof target?.route?.jid === 'string' ? target.route.jid.trim() : '';
const validUser = target?.kind === 'user'
&& /^[^@\s]+@(s\.whatsapp\.net|lid)$/.test(jid);
@ -931,7 +938,14 @@ export class WhatsappRuntime {
throw error;
}
signal?.throwIfAborted();
return this.#client.sendText({ jid }, text);
const mentionList = Array.isArray(mentions)
? mentions.filter((entry) => typeof entry === 'string' && entry.includes('@'))
: [];
return this.#client.sendText(
{ jid },
text,
mentionList.length > 0 ? { mentions: mentionList } : undefined,
);
}
async stop() {