mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
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:
parent
7f1b9bd480
commit
d9edca4d5f
9 changed files with 472 additions and 203 deletions
6
FORK.md
6
FORK.md
|
|
@ -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 策略独立
|
||||||
|
|
||||||
## 同步上游
|
## 同步上游
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
390
lib/index.js
390
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
src/channels/whatsapp/whatsapp-identity.mjs
Normal file
174
src/channels/whatsapp/whatsapp-identity.mjs
Normal 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] }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
74
test/channels/whatsapp/whatsapp-identity.test.mjs
Normal file
74
test/channels/whatsapp/whatsapp-identity.test.mjs
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue