From 56b5e2f97acfe9bece34ecdff5e6c40122e7a8ef Mon Sep 17 00:00:00 2001 From: "Sven Nicolai E. Eger" Date: Fri, 28 Aug 2026 12:13:50 +0200 Subject: [PATCH] fix(bridge): persist shared contact cards (vCards) as searchable text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContactMessage and ContactsArrayMessage carry their vCard inline (no CDN payload), so extractTextContent and extractMediaInfo both returned empty and the message was silently dropped at the no-content/no-media gate in handleMessage — shared contacts never reached messages.db. Because the row is never written, a reaction to a shared contact is also left as an orphaned pointer. Store them as searchable text instead: display name plus every TEL value from the vCard body, e.g. "📇 John Doe (+62 812..., +47 22...)". Handles iPhone-style grouped properties (item1.TEL;...) and CRLF vCards. A card with neither a display name nor a TEL line yields "" so it still hits the gate rather than writing a "📇 " placeholder row. Also route the history-sync message loop through extractTextContent instead of its own inline Conversation/ExtendedText check, so contact cards (and media captions and hydrated templates, which the live path already handled) are surfaced when they arrive via history sync — a freshly paired device, or the on-demand per-chat sync from #168. Thanks to @GeRryCh for independently reproducing this and pointing out the history-sync gap and the degenerate-card edge case. Co-Authored-By: Claude Sonnet 5 --- whatsapp-bridge/main.go | 83 ++++++++++++++++++++++++++++++++---- whatsapp-bridge/main_test.go | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 9 deletions(-) diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index a17c0db..2d00810 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -1016,6 +1016,30 @@ func extractTextContent(msg *waProto.Message) string { return doc.GetCaption() } + // Shared contact cards (vCards) carry no URL/MediaKey — the vCard text is + // embedded directly in the message rather than fetched from the CDN — so + // without this branch extractMediaInfo also returns "" for them and the + // message is silently dropped at the "no content and no media" gate in + // handleMessage: shared contacts were vanishing entirely. + if contact := msg.GetContactMessage(); contact != nil { + if body := formatContactContent(contact.GetDisplayName(), contact.GetVcard()); body != "" { + return "📇 " + body + } + } + if contacts := msg.GetContactsArrayMessage(); contacts != nil { + if list := contacts.GetContacts(); len(list) > 0 { + names := make([]string, 0, len(list)) + for _, c := range list { + if body := formatContactContent(c.GetDisplayName(), c.GetVcard()); body != "" { + names = append(names, body) + } + } + if len(names) > 0 { + return fmt.Sprintf("📇 %d contacts shared: %s", len(names), strings.Join(names, "; ")) + } + } + } + // WhatsApp Business templates arrive hydrated — body lives in // HydratedTemplate.HydratedContentText. Without this branch every // template-sent message (e.g. WABA Connect Hrms_* notifications) @@ -1061,6 +1085,49 @@ func extractTextContent(msg *waProto.Message) string { return "" } +// formatContactContent renders a shared contact card as searchable text: +// the display name plus every phone number in the vCard body. All numbers are +// kept (not just the first) because the vCard is the only copy we ever get — +// there is no CDN payload to re-download later. Returns "" when there is +// nothing usable (no display name and no TEL line). +func formatContactContent(displayName, vcard string) string { + phones := extractVCardPhones(vcard) + if displayName == "" && len(phones) == 0 { + return "" + } + if len(phones) > 0 { + return fmt.Sprintf("%s (%s)", displayName, strings.Join(phones, ", ")) + } + return displayName +} + +// extractVCardPhones returns the values of all TEL lines in a vCard blob, +// e.g. "TEL;type=CELL;waid=6281234567890:+62 812-3456-7890" -> "+62 812-3456-7890". +// iPhone-exported vCards wrap properties in groups ("item1.TEL;...:+62 ..."), +// so the property name is compared after stripping any group prefix. +func extractVCardPhones(vcard string) []string { + var phones []string + for _, line := range strings.Split(vcard, "\n") { + line = strings.TrimSpace(line) + prop := line + if i := strings.IndexAny(prop, ";:"); i != -1 { + prop = prop[:i] + } + if dot := strings.LastIndex(prop, "."); dot != -1 { + prop = prop[dot+1:] + } + if !strings.EqualFold(prop, "TEL") { + continue + } + if idx := strings.LastIndex(line, ":"); idx != -1 { + if phone := strings.TrimSpace(line[idx+1:]); phone != "" { + phones = append(phones, phone) + } + } + } + return phones +} + // SendMessageResponse represents the response for the send message API type SendMessageResponse struct { Success bool `json:"success"` @@ -3399,15 +3466,13 @@ func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, his continue } - // Extract text content - var content string - if msg.Message.Message != nil { - if conv := msg.Message.Message.GetConversation(); conv != "" { - content = conv - } else if ext := msg.Message.Message.GetExtendedTextMessage(); ext != nil { - content = ext.GetText() - } - } + // Extract text content via the shared extractor — the same + // one the live path uses — so contact cards, media captions + // and hydrated templates are surfaced on the history-sync + // path too. This inline block previously handled only + // Conversation/ExtendedText and silently dropped everything + // else arriving through history sync. + content := extractTextContent(msg.Message.Message) // Extract media info - pass message timestamp + ID for unique filenames var mediaType, filename, url string diff --git a/whatsapp-bridge/main_test.go b/whatsapp-bridge/main_test.go index 04c2c83..56342a3 100644 --- a/whatsapp-bridge/main_test.go +++ b/whatsapp-bridge/main_test.go @@ -1287,6 +1287,86 @@ func TestExtractTextContent_SurfacesMediaCaptions(t *testing.T) { msg: &waProto.Message{ImageMessage: &waProto.ImageMessage{}}, want: "", }, + { + name: "ContactMessage with phone number in vCard", + msg: &waProto.Message{ + ContactMessage: &waProto.ContactMessage{ + DisplayName: proto.String("John Doe"), + Vcard: proto.String("BEGIN:VCARD\nVERSION:3.0\nN:;John Doe;;;\nFN:John Doe\n" + + "TEL;type=CELL;waid=6281234567890:+62 812-3456-7890\nEND:VCARD"), + }, + }, + want: "📇 John Doe (+62 812-3456-7890)", + }, + { + name: "ContactMessage with iPhone-style grouped TEL property", + msg: &waProto.Message{ + ContactMessage: &waProto.ContactMessage{ + DisplayName: proto.String("Adie Taxi"), + Vcard: proto.String("BEGIN:VCARD\nVERSION:3.0\nFN:Adie Taxi\n" + + "item1.TEL;waid=6281338417222:+62 813-3841-7222\nitem1.X-ABLabel:Mobil\nEND:VCARD"), + }, + }, + want: "📇 Adie Taxi (+62 813-3841-7222)", + }, + { + name: "ContactMessage with multiple TEL lines keeps every number", + msg: &waProto.Message{ + ContactMessage: &waProto.ContactMessage{ + DisplayName: proto.String("John Doe"), + Vcard: proto.String("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\n" + + "TEL;type=CELL:+62 812-3456-7890\r\nTEL;type=WORK:+47 22 33 44 55\r\nEND:VCARD"), + }, + }, + want: "📇 John Doe (+62 812-3456-7890, +47 22 33 44 55)", + }, + { + name: "ContactMessage without a TEL line falls back to name only", + msg: &waProto.Message{ + ContactMessage: &waProto.ContactMessage{ + DisplayName: proto.String("Jane Doe"), + Vcard: proto.String("BEGIN:VCARD\nVERSION:3.0\nFN:Jane Doe\nEND:VCARD"), + }, + }, + want: "📇 Jane Doe", + }, + { + name: "ContactMessage with neither name nor TEL returns empty (no placeholder row)", + msg: &waProto.Message{ + ContactMessage: &waProto.ContactMessage{ + Vcard: proto.String("BEGIN:VCARD\nVERSION:3.0\nEND:VCARD"), + }, + }, + want: "", + }, + { + name: "ContactsArrayMessage with multiple shared contacts", + msg: &waProto.Message{ + ContactsArrayMessage: &waProto.ContactsArrayMessage{ + DisplayName: proto.String("2 contacts"), + Contacts: []*waProto.ContactMessage{ + { + DisplayName: proto.String("John Doe"), + Vcard: proto.String("BEGIN:VCARD\nTEL;waid=1:+1 111\nEND:VCARD"), + }, + { + DisplayName: proto.String("Jane Doe"), + Vcard: proto.String("BEGIN:VCARD\nFN:Jane Doe\nEND:VCARD"), + }, + }, + }, + }, + want: "📇 2 contacts shared: John Doe (+1 111); Jane Doe", + }, + { + name: "ContactsArrayMessage with no contacts returns empty (no placeholder row)", + msg: &waProto.Message{ + ContactsArrayMessage: &waProto.ContactsArrayMessage{ + DisplayName: proto.String("0 contacts"), + }, + }, + want: "", + }, { name: "Nil message returns empty", msg: nil,