Merge pull request #622 from mahmoudashraf93/fix/whatsapp-contact-cards

fix: handle WhatsApp contact cards inbound
This commit is contained in:
Peter Steinberger 2026-01-09 23:09:40 +00:00 committed by GitHub
commit 2396a66e82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 148 additions and 0 deletions

View File

@ -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 (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

View File

@ -28,6 +28,52 @@ 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("<contact: Ada Lovelace, +15555550123>");
});
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("<contact: Ada Lovelace, +15555550123>");
});
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("<contacts: Alice, Bob, Charlie +1 more>");
});
it("unwraps view-once v2 extension messages", () => {
const body = extractText({
viewOnceMessageV2Extension: {

View File

@ -732,6 +732,12 @@ 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 +754,101 @@ 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 nameFromN: string | undefined;
let nameFromFn: 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" && !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: nameFromFn ?? nameFromN, 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 "<contact>";
return `<contact: ${parts.join(", ")}>`;
}
function formatContactsPlaceholder(labels: string[], total: number): string {
const cleaned = labels.map((label) => label.trim()).filter(Boolean);
if (cleaned.length === 0) return "<contacts>";
const shown = cleaned.slice(0, 3);
const remaining = Math.max(total - shown.length, 0);
const suffix = remaining > 0 ? ` +${remaining} more` : "";
return `<contacts: ${shown.join(", ")}${suffix}>`;
}
export function extractLocationData(
rawMessage: proto.IMessage | undefined,
): NormalizedLocation | null {