Old behaviour: HEIC/AVIF photos, .mov / .webm / .mkv videos, and niche
audio (FLAC, etc.) got rejected outright at upload with "Images are
not supported" / "Videos are not supported" errors. Strict but
unfriendly — recipients could still receive these as a downloadable
file via WhatsApp's document path; we just weren't using it.
New behaviour: anything not playable inline gets routed through the
document path automatically. The recipient downloads the file and
opens it in their default app. The 100 MB document cap applies
instead of the inline 5 / 16 / 16 MB caps. Only oversized uploads
get rejected.
Where the policy lives
----------------------
The classifier moved into a new `@cmbot/shared/whatsapp-media`
module so the web upload validator AND the bot's fire-reminder send
path share one source of truth:
- resolveDeliveryKind(mime, bytes?) → "image" | "video" | "audio"
| "document". Native types stay as-is; HEIF / AVIF / QuickTime /
WebM / Matroska / non-MP3-or-M4A audio all collapse to "document".
- Bytes argument is optional but recommended — sniffing the first
12 bytes of the file catches iOS Safari's habit of labelling
a HEIC as image/jpeg or a .mov as video/mp4. Bytes win when they
disagree with the mime.
Web side
--------
- `lib/whatsapp-media.ts` re-exports the shared helpers and keeps
only the validator + byte-formatter. `validateForWhatsApp` calls
resolveDeliveryKind internally; the size cap it returns is for the
RESOLVED kind (so a HEIC routes to document and gets the 100 MB
cap). The "Images are not supported" / "Videos are not supported"
rejection messages are gone — there's no format rejection anymore.
- `actions/media.ts` collapses the previous explicit-mime + byte-sniff
pair into a single `validateForWhatsApp(mime, size, bytes)` call.
- Compose-step upload-zone hint updated to spell out the per-kind
caps: "JPEG/PNG up to 5 MB · MP4/3GP up to 16 MB · MP3/M4A/OGG
up to 16 MB · documents up to 100 MB".
Bot side
--------
- `fire-reminder.ts` reads the first 12 bytes of the file before
dispatching and calls `resolveDeliveryKind(mimeType, head)` to
pick the senderKind. So a HEIC on disk (whose mime claims
image/jpeg) gets sent via Baileys' document path — no failed
thumbnail extraction, message arrives as a downloadable .heic.
- New `readHeadBytes(filePath, n)` helper opens, reads N bytes,
closes — no full-file slurp.
Tests
-----
249 web + 31 shared + 26 bot = 306 passing total.
Web (`lib/whatsapp-media.test.ts`):
- "HEIC at 30 MB allowed: routes to document (100 MB cap)"
- "HEIC at 110 MB rejects: exceeds the document cap"
- "MOV at 50 MB allowed (would be 16 MB cap as video, 100 MB as
document)"
- "MOV pretending to be mp4 demotes to document (50 MB allowed)"
- "FLAC audio routes to document path"
- "genuine MP4 byte-sniff path keeps it as video"
Shared (`packages/shared/src/whatsapp-media.test.ts`, new):
- The cross-package contract: 11 tests covering size limits,
classifyMediaKind, resolveDeliveryKind for native + demoted +
byte-sniff cases, plus the underlying helpers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three threads from the recent UX iteration:
1. Reminder list / detail no longer shows raw "Cron: 32 11 * * *"
----------------------------------------------------------------
`describeRecurrence` for a kind=cron spec used to emit
"Cron: <expr>" verbatim, which is unreadable on the list row's
recurrence line.
New pure helper `describeCronRule(rule)` parses the cron shapes
the recurrence picker produces and renders them as natural
sentences:
"0 9 * * *" → "Every day at 09:00"
"0 9 * * 1-5" → "Every week on Mon, Tue, Wed, Thu, Fri at 09:00"
"0 9 * * 1,3,5" → "Every week on Mon, Wed, Fri at 09:00"
"0 9 1,15 * *" → "Every month on days 1, 15 at 09:00"
"0 9 13 5 *" → "Every year in May on day 13 at 09:00"
"30 17 1,15 1,4,7,10 *" → "Every year in Jan, Apr, Jul, Oct on days 1, 15 at 17:30"
Multi-line rules ("0 9 * * 1\n0 17 * * 5") join the per-line
descriptions with " · " for compactness in the list density.
Long DOM lists (>6 days) collapse with a "+N more" tail to keep
the line short; same convention the picker's per-row preview uses.
Unrecognised shapes (e.g. "*/5 * * * *") fall back to the raw
expression — better than swallowing entirely.
2. HEIC/AVIF magic-byte sniffing at upload
----------------------------------------------------------------
The mime-only check we shipped earlier missed iOS Safari's
habit of uploading HEIC photos with Content-Type: image/jpeg.
The file then made it to the bot, where Sharp's HEIF decoder
plugin is missing, the thumbnail extraction failed, and the
message went out without a working preview — read by the user
as "image still not send".
New helper `sniffUnsupportedImage(bytes)` reads bytes 4..11 of
the upload and looks for the ISOBMFF "ftyp" marker followed by
one of the brands Sharp can't decode (HEIF: heic / heix / hevc
/ heim / heis / mif1 / msf1; AVIF: avif / avis). Brand match is
case-insensitive. Plain JPEG / PNG / unrelated ftyp brands like
mp4 are not flagged.
`uploadMediaAction` now runs the sniff against the buffered
bytes before persisting, returning the same "Images are not
supported, please re-upload images" error as the mime path.
3. Sidebar brand link → dashboard tests
----------------------------------------------------------------
Asserts the desktop <aside> contains an <a href="/" aria-label=
"Go to dashboard"> at the top, scoped via a new extractSidebar
helper so it can't accidentally match the mobile-header brand
link (which uses aria-label="Go home"). A second test confirms
the two aria-labels stay distinct.
22 web test files / 232 passing (was 212):
- +12 cron-description cases in lib/recurrence.test.ts
- +6 magic-byte sniff cases in lib/whatsapp-media.test.ts
- +2 sidebar-brand-link cases in app-shell.test.tsx
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small follow-ups:
1. HEIC/HEIF/AVIF uploads now rejected at the door
----------------------------------------------------
Symptom: an iPhone-shot image uploaded fine but came through on
WhatsApp without a thumbnail. Bot logs:
failed to obtain extra info
heif: Error while loading plugin: Support for this compression
format has not been built in
Cause: the bot container's Sharp ships without a HEIF/AVIF
decoder, so the thumbnail-extraction step Baileys runs throws and
the message is sent without a preview.
Fix: the upload validator (`validateForWhatsApp`) now rejects the
HEIF family before the file ever reaches the action body. Error
message: "Images are not supported, please re-upload images".
New tests in `lib/whatsapp-media.test.ts`:
- `isUnsupportedImageMime` recognises image/heic, image/heif,
image/heic-sequence, image/avif (case-insensitive).
- `isUnsupportedImageMime` does NOT flag jpeg/png/webp/gif.
- `validateForWhatsApp` rejects a HEIC upload regardless of size,
even below the 5 MB image cap.
2. Desktop sidebar brand is now a link to /
----------------------------------------------------
The mobile header brand pill was already a link to /; the desktop
sidebar version was a static <div>, so clicking the "cm WhatsApp
Bot" header in the sidebar did nothing. Wrapped in <Link href="/">
with `aria-label="Go to dashboard"` and a hover background to
make the affordance obvious.
3. Activity tab strip switched from full-width to scrollable
----------------------------------------------------
The activity page has six tabs (All / Success / Partial / Failed
/ Skipped / Archived) — packing them into a `w-full` row at h-8
left every label squeezed to ~50px on mobile. Wrapped the
<TabsList> in an `overflow-x-auto` scroller (with negative
horizontal margins so the strip extends to the page edges and the
first/last tabs aren't clipped) so each tab keeps a comfortable
touch target on phones; on desktop the row fits naturally and no
scroll bar appears.
Reminders page kept its full-width layout — only 4 tabs there,
they don't crowd.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom
-------
The upload action rejected anything over 50 MB with a flat
"File too large (>50MB)" — a number that was both too generous for
images (WA caps at 5 MB) and too restrictive for documents (WA
allows 100 MB). And anything over 1 MB was being rejected even
earlier by Next's default Server Action body limit, with a much
less actionable error.
Fix
---
1. New `lib/whatsapp-media.ts` resolves an uploaded file's MIME type
to a WhatsApp delivery kind and validates it against the
per-kind cap that WA actually enforces:
image → 5 MB image/* except sticker-mode
video → 16 MB video/*
audio → 16 MB audio/*
document → 100 MB anything else (PDFs, office docs, …)
Anything not recognised as image/video/audio falls through to
"document", which is also the Baileys sender path the bot uses
to deliver it. So a .zip or .csv ends up correctly classified
AND correctly limited to the document cap.
Error messages now name the kind and show both the actual size
and the cap: "Image too large (5.2 MB > 5.0 MB limit on
WhatsApp)".
2. `next.config.ts` lifts the Server Action body limit from the 1 MB
default to 100 MB, so document uploads actually reach the action
instead of getting bounced at the framework boundary. The WA
per-kind validator inside the action enforces the real limit
from there.
3. The compose-step upload zone hint now reflects the per-kind caps
("Image up to 5 MB · video / audio up to 16 MB · document up to
100 MB") instead of the wrong flat "up to 50 MB" value.
Tests (17 new cases, total 189)
-------------------------------
- classifyMediaKind: image/video/audio prefix routing, fall-through
to document for unknown / empty / octet-stream / text/plain.
- validateForWhatsApp: at-cap, just-under-cap, just-over-cap for
image (5 MB) / video (16 MB) / audio (16 MB) / document (100 MB);
zero-byte rejected; unknown-mime 60 MB upload accepted as document.
- WA_MAX_BYTES sanity: equals the document cap and is >= every other
per-kind limit (so it's safe to use as the framework body cap).
- formatBytes: bytes / KB (no decimals) / MB (one decimal) rendering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>