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>
88 lines
2.5 KiB
TypeScript
88 lines
2.5 KiB
TypeScript
/**
|
|
* Web upload validator. Builds on the shared media classifier so the
|
|
* web upload action and the bot's send path agree on which delivery
|
|
* kind a file gets routed to (image / video / audio / document).
|
|
*
|
|
* Policy: never reject on format alone. If a JPEG-tagged HEIC photo
|
|
* gets uploaded, route it through the document path so the recipient
|
|
* still gets the file (just without an inline preview). Only reject
|
|
* on size — image/video/audio inline limits are tighter than the
|
|
* 100 MB document cap, so the validator picks the cap based on the
|
|
* RESOLVED delivery kind, not the original mime category.
|
|
*/
|
|
export {
|
|
classifyMediaKind,
|
|
isUnsupportedImageMime,
|
|
isSupportedVideoMime,
|
|
isSupportedAudioMime,
|
|
resolveDeliveryKind,
|
|
sniffUnsupportedImage,
|
|
sniffUnsupportedVideo,
|
|
WA_LIMITS,
|
|
WA_MAX_BYTES,
|
|
type WaMediaKind,
|
|
} from "@cmbot/shared";
|
|
|
|
import {
|
|
resolveDeliveryKind as _resolve,
|
|
WA_LIMITS as _WA_LIMITS,
|
|
type WaMediaKind as _WaMediaKind,
|
|
} from "@cmbot/shared";
|
|
|
|
const KB = 1024;
|
|
const MB = 1024 * 1024;
|
|
|
|
export type WaSizeCheck =
|
|
| { ok: true; kind: _WaMediaKind; limitBytes: number }
|
|
| { ok: false; kind: _WaMediaKind; limitBytes: number; error: string };
|
|
|
|
/**
|
|
* Validate an uploaded file. Returns the resolved delivery kind and
|
|
* the cap regardless of pass/fail. The optional `bytes` arg lets the
|
|
* caller pass the buffered payload so iOS-Safari-style mime spoofs
|
|
* (HEIC labelled image/jpeg, .mov labelled video/mp4) get caught and
|
|
* routed to the document path.
|
|
*/
|
|
export function validateForWhatsApp(
|
|
mimeType: string,
|
|
sizeBytes: number,
|
|
bytes?: Uint8Array,
|
|
): WaSizeCheck {
|
|
const kind = _resolve(mimeType, bytes);
|
|
const limitBytes = _WA_LIMITS[kind];
|
|
if (sizeBytes <= 0) {
|
|
return { ok: false, kind, limitBytes, error: "Empty file" };
|
|
}
|
|
if (sizeBytes > limitBytes) {
|
|
return {
|
|
ok: false,
|
|
kind,
|
|
limitBytes,
|
|
error: `${labelFor(kind)} too large (${formatBytes(sizeBytes)} > ${formatBytes(limitBytes)} limit on WhatsApp)`,
|
|
};
|
|
}
|
|
return { ok: true, kind, limitBytes };
|
|
}
|
|
|
|
function labelFor(kind: _WaMediaKind): string {
|
|
switch (kind) {
|
|
case "image":
|
|
return "Image";
|
|
case "video":
|
|
return "Video";
|
|
case "audio":
|
|
return "Audio";
|
|
case "document":
|
|
return "Document";
|
|
case "sticker":
|
|
return "Sticker";
|
|
}
|
|
}
|
|
|
|
/** Render a byte count with a sensible WhatsApp-style unit. */
|
|
export function formatBytes(n: number): string {
|
|
if (n < KB) return `${n} B`;
|
|
if (n < MB) return `${(n / KB).toFixed(0)} KB`;
|
|
return `${(n / MB).toFixed(1)} MB`;
|
|
}
|