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