feat: mirror inbound media into chat-media so attachments survive Meta's 30-day expiry - #496
Merged
Merged
Conversation
…a's expiry (#466) Inbound media was never persisted. The webhook verified the Meta media id and stored a POINTER — `/api/whatsapp/media/<id>` — and that proxy re-streamed from Meta on every view. Meta deletes media roughly 30 days after receipt, so every inbound photo, voice note and document quietly became "Photo unavailable" a month later, with no way to recover it. Outbound media never had the problem: the composer uploads to the public `chat-media` bucket and stores a durable URL. Inbound now takes the same route. After a successful verify the bytes are downloaded once and uploaded to `chat-media` under the existing account-scoped path convention, and that public URL is stored in `media_url`. Decisions behind the four open questions in the issue: - Storage cost. Per-account opt-out on `whatsapp_config` (`mirror_inbound_media`), surfaced as a switch in Settings → WhatsApp and gated to admins by the table's existing RLS. It defaults to ON: the defect is silent data loss, so an account that never finds the setting should be the one that keeps its attachments. - Failure handling. `mirrorInboundMedia` swallows its own failures and returns null; the caller falls back to the proxy URL. A throw here would fail the webhook and have Meta redeliver the whole message. - Size limits. Meta's `file_size` is now returned by `getMediaUrl`, so media past the bucket's 16 MB ceiling is skipped before the transfer rather than after. The downloaded length is re-checked, since that field is advisory. - No backfill. Media Meta has already expired is gone; existing rows keep their proxy URL and the proxy route still serves them. Object paths are keyed on the Meta media id and carry no wall-clock stamp, so a redelivery rewrites one object instead of orphaning a second copy. A document keeps the sender's own filename, which is what a later download is named. Also persists the MIME type the webhook used to discard outright (`void mediaType`) as `messages.media_type`, and prefers it over `blob.type` when deriving a download filename — a blob with no usable Content-Type otherwise resolves to a useless `.bin`. Migration 039 adds both columns and widens the `chat-media` MIME allow-list with the inbound-only types Meta can send (GIF, QuickTime, bare Opus, and Meta's `video/3gp` spelling); the bucket enforces that list against the service role too, so without it those uploads would be refused and fall back to the expiring proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mirror grows a self-hosted Supabase project's storage with inbound volume, which is exactly the kind of runtime behaviour change a forker should not have to discover from a quota alert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Updates to Preview Branch (feat/466-mirror-inbound-media) ↗︎
Tasks are run on every commit but only new migration files are pushed.
❌ Branch Error • Wed, 12 Aug 2026 08:19:04 UTC View logs for this Workflow Run ↗︎. |
ArnasDon
added a commit
that referenced
this pull request
Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Inbound WhatsApp media is now copied into the
chat-mediabucket when it arrives, so attachments stay viewable after Meta deletes its own copy (~30 days). Previously the webhook stored only a pointer —/api/whatsapp/media/<id>— and the proxy behind it re-fetched from Meta on every view, so every inbound photo, voice note and document quietly became "Photo unavailable" a month later. Closes #466.What changed
The mirror —
src/lib/whatsapp/mirror-inbound-media.ts(new). After a successful verify,parseMessageContentdownloads the bytes once and uploads them tochat-mediaunder the existing account-scoped path convention, storing that public URL inmedia_url. It is strictly best-effort: it never throws, and on any failure the caller keeps the proxy URL, which still serves rows written before this change.Object paths are keyed on Meta's media id and carry no wall-clock stamp (
account-<id>/inbound/<mediaId>-<name>.<ext>), so a Meta redelivery rewrites one object instead of orphaning a second copy. A document keeps the sender's own filename, which is what a later download gets named.Migration 039 — adds
messages.media_typeandwhatsapp_config.mirror_inbound_media, and widens thechat-mediaMIME allow-list with the inbound-only types Meta can send (GIF, QuickTime, bare Opus, Meta'svideo/3gpspelling). That last part matters: the bucket enforces its allow-list against the service role too, so without it those uploads would be refused and silently fall back to the expiring proxy.The four things the issue left open:
mirror_inbound_media), surfaced as a switch in Settings → WhatsApp and admin-gated to match the table's existing RLS. Defaults to ON, because the defect is silent data loss — an account that never finds the setting should be the one that keeps its attachments.getMediaUrlnow returns Meta'sfile_size, so media past the bucket's 16 MB ceiling is skipped before the transfer. The downloaded length is re-checked, since that field is advisory.MIME type — the webhook used to discard it outright (
void mediaType); it's now persisted and preferred overblob.typewhen deriving a download filename, since a blob with no usableContent-Typeotherwise resolves to a useless.bin.buildMediaPathgrew two optional params (suppress the timestamp, nest a subfolder). Both default to today's behaviour, so the composer and Flows builder are unaffected.Test plan
npm run typecheckclean.npm run lint— 0 errors, 37 warnings (belowmain's 39; none added).npm run buildsucceeds.npm test— 825 pass across 79 files, 26 of them new: 15 unit tests for the mirror helper and its naming rules, 8 webhook-route tests (durable URL stored, fallback on refused upload, fallback on failed download, oversized skipped without downloading, document filename preserved, opt-out honoured, absent column treated as on, text messages untouched), and 3 forbuildMediaPath/mediaFilename.vitestrunsenvironment: "node"with no jsdom, so the settings switch has no component test either; it's aSwitchwired to a directwhatsapp_configupdate.Migration 039 has not been applied anywhere — there's no local Postgres in this repo. It's structurally checked only (5 statements, quotes and parens balanced). Worth a real apply before merge.
Related
Closes #466. The viewer/download UI it splits from shipped in #373.
🤖 Generated with Claude Code