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

@ -24,7 +24,7 @@ dsh plugin --profile web add -w "github:hansjone/dsh-im-ops"
# 重启 dsh web # 重启 dsh web
``` ```
包名:`dsh-im-ops@4.9.1-ops.1`(cordis id:`dsh-im-ops`)。 包名:`dsh-im-ops@4.9.1-ops.2`(cordis id:`dsh-im-ops`)。
扫码态一般仍在 `~/.dsh/integrations/…`;换包后若异常,在 IM 设置里重新关联设备。 扫码态一般仍在 `~/.dsh/integrations/…`;换包后若异常,在 IM 设置里重新关联设备。
@ -38,7 +38,9 @@ dsh plugin --profile web add -w "github:hansjone/dsh-im-ops"
2. **访问控制**仍由本 fork 持久化(`workspaces.json` 的 `accessPolicies`),设置 UI 与上游同构,运维可直接改白名单 / open 模式 2. **访问控制**仍由本 fork 持久化(`workspaces.json` 的 `accessPolicies`),设置 UI 与上游同构,运维可直接改白名单 / open 模式
3. **主动群通告**继续用既有 delivery(`botId + targetId`),与入站 Session 策略独立 3. **WhatsApp LID**:群参与者常为不透明 `@lid`,白名单只填手机号时会被静默拒绝。`4.9.1-ops.2` 用 Baileys LID→PN 映射扩展 sender 别名,并在 @ 提及匹配时同样解析;若仍被拒绝会回一句白名单提示
4. **主动群通告**继续用既有 delivery(`botId + targetId`),与入站 Session 策略独立
## 同步上游 ## 同步上游

View file

@ -571,7 +571,7 @@ var React23 = __toESM(require("react"), 1);
// package.json // package.json
var package_default = { var package_default = {
name: "dsh-im-ops", name: "dsh-im-ops",
version: "4.9.1-ops.1", version: "4.9.1-ops.2",
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 \u2014 access/session policies owned here.", description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 \u2014 access/session policies owned here.",
keywords: [ keywords: [
"deepseek-harness", "deepseek-harness",

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{ {
"name": "dsh-im-ops", "name": "dsh-im-ops",
"version": "4.9.1-ops.1", "version": "4.9.1-ops.2",
"description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 — access/session policies owned here.", "description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 — access/session policies owned here.",
"keywords": [ "keywords": [
"deepseek-harness", "deepseek-harness",

View file

@ -228,7 +228,11 @@ export class TextHarnessBridge {
const hasFiles = hasInboundFiles(normalized); const hasFiles = hasInboundFiles(normalized);
const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, { const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
conversationType: kind, 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, text: normalized.content,
hasImages, hasImages,
hasFiles, hasFiles,
@ -244,8 +248,14 @@ export class TextHarnessBridge {
messageId, messageId,
decision.reason === 'command-not-allowed' decision.reason === 'command-not-allowed'
? t(COMMAND_PERMISSION_DENIED_MESSAGE) ? t(COMMAND_PERMISSION_DENIED_MESSAGE)
: null, : (decision.reason === 'sender-not-allowed'
{ recordReceived: decision.reason === 'command-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 { ImagePromptError } from '../shared/image-prompt.mjs';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs'; import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs'; import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import { enrichWhatsappInboundIdentities } from './whatsapp-identity.mjs';
import { import {
WHATSAPP_ACCESS_MODES, WHATSAPP_ACCESS_MODES,
} from './config-store.mjs'; } from './config-store.mjs';
@ -637,6 +638,7 @@ export class WhatsappRuntime {
#client = null; #client = null;
#bridge = null; #bridge = null;
#starting = null; #starting = null;
#lidPnCache = new Map();
constructor({ constructor({
config, config,
@ -709,12 +711,17 @@ export class WhatsappRuntime {
{ code: 'relink-required' }, { code: 'relink-required' },
)), )),
onMessage: async (raw, context) => { onMessage: async (raw, context) => {
const message = normalizeWhatsappMessage(raw, this.#config.accountJid, { const normalized = normalizeWhatsappMessage(raw, this.#config.accountJid, {
download: createWhatsappMediaDownloader({ download: createWhatsappMediaDownloader({
socket: context?.socket, socket: context?.socket,
logger: this.#logger, 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; if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
this.#status.lastCheckedAt = Date.now(); this.#status.lastCheckedAt = Date.now();
await this.#bridge.accept(message); await this.#bridge.accept(message);

View file

@ -158,6 +158,7 @@ function createBridge({
signal, signal,
logger, logger,
reactions, reactions,
groupSessionScope = 'chat',
} = {}) { } = {}) {
return new TextHarnessBridge({ return new TextHarnessBridge({
descriptor: { key: 'test', label: 'Test', reactions }, descriptor: { key: 'test', label: 'Test', reactions },
@ -166,6 +167,7 @@ function createBridge({
state, state,
signal, signal,
logger: logger ?? { warn() {}, error() {} }, logger: logger ?? { warn() {}, error() {} },
groupSessionScope,
}); });
} }

View file

@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
enrichWhatsappInboundIdentities,
isWhatsappLidJid,
rememberWhatsappLidPnPairs,
} from '../../../src/channels/whatsapp/whatsapp-identity.mjs';
describe('whatsapp-identity', () => {
it('recognizes lid jids', () => {
assert.equal(isWhatsappLidJid('91010910658657@lid'), true);
assert.equal(isWhatsappLidJid('8618142387786@s.whatsapp.net'), false);
});
it('remembers lid/pn pairs from message keys', () => {
const cache = new Map();
rememberWhatsappLidPnPairs(cache, {
participant: '91010910658657@lid',
participantAlt: '8618142387786@s.whatsapp.net',
});
assert.equal(cache.get('91010910658657'), '8618142387786@s.whatsapp.net');
});
it('enriches group sender aliases and mentions from lid mapping', async () => {
const cache = new Map();
const socket = {
signalRepository: {
lidMapping: {
async getPNForLID(lid) {
if (lid === '91010910658657@lid') return '8618142387786@s.whatsapp.net';
if (lid === '111222333444555@lid') return '8615601877957@s.whatsapp.net';
return null;
},
async getLIDForPN(pn) {
if (pn === '8615601877957@s.whatsapp.net') return '111222333444555@lid';
return null;
},
},
},
};
const raw = {
key: {
remoteJid: '120363429229984366@g.us',
participant: '91010910658657@lid',
id: 'msg-1',
fromMe: false,
},
message: {
extendedTextMessage: {
text: '@bot hello',
contextInfo: { mentionedJid: ['111222333444555@lid'] },
},
},
};
const normalized = {
messageId: 'g:msg-1',
providerMessageId: 'msg-1',
senderId: '91010910658657@lid',
senderAlternateId: '',
kind: 'group',
conversationId: '120363429229984366@g.us',
content: '@bot hello',
addressed: false,
};
const enriched = await enrichWhatsappInboundIdentities(normalized, raw, {
accountJid: '8615601877957@s.whatsapp.net',
socket,
lidPnCache: cache,
});
assert.equal(enriched.addressed, true);
assert.ok(enriched.senderAliasIds.includes('8618142387786@s.whatsapp.net'));
});
});