feat(inbox): send photo, video, document & voice in chat (#213)

Agents can now attach a photo/video/document or record a voice note in
the inbox composer and send it live in a 1:1 thread — previously media
could only be received from customers or sent via the Flows builder.

- 023_chat_media.sql: new account-scoped `chat-media` Storage bucket
  (mirrors flow-media RLS from 020) plus Meta-accepted audio MIME types.
- meta-api: sendMediaMessage gains an `audio` kind (link-only — Meta
  rejects caption/filename on audio; WhatsApp renders OGG/Opus as a
  voice note).
- /api/whatsapp/send: media branch reusing the phone-variant retry loop.
- /api/whatsapp/transcode-audio: Node-runtime route remuxing the
  WebM/Opus that Chromium's MediaRecorder produces to OGG/Opus via
  ffmpeg-static (Firefox/Safari record accepted formats and skip it).
- composer: attach menu, file pickers, voice recorder + auto-transcode,
  preview with caption; thread: optimistic handleSendMedia.
- shared uploadAccountMedia helper (flows form refactored onto it).
- next.config: allow microphone=(self) (was fully blocked) + CSP
  media-src; document optional FFMPEG_PATH override.

Tests: meta-api audio payload shape, buildMediaPath, audio-transcode
helpers (14 new). typecheck/lint/build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-18 10:07:16 +03:00
parent b56e6b6e3a
commit 312683ca05
No known key found for this signature in database
18 changed files with 1374 additions and 110 deletions

View File

@ -83,3 +83,9 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com
# Set this in CI and local development so you can exercise the full
# template UI without a real WABA. Leave unset (or "false") in prod.
# WHATSAPP_TEMPLATES_DRY_RUN=true
# Override the ffmpeg binary used by POST /api/whatsapp/transcode-audio
# (voice notes are remuxed to OGG/Opus before being sent to Meta). By
# default the bundled `ffmpeg-static` binary is used; set this only if
# that binary can't run on your host and you have a system ffmpeg.
# FFMPEG_PATH=/usr/bin/ffmpeg

View File

@ -25,8 +25,12 @@ const SECURITY_HEADERS = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
// Microphone is allowed for same-origin (`self`) so the inbox
// composer can record voice notes via MediaRecorder. Everything
// else stays denied — a compromised dependency can't silently grab
// the camera / geolocation / etc.
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
value: "camera=(), microphone=(self), geolocation=(), payment=(), usb=()",
},
{
key: "Content-Security-Policy-Report-Only",
@ -42,6 +46,9 @@ const SECURITY_HEADERS = [
// https URLs paste-able from the UI), OG images, data URLs for
// tiny inline assets.
"img-src 'self' data: blob: https:",
// Outbound media previews (blob: from MediaRecorder + file picker)
// and Supabase public-bucket audio/video the inbox renders.
"media-src 'self' blob: https://*.supabase.co",
"font-src 'self' data:",
// Supabase REST + realtime (WSS). All Meta API calls happen
// server-side, so graph.facebook.com does not belong here.

162
package-lock.json generated
View File

@ -20,6 +20,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"ffmpeg-static": "^5.3.0",
"lucide-react": "^1.18.0",
"next": "16.2.6",
"react": "19.2.4",
@ -549,6 +550,21 @@
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
"license": "MIT"
},
"node_modules/@derhuerst/http-basic": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
"license": "MIT",
"dependencies": {
"caseless": "^0.12.0",
"concat-stream": "^2.0.0",
"http-response-object": "^3.0.1",
"parse-cache-control": "^1.0.1"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@ -4206,6 +4222,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@ -4307,6 +4329,12 @@
],
"license": "CC-BY-4.0"
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -4436,6 +4464,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@ -5977,6 +6020,47 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/ffmpeg-static": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
"@derhuerst/http-basic": "^8.2.0",
"env-paths": "^2.2.0",
"https-proxy-agent": "^5.0.0",
"progress": "^2.0.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/ffmpeg-static/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ffmpeg-static/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
@ -6523,6 +6607,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/http-response-object": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
"license": "MIT",
"dependencies": {
"@types/node": "^10.0.3"
}
},
"node_modules/http-response-object/node_modules/@types/node": {
"version": "10.17.60",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@ -8434,6 +8533,11 @@
"node": ">=6"
}
},
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -8722,6 +8826,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -8888,6 +9001,20 @@
}
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/recast": {
"version": "0.23.11",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
@ -9198,6 +9325,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@ -9709,6 +9856,15 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@ -10262,6 +10418,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",

View File

@ -50,6 +50,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"ffmpeg-static": "^5.3.0",
"lucide-react": "^1.18.0",
"next": "16.2.6",
"react": "19.2.4",

View File

@ -1,6 +1,11 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import {
sendTextMessage,
sendTemplateMessage,
sendMediaMessage,
type MediaKind,
} from '@/lib/whatsapp/meta-api'
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
import { supabaseAdmin } from '@/lib/flows/admin-client'
import {
@ -63,6 +68,7 @@ export async function POST(request: Request) {
message_type,
content_text,
media_url,
filename,
template_name,
template_language,
template_params,
@ -77,6 +83,11 @@ export async function POST(request: Request) {
)
}
// Media kinds (image/video/document/audio) are sent to Meta via a
// public URL the composer already uploaded to the chat-media bucket.
const MEDIA_KINDS = ['image', 'video', 'document', 'audio'] as const
const isMediaKind = (MEDIA_KINDS as readonly string[]).includes(message_type)
if (message_type === 'text' && !content_text) {
return NextResponse.json(
{ error: 'content_text is required for text messages' },
@ -91,6 +102,13 @@ export async function POST(request: Request) {
)
}
if (isMediaKind && !media_url) {
return NextResponse.json(
{ error: `media_url is required for ${message_type} messages` },
{ status: 400 }
)
}
// Fetch conversation and contact
const { data: conversation, error: convError } = await supabase
.from('conversations')
@ -246,6 +264,22 @@ export async function POST(request: Request) {
})
return result.messageId
}
if (isMediaKind) {
// content_text doubles as the caption (ignored for audio inside
// sendMediaMessage). filename surfaces in the recipient's chat
// for documents only.
const result = await sendMediaMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
kind: message_type as MediaKind,
link: media_url,
caption: content_text || undefined,
filename: filename || undefined,
contextMessageId,
})
return result.messageId
}
const result = await sendTextMessage({
phoneNumberId: config.phone_number_id,
accessToken,

View File

@ -0,0 +1,127 @@
import { NextResponse } from 'next/server'
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import ffmpegStatic from 'ffmpeg-static'
import { createClient } from '@/lib/supabase/server'
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit'
import { buildFfmpegArgs } from '@/lib/whatsapp/audio-transcode'
// ffmpeg + temp-file I/O need the Node runtime (not Edge).
export const runtime = 'nodejs'
// Recorded clips are short voice notes — same 16 MB ceiling as the
// chat-media bucket so we reject anything that couldn't be stored anyway.
const MAX_INPUT_BYTES = 16 * 1024 * 1024
/**
* Resolve the ffmpeg binary path. `ffmpeg-static` ships a per-platform
* binary and exports its absolute path; `FFMPEG_PATH` overrides it for
* hosts where the bundled binary can't run (e.g. a system ffmpeg).
*/
function resolveFfmpegPath(): string | null {
return process.env.FFMPEG_PATH || ffmpegStatic || null
}
function runFfmpeg(binary: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
execFile(binary, args, { timeout: 30_000 }, (error, _stdout, stderr) => {
if (error) {
reject(new Error(stderr?.toString().slice(-500) || error.message))
return
}
resolve()
})
})
}
/**
* POST /api/whatsapp/transcode-audio
*
* Accepts a recorded audio clip (multipart form field `file`, typically
* Chromium's WebM/Opus) and returns OGG/Opus bytes that Meta accepts and
* WhatsApp renders as a playable voice note. The client only calls this
* when its recording isn't already a Meta-accepted format — Firefox
* records OGG and Safari records MP4/AAC, which skip the round-trip.
*
* The route does NOT upload anywhere: it returns the bytes and the client
* uploads them to the account-scoped chat-media bucket (same RLS path as
* every other attachment).
*/
export async function POST(request: Request) {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const limit = checkRateLimit(`transcode:${user.id}`, RATE_LIMITS.transcodeAudio)
if (!limit.success) {
return rateLimitResponse(limit)
}
const ffmpegPath = resolveFfmpegPath()
if (!ffmpegPath) {
console.error('[transcode-audio] ffmpeg binary not found')
return NextResponse.json(
{ error: 'Audio transcoding is unavailable on this server.' },
{ status: 500 },
)
}
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return NextResponse.json(
{ error: 'Expected a `file` form field.' },
{ status: 400 },
)
}
if (file.size === 0) {
return NextResponse.json({ error: 'Empty audio file.' }, { status: 400 })
}
if (file.size > MAX_INPUT_BYTES) {
return NextResponse.json(
{ error: 'Audio clip exceeds the 16 MB limit.' },
{ status: 400 },
)
}
// Stage input + output in a per-request temp dir so concurrent
// transcodes never collide, and clean it up no matter what.
const dir = await mkdtemp(join(tmpdir(), 'wacrm-audio-'))
const inputPath = join(dir, 'in')
const outputPath = join(dir, 'out.ogg')
try {
await writeFile(inputPath, Buffer.from(await file.arrayBuffer()))
await runFfmpeg(ffmpegPath, buildFfmpegArgs(inputPath, outputPath))
const ogg = await readFile(outputPath)
return new Response(new Uint8Array(ogg), {
status: 200,
headers: {
'Content-Type': 'audio/ogg',
'Cache-Control': 'no-store',
},
})
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {})
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Audio transcoding failed'
console.error('[transcode-audio] failed:', message)
return NextResponse.json(
{ error: 'Failed to process the recording.' },
{ status: 500 },
)
}
}

View File

@ -45,7 +45,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client";
import { uploadAccountMedia, MEDIA_MAX_BYTES } from "@/lib/storage/upload-media";
import { slugify, type BuilderNode } from "../shared";
import { NextNodeRow, NodeKeySelect, TextRow } from "./fields";
@ -872,8 +872,6 @@ const MEDIA_ACCEPT: Record<NonNullable<SendMediaCfg["media_type"]>, string> = {
};
const FLOW_MEDIA_BUCKET = "flow-media";
// 16 MB — matches the bucket file_size_limit set in migration 016.
const FLOW_MEDIA_MAX_BYTES = 16 * 1024 * 1024;
function SendMediaForm({
cfg,
@ -897,7 +895,7 @@ function SendMediaForm({
const handleFile = useCallback(
async (file: File) => {
if (file.size > FLOW_MEDIA_MAX_BYTES) {
if (file.size > MEDIA_MAX_BYTES) {
toast.error(
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
);
@ -905,49 +903,9 @@ function SendMediaForm({
}
setUploading(true);
try {
const supabase = createClient();
const {
data: { user },
error: userErr,
} = await supabase.auth.getUser();
if (userErr || !user) {
throw new Error("Not signed in.");
}
// Resolve the caller's account_id so the path is account-
// scoped rather than user-scoped. The 020 migration's RLS
// policy allows any account member to write under
// `account-<account_id>/...`, so a flow's media survives a
// teammate leaving the account (which was a data-integrity
// hole in the original 016 storage policies).
const { data: profile, error: profileErr } = await supabase
.from("profiles")
.select("account_id")
.eq("user_id", user.id)
.maybeSingle();
if (profileErr || !profile?.account_id) {
throw new Error("Could not resolve your account.");
}
// Path convention matches migration 020's RLS policy: first
// segment is `account-<uuid>`. Timestamp + random suffix
// prevents collisions between two builders open at once.
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const safeBase =
file.name
.replace(/\.[^.]+$/, "")
.replace(/[^a-zA-Z0-9_-]+/g, "_")
.slice(0, 40) || "file";
const path = `account-${profile.account_id}/${Date.now()}-${safeBase}.${ext}`;
const { error: upErr } = await supabase.storage
.from(FLOW_MEDIA_BUCKET)
.upload(path, file, {
cacheControl: "3600",
upsert: false,
contentType: file.type,
});
if (upErr) throw new Error(upErr.message);
const {
data: { publicUrl },
} = supabase.storage.from(FLOW_MEDIA_BUCKET).getPublicUrl(path);
// Account-scoped upload (path `account-<id>/...`) — see
// uploadAccountMedia + migration 020's flow-media RLS policy.
const { publicUrl } = await uploadAccountMedia(FLOW_MEDIA_BUCKET, file);
// Patch all fields in one call so the form doesn't re-render
// with a half-uploaded state.
onUpdateConfig({

View File

@ -1,13 +1,56 @@
"use client";
import { useState, useRef, useCallback, KeyboardEvent } from "react";
import { Send, LayoutTemplate } from "lucide-react";
import {
useState,
useRef,
useCallback,
useEffect,
KeyboardEvent,
} from "react";
import {
Send,
LayoutTemplate,
Paperclip,
Image as ImageIcon,
Video,
FileText,
Mic,
Square,
X,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCan } from "@/hooks/use-can";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
uploadAccountMedia,
MEDIA_MAX_BYTES,
} from "@/lib/storage/upload-media";
import { isMetaAcceptedAudio } from "@/lib/whatsapp/audio-transcode";
import { ReplyQuote } from "./reply-quote";
/** Media content types an agent can send from the composer. */
export type ComposerMediaKind = "image" | "video" | "document" | "audio";
export interface SendMediaPayload {
kind: ComposerMediaKind;
/** Public chat-media URL Meta fetches at send time. */
mediaUrl: string;
/** Optional caption (image/video/document only). */
caption?: string;
/** Original file name — surfaced to the recipient for documents. */
filename?: string;
replyToId?: string;
}
interface ReplyDraft {
/** Internal UUID of the message being replied to — sent back through onSend. */
id: string;
@ -15,19 +58,62 @@ interface ReplyDraft {
preview: string;
}
const CHAT_MEDIA_BUCKET = "chat-media";
// Mirrors the chat-media bucket's allowed_mime_types (migration 023) for
// the file picker so unsupported files are rejected before upload rather
// than failing with a confusing Storage error. Audio has no picker — it's
// captured via the recorder.
const PICKER_ACCEPT: Record<"image" | "video" | "document", string> = {
image: "image/png,image/jpeg,image/webp",
video: "video/mp4,video/3gpp",
document:
"application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain",
};
interface MediaDraft {
kind: ComposerMediaKind;
mediaUrl: string;
filename: string;
caption: string;
}
interface MessageComposerProps {
conversationId: string;
sessionExpired: boolean;
onSend: (text: string, replyToId?: string) => void;
onSendMedia: (payload: SendMediaPayload) => void;
onOpenTemplates: () => void;
replyTo?: ReplyDraft | null;
onClearReply?: () => void;
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
/**
* Map a Meta-accepted audio MIME to a file extension for the upload path.
* Recordings that aren't already accepted are transcoded to OGG upstream,
* so this only ever sees accepted types.
*/
function audioExtForMime(mime: string): string {
const base = mime.split(";")[0];
if (base.includes("ogg")) return "ogg";
if (base.includes("mp4")) return "m4a";
if (base.includes("mpeg")) return "mp3";
if (base.includes("aac")) return "aac";
if (base.includes("amr")) return "amr";
return "ogg";
}
export function MessageComposer({
conversationId,
sessionExpired,
onSend,
onSendMedia,
onOpenTemplates,
replyTo,
onClearReply,
@ -35,11 +121,47 @@ export function MessageComposer({
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Media attachment state. `draft` holds an uploaded-but-not-yet-sent
// attachment; `busy` covers the upload/transcode window.
const [draft, setDraft] = useState<MediaDraft | null>(null);
const [busy, setBusy] = useState(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const documentInputRef = useRef<HTMLInputElement>(null);
// Voice recording state.
const [recording, setRecording] = useState(false);
const [recordSeconds, setRecordSeconds] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const cancelledRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Viewers (read-only role) can browse the inbox but never send.
// For solo users this is always true — single-owner accounts pass
// every capability — so the disabled branch is a no-op there.
const canSend = useCan("send-messages");
const readOnly = !canSend;
// Media (like free-form text) is only allowed inside the 24h window.
const inputsDisabled = readOnly || sessionExpired;
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
// Tear down any live recording + timer on unmount so a mid-record
// navigation doesn't leak the mic.
useEffect(() => {
return () => {
clearTimer();
streamRef.current?.getTracks().forEach((t) => t.stop());
};
}, [clearTimer]);
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
@ -83,6 +205,228 @@ export function MessageComposer({
[adjustHeight]
);
// Upload a captured file to chat-media and stage it as a draft.
const stageUpload = useCallback(
async (kind: ComposerMediaKind, file: File) => {
if (file.size > MEDIA_MAX_BYTES) {
toast.error(
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
);
return;
}
setBusy(true);
try {
const { publicUrl } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
setDraft({ kind, mediaUrl: publicUrl, filename: file.name, caption: "" });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed.");
} finally {
setBusy(false);
}
},
[],
);
const handlePicked = useCallback(
(kind: "image" | "video" | "document", file: File | undefined) => {
if (file) void stageUpload(kind, file);
},
[stageUpload],
);
// ---- Voice recording ----------------------------------------------
const finalizeRecording = useCallback(
async (blob: Blob, mime: string) => {
setBusy(true);
try {
let file: File;
if (isMetaAcceptedAudio(mime)) {
// Firefox (ogg) / Safari (mp4) already give a Meta-accepted
// format — upload as-is.
const base = mime.split(";")[0];
file = new File([blob], `voice-${Date.now()}.${audioExtForMime(mime)}`, {
type: base,
});
} else {
// Chromium records WebM/Opus, which Meta rejects — remux to
// OGG/Opus server-side first.
const form = new FormData();
form.append("file", blob, "recording");
const res = await fetch("/api/whatsapp/transcode-audio", {
method: "POST",
body: form,
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.error || `Transcode failed (HTTP ${res.status})`);
}
const ogg = await res.blob();
file = new File([ogg], `voice-${Date.now()}.ogg`, { type: "audio/ogg" });
}
if (file.size > MEDIA_MAX_BYTES) {
toast.error("Recording is too long (over 16 MB).");
return;
}
const { publicUrl } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
setDraft({ kind: "audio", mediaUrl: publicUrl, filename: file.name, caption: "" });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Could not process the recording.");
} finally {
setBusy(false);
}
},
[],
);
const startRecording = useCallback(async () => {
if (inputsDisabled || busy || recording) return;
if (typeof MediaRecorder === "undefined" || !navigator.mediaDevices?.getUserMedia) {
toast.error("Voice recording isn't supported in this browser.");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
// Prefer a Meta-accepted container (Firefox supports ogg/opus) so
// we can skip the transcode round-trip; Chromium falls back to webm.
const preferred = ["audio/ogg;codecs=opus", "audio/webm;codecs=opus", "audio/webm"];
const mimeType = preferred.find((t) => MediaRecorder.isTypeSupported(t)) || "";
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
chunksRef.current = [];
cancelledRef.current = false;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
streamRef.current = null;
const chunks = chunksRef.current;
chunksRef.current = [];
if (cancelledRef.current) return;
const type = recorder.mimeType || mimeType || "audio/webm";
const blob = new Blob(chunks, { type });
if (blob.size > 0) void finalizeRecording(blob, type);
};
recorderRef.current = recorder;
recorder.start();
setRecording(true);
setRecordSeconds(0);
timerRef.current = setInterval(() => setRecordSeconds((s) => s + 1), 1000);
} catch {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
toast.error("Microphone access denied or unavailable.");
}
}, [inputsDisabled, busy, recording, finalizeRecording]);
const stopRecording = useCallback(() => {
clearTimer();
setRecording(false);
recorderRef.current?.stop();
}, [clearTimer]);
const cancelRecording = useCallback(() => {
cancelledRef.current = true;
clearTimer();
setRecording(false);
recorderRef.current?.stop();
}, [clearTimer]);
// ---- Draft send / discard -----------------------------------------
const sendDraft = useCallback(() => {
if (!draft || busy) return;
onSendMedia({
kind: draft.kind,
mediaUrl: draft.mediaUrl,
// Audio takes no caption (Meta rejects it). Everything else: the
// trimmed caption, or undefined when blank.
caption:
draft.kind === "audio" ? undefined : draft.caption.trim() || undefined,
filename: draft.kind === "document" ? draft.filename : undefined,
replyToId: replyTo?.id,
});
setDraft(null);
onClearReply?.();
}, [draft, busy, onSendMedia, replyTo?.id, onClearReply]);
const discardDraft = useCallback(() => setDraft(null), []);
// ---- Render --------------------------------------------------------
const DraftPreview = () => {
if (!draft) return null;
return (
<div className="rounded-xl border border-border bg-muted/40 p-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
{draft.kind === "image" && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={draft.mediaUrl}
alt={draft.filename}
className="max-h-40 rounded-lg object-cover"
/>
)}
{draft.kind === "video" && (
<video src={draft.mediaUrl} controls className="max-h-40 rounded-lg" />
)}
{draft.kind === "audio" && (
<audio src={draft.mediaUrl} controls className="w-full" />
)}
{draft.kind === "document" && (
<div className="flex items-center gap-2 text-sm text-foreground">
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate">{draft.filename}</span>
</div>
)}
</div>
<button
type="button"
onClick={discardDraft}
aria-label="Remove attachment"
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex items-end gap-2">
{draft.kind !== "audio" && (
<input
value={draft.caption}
onChange={(e) =>
setDraft((d) => (d ? { ...d, caption: e.target.value } : d))
}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendDraft();
}
}}
placeholder="Add a caption…"
className="flex-1 rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50"
/>
)}
<GatedButton
size="sm"
canAct={!readOnly}
gateReason="send messages"
disabled={busy}
onClick={sendDraft}
className={cn(
"h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40",
draft.kind === "audio" && "ml-auto",
)}
>
<Send className="h-4 w-4" />
</GatedButton>
</div>
</div>
);
};
return (
<div className="border-t border-border bg-card p-3">
{replyTo && (
@ -111,61 +455,161 @@ export function MessageComposer({
</div>
)}
<div className="flex items-end gap-2">
<GatedButton
variant="ghost"
size="sm"
canAct={!readOnly}
gateReason="send messages"
title={readOnly ? undefined : "Send template"}
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenTemplates}
>
<LayoutTemplate className="h-4 w-4" />
</GatedButton>
{/* Hidden file inputs driven by the attach menu. */}
<input
ref={imageInputRef}
type="file"
accept={PICKER_ACCEPT.image}
className="hidden"
onChange={(e) => {
handlePicked("image", e.target.files?.[0]);
e.target.value = "";
}}
/>
<input
ref={videoInputRef}
type="file"
accept={PICKER_ACCEPT.video}
className="hidden"
onChange={(e) => {
handlePicked("video", e.target.files?.[0]);
e.target.value = "";
}}
/>
<input
ref={documentInputRef}
type="file"
accept={PICKER_ACCEPT.document}
className="hidden"
onChange={(e) => {
handlePicked("document", e.target.files?.[0]);
e.target.value = "";
}}
/>
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={
readOnly
? "Read-only — viewers can browse but not reply"
: sessionExpired
? "Session expired - use a template"
: "Type a message... (Shift+Enter for new line)"
}
disabled={sessionExpired || readOnly}
rows={1}
// Textarea keeps its own inline title — the GatedButton
// wrapping pattern doesn't apply to non-button inputs.
// The placeholder text also surfaces the read-only state.
title={readOnly ? "Read-only — your role can't send messages" : undefined}
className={cn(
"flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50",
(sessionExpired || readOnly) && "cursor-not-allowed opacity-50"
)}
/>
{draft ? (
<DraftPreview />
) : recording ? (
// Recording bar — replaces the composer while the mic is live.
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted px-4 py-2.5">
<span className="flex h-2.5 w-2.5 shrink-0 animate-pulse rounded-full bg-red-500" />
<span className="flex-1 text-sm text-foreground">
Recording… {formatDuration(recordSeconds)}
</span>
<button
type="button"
onClick={cancelRecording}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-card hover:text-foreground"
>
Cancel
</button>
<Button
size="sm"
onClick={stopRecording}
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90"
title="Stop and attach"
>
<Square className="h-4 w-4" />
</Button>
</div>
) : (
<div className="flex items-end gap-2">
{/* Attach menu — photo / video / document / voice. */}
<DropdownMenu>
<DropdownMenuTrigger
disabled={inputsDisabled || busy}
title={
readOnly
? "Read-only — your role can't send messages"
: inputsDisabled
? undefined
: "Attach media"
}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Paperclip className="h-4 w-4" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="border-border bg-popover">
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
<ImageIcon className="mr-2 h-4 w-4" />
Photo
</DropdownMenuItem>
<DropdownMenuItem onClick={() => videoInputRef.current?.click()}>
<Video className="mr-2 h-4 w-4" />
Video
</DropdownMenuItem>
<DropdownMenuItem onClick={() => documentInputRef.current?.click()}>
<FileText className="mr-2 h-4 w-4" />
Document
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void startRecording()}>
<Mic className="mr-2 h-4 w-4" />
Voice note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<GatedButton
size="sm"
canAct={!readOnly}
gateReason="send messages"
disabled={!text.trim() || sessionExpired || sending}
onClick={handleSend}
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40"
>
<Send className="h-4 w-4" />
</GatedButton>
</div>
<GatedButton
variant="ghost"
size="sm"
canAct={!readOnly}
gateReason="send messages"
title={readOnly ? undefined : "Send template"}
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenTemplates}
>
<LayoutTemplate className="h-4 w-4" />
</GatedButton>
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={
readOnly
? "Read-only — viewers can browse but not reply"
: sessionExpired
? "Session expired - use a template"
: "Type a message... (Shift+Enter for new line)"
}
disabled={sessionExpired || readOnly}
rows={1}
// Textarea keeps its own inline title — the GatedButton
// wrapping pattern doesn't apply to non-button inputs.
// The placeholder text also surfaces the read-only state.
title={readOnly ? "Read-only — your role can't send messages" : undefined}
className={cn(
"flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50",
(sessionExpired || readOnly) && "cursor-not-allowed opacity-50"
)}
/>
<GatedButton
size="sm"
canAct={!readOnly}
gateReason="send messages"
disabled={!text.trim() || sessionExpired || sending}
onClick={handleSend}
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40"
>
<Send className="h-4 w-4" />
</GatedButton>
</div>
)}
{/* Hint sits outside the flex row so its height doesn't push
`items-end` buttons below the textarea. Indented to line up
under the textarea left edge (w-9 button + gap-2 = 44px). */}
<p className="mt-1 pl-11 text-[10px] text-muted-foreground">
Type &apos;/&apos; for quick replies
</p>
under the textarea left edge. */}
{!draft && !recording && (
<p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground">
Type &apos;/&apos; for quick replies
</p>
)}
</div>
);
}

View File

@ -34,7 +34,7 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageBubble } from "./message-bubble";
import { MessageActions } from "./message-actions";
import { MessageComposer } from "./message-composer";
import { MessageComposer, type SendMediaPayload } from "./message-composer";
import { TemplatePicker } from "./template-picker";
import { buildReplyPreview } from "./reply-quote";
import { toast } from "sonner";
@ -473,6 +473,68 @@ export function MessageThread({
[conversation, onNewMessage, onUpdateMessage]
);
const handleSendMedia = useCallback(
async (payload: SendMediaPayload) => {
if (!conversation) return;
// Documents show their filename in our own bubble (and to the
// recipient as the Meta caption when no caption was typed); other
// kinds use the caption as-is. Audio carries no caption.
const contentText =
payload.kind === "document"
? payload.caption || payload.filename || "Document"
: payload.caption;
const tempId = `temp-${Date.now()}`;
const optimisticMsg: Message = {
id: tempId,
conversation_id: conversation.id,
sender_type: "agent",
content_type: payload.kind,
content_text: contentText,
media_url: payload.mediaUrl,
status: "sending",
created_at: new Date().toISOString(),
reply_to_message_id: payload.replyToId,
};
onNewMessage(optimisticMsg);
setReplyTo(null);
try {
const res = await fetch("/api/whatsapp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversation_id: conversation.id,
message_type: payload.kind,
media_url: payload.mediaUrl,
content_text: contentText,
filename: payload.filename,
reply_to_message_id: payload.replyToId,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const reason = data?.error || `HTTP ${res.status}`;
console.error("Failed to send media:", reason);
toast.error(`Failed to send: ${reason}`);
onUpdateMessage(tempId, { status: "failed" });
return;
}
onUpdateMessage(tempId, { status: "sent" });
} catch (err) {
console.error("Failed to send media:", err);
const reason = err instanceof Error ? err.message : "network error";
toast.error(`Failed to send: ${reason}`);
onUpdateMessage(tempId, { status: "failed" });
}
},
[conversation, onNewMessage, onUpdateMessage],
);
const handleStatusChange = useCallback(
async (status: ConversationStatus) => {
if (!conversation) return;
@ -942,6 +1004,7 @@ export function MessageThread({
conversationId={conversation.id}
sessionExpired={sessionInfo.expired}
onSend={handleSend}
onSendMedia={handleSendMedia}
onOpenTemplates={handleOpenTemplates}
replyTo={replyTo}
onClearReply={() => setReplyTo(null)}

View File

@ -125,6 +125,10 @@ export const RATE_LIMITS = {
* fidget with reactions and a single "swap" is actually two calls
* (remove + add) under the hood. */
react: { limit: 120, windowMs: 60_000 },
/** Voice-note transcode (WebM/Opus → OGG/Opus). One call per recorded
* clip; tighter than send because each spawns an ffmpeg process. 20/min
* is far above any human recording cadence. */
transcodeAudio: { limit: 20, windowMs: 60_000 },
/** Invitation peek (public, per-IP). 30/min lets a forwarded link
* retry a handful of times under flaky connectivity without
* enabling brute-force token enumeration. With 256-bit tokens the

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { buildMediaPath } from "./upload-media";
const ACCOUNT = "11111111-2222-3333-4444-555555555555";
describe("buildMediaPath", () => {
it("namespaces under account-<id> so RLS write policies match", () => {
const path = buildMediaPath(ACCOUNT, "photo.png", 1700000000000);
expect(path).toBe(`account-${ACCOUNT}/1700000000000-photo.png`);
expect(path.split("/")[0]).toBe(`account-${ACCOUNT}`);
});
it("lower-cases the extension and sanitizes the basename", () => {
const path = buildMediaPath(ACCOUNT, "My Invoice (final).PDF", 1700000000000);
expect(path).toBe(`account-${ACCOUNT}/1700000000000-My_Invoice_final_.pdf`);
});
it("caps the basename at 40 chars", () => {
const long = "a".repeat(100) + ".png";
const path = buildMediaPath(ACCOUNT, long, 1700000000000);
const base = path.split("/")[1].replace("1700000000000-", "").replace(".png", "");
expect(base.length).toBe(40);
});
it("falls back to 'file' / 'bin' for a nameless input", () => {
const path = buildMediaPath(ACCOUNT, "", 1700000000000);
expect(path).toBe(`account-${ACCOUNT}/1700000000000-file.bin`);
});
it("defaults the extension to bin when there is none", () => {
const path = buildMediaPath(ACCOUNT, "README", 1700000000000);
expect(path).toBe(`account-${ACCOUNT}/1700000000000-README.bin`);
});
});

View File

@ -0,0 +1,102 @@
import { createClient } from "@/lib/supabase/client";
/**
* Shared media-upload helper for Supabase Storage buckets that use the
* account-scoped path convention introduced in migration 020
* (`flow-media`) and reused by migration 023 (`chat-media`):
*
* <bucket>/account-<account_id>/<timestamp>-<basename>.<ext>
*
* The first path segment (`account-<uuid>`) is what the bucket's RLS
* write policies match on, so every caller MUST go through here rather
* than hand-rolling a path — a mismatched segment is silently rejected
* by RLS. Both the Flows builder (`node-config-form`) and the inbox
* composer call this so the logic lives in exactly one place.
*/
/** 16 MB — matches the `file_size_limit` on both buckets (migrations 016/020/023). */
export const MEDIA_MAX_BYTES = 16 * 1024 * 1024;
/**
* Build the account-scoped object path for an upload. Pure + exported so
* it can be unit-tested without a Supabase client.
*
* - `basename` is stripped of its extension, lower-cased non-safe chars
* are collapsed to `_`, and it's capped at 40 chars (falls back to
* "file" when empty).
* - The timestamp + the original name keep collisions between two
* concurrent uploads astronomically unlikely.
*/
export function buildMediaPath(
accountId: string,
fileName: string,
now: number = Date.now(),
): string {
// Only treat the trailing segment as an extension when there's a real
// one — a bare name like "README" has no extension and falls back to
// "bin" rather than becoming "readme".
const hasExt = /\.[^.]+$/.test(fileName);
const ext = hasExt ? fileName.split(".").pop()!.toLowerCase() : "bin";
const safeBase =
fileName
.replace(/\.[^.]+$/, "")
.replace(/[^a-zA-Z0-9_-]+/g, "_")
.slice(0, 40) || "file";
return `account-${accountId}/${now}-${safeBase}.${ext}`;
}
export interface UploadAccountMediaResult {
/** Public URL Meta can fetch at send time. */
publicUrl: string;
/** Storage object path (account-scoped). */
path: string;
}
/**
* Upload a file to an account-scoped Storage bucket and return its public
* URL. Throws with a user-facing message on auth / account-resolution /
* upload failure — callers surface it via a toast.
*
* Size validation is the caller's responsibility (limits can differ per
* feature); `MEDIA_MAX_BYTES` is exported for the common case.
*/
export async function uploadAccountMedia(
bucket: string,
file: File,
): Promise<UploadAccountMediaResult> {
const supabase = createClient();
const {
data: { user },
error: userErr,
} = await supabase.auth.getUser();
if (userErr || !user) {
throw new Error("Not signed in.");
}
// Resolve account_id so the path is account-scoped (matches the
// bucket's RLS write policy from migration 020/023). User-scoped
// paths would be rejected.
const { data: profile, error: profileErr } = await supabase
.from("profiles")
.select("account_id")
.eq("user_id", user.id)
.maybeSingle();
if (profileErr || !profile?.account_id) {
throw new Error("Could not resolve your account.");
}
const path = buildMediaPath(profile.account_id as string, file.name);
const { error: upErr } = await supabase.storage.from(bucket).upload(path, file, {
cacheControl: "3600",
upsert: false,
contentType: file.type,
});
if (upErr) throw new Error(upErr.message);
const {
data: { publicUrl },
} = supabase.storage.from(bucket).getPublicUrl(path);
return { publicUrl, path };
}

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
buildFfmpegArgs,
isMetaAcceptedAudio,
META_ACCEPTED_AUDIO_MIME,
} from "./audio-transcode";
describe("isMetaAcceptedAudio", () => {
it("accepts the Meta-supported audio types", () => {
for (const mime of META_ACCEPTED_AUDIO_MIME) {
expect(isMetaAcceptedAudio(mime)).toBe(true);
}
});
it("strips the codec suffix before matching", () => {
expect(isMetaAcceptedAudio("audio/ogg;codecs=opus")).toBe(true);
expect(isMetaAcceptedAudio("audio/mp4; codecs=mp4a.40.2")).toBe(true);
});
it("rejects WebM (Chromium's MediaRecorder default) — needs transcode", () => {
expect(isMetaAcceptedAudio("audio/webm")).toBe(false);
expect(isMetaAcceptedAudio("audio/webm;codecs=opus")).toBe(false);
});
it("rejects empty / nullish input", () => {
expect(isMetaAcceptedAudio("")).toBe(false);
expect(isMetaAcceptedAudio(undefined)).toBe(false);
expect(isMetaAcceptedAudio(null)).toBe(false);
});
});
describe("buildFfmpegArgs", () => {
it("targets OGG/Opus mono and overwrites the output", () => {
const args = buildFfmpegArgs("/tmp/in", "/tmp/out.ogg");
expect(args).toContain("-y");
expect(args).toEqual(expect.arrayContaining(["-i", "/tmp/in"]));
expect(args).toEqual(expect.arrayContaining(["-c:a", "libopus"]));
expect(args).toEqual(expect.arrayContaining(["-f", "ogg"]));
expect(args).toEqual(expect.arrayContaining(["-ac", "1"]));
// Output path is last so ffmpeg treats it as the destination.
expect(args[args.length - 1]).toBe("/tmp/out.ogg");
});
});

View File

@ -0,0 +1,63 @@
/**
* Pure helpers for the voice-note transcode flow. Kept free of Node /
* ffmpeg imports so they can be unit-tested without spawning a process —
* the route handler (`/api/whatsapp/transcode-audio`) does the actual
* spawning.
*/
/**
* Audio MIME types Meta accepts for OUTBOUND messages on the WhatsApp
* Cloud API. Anything outside this set must be transcoded before send.
* Notably absent: `audio/webm` (Chromium's default MediaRecorder output)
* — which is exactly why the transcode step exists.
*
* https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types
*/
export const META_ACCEPTED_AUDIO_MIME = new Set<string>([
"audio/aac",
"audio/mp4",
"audio/mpeg",
"audio/amr",
"audio/ogg", // Opus only — the codec MediaRecorder + ffmpeg produce here
]);
/**
* Whether a recorded clip's MIME type can be sent to Meta as-is. The
* client only round-trips through the transcode route when this is false.
* The codec suffix (e.g. `audio/ogg;codecs=opus`) is stripped before the
* lookup.
*/
export function isMetaAcceptedAudio(mimeType: string | undefined | null): boolean {
if (!mimeType) return false;
const base = mimeType.split(";")[0]!.trim().toLowerCase();
return META_ACCEPTED_AUDIO_MIME.has(base);
}
/**
* ffmpeg arguments to remux/encode an arbitrary recorded clip into
* OGG/Opus — the format WhatsApp renders as a playable voice note.
*
* We re-encode to libopus rather than `-c:a copy` so the output is valid
* regardless of the input container/codec (Chromium gives WebM/Opus,
* older browsers may give other codecs). Opus at 48k mono is the voice-
* note sweet spot and keeps the file tiny.
*/
export function buildFfmpegArgs(inputPath: string, outputPath: string): string[] {
return [
"-y", // overwrite output if it exists
"-i",
inputPath,
"-vn", // no video stream
"-c:a",
"libopus",
"-b:a",
"32k",
"-ar",
"48000",
"-ac",
"1",
"-f",
"ogg",
outputPath,
];
}

View File

@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sendMediaMessage } from "./meta-api";
// Capture the JSON body each helper POSTs to Meta so we can assert the
// exact payload shape per media kind without hitting the network.
interface CapturedBody {
type?: string;
image?: Record<string, unknown>;
video?: Record<string, unknown>;
document?: Record<string, unknown>;
audio?: Record<string, unknown>;
}
let captured: CapturedBody | null = null;
function okFetch() {
return vi.fn(async (_url: string, init?: RequestInit) => {
captured = init?.body ? (JSON.parse(init.body as string) as CapturedBody) : null;
return {
ok: true,
json: async () => ({ messages: [{ id: "wamid.TEST" }] }),
} as Response;
});
}
const BASE = {
phoneNumberId: "test-phone",
accessToken: "test-token",
to: "1234567890",
link: "https://cdn.example.com/file",
} as const;
describe("sendMediaMessage — payload shape", () => {
beforeEach(() => {
captured = null;
vi.stubGlobal("fetch", okFetch());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("sends image with a caption and no filename", async () => {
await sendMediaMessage({ ...BASE, kind: "image", caption: "hello", filename: "x.png" });
expect(captured?.type).toBe("image");
expect(captured?.image).toEqual({ link: BASE.link, caption: "hello" });
expect(captured?.image?.filename).toBeUndefined();
});
it("sends document with both caption and filename", async () => {
await sendMediaMessage({
...BASE,
kind: "document",
caption: "invoice",
filename: "invoice.pdf",
});
expect(captured?.type).toBe("document");
expect(captured?.document).toEqual({
link: BASE.link,
caption: "invoice",
filename: "invoice.pdf",
});
});
it("sends audio with NO caption and NO filename (Meta rejects both)", async () => {
await sendMediaMessage({
...BASE,
kind: "audio",
caption: "should be dropped",
filename: "voice.ogg",
});
expect(captured?.type).toBe("audio");
expect(captured?.audio).toEqual({ link: BASE.link });
});
it("throws when no link is provided", async () => {
await expect(
sendMediaMessage({ ...BASE, link: "", kind: "image" }),
).rejects.toThrow(/requires a link/);
});
});

View File

@ -259,7 +259,7 @@ export async function sendTextMessage(
return { messageId: data.messages[0].id }
}
export type MediaKind = 'image' | 'video' | 'document'
export type MediaKind = 'image' | 'video' | 'document' | 'audio'
export interface SendMediaMessageArgs {
phoneNumberId: string
@ -268,19 +268,24 @@ export interface SendMediaMessageArgs {
kind: MediaKind
/** Public URL Meta fetches at send time. */
link: string
/** Optional caption — Meta caps at 1024 chars. Documents + images + videos all accept it. */
/** Optional caption — Meta caps at 1024 chars. Documents + images + videos accept it; audio does NOT. */
caption?: string
/** Document-only. Shown in the recipient's chat as the file name. Ignored for image/video. */
/** Document-only. Shown in the recipient's chat as the file name. Ignored for image/video/audio. */
filename?: string
contextMessageId?: string
}
/**
* Send an image, video, or document via a public URL.
* Send an image, video, document, or audio (voice note) via a public URL.
*
* Used by the Flows engine's `send_media` node. Mirrors
* `sendTextMessage` — single fetch, throws on non-2xx, returns Meta's
* message id.
* Used by the Flows engine's `send_media` node and the inbox composer's
* agent-initiated media sends. Mirrors `sendTextMessage` — single fetch,
* throws on non-2xx, returns Meta's message id.
*
* Audio is special-cased: Meta rejects `caption` and `filename` on audio
* messages, so we send `{ link }` only. WhatsApp auto-renders an
* OGG/Opus file as a playable voice note (waveform) rather than a file
* attachment.
*/
export async function sendMediaMessage(
args: SendMediaMessageArgs,
@ -289,8 +294,11 @@ export async function sendMediaMessage(
if (!link) throw new Error('sendMediaMessage requires a link.')
const url = `${META_API_BASE}/${phoneNumberId}/messages`
// Audio accepts neither caption nor filename per Meta's spec — adding
// either yields a 400. image/video/document accept a caption; only
// document accepts a filename.
const media: Record<string, unknown> = { link }
if (caption) media.caption = caption
if (caption && kind !== 'audio') media.caption = caption
if (kind === 'document' && filename) media.filename = filename
const body: Record<string, unknown> = {

7
src/types/ffmpeg-static.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
// `ffmpeg-static` ships no type declarations. Its default export is the
// absolute path to the bundled ffmpeg binary (or null if unavailable on
// the current platform).
declare module "ffmpeg-static" {
const ffmpegPath: string | null;
export default ffmpegPath;
}

View File

@ -0,0 +1,122 @@
-- ============================================================
-- 023_chat_media.sql
--
-- Adds the `chat-media` Supabase Storage bucket used when an agent
-- sends a photo / video / document / voice note from the inbox
-- composer (issue #213). Today media can only be RECEIVED from
-- customers or sent via the Flows `send_media` node — never typed
-- and sent live in a 1:1 thread.
--
-- Mirrors the `flow-media` bucket (migration 016) and its
-- account-scoped storage RLS (migration 020), with two differences:
--
-- 1. A separate bucket so chat attachments and flow-builder media
-- stay conceptually distinct (and so a future per-bucket size /
-- retention policy can diverge without touching flows).
--
-- 2. The allowed MIME list adds the audio types Meta accepts for
-- outbound voice notes — audio/ogg (Opus), audio/mpeg, audio/aac,
-- audio/mp4, audio/amr. Browser recordings (WebM/Opus) are
-- transcoded to audio/ogg BEFORE upload, so WebM never lands
-- here and isn't allow-listed.
--
-- Path convention (same as flow-media post-020):
-- chat-media/account-<account_id>/<timestamp>-<basename>.<ext>
-- The bucket is public so Meta can fetch the URL without auth; writes
-- are scoped to account members via the path's first segment.
--
-- Size limit 16 MB — Meta's tightest universal cap (video). Documents
-- can technically be 100 MB on Meta, but we hold the universal cap to
-- match flow-media and keep one limit to reason about.
--
-- Idempotent — safe to re-run.
-- ============================================================
-- ============================================================
-- 1. chat-media storage bucket
-- ============================================================
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'chat-media',
'chat-media',
TRUE,
16777216, -- 16 MB (Meta video cap; documents/images/audio fit under this)
ARRAY[
-- Images
'image/png', 'image/jpeg', 'image/webp',
-- Videos
'video/mp4', 'video/3gpp',
-- Documents
'application/pdf',
'application/vnd.ms-powerpoint',
'application/msword',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
-- Audio (voice notes) — only Meta-accepted outbound types. Browser
-- WebM/Opus is transcoded to audio/ogg before upload.
'audio/ogg',
'audio/mpeg',
'audio/aac',
'audio/mp4',
'audio/amr'
]
)
ON CONFLICT (id) DO UPDATE
SET
public = EXCLUDED.public,
file_size_limit = EXCLUDED.file_size_limit,
allowed_mime_types = EXCLUDED.allowed_mime_types;
-- ============================================================
-- 2. Storage RLS — account-scoped writes, public reads
--
-- Same predicate shape as migration 020's flow-media policies:
-- writes are allowed when the path's first segment is
-- `account-<account_id>` for an account the caller belongs to.
-- Reads are public (the bucket is public so Meta can fetch links).
--
-- Drop-then-create (Postgres has no CREATE POLICY IF NOT EXISTS).
-- ============================================================
DROP POLICY IF EXISTS "Chat media is publicly readable" ON storage.objects;
CREATE POLICY "Chat media is publicly readable"
ON storage.objects FOR SELECT
USING (bucket_id = 'chat-media');
DROP POLICY IF EXISTS "Members can upload chat media" ON storage.objects;
CREATE POLICY "Members can upload chat media"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'chat-media'
AND EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.user_id = auth.uid()
AND ('account-' || p.account_id::text) = (storage.foldername(name))[1]
)
);
DROP POLICY IF EXISTS "Members can update chat media" ON storage.objects;
CREATE POLICY "Members can update chat media"
ON storage.objects FOR UPDATE
USING (
bucket_id = 'chat-media'
AND EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.user_id = auth.uid()
AND ('account-' || p.account_id::text) = (storage.foldername(name))[1]
)
);
DROP POLICY IF EXISTS "Members can delete chat media" ON storage.objects;
CREATE POLICY "Members can delete chat media"
ON storage.objects FOR DELETE
USING (
bucket_id = 'chat-media'
AND EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.user_id = auth.uid()
AND ('account-' || p.account_id::text) = (storage.foldername(name))[1]
)
);