From 53c24bcdc74a98578d5170241db37ac870a8e830 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 6 Mar 2026 11:07:32 -0800 Subject: [PATCH] fix: resolve WhatsApp LID to phone number for inbound DMs (#510) --- src/channels/whatsapp/inbound/extract.test.ts | 118 ++++++++++++++++++ src/channels/whatsapp/inbound/extract.ts | 95 +++++++++++++- 2 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 src/channels/whatsapp/inbound/extract.test.ts diff --git a/src/channels/whatsapp/inbound/extract.test.ts b/src/channels/whatsapp/inbound/extract.test.ts new file mode 100644 index 0000000..9e2acbd --- /dev/null +++ b/src/channels/whatsapp/inbound/extract.test.ts @@ -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; + message?: Record; + messageTimestamp?: number; + pushName?: string; + } = {} +): Record { + 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 } = {}): Record { + 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'); + }); +}); diff --git a/src/channels/whatsapp/inbound/extract.ts b/src/channels/whatsapp/inbound/extract.ts index ba37bdf..35acd14 100644 --- a/src/channels/whatsapp/inbound/extract.ts +++ b/src/channels/whatsapp/inbound/extract.ts @@ -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 } | 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),