fix: resolve WhatsApp LID to phone number for inbound DMs (#510)
This commit is contained in:
118
src/channels/whatsapp/inbound/extract.test.ts
Normal file
118
src/channels/whatsapp/inbound/extract.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GroupMetaCache } from '../utils.js';
|
||||
import { extractInboundMessage } from './extract.js';
|
||||
|
||||
const REMOTE_LID = '210501234567890@lid';
|
||||
|
||||
function createMessage(
|
||||
overrides: {
|
||||
key?: Record<string, unknown>;
|
||||
message?: Record<string, unknown>;
|
||||
messageTimestamp?: number;
|
||||
pushName?: string;
|
||||
} = {}
|
||||
): Record<string, unknown> {
|
||||
const { key: keyOverrides, message: messageOverrides, ...rest } = overrides;
|
||||
return {
|
||||
key: {
|
||||
remoteJid: REMOTE_LID,
|
||||
id: 'msg-1',
|
||||
...keyOverrides,
|
||||
},
|
||||
message: {
|
||||
conversation: 'hello from web',
|
||||
...messageOverrides,
|
||||
},
|
||||
messageTimestamp: 1700000000,
|
||||
pushName: 'Alice',
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function createSocket(options: { lidMapping?: Map<string, string> } = {}): Record<string, unknown> {
|
||||
return {
|
||||
user: { id: '19998887777@s.whatsapp.net' },
|
||||
signalRepository: options.lidMapping ? { lidMapping: options.lidMapping } : {},
|
||||
groupMetadata: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupMetaCache(): GroupMetaCache {
|
||||
return {
|
||||
get: vi.fn(async () => ({ expires: Date.now() + 60_000 })),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('extractInboundMessage (LID DM resolution)', () => {
|
||||
it('resolves LID DMs via senderPn and normalizes chatId to a PN JID', async () => {
|
||||
const msg = createMessage({
|
||||
key: {
|
||||
senderPn: '15551234567@s.whatsapp.net',
|
||||
},
|
||||
});
|
||||
const sock = createSocket();
|
||||
|
||||
const extracted = await extractInboundMessage(
|
||||
msg as any,
|
||||
sock as any,
|
||||
createGroupMetaCache()
|
||||
);
|
||||
|
||||
expect(extracted?.chatId).toBe('15551234567@s.whatsapp.net');
|
||||
expect(extracted?.from).toBe('15551234567');
|
||||
expect(extracted?.senderE164).toBe('15551234567');
|
||||
});
|
||||
|
||||
it('falls back to signalRepository.lidMapping when senderPn is missing', async () => {
|
||||
const msg = createMessage();
|
||||
const sock = createSocket({
|
||||
lidMapping: new Map([[REMOTE_LID, '16667778888@s.whatsapp.net']]),
|
||||
});
|
||||
|
||||
const extracted = await extractInboundMessage(
|
||||
msg as any,
|
||||
sock as any,
|
||||
createGroupMetaCache()
|
||||
);
|
||||
|
||||
expect(extracted?.chatId).toBe('16667778888@s.whatsapp.net');
|
||||
expect(extracted?.from).toBe('16667778888');
|
||||
expect(extracted?.senderE164).toBe('16667778888');
|
||||
});
|
||||
|
||||
it('falls back to the LID-derived number when no mapping is available', async () => {
|
||||
const msg = createMessage();
|
||||
const sock = createSocket();
|
||||
|
||||
const extracted = await extractInboundMessage(
|
||||
msg as any,
|
||||
sock as any,
|
||||
createGroupMetaCache()
|
||||
);
|
||||
|
||||
expect(extracted?.chatId).toBe(REMOTE_LID);
|
||||
expect(extracted?.from).toBe('210501234567890');
|
||||
expect(extracted?.senderE164).toBe('210501234567890');
|
||||
});
|
||||
|
||||
it('accepts plain phone-number senderPn values by converting them to PN JIDs', async () => {
|
||||
const msg = createMessage({
|
||||
key: {
|
||||
senderPn: '+1 (555) 222-3333',
|
||||
},
|
||||
});
|
||||
const sock = createSocket();
|
||||
|
||||
const extracted = await extractInboundMessage(
|
||||
msg as any,
|
||||
sock as any,
|
||||
createGroupMetaCache()
|
||||
);
|
||||
|
||||
expect(extracted?.chatId).toBe('15552223333@s.whatsapp.net');
|
||||
expect(extracted?.from).toBe('15552223333');
|
||||
expect(extracted?.senderE164).toBe('15552223333');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* Based on OpenClaw's extract.ts pattern.
|
||||
*/
|
||||
|
||||
import { jidToE164, isGroupJid } from "../utils.js";
|
||||
import { jidToE164, isGroupJid, isLid } from "../utils.js";
|
||||
import type { WebInboundMessage, AttachmentExtractionConfig } from "./types.js";
|
||||
import type { GroupMetaCache } from "../utils.js";
|
||||
import { unwrapMessageContent, extractMediaPreview, collectAttachments } from "./media.js";
|
||||
@@ -115,6 +115,69 @@ export function extractMentionedJids(message: import("@whiskeysockets/baileys").
|
||||
* @param groupMetaCache - Group metadata cache
|
||||
* @returns Normalized message or null if invalid
|
||||
*/
|
||||
/**
|
||||
* Resolve an LID (Linked ID) to a real phone number JID for inbound messages.
|
||||
*
|
||||
* LIDs are privacy-focused identifiers used by WhatsApp Web. The same user
|
||||
* has different JIDs depending on their device:
|
||||
* - Phone app: 34600...@s.whatsapp.net (real phone number)
|
||||
* - WhatsApp Web: xxxxx@lid (opaque linked ID)
|
||||
*
|
||||
* Without resolution, the same user gets different userIds, breaking
|
||||
* debouncing, daily limits, and conversation routing.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. msg.key.senderPn - provided by Baileys when available
|
||||
* 2. sock.signalRepository.lidMapping - Baileys built-in mapping
|
||||
* 3. Fall back to LID-stripped number (current behavior)
|
||||
*
|
||||
* @param lidJid - The LID JID to resolve (e.g., "12345@lid")
|
||||
* @param msg - Baileys message (may contain senderPn)
|
||||
* @param sock - Baileys socket (has signalRepository)
|
||||
* @returns Resolved phone number JID or null if not resolvable
|
||||
*/
|
||||
function resolveLidToPhoneJid(
|
||||
lidJid: string,
|
||||
msg: import("@whiskeysockets/baileys").WAMessage,
|
||||
sock: import("@whiskeysockets/baileys").WASocket
|
||||
): string | null {
|
||||
const normalizePhoneJid = (value: string | undefined): string | null => {
|
||||
if (!value) return null;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isLid(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.includes('@')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Defensive fallback: handle plain phone numbers by converting to a PN JID.
|
||||
const digits = trimmed.replace(/[^\d]/g, '');
|
||||
if (!digits) {
|
||||
return null;
|
||||
}
|
||||
return `${digits}@s.whatsapp.net`;
|
||||
};
|
||||
|
||||
// Try senderPn from message key (most reliable)
|
||||
const senderPn = normalizePhoneJid(msg.key?.senderPn);
|
||||
if (senderPn) {
|
||||
return senderPn;
|
||||
}
|
||||
|
||||
// Try signalRepository.lidMapping (Baileys built-in)
|
||||
const signalRepo = sock.signalRepository as unknown as { lidMapping?: Map<string, string> } | undefined;
|
||||
const signalMapping = normalizePhoneJid(signalRepo?.lidMapping?.get(lidJid));
|
||||
if (signalMapping) {
|
||||
return signalMapping;
|
||||
}
|
||||
|
||||
// Could not resolve
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function extractInboundMessage(
|
||||
msg: import("@whiskeysockets/baileys").WAMessage,
|
||||
sock: import("@whiskeysockets/baileys").WASocket,
|
||||
@@ -163,10 +226,13 @@ export async function extractInboundMessage(
|
||||
return null; // Skip messages with no text and no media
|
||||
}
|
||||
|
||||
// Determine sender
|
||||
// Determine sender and chatId
|
||||
// For LID-based DMs, we need to resolve to the real phone number
|
||||
// so the same user gets consistent userIds regardless of device
|
||||
let from: string;
|
||||
let senderE164: string | undefined;
|
||||
let senderJid: string | undefined;
|
||||
let resolvedChatId = remoteJid; // Normalized chat ID (phone JID for LID DMs)
|
||||
|
||||
if (isGroup) {
|
||||
// Group message - sender is the participant
|
||||
@@ -174,9 +240,26 @@ export async function extractInboundMessage(
|
||||
senderJid = participantJid ? participantJid : undefined;
|
||||
senderE164 = participantJid ? jidToE164(participantJid) : undefined;
|
||||
} else {
|
||||
// DM - sender is the remote JID
|
||||
from = jidToE164(remoteJid);
|
||||
senderE164 = from;
|
||||
// DM - check if this is an LID that needs resolution
|
||||
if (isLid(remoteJid)) {
|
||||
// Try to resolve LID to real phone number JID
|
||||
const resolvedJid = resolveLidToPhoneJid(remoteJid, msg, sock);
|
||||
if (resolvedJid) {
|
||||
// Successfully resolved - use real phone number
|
||||
from = jidToE164(resolvedJid);
|
||||
senderE164 = from;
|
||||
resolvedChatId = resolvedJid; // Normalize chatId to phone JID
|
||||
} else {
|
||||
// Could not resolve - fall back to LID-stripped number
|
||||
// This maintains backward compatibility but may cause user ID fragmentation
|
||||
from = jidToE164(remoteJid);
|
||||
senderE164 = from;
|
||||
}
|
||||
} else {
|
||||
// Regular phone JID - use as-is
|
||||
from = jidToE164(remoteJid);
|
||||
senderE164 = from;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch group metadata if needed
|
||||
@@ -209,7 +292,7 @@ export async function extractInboundMessage(
|
||||
id: messageId ?? undefined,
|
||||
from,
|
||||
to: selfE164 ?? "me",
|
||||
chatId: remoteJid,
|
||||
chatId: resolvedChatId, // Use resolved chatId (phone JID for LID DMs)
|
||||
body: finalBody,
|
||||
pushName: msg.pushName ?? undefined,
|
||||
timestamp: new Date(Number(msg.messageTimestamp) * 1000),
|
||||
|
||||
Reference in New Issue
Block a user