Show WhatsApp channel peer next to Agent Preset in session header (4.9.1-ops.20).

Plugins inject conversation.session.header.actions; resolve sessionId back to the bound chat and render nickname/phone or group title.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 19:10:07 +08:00
parent 2718586fda
commit 37742043a9
15 changed files with 852 additions and 326 deletions

View file

@ -28,6 +28,7 @@ import {
resolveAccessPending,
validateAccessGrant,
} from './access-grant.mjs';
import { resolveChannelPeerFromBinding } from './channel-peer.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import {
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
@ -1458,10 +1459,16 @@ export function createBotScopedHarness(harness, options) {
return createBotWorkspaceScope(harness, options).harness;
}
export function createWorkspaceAwareController(controller, { workspaces, stateFor, agentPresetCatalog } = {}) {
export function createWorkspaceAwareController(controller, {
workspaces,
stateFor,
agentPresetCatalog,
channel = null,
} = {}) {
if (!controller || !workspaces || typeof stateFor !== 'function') {
throw new TypeError('controller, workspaces, and stateFor are required');
}
const channelId = typeof channel === 'string' && channel.trim() ? channel.trim() : null;
const transitions = new Map();
const withBotTransition = (botId, operation) => {
const previous = transitions.get(botId) ?? Promise.resolve();
@ -1674,6 +1681,35 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
}
});
const resolveChannelPeer = async (sessionId) => {
if (!channelId) return null;
const id = typeof sessionId === 'string' ? sessionId.trim() : '';
if (!id || id.length > 256) return null;
const snapshot = await controller.status();
const bots = Array.isArray(snapshot?.bots) ? snapshot.bots : [];
for (const bot of bots) {
const botId = typeof bot?.botId === 'string' ? bot.botId.trim() : '';
if (!botId) continue;
let state;
try {
state = await stateFor(botId);
} catch {
continue;
}
const conversationKey = typeof state?.keyForSession === 'function'
? state.keyForSession(id)
: null;
if (!conversationKey) continue;
return resolveChannelPeerFromBinding({
channel: channelId,
botId,
conversationKey,
grant: workspaces.accessGrantFor(botId),
});
}
return null;
};
return new Proxy(controller, {
get(target, property) {
if (property === 'updateWorkspace') return updateWorkspace;
@ -1683,6 +1719,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
if (property === 'updateGroupSessionScope') return updateGroupSessionScope;
if (property === 'updateAccessGrant') return updateAccessGrant;
if (property === 'resolveAccessPending') return resolveAccessPendingProxy;
if (property === 'resolveChannelPeer') return resolveChannelPeer;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {

View file

@ -0,0 +1,115 @@
import { normalizeAccessPhone, phoneFromWhatsappJid } from './access-grant.mjs';
/**
* Parse a durable conversation binding key into chat identity fields.
* @param {unknown} key
* @returns {{ kind: 'direct' | 'group', conversationId: string, senderId: string | null } | null}
*/
export function parseConversationKey(key) {
if (typeof key !== 'string' || !key) return null;
if (key.startsWith('direct:')) {
const conversationId = key.slice('direct:'.length).trim();
return conversationId ? { kind: 'direct', conversationId, senderId: null } : null;
}
if (!key.startsWith('group:')) return null;
const rest = key.slice('group:'.length);
const userMarker = ':user:';
const userAt = rest.indexOf(userMarker);
if (userAt >= 0) {
const conversationId = rest.slice(0, userAt).trim();
const senderId = rest.slice(userAt + userMarker.length).trim();
if (!conversationId || !senderId) return null;
return { kind: 'group', conversationId, senderId };
}
const conversationId = rest.trim();
return conversationId ? { kind: 'group', conversationId, senderId: null } : null;
}
function contactPushName(grant, phone) {
if (!phone || !grant || !Array.isArray(grant.contacts)) return '';
const hit = grant.contacts.find((contact) => contact?.phone === phone);
return typeof hit?.pushName === 'string' ? hit.pushName.trim() : '';
}
function groupTitle(grant, groupJid) {
const title = grant?.groups?.[groupJid]?.title;
return typeof title === 'string' && title.trim() ? title.trim() : '';
}
function phoneFromIdentity(value) {
return phoneFromWhatsappJid(value) ?? normalizeAccessPhone(value);
}
/**
* Build a public channel-peer summary for session-header display.
* @param {{
* channel: string,
* botId: string,
* conversationKey: string,
* grant?: object | null,
* }} input
* @returns {object | null}
*/
export function resolveChannelPeerFromBinding(input) {
const channel = typeof input?.channel === 'string' ? input.channel.trim() : '';
const botId = typeof input?.botId === 'string' ? input.botId.trim() : '';
const conversationKey = typeof input?.conversationKey === 'string'
? input.conversationKey.trim() : '';
if (!channel || !botId || !conversationKey) return null;
const parsed = parseConversationKey(conversationKey);
if (!parsed) return null;
const grant = input.grant && typeof input.grant === 'object' ? input.grant : null;
if (parsed.kind === 'direct') {
const phone = phoneFromIdentity(parsed.conversationId);
const pushName = contactPushName(grant, phone);
const who = pushName || phone || parsed.conversationId;
const label = phone && pushName && pushName !== phone
? `${pushName} · ${phone}`
: who;
return Object.freeze({
channel,
botId,
kind: 'direct',
conversationKey,
conversationId: parsed.conversationId,
senderId: null,
phone: phone || null,
pushName: pushName || null,
groupTitle: null,
label,
});
}
const title = groupTitle(grant, parsed.conversationId) || '未命名群';
if (!parsed.senderId) {
return Object.freeze({
channel,
botId,
kind: 'group',
conversationKey,
conversationId: parsed.conversationId,
senderId: null,
phone: null,
pushName: null,
groupTitle: title,
label: `群 · ${title}`,
});
}
const phone = phoneFromIdentity(parsed.senderId);
const pushName = contactPushName(grant, phone);
const who = pushName || phone || parsed.senderId;
return Object.freeze({
channel,
botId,
kind: 'group',
conversationKey,
conversationId: parsed.conversationId,
senderId: parsed.senderId,
phone: phone || null,
pushName: pushName || null,
groupTitle: title,
label: `${title} · ${who}`,
});
}

View file

@ -47,6 +47,19 @@ export class ConversationStateStore {
return this.#state.sessions[key] ?? null;
}
/**
* Reverse-map a Harness session id to its conversation binding key.
* @param {string} sessionId
* @returns {string|null}
*/
keyForSession(sessionId) {
if (typeof sessionId !== 'string' || !sessionId) return null;
for (const [key, bound] of Object.entries(this.#state.sessions)) {
if (bound === sessionId) return key;
}
return null;
}
async setSession(key, sessionId) {
this.#state.sessions[key] = sessionId;
await this.#persist();