From 30b3d6ce619b564f42e23af831fc5c07f5d46b50 Mon Sep 17 00:00:00 2001 From: Mahmoud Ibrahim Date: Sat, 10 Jan 2026 01:01:51 +0200 Subject: [PATCH 1/3] WhatsApp: handle contact cards inbound --- src/web/inbound.test.ts | 30 ++++++++++++ src/web/inbound.ts | 100 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/src/web/inbound.test.ts b/src/web/inbound.test.ts index 6efcfa9e0..19c641240 100644 --- a/src/web/inbound.test.ts +++ b/src/web/inbound.test.ts @@ -28,6 +28,36 @@ describe("web inbound helpers", () => { expect(body).toBe("doc"); }); + it("extracts WhatsApp contact cards", () => { + const body = extractText({ + contactMessage: { + displayName: "Ada Lovelace", + vcard: [ + "BEGIN:VCARD", + "VERSION:3.0", + "FN:Ada Lovelace", + "TEL;TYPE=CELL:+15555550123", + "END:VCARD", + ].join("\n"), + }, + } as unknown as import("@whiskeysockets/baileys").proto.IMessage); + expect(body).toBe(""); + }); + + it("extracts multiple WhatsApp contact cards", () => { + const body = extractText({ + contactsArrayMessage: { + contacts: [ + { displayName: "Alice" }, + { displayName: "Bob" }, + { displayName: "Charlie" }, + { displayName: "Dana" }, + ], + }, + } as unknown as import("@whiskeysockets/baileys").proto.IMessage); + expect(body).toBe(""); + }); + it("unwraps view-once v2 extension messages", () => { const body = extractText({ viewOnceMessageV2Extension: { diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 1b112515f..d25768a55 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -732,6 +732,14 @@ export function extractText( candidate.documentMessage?.caption; if (caption?.trim()) return caption.trim(); } + const contactPlaceholder = + extractContactPlaceholder(message) ?? + (extracted && extracted !== message + ? extractContactPlaceholder( + extracted as proto.IMessage | undefined, + ) + : undefined); + if (contactPlaceholder) return contactPlaceholder; return undefined; } @@ -748,6 +756,98 @@ export function extractMediaPlaceholder( return undefined; } +function extractContactPlaceholder( + rawMessage: proto.IMessage | undefined, +): string | undefined { + const message = unwrapMessage(rawMessage); + if (!message) return undefined; + const contact = message.contactMessage ?? undefined; + if (contact) { + const { name, phone } = describeContact({ + displayName: contact.displayName, + vcard: contact.vcard, + }); + return formatContactPlaceholder(name, phone); + } + const contactsArray = message.contactsArrayMessage?.contacts ?? undefined; + if (!contactsArray || contactsArray.length === 0) return undefined; + const labels = contactsArray + .map((entry) => + describeContact({ displayName: entry.displayName, vcard: entry.vcard }), + ) + .map((entry) => entry.name ?? entry.phone) + .filter((value): value is string => Boolean(value)); + return formatContactsPlaceholder(labels, contactsArray.length); +} + +function describeContact(input: { + displayName?: string | null; + vcard?: string | null; +}): { name?: string; phone?: string } { + const displayName = (input.displayName ?? "").trim(); + const parsed = parseVcard(input.vcard ?? undefined); + const name = displayName || parsed.name; + const phone = parsed.phones[0]; + return { name, phone }; +} + +function parseVcard( + vcard?: string, +): { name?: string; phones: string[] } { + if (!vcard) return { phones: [] }; + const lines = vcard.split(/\r?\n/); + let name: string | undefined; + const phones: string[] = []; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) continue; + const key = line.slice(0, colonIndex).toUpperCase(); + const rawValue = line.slice(colonIndex + 1).trim(); + if (!rawValue) continue; + const value = cleanVcardValue(rawValue); + if (!value) continue; + if ((key === "FN" || key === "N") && !name) { + name = normalizeVcardName(value); + continue; + } + if (key.startsWith("TEL") || key.includes(".TEL")) { + phones.push(value); + } + } + return { name, phones }; +} + +function cleanVcardValue(value: string): string { + return value + .replace(/\\n/gi, " ") + .replace(/\\,/g, ",") + .replace(/\\;/g, ";") + .trim(); +} + +function normalizeVcardName(value: string): string { + return value.replace(/;/g, " ").replace(/\s+/g, " ").trim(); +} + +function formatContactPlaceholder(name?: string, phone?: string): string { + const parts = [name, phone].filter( + (value): value is string => Boolean(value), + ); + if (parts.length === 0) return ""; + return ``; +} + +function formatContactsPlaceholder(labels: string[], total: number): string { + const cleaned = labels.map((label) => label.trim()).filter(Boolean); + if (cleaned.length === 0) return ""; + const shown = cleaned.slice(0, 3); + const remaining = Math.max(total - shown.length, 0); + const suffix = remaining > 0 ? ` +${remaining} more` : ""; + return ``; +} + export function extractLocationData( rawMessage: proto.IMessage | undefined, ): NormalizedLocation | null { From c9c5a71d8b7c7b8a2869def9ef2a0c7713ff4d1c Mon Sep 17 00:00:00 2001 From: Mahmoud Ibrahim Date: Sat, 10 Jan 2026 01:02:24 +0200 Subject: [PATCH 2/3] Changelog: note WhatsApp contact cards --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8062d27d5..79768229a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ - WhatsApp: improve "no active web listener" errors (include account + relink hint). (#612) — thanks @YuriNachos - WhatsApp: add broadcast groups for multi-agent replies. (#547) — thanks @pasogott - WhatsApp: resolve @lid inbound senders via auth-dir mapping fallback + shared resolver. (#365) +- WhatsApp: treat shared contact cards as inbound messages. (#622) — thanks @mahmoudashraf93 - iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj From 1277f6e27bd18d47a66d8a1eecee532fdfece6ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 00:08:30 +0100 Subject: [PATCH 3/3] fix: prefer FN for WhatsApp contact cards (#622) (thanks @mahmoudashraf93) --- CHANGELOG.md | 2 +- src/web/inbound.test.ts | 16 ++++++++++++++++ src/web/inbound.ts | 25 +++++++++++++------------ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79768229a..03c27cef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ - WhatsApp: improve "no active web listener" errors (include account + relink hint). (#612) — thanks @YuriNachos - WhatsApp: add broadcast groups for multi-agent replies. (#547) — thanks @pasogott - WhatsApp: resolve @lid inbound senders via auth-dir mapping fallback + shared resolver. (#365) -- WhatsApp: treat shared contact cards as inbound messages. (#622) — thanks @mahmoudashraf93 +- WhatsApp: treat shared contact cards as inbound messages (prefer vCard FN). (#622) — thanks @mahmoudashraf93 - iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj diff --git a/src/web/inbound.test.ts b/src/web/inbound.test.ts index 19c641240..32817adfe 100644 --- a/src/web/inbound.test.ts +++ b/src/web/inbound.test.ts @@ -44,6 +44,22 @@ describe("web inbound helpers", () => { expect(body).toBe(""); }); + it("prefers FN over N in WhatsApp vcards", () => { + const body = extractText({ + contactMessage: { + vcard: [ + "BEGIN:VCARD", + "VERSION:3.0", + "N:Lovelace;Ada;;;", + "FN:Ada Lovelace", + "TEL;TYPE=CELL:+15555550123", + "END:VCARD", + ].join("\n"), + }, + } as unknown as import("@whiskeysockets/baileys").proto.IMessage); + expect(body).toBe(""); + }); + it("extracts multiple WhatsApp contact cards", () => { const body = extractText({ contactsArrayMessage: { diff --git a/src/web/inbound.ts b/src/web/inbound.ts index d25768a55..e44d4065d 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -735,9 +735,7 @@ export function extractText( const contactPlaceholder = extractContactPlaceholder(message) ?? (extracted && extracted !== message - ? extractContactPlaceholder( - extracted as proto.IMessage | undefined, - ) + ? extractContactPlaceholder(extracted as proto.IMessage | undefined) : undefined); if (contactPlaceholder) return contactPlaceholder; return undefined; @@ -791,12 +789,11 @@ function describeContact(input: { return { name, phone }; } -function parseVcard( - vcard?: string, -): { name?: string; phones: string[] } { +function parseVcard(vcard?: string): { name?: string; phones: string[] } { if (!vcard) return { phones: [] }; const lines = vcard.split(/\r?\n/); - let name: string | undefined; + let nameFromN: string | undefined; + let nameFromFn: string | undefined; const phones: string[] = []; for (const rawLine of lines) { const line = rawLine.trim(); @@ -808,15 +805,19 @@ function parseVcard( if (!rawValue) continue; const value = cleanVcardValue(rawValue); if (!value) continue; - if ((key === "FN" || key === "N") && !name) { - name = normalizeVcardName(value); + if (key === "FN" && !nameFromFn) { + nameFromFn = normalizeVcardName(value); + continue; + } + if (key === "N" && !nameFromN) { + nameFromN = normalizeVcardName(value); continue; } if (key.startsWith("TEL") || key.includes(".TEL")) { phones.push(value); } } - return { name, phones }; + return { name: nameFromFn ?? nameFromN, phones }; } function cleanVcardValue(value: string): string { @@ -832,8 +833,8 @@ function normalizeVcardName(value: string): string { } function formatContactPlaceholder(name?: string, phone?: string): string { - const parts = [name, phone].filter( - (value): value is string => Boolean(value), + const parts = [name, phone].filter((value): value is string => + Boolean(value), ); if (parts.length === 0) return ""; return ``;