Fix WhatsApp group access when senders are opaque LIDs.

Resolve LID to phone aliases before allowlist and @mention checks so
group prompts are not silently dropped after a working direct chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 15:56:24 +08:00
parent 7f1b9bd480
commit d9edca4d5f
9 changed files with 472 additions and 203 deletions

View file

@ -228,7 +228,11 @@ export class TextHarnessBridge {
const hasFiles = hasInboundFiles(normalized);
const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
conversationType: kind,
senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
senderIds: [
senderId,
cleanText(normalized.senderAlternateId),
...(Array.isArray(normalized.senderAliasIds) ? normalized.senderAliasIds : []),
].filter(Boolean),
text: normalized.content,
hasImages,
hasFiles,
@ -244,8 +248,14 @@ export class TextHarnessBridge {
messageId,
decision.reason === 'command-not-allowed'
? t(COMMAND_PERMISSION_DENIED_MESSAGE)
: null,
{ recordReceived: decision.reason === 'command-not-allowed' },
: (decision.reason === 'sender-not-allowed'
&& (normalized.kind !== 'group' || normalized.addressed === true)
? t('你不在当前机器人的访问白名单中。')
: null),
{
recordReceived: decision.reason === 'command-not-allowed'
|| decision.reason === 'sender-not-allowed',
},
);
}
}

View file

@ -0,0 +1,174 @@
/**
* Resolve WhatsApp LID ↔ phone JIDs so group allowlists and @mentions work
* when Baileys only surfaces opaque @lid identities on the wire.
*/
import { areJidsSameUser, jidDecode } from '@whiskeysockets/baileys';
function isLidServer(server) {
return server === 'lid' || server === 'hosted.lid';
}
function isPnServer(server) {
return server === 's.whatsapp.net' || server === 'c.us' || server === 'hosted';
}
export function isWhatsappLidJid(value) {
if (typeof value !== 'string' || !value) return false;
const decoded = jidDecode(value.trim());
return Boolean(decoded && isLidServer(decoded.server));
}
export function isWhatsappPnJid(value) {
if (typeof value !== 'string' || !value) return false;
const decoded = jidDecode(value.trim());
return Boolean(decoded && isPnServer(decoded.server) && /^\d+$/.test(decoded.user));
}
/**
* Learn LID/PN pairs from a Baileys message key (remoteJidAlt / participantAlt).
* @param {Map<string, string>} cache lid-user → pn JID
* @param {object} key
*/
export function rememberWhatsappLidPnPairs(cache, key) {
if (!cache || !key || typeof key !== 'object') return;
const pairs = [
[key.remoteJid, key.remoteJidAlt],
[key.participant, key.participantAlt],
[key.remoteJidAlt, key.remoteJid],
[key.participantAlt, key.participant],
];
for (const [left, right] of pairs) {
if (isWhatsappLidJid(left) && isWhatsappPnJid(right)) {
const decoded = jidDecode(left);
if (decoded?.user) cache.set(decoded.user, right);
}
}
}
async function lookupPnForLid(socket, cache, lid) {
const decoded = jidDecode(lid);
if (!decoded?.user) return null;
const cached = cache?.get(decoded.user);
if (cached) return cached;
const mapping = socket?.signalRepository?.lidMapping;
if (!mapping || typeof mapping.getPNForLID !== 'function') return null;
try {
const pn = await mapping.getPNForLID(lid);
if (typeof pn === 'string' && pn) {
cache?.set(decoded.user, pn);
return pn;
}
} catch {
// Mapping misses stay fail-closed for that alias only.
}
return null;
}
async function lookupLidForPn(socket, pn) {
const mapping = socket?.signalRepository?.lidMapping;
if (!mapping || typeof mapping.getLIDForPN !== 'function') return null;
try {
const lid = await mapping.getLIDForPN(pn);
return typeof lid === 'string' && lid ? lid : null;
} catch {
return null;
}
}
/**
* Expand inbound WhatsApp identities with PN/LID aliases and re-evaluate
* group @mention against the linked account.
*
* @param {object|null} message normalizeWhatsappMessage result
* @param {object} raw Baileys WAMessage
* @param {{ accountJid: string, socket?: object, lidPnCache?: Map<string, string> }} options
* @returns {Promise<object|null>}
*/
export async function enrichWhatsappInboundIdentities(message, raw, {
accountJid,
socket,
lidPnCache,
} = {}) {
if (!message) return null;
if (raw?.key) rememberWhatsappLidPnPairs(lidPnCache, raw.key);
const aliasIds = new Set();
if (typeof message.senderAlternateId === 'string' && message.senderAlternateId) {
aliasIds.add(message.senderAlternateId);
}
for (const candidate of [message.senderId, message.senderAlternateId]) {
if (!isWhatsappLidJid(candidate)) continue;
const pn = await lookupPnForLid(socket, lidPnCache, candidate);
if (pn) aliasIds.add(pn);
}
let addressed = message.addressed === true;
if (message.kind === 'group' && addressed !== true) {
const content = raw?.message;
let current = content;
let context = null;
for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth += 1) {
context = current.extendedTextMessage?.contextInfo
?? current.imageMessage?.contextInfo
?? current.videoMessage?.contextInfo
?? current.documentMessage?.contextInfo
?? null;
if (context) break;
const wrapper = ['ephemeralMessage', 'viewOnceMessage', 'documentWithCaptionMessage',
'viewOnceMessageV2', 'viewOnceMessageV2Extension', 'editedMessage',
'associatedChildMessage', 'groupStatusMessage', 'groupStatusMessageV2']
.find((key) => current[key]?.message);
if (!wrapper) break;
current = current[wrapper].message;
}
const mentioned = Array.isArray(context?.mentionedJid) ? context.mentionedJid : [];
const accountLid = await lookupLidForPn(socket, accountJid);
for (const jid of mentioned) {
if (typeof jid !== 'string' || !jid) continue;
try {
if (areJidsSameUser(jid, accountJid) === true) {
addressed = true;
break;
}
} catch {
// continue
}
if (accountLid) {
try {
if (areJidsSameUser(jid, accountLid) === true) {
addressed = true;
break;
}
} catch {
// continue
}
}
if (isWhatsappLidJid(jid)) {
const pn = await lookupPnForLid(socket, lidPnCache, jid);
if (pn) {
try {
if (areJidsSameUser(pn, accountJid) === true) {
addressed = true;
break;
}
} catch {
// continue
}
}
}
}
}
aliasIds.delete(message.senderId);
const senderAliasIds = [...aliasIds];
return {
...message,
addressed,
...(senderAliasIds.length > 0 ? { senderAliasIds } : {}),
...(senderAliasIds[0] && !message.senderAlternateId
? { senderAlternateId: senderAliasIds[0] }
: {}),
};
}

View file

@ -12,6 +12,7 @@ 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 } from './whatsapp-identity.mjs';
import {
WHATSAPP_ACCESS_MODES,
} from './config-store.mjs';
@ -637,6 +638,7 @@ export class WhatsappRuntime {
#client = null;
#bridge = null;
#starting = null;
#lidPnCache = new Map();
constructor({
config,
@ -709,12 +711,17 @@ export class WhatsappRuntime {
{ code: 'relink-required' },
)),
onMessage: async (raw, context) => {
const message = normalizeWhatsappMessage(raw, this.#config.accountJid, {
const normalized = normalizeWhatsappMessage(raw, this.#config.accountJid, {
download: createWhatsappMediaDownloader({
socket: context?.socket,
logger: this.#logger,
}),
});
const message = await enrichWhatsappInboundIdentities(normalized, raw, {
accountJid: this.#config.accountJid,
socket: context?.socket ?? this.#session?.socket,
lidPnCache: this.#lidPnCache,
});
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
this.#status.lastCheckedAt = Date.now();
await this.#bridge.accept(message);