Skip to content

fix(bridge): persist shared contact cards (vCards) as searchable text - #214

Open
SvenNico wants to merge 1 commit into
verygoodplugins:mainfrom
SvenNico:fix/store-contact-cards
Open

fix(bridge): persist shared contact cards (vCards) as searchable text#214
SvenNico wants to merge 1 commit into
verygoodplugins:mainfrom
SvenNico:fix/store-contact-cards

Conversation

@SvenNico

Copy link
Copy Markdown

Problem

Shared contact cards (ContactMessage / ContactsArrayMessage) are silently dropped by the bridge. Their vCard payload is embedded inline in the message — there is no CDN URL/MediaKey — so both extractTextContent and extractMediaInfo return empty, and the message is discarded at the no-content/no-media gate in handleMessage. The contact never reaches messages.db, and nothing downstream (MCP server, webhooks) can recover it.

Fix

Store contact cards as searchable text in content:

  • Single contact: 📇 John Doe (+62 812-3456-7890, +47 22 33 44 55) — display name plus every TEL value from the vCard (the vCard is the only copy we ever get, so no number is thrown away).
  • Multiple contacts: 📇 2 contacts shared: John Doe (+1 111); Jane Doe.
  • Handles iPhone-style grouped properties (item1.TEL;waid=...:...) and CRLF line endings — both verified against real WhatsApp traffic, where the grouped form is what iOS actually sends.
  • Falls back to display name alone when the vCard has no TEL line.

No media_type is introduced: there is no downloadable payload, so treating it as media would ripple into the download endpoint and MCP models for no benefit. Quoted contact cards also render now, since extractQuotedMessageInfo reuses extractTextContent.

Testing

  • 6 new cases in TestExtractTextContent_SurfacesMediaCaptions covering single contact, grouped TEL, multiple TELs, no TEL, contact arrays, and CRLF vCards; full go test ./... green.
  • Verified end-to-end on a live bridge: real card shared from iOS arrives in messages.db as 📇 <name> (<number>).

🤖 Generated with Claude Code

@GeRryCh

GeRryCh commented Aug 28, 2026

Copy link
Copy Markdown

Independently hit this on v0.6.0 and arrived at the same diagnosis and the same design call (store in content, introduce no media_type) before finding this PR. Confirming the repro, and flagging one gap plus one edge case.

Repro evidence

The failure is quieter than "the contact doesn't render" — the row is never written at all, so a reaction to a shared contact leaves an orphaned pointer. In my store:

select id, content, timestamp, filename from messages
where media_type='reaction' and filename='ACDD6CF9AF049962D3A96F1822B152A4';
-- 2A6D6FF59E49ECEB1F6F | 🙏 | 2026-08-26 18:20:28+03:00 | ACDD6CF9AF049962D3A96F1822B152A4

ACDD6CF9… is not in messages. It is the only orphaned reaction out of 51 in that store, and a contact card is what produced it. That query is a decent post-fix regression check:

select count(*) from messages r where r.media_type='reaction'
  and not exists (select 1 from messages m where m.id = r.filename);

(Upper bound only — pre-history-window messages orphan reactions too.)

Gap: the history-sync path is untouched

extractTextContent is not the only extraction site. The history-sync loop at main.go:3402 does its own inline check:

// 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()
    }
}

then hits the same gate at line 3429. So contact cards arriving through history sync — a freshly paired device, or the on-demand per-chat sync from #168 — are still dropped after this PR. It also silently drops media captions and hydrated templates, which the live path has handled for a while; the two sites have quietly diverged.

One line fixes it and removes the divergence permanently:

content := extractTextContent(msg.Message.Message)

extractTextContent opens with exactly those two branches, so this is a strict superset — no behaviour is lost. extractMediaInfo is already shared by both paths; only the text side forked.

Edge case: degenerate cards store a placeholder row

return "📇 " + formatContactContent(...) is unconditionally non-empty, so a ContactMessage with neither DisplayName nor a TEL line passes the gate and writes a row whose entire content is "📇 ". Same for ContactsArrayMessage with an empty Contacts slice — that yields "📇 0 contacts shared: ". Guarding the return keeps the gate doing its job:

if contact := msg.GetContactMessage(); contact != nil {
    if body := formatContactContent(contact.GetDisplayName(), contact.GetVcard()); body != "" {
        return "📇 " + body
    }
}

Housekeeping

The diff currently reads as +14,811/−1,019 across 60 files, with commits going back to 12412059 Fork and improve WhatsApp MCP for verygoodplugins — looks like the branch is cut from an old base rather than current main. Probably worth a rebase so the actual change is reviewable.

Running the vCard fix plus the history-sync line locally on v0.6.0: go test ./... green, including six new extractTextContent cases (grouped iPhone item1.TEL, multiple TELs, no-TEL fallback, contact arrays, and the two degenerate cases above). Live end-to-end confirmation on a real shared card is still pending on my side. Happy to open a follow-up PR for the history-sync hunk once this one merges, or you're welcome to fold it in here.

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 verygoodplugins#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 <noreply@anthropic.com>
@SvenNico
SvenNico force-pushed the fix/store-contact-cards branch from fd2d8a3 to 56b5e2f Compare August 30, 2026 08:18
@SvenNico

Copy link
Copy Markdown
Author

Thanks for the independent repro and the detailed review — all three points landed.

I've force-pushed a rebase onto current main. The PR is now a single commit touching main.go + main_test.go (the old branch was cut from a stale fork base, hence the 60-file diff).

Folded in:

  • History-sync gap — the loop at main.go:3402 now calls extractTextContent(msg.Message.Message) instead of its own inline Conversation/ExtendedText check. Strict superset, so contact cards / media captions / hydrated templates arriving via history sync (fresh pair, or the on-demand per-chat sync from feat(bridge): add on-demand history sync for a single chat #168) are no longer dropped.
  • Degenerate cardsformatContactContent returns "" when there's no display name and no TEL, so a bare ContactMessage (or empty ContactsArrayMessage) hits the gate instead of writing a "📇 " placeholder row. Two new test cases cover it.
  • Plus your other four extractTextContent cases (grouped item1.TEL, multiple TELs, no-TEL fallback, contact arrays).

go build ./..., go vet ./..., go test ./... all green.

I left the orphaned-reaction regression query out of the Go tests since it needs a populated store — happy to add it as a script-level check if you think it's worth it. Thanks for the offer on the history-sync follow-up; folding it in here seemed cleaner given it's one line.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants