diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index d26a944..7e59214 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -19,6 +19,15 @@ const nextConfig: NextConfig = {
allowedDevOrigins: ["192.168.0.253", "test.04080616.xyz", "rexwa.04080616.xyz"],
experimental: {
typedRoutes: true,
+ serverActions: {
+ // Default Server Action body limit is 1 MB — way under WhatsApp's
+ // 100 MB document cap. Lifted to 100 MB so document uploads reach
+ // the action; the per-kind WhatsApp validator
+ // (lib/whatsapp-media.ts) then enforces the actual limit
+ // (5 MB image / 16 MB video/audio / 100 MB document) and returns
+ // a useful error for the rest.
+ bodySizeLimit: "100mb",
+ },
},
turbopack: {
root: workspaceRoot,
diff --git a/apps/web/src/actions/media.ts b/apps/web/src/actions/media.ts
index d64a746..69db040 100644
--- a/apps/web/src/actions/media.ts
+++ b/apps/web/src/actions/media.ts
@@ -10,8 +10,7 @@ import { db } from "@/lib/db";
import { env } from "@/env";
import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit";
-
-const MAX_BYTES = 50 * 1024 * 1024;
+import { validateForWhatsApp } from "@/lib/whatsapp-media";
async function rateLimit(key: string) {
const h = await headers();
@@ -31,8 +30,12 @@ export async function uploadMediaAction(
await rateLimit("media-upload");
const file = formData.get("file");
if (!(file instanceof File)) return { ok: false, error: "No file uploaded" };
- if (file.size === 0) return { ok: false, error: "Empty file" };
- if (file.size > MAX_BYTES) return { ok: false, error: "File too large (>50MB)" };
+ // Per-kind WhatsApp size validation: 5 MB images, 16 MB video/audio,
+ // 100 MB documents. Fall-through everything we don't recognise to
+ // "document" (matches the bot's sender path).
+ const mimeType = file.type || "application/octet-stream";
+ const sizeCheck = validateForWhatsApp(mimeType, file.size);
+ if (!sizeCheck.ok) return { ok: false, error: sizeCheck.error };
const op = await getSeededOperator();
const buffer = Buffer.from(await file.arrayBuffer());
@@ -47,7 +50,7 @@ export async function uploadMediaAction(
.values({
operatorId: op.id,
filenameOriginal: file.name,
- mimeType: file.type || "application/octet-stream",
+ mimeType,
sizeBytes: buffer.byteLength,
sha256,
storagePath,
@@ -58,6 +61,6 @@ export async function uploadMediaAction(
ok: true,
mediaId: row!.id,
filename: file.name,
- mimeType: file.type || "application/octet-stream",
+ mimeType,
};
}
diff --git a/apps/web/src/components/message-stack.tsx b/apps/web/src/components/message-stack.tsx
index 4dc824e..649dcc1 100644
--- a/apps/web/src/components/message-stack.tsx
+++ b/apps/web/src/components/message-stack.tsx
@@ -377,7 +377,7 @@ function MediaBlock({ part, onChange }: MediaBlockProps) {
Click to upload or drag & drop
- Images, documents, audio — up to 50 MB
+ Image up to 5 MB · video / audio up to 16 MB · document up to 100 MB
>
diff --git a/apps/web/src/lib/whatsapp-media.test.ts b/apps/web/src/lib/whatsapp-media.test.ts
new file mode 100644
index 0000000..40cda6d
--- /dev/null
+++ b/apps/web/src/lib/whatsapp-media.test.ts
@@ -0,0 +1,130 @@
+import { describe, it, expect } from "vitest";
+import {
+ classifyMediaKind,
+ formatBytes,
+ validateForWhatsApp,
+ WA_LIMITS,
+ WA_MAX_BYTES,
+} from "./whatsapp-media";
+
+const MB = 1024 * 1024;
+
+describe("classifyMediaKind", () => {
+ it("classifies image/* as image", () => {
+ expect(classifyMediaKind("image/jpeg")).toBe("image");
+ expect(classifyMediaKind("image/png")).toBe("image");
+ expect(classifyMediaKind("image/webp")).toBe("image");
+ });
+
+ it("classifies video/* as video", () => {
+ expect(classifyMediaKind("video/mp4")).toBe("video");
+ expect(classifyMediaKind("video/3gpp")).toBe("video");
+ });
+
+ it("classifies audio/* as audio", () => {
+ expect(classifyMediaKind("audio/aac")).toBe("audio");
+ expect(classifyMediaKind("audio/ogg")).toBe("audio");
+ expect(classifyMediaKind("audio/mpeg")).toBe("audio");
+ });
+
+ it("classifies anything else as document (PDFs, office, plain text…)", () => {
+ expect(classifyMediaKind("application/pdf")).toBe("document");
+ expect(classifyMediaKind("application/zip")).toBe("document");
+ expect(classifyMediaKind("text/plain")).toBe("document");
+ expect(classifyMediaKind("application/octet-stream")).toBe("document");
+ expect(classifyMediaKind("")).toBe("document");
+ });
+});
+
+describe("validateForWhatsApp — per-kind WA caps", () => {
+ it("accepts an image just under the 5 MB cap", () => {
+ const r = validateForWhatsApp("image/jpeg", 5 * MB - 1);
+ expect(r.ok).toBe(true);
+ if (r.ok) {
+ expect(r.kind).toBe("image");
+ expect(r.limitBytes).toBe(WA_LIMITS.image);
+ }
+ });
+
+ it("accepts an image at exactly the 5 MB cap", () => {
+ const r = validateForWhatsApp("image/png", 5 * MB);
+ expect(r.ok).toBe(true);
+ });
+
+ it("rejects a 6 MB image with a useful WhatsApp-flavoured message", () => {
+ const r = validateForWhatsApp("image/jpeg", 6 * MB);
+ expect(r.ok).toBe(false);
+ if (!r.ok) {
+ expect(r.kind).toBe("image");
+ expect(r.error).toMatch(/Image too large/);
+ expect(r.error).toMatch(/6\.0 MB/);
+ expect(r.error).toMatch(/5\.0 MB/);
+ expect(r.error).toMatch(/WhatsApp/);
+ }
+ });
+
+ it("rejects a 16 MB + 1 byte video", () => {
+ const r = validateForWhatsApp("video/mp4", 16 * MB + 1);
+ expect(r.ok).toBe(false);
+ if (!r.ok) {
+ expect(r.kind).toBe("video");
+ expect(r.error).toMatch(/Video too large/);
+ }
+ });
+
+ it("accepts a 16 MB audio file (boundary)", () => {
+ const r = validateForWhatsApp("audio/mpeg", 16 * MB);
+ expect(r.ok).toBe(true);
+ });
+
+ it("accepts a 99 MB document and rejects 101 MB", () => {
+ expect(validateForWhatsApp("application/pdf", 99 * MB).ok).toBe(true);
+ const r = validateForWhatsApp("application/pdf", 101 * MB);
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toMatch(/Document too large/);
+ });
+
+ it("rejects empty / zero-byte uploads regardless of kind", () => {
+ expect(validateForWhatsApp("image/png", 0).ok).toBe(false);
+ expect(validateForWhatsApp("application/pdf", 0).ok).toBe(false);
+ });
+
+ it("a 60 MB unknown-mime upload is treated as a document and accepted", () => {
+ // The "anything not image/video/audio is a document" fall-through
+ // is what makes this lenient — that's intentional, mirrors how
+ // the bot's sender path delivers it.
+ const r = validateForWhatsApp("application/octet-stream", 60 * MB);
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.kind).toBe("document");
+ });
+});
+
+describe("WA_MAX_BYTES is the largest single-kind cap", () => {
+ it("equals the document cap (100 MB)", () => {
+ expect(WA_MAX_BYTES).toBe(WA_LIMITS.document);
+ expect(WA_MAX_BYTES).toBe(100 * MB);
+ });
+
+ it("is at least as large as every per-kind limit", () => {
+ for (const limit of Object.values(WA_LIMITS)) {
+ expect(WA_MAX_BYTES).toBeGreaterThanOrEqual(limit);
+ }
+ });
+});
+
+describe("formatBytes", () => {
+ it("renders bytes < 1 KB plainly", () => {
+ expect(formatBytes(0)).toBe("0 B");
+ expect(formatBytes(512)).toBe("512 B");
+ });
+
+ it("renders KB rounded to no decimals", () => {
+ expect(formatBytes(1024)).toBe("1 KB");
+ expect(formatBytes(102_400)).toBe("100 KB");
+ });
+
+ it("renders MB to one decimal", () => {
+ expect(formatBytes(5 * MB)).toBe("5.0 MB");
+ expect(formatBytes(5_400_000)).toBe("5.1 MB");
+ });
+});
diff --git a/apps/web/src/lib/whatsapp-media.ts b/apps/web/src/lib/whatsapp-media.ts
new file mode 100644
index 0000000..111001a
--- /dev/null
+++ b/apps/web/src/lib/whatsapp-media.ts
@@ -0,0 +1,105 @@
+/**
+ * WhatsApp media size limits, by deliverable type.
+ *
+ * These are the practical WA Web upload limits Baileys passes through
+ * — anything over the cap is either re-encoded by WA's server (image)
+ * or rejected outright (everything else). Sourced from WA Business
+ * docs and Baileys' own validation paths; aligned to the most
+ * conservative documented value where two specs disagree.
+ *
+ * image : 5 MB image/jpeg, image/png, image/webp (non-sticker)
+ * video : 16 MB video/mp4, video/3gpp
+ * audio : 16 MB audio/aac, audio/ogg, audio/amr, audio/mp4, audio/mpeg
+ * document: 100 MB everything else (PDFs, office docs, plain text…)
+ * sticker : 100 KB image/webp marked as a sticker (we don't support
+ * sticker-mode uploads yet — kept here for future)
+ */
+const MB = 1024 * 1024;
+const KB = 1024;
+
+export const WA_LIMITS = {
+ image: 5 * MB,
+ video: 16 * MB,
+ audio: 16 * MB,
+ document: 100 * MB,
+ sticker: 100 * KB,
+} as const;
+
+export type WaMediaKind = keyof typeof WA_LIMITS;
+
+/** The largest single-file upload WA will accept across all kinds.
+ * Doubles as the Next Server Action body-size cap. */
+export const WA_MAX_BYTES = WA_LIMITS.document;
+
+/**
+ * Map an uploaded file's MIME type to the WhatsApp delivery kind. We
+ * use the kind both to pick the right size limit AND to decide which
+ * Baileys sender path the bot will use (image / video / document).
+ *
+ * Anything we don't recognise as image/video/audio falls through to
+ * "document", which is also how the bot delivers it (file with a
+ * filename and the original mime type preserved).
+ */
+export function classifyMediaKind(mimeType: string): WaMediaKind {
+ if (mimeType.startsWith("image/")) return "image";
+ if (mimeType.startsWith("video/")) return "video";
+ if (mimeType.startsWith("audio/")) return "audio";
+ return "document";
+}
+
+export type WaSizeCheck =
+ | { ok: true; kind: WaMediaKind; limitBytes: number }
+ | { ok: false; kind: WaMediaKind; limitBytes: number; error: string };
+
+/**
+ * Validate an uploaded file against WhatsApp's per-kind size cap.
+ *
+ * Returns the resolved kind and the cap regardless of pass/fail so
+ * the UI can show "Image: 4.8 MB / 5 MB" or "Image too large
+ * (5.2 MB > 5 MB)" without re-deriving the kind itself.
+ */
+export function validateForWhatsApp(
+ mimeType: string,
+ sizeBytes: number,
+): WaSizeCheck {
+ const kind = classifyMediaKind(mimeType);
+ 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. Keeps a
+ * single decimal for MB so 5.0 MB and 4.8 MB read consistently in the
+ * error message.
+ */
+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`;
+}