diff --git a/.env.local.example b/.env.local.example index 39162d0..2d29a7c 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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 diff --git a/next.config.ts b/next.config.ts index bf07554..dba6d2c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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. diff --git a/package-lock.json b/package-lock.json index 426aa7e..646bf1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e99faf0..b913085 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index 5aff6b9..31a6383 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -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 { @@ -16,6 +21,7 @@ import { } from '@/lib/rate-limit' import type { MessageTemplate } from '@/types' import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard' +import { hasBetaFeature, CHAT_MEDIA_BETA } from '@/lib/auth/beta-features' export async function POST(request: Request) { try { @@ -46,7 +52,7 @@ export async function POST(request: Request) { // returned nothing for teammates who didn't author the row. const { data: profile } = await supabase .from('profiles') - .select('account_id') + .select('account_id, beta_features') .eq('user_id', user.id) .maybeSingle() const accountId = profile?.account_id as string | undefined @@ -63,6 +69,7 @@ export async function POST(request: Request) { message_type, content_text, media_url, + filename, template_name, template_language, template_params, @@ -77,6 +84,21 @@ 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) + + // Reject anything outside the known set up front rather than letting + // an unknown type fall through to the text path with empty content. + const VALID_MESSAGE_TYPES = ['text', 'template', ...MEDIA_KINDS] as const + if (!(VALID_MESSAGE_TYPES as readonly string[]).includes(message_type)) { + return NextResponse.json( + { error: `Unsupported message_type "${message_type}"` }, + { status: 400 } + ) + } + if (message_type === 'text' && !content_text) { return NextResponse.json( { error: 'content_text is required for text messages' }, @@ -91,6 +113,38 @@ export async function POST(request: Request) { ) } + if (isMediaKind && !media_url) { + return NextResponse.json( + { error: `media_url is required for ${message_type} messages` }, + { status: 400 } + ) + } + + // Media sends are beta-gated (issue #213). Text + templates stay + // available to everyone; only accounts opted into 'chat_media' may + // send media — mirrors the client gate so the endpoint can't be + // driven ahead of the rollout. + if (isMediaKind && !hasBetaFeature(profile?.beta_features, CHAT_MEDIA_BETA)) { + return NextResponse.json( + { error: 'Media messages are not enabled for your account.' }, + { status: 403 } + ) + } + + // Meta caps media captions at 1024 chars; reject before the upload is + // wasted at the Meta call. (Audio carries no caption — see meta-api.) + if ( + isMediaKind && + message_type !== 'audio' && + typeof content_text === 'string' && + content_text.length > 1024 + ) { + return NextResponse.json( + { error: 'Caption exceeds the 1024-character limit' }, + { status: 400 } + ) + } + // Fetch conversation and contact const { data: conversation, error: convError } = await supabase .from('conversations') @@ -246,6 +300,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, diff --git a/src/app/api/whatsapp/transcode-audio/route.ts b/src/app/api/whatsapp/transcode-audio/route.ts new file mode 100644 index 0000000..c6de773 --- /dev/null +++ b/src/app/api/whatsapp/transcode-audio/route.ts @@ -0,0 +1,148 @@ +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' +import { hasBetaFeature, CHAT_MEDIA_BETA } from '@/lib/auth/beta-features' + +// 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 { + 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) + } + + // Voice notes are part of the beta-gated media feature — only opted-in + // accounts may transcode (mirrors the send route + client gate). + const { data: profile } = await supabase + .from('profiles') + .select('beta_features') + .eq('user_id', user.id) + .maybeSingle() + if (!hasBetaFeature(profile?.beta_features, CHAT_MEDIA_BETA)) { + return NextResponse.json( + { error: 'Voice notes are not enabled for your account.' }, + { status: 403 }, + ) + } + + 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) + if (ogg.byteLength > MAX_INPUT_BYTES) { + return NextResponse.json( + { error: 'Transcoded audio exceeds the 16 MB limit.' }, + { status: 400 }, + ) + } + 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 }, + ) + } +} diff --git a/src/components/flows/forms/node-config-form.tsx b/src/components/flows/forms/node-config-form.tsx index 1b070d7..7ad15ef 100644 --- a/src/components/flows/forms/node-config-form.tsx +++ b/src/components/flows/forms/node-config-form.tsx @@ -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, 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-/...`, 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-`. 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-/...`) — 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({ diff --git a/src/components/inbox/message-composer.tsx b/src/components/inbox/message-composer.tsx index cdd633c..4f69f2c 100644 --- a/src/components/inbox/message-composer.tsx +++ b/src/components/inbox/message-composer.tsx @@ -1,13 +1,70 @@ "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 { useBetaFeature } from "@/hooks/use-beta-feature"; +import { CHAT_MEDIA_BETA } from "@/lib/auth/beta-features"; import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + uploadAccountMedia, + deleteAccountMedia, + MEDIA_MAX_BYTES_BY_KIND, +} from "@/lib/storage/upload-media"; import { ReplyQuote } from "./reply-quote"; +/** Media content types an agent can send from the composer. */ +export type ComposerMediaKind = "image" | "video" | "document" | "audio"; + +/** Supabase Storage bucket holding agent-sent chat attachments (migration 023). */ +export const CHAT_MEDIA_BUCKET = "chat-media"; + +/** Meta caps media captions at 1024 chars. Enforced here and in the send route. */ +export const MEDIA_CAPTION_MAX = 1024; + +/** Hard cap on a single voice recording so it can't blow the upload/ + * transcode limits — auto-stops the recorder when reached. */ +const MAX_RECORDING_SECONDS = 5 * 60; + +export interface SendMediaPayload { + kind: ComposerMediaKind; + /** Public chat-media URL Meta fetches at send time. */ + mediaUrl: string; + /** Storage object path — lets the caller GC the object if the send fails. */ + path: 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 +72,56 @@ interface ReplyDraft { preview: string; } +// 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; + /** Storage path — used to GC the object if the draft is discarded. */ + path: 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")}`; +} + +/** A recording can skip the transcode round-trip only if it's already + * OGG/Opus — the one format WhatsApp reliably renders as a voice note + * with a waveform. Everything else (Chromium WebM, Safari MP4/AAC) is + * remuxed server-side so the voice-note UX is consistent across + * browsers, not a plain audio attachment on some of them. */ +function isOggOpus(mime: string): boolean { + return mime.split(";")[0].trim().toLowerCase() === "audio/ogg"; +} + export function MessageComposer({ conversationId, sessionExpired, onSend, + onSendMedia, onOpenTemplates, replyTo, onClearReply, @@ -35,11 +129,66 @@ export function MessageComposer({ const [text, setText] = useState(""); const [sending, setSending] = useState(false); const textareaRef = useRef(null); + + // Media attachment state. `draft` holds an uploaded-but-not-yet-sent + // attachment; `busy` covers the upload/transcode window. + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const imageInputRef = useRef(null); + const videoInputRef = useRef(null); + const documentInputRef = useRef(null); + // Mirror of `draft` for the unmount cleanup, which can't read render + // state. Kept in sync below so navigating away with a staged-but-unsent + // attachment GCs the orphaned object. + const draftRef = useRef(null); + useEffect(() => { + draftRef.current = draft; + }, [draft]); + + // Best-effort GC of a staged object the user never sent. Fire-and-forget. + const removeStaged = useCallback((path: string | undefined) => { + if (!path) return; + void deleteAccountMedia(CHAT_MEDIA_BUCKET, path).catch(() => {}); + }, []); + + // Voice recording state. + const [recording, setRecording] = useState(false); + const [recordSeconds, setRecordSeconds] = useState(0); + const recorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + const cancelledRef = useRef(false); + const timerRef = useRef | 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; + // Beta-gated: only accounts opted into 'chat_media' see the attach + // menu / voice recorder. Everyone else gets the original text + template + // composer with no visible change. + const chatMediaEnabled = useBetaFeature(CHAT_MEDIA_BETA); + + 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, and GC a staged-but-unsent + // attachment so it doesn't orphan in the bucket. + useEffect(() => { + return () => { + clearTimer(); + streamRef.current?.getTracks().forEach((t) => t.stop()); + removeStaged(draftRef.current?.path); + }; + }, [clearTimer, removeStaged]); const adjustHeight = useCallback(() => { const el = textareaRef.current; @@ -83,6 +232,179 @@ 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) => { + // Per-kind ceiling mirrors Meta's caps (image 5 MB, etc.) so we + // reject before upload rather than orphaning an object that Meta + // would then refuse at send. + const max = MEDIA_MAX_BYTES_BY_KIND[kind]; + if (file.size > max) { + toast.error( + `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — ${kind} limit is ${Math.round( + max / 1024 / 1024, + )} MB.`, + ); + return; + } + setBusy(true); + try { + const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file); + // Replacing an existing draft? GC the previous object first. + removeStaged(draftRef.current?.path); + setDraft({ kind, mediaUrl: publicUrl, path, filename: file.name, caption: "" }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Upload failed."); + } finally { + setBusy(false); + } + }, + [removeStaged], + ); + + 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 (isOggOpus(mime)) { + // Firefox records OGG/Opus directly — upload as-is. + file = new File([blob], `voice-${Date.now()}.ogg`, { type: "audio/ogg" }); + } else { + // Chromium (WebM) and Safari (MP4) are remuxed to OGG/Opus so + // WhatsApp renders a voice note, not a plain audio attachment. + 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_BY_KIND.audio) { + toast.error("Recording is too long (over 16 MB)."); + return; + } + const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file); + removeStaged(draftRef.current?.path); + setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Could not process the recording."); + } finally { + setBusy(false); + } + }, + [removeStaged], + ); + + 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]); + + // Auto-stop at the cap so a forgotten recording can't blow the + // upload/transcode limits. + useEffect(() => { + if (recording && recordSeconds >= MAX_RECORDING_SECONDS) { + stopRecording(); + } + }, [recording, recordSeconds, stopRecording]); + + // ---- Draft send / discard ----------------------------------------- + + const sendDraft = useCallback(() => { + if (!draft || busy) return; + onSendMedia({ + kind: draft.kind, + mediaUrl: draft.mediaUrl, + path: draft.path, + // 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, + }); + // The object is now owned by the sent message — clear without GC. + setDraft(null); + onClearReply?.(); + }, [draft, busy, onSendMedia, replyTo?.id, onClearReply]); + + // Discard GCs the staged object — it was uploaded but never sent. + const discardDraft = useCallback(() => { + removeStaged(draft?.path); + setDraft(null); + }, [draft?.path, removeStaged]); + + const setCaption = useCallback((caption: string) => { + setDraft((d) => (d ? { ...d, caption } : d)); + }, []); + + // ---- Render -------------------------------------------------------- + return (
{replyTo && ( @@ -111,61 +433,274 @@ export function MessageComposer({
)} -
- - - + {/* Hidden file inputs driven by the attach menu — only mounted when + the beta feature is on. */} + {chatMediaEnabled && ( + <> + { + handlePicked("image", e.target.files?.[0]); + e.target.value = ""; + }} + /> + { + handlePicked("video", e.target.files?.[0]); + e.target.value = ""; + }} + /> + { + handlePicked("document", e.target.files?.[0]); + e.target.value = ""; + }} + /> + + )} -