Sync WhatsApp group subjects into access UI (4.9.1-ops.10).

Refresh missing/forced group titles via groupMetadata and participating catalog so cards show real names instead of bare JIDs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 17:39:25 +08:00
parent a169b164a8
commit f96157da1e
13 changed files with 568 additions and 260 deletions

View file

@ -238,6 +238,22 @@ export class WhatsappController {
});
}
async refreshAccessGroupTitles(botId, groupJids) {
if (this.#closed) throw new Error('WhatsApp controller is closed');
return this.#withBotTransition(botId, async () => {
if (this.#closed) throw new Error('WhatsApp controller is closed');
const runtime = this.#runtimes.get(botId);
if (!runtime || typeof runtime.refreshGroupTitles !== 'function') {
const error = new Error(t('WhatsApp机器人尚未连接'));
error.code = 'bot-not-connected';
throw error;
}
await runtime.refreshGroupTitles(groupJids);
this.#touch();
return this.status();
});
}
async setAccessPolicy(botId, value) {
if (this.#closed) throw new Error('WhatsApp controller is closed');
const accessPolicy = normalizeWhatsappAccessPolicy(value);

View file

@ -241,18 +241,86 @@ export async function enrichWhatsappInboundIdentities(message, raw, {
/**
* Resolve a WhatsApp group subject for display / access-grant group cards.
* @param {{ groupMetadata?: (jid: string) => Promise<{ subject?: string }> }|null|undefined} socket
* Tries groupMetadata first, then groupFetchAllParticipating as a linked-device fallback.
* @param {object|null|undefined} socket
* @param {string} groupJid
* @returns {Promise<string>}
*/
export async function resolveWhatsappGroupSubject(socket, groupJid) {
if (!socket || typeof socket.groupMetadata !== 'function') return '';
if (typeof groupJid !== 'string' || !groupJid.endsWith('@g.us')) return '';
try {
const meta = await socket.groupMetadata(groupJid);
const subject = typeof meta?.subject === 'string' ? meta.subject.trim() : '';
return subject.slice(0, 128);
} catch {
if (!socket || typeof groupJid !== 'string' || !groupJid.endsWith('@g.us')) return '';
const fromMeta = (meta) => {
for (const key of ['subject', 'name', 'topic']) {
if (typeof meta?.[key] === 'string' && meta[key].trim()) {
return meta[key].trim().slice(0, 128);
}
}
return '';
};
if (typeof socket.groupMetadata === 'function') {
try {
const subject = fromMeta(await socket.groupMetadata(groupJid));
if (subject) return subject;
} catch {
// Fall through to participating catalog.
}
}
if (typeof socket.groupFetchAllParticipating === 'function') {
try {
const catalog = await socket.groupFetchAllParticipating();
if (catalog && typeof catalog === 'object') {
const direct = fromMeta(catalog[groupJid]);
if (direct) return direct;
for (const meta of Object.values(catalog)) {
if (meta?.id === groupJid || meta?.id === groupJid.replace(/@g\.us$/i, '')) {
const subject = fromMeta(meta);
if (subject) return subject;
}
}
}
} catch {
return '';
}
}
return '';
}
/**
* Resolve many group subjects in one participating fetch when possible.
* @param {object|null|undefined} socket
* @param {string[]} groupJids
* @returns {Promise<Record<string, string>>}
*/
export async function resolveWhatsappGroupSubjects(socket, groupJids) {
const jids = [...new Set((Array.isArray(groupJids) ? groupJids : [])
.filter((jid) => typeof jid === 'string' && jid.endsWith('@g.us')))];
/** @type {Record<string, string>} */
const titles = {};
if (!socket || jids.length === 0) return titles;
let catalog = null;
if (typeof socket.groupFetchAllParticipating === 'function') {
try {
catalog = await socket.groupFetchAllParticipating();
} catch {
catalog = null;
}
}
for (const jid of jids) {
let title = '';
if (catalog && typeof catalog === 'object') {
const meta = catalog[jid]
?? Object.values(catalog).find((entry) => entry?.id === jid);
if (typeof meta?.subject === 'string' && meta.subject.trim()) {
title = meta.subject.trim().slice(0, 128);
}
}
if (!title) title = await resolveWhatsappGroupSubject(socket, jid);
if (title) titles[jid] = title;
}
return titles;
}

View file

@ -7,12 +7,13 @@ import {
normalizeMessageContent,
} from '@whiskeysockets/baileys';
import { emptyAccessGrant, ensureGroupBucket } from '../shared/access-grant.mjs';
import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { t } from '../shared/i18n.mjs';
import { ImagePromptError } from '../shared/image-prompt.mjs';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import { enrichWhatsappInboundIdentities, resolveWhatsappGroupSubject } from './whatsapp-identity.mjs';
import { enrichWhatsappInboundIdentities, resolveWhatsappGroupSubject, resolveWhatsappGroupSubjects } from './whatsapp-identity.mjs';
import {
gateWhatsappInbound,
loadWhatsappAccessGrant,
@ -859,6 +860,47 @@ export class WhatsappRuntime {
return { sent: true };
}
/**
* Sync WhatsApp group subjects into accessGrant.groups[*].title.
* @param {string[]|null|undefined} groupJids
*/
async refreshGroupTitles(groupJids) {
if (!this.#workspaces || !this.#botId) {
const error = new Error('Access grant store is unavailable');
error.code = 'access-grant-unavailable';
throw error;
}
const socket = this.#session?.socket;
if (!this.#status.ready || !socket) {
const error = new Error(t('WhatsApp机器人尚未连接'));
error.code = 'bot-not-connected';
throw error;
}
let grant = this.#workspaces.accessGrantFor(this.#botId) ?? emptyAccessGrant();
const targets = Array.isArray(groupJids) && groupJids.length > 0
? groupJids
: Object.keys(grant.groups ?? {});
const missing = targets.filter((jid) => {
const title = grant.groups?.[jid]?.title;
return typeof jid === 'string' && jid.endsWith('@g.us')
&& !(typeof title === 'string' && title.trim());
});
// Always allow forced refresh of explicit jids even when title already set.
const forced = Array.isArray(groupJids) && groupJids.length > 0
? targets.filter((jid) => typeof jid === 'string' && jid.endsWith('@g.us'))
: missing;
const titles = await resolveWhatsappGroupSubjects(socket, forced);
let changed = false;
for (const [jid, title] of Object.entries(titles)) {
const previousTitle = grant.groups?.[jid]?.title ?? '';
if (previousTitle === title) continue;
grant = ensureGroupBucket(grant, jid, { title });
changed = true;
}
if (changed) await this.#workspaces.setAccessGrant(this.#botId, grant);
return grant;
}
async sendProactiveText(target, text, { signal } = {}) {
const jid = typeof target?.route?.jid === 'string' ? target.route.jid.trim() : '';
const validUser = target?.kind === 'user'