mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #259 from ArnasDon/feat/chat-media-send
feat(inbox): send photo, video, document & voice in chat (#213)
This commit is contained in:
commit
f35ae4ef1c
@ -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
|
# 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.
|
# template UI without a real WABA. Leave unset (or "false") in prod.
|
||||||
# WHATSAPP_TEMPLATES_DRY_RUN=true
|
# 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
|
||||||
|
|||||||
@ -25,8 +25,12 @@ const SECURITY_HEADERS = [
|
|||||||
{ key: "X-Frame-Options", value: "DENY" },
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
{ 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",
|
key: "Permissions-Policy",
|
||||||
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
|
value: "camera=(), microphone=(self), geolocation=(), payment=(), usb=()",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Content-Security-Policy-Report-Only",
|
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
|
// https URLs paste-able from the UI), OG images, data URLs for
|
||||||
// tiny inline assets.
|
// tiny inline assets.
|
||||||
"img-src 'self' data: blob: https:",
|
"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:",
|
"font-src 'self' data:",
|
||||||
// Supabase REST + realtime (WSS). All Meta API calls happen
|
// Supabase REST + realtime (WSS). All Meta API calls happen
|
||||||
// server-side, so graph.facebook.com does not belong here.
|
// server-side, so graph.facebook.com does not belong here.
|
||||||
|
|||||||
162
package-lock.json
generated
162
package-lock.json
generated
@ -20,6 +20,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@ -549,6 +550,21 @@
|
|||||||
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
|
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"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": "^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": {
|
"node_modules/bundle-name": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||||
@ -4307,6 +4329,12 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chai": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
@ -4436,6 +4464,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
@ -5977,6 +6020,47 @@
|
|||||||
"node": "^12.20 || >= 14.13"
|
"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": {
|
"node_modules/figures": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||||
@ -6523,6 +6607,21 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
@ -8434,6 +8533,11 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/parse-json": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||||
@ -8722,6 +8826,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/prompts": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
"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": {
|
"node_modules/recast": {
|
||||||
"version": "0.23.11",
|
"version": "0.23.11",
|
||||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
|
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
|
||||||
@ -9198,6 +9325,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@ -9709,6 +9856,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/string-width": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
@ -10262,6 +10418,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
|||||||
@ -50,6 +50,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/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 { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
|
||||||
import { supabaseAdmin } from '@/lib/flows/admin-client'
|
import { supabaseAdmin } from '@/lib/flows/admin-client'
|
||||||
import {
|
import {
|
||||||
@ -16,6 +21,7 @@ import {
|
|||||||
} from '@/lib/rate-limit'
|
} from '@/lib/rate-limit'
|
||||||
import type { MessageTemplate } from '@/types'
|
import type { MessageTemplate } from '@/types'
|
||||||
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
|
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
|
||||||
|
import { hasBetaFeature, CHAT_MEDIA_BETA } from '@/lib/auth/beta-features'
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@ -46,7 +52,7 @@ export async function POST(request: Request) {
|
|||||||
// returned nothing for teammates who didn't author the row.
|
// returned nothing for teammates who didn't author the row.
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('account_id')
|
.select('account_id, beta_features')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
const accountId = profile?.account_id as string | undefined
|
const accountId = profile?.account_id as string | undefined
|
||||||
@ -63,6 +69,7 @@ export async function POST(request: Request) {
|
|||||||
message_type,
|
message_type,
|
||||||
content_text,
|
content_text,
|
||||||
media_url,
|
media_url,
|
||||||
|
filename,
|
||||||
template_name,
|
template_name,
|
||||||
template_language,
|
template_language,
|
||||||
template_params,
|
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) {
|
if (message_type === 'text' && !content_text) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'content_text is required for text messages' },
|
{ 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
|
// Fetch conversation and contact
|
||||||
const { data: conversation, error: convError } = await supabase
|
const { data: conversation, error: convError } = await supabase
|
||||||
.from('conversations')
|
.from('conversations')
|
||||||
@ -246,6 +300,22 @@ export async function POST(request: Request) {
|
|||||||
})
|
})
|
||||||
return result.messageId
|
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({
|
const result = await sendTextMessage({
|
||||||
phoneNumberId: config.phone_number_id,
|
phoneNumberId: config.phone_number_id,
|
||||||
accessToken,
|
accessToken,
|
||||||
|
|||||||
148
src/app/api/whatsapp/transcode-audio/route.ts
Normal file
148
src/app/api/whatsapp/transcode-audio/route.ts
Normal file
@ -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<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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,7 +45,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
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 { slugify, type BuilderNode } from "../shared";
|
||||||
import { NextNodeRow, NodeKeySelect, TextRow } from "./fields";
|
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";
|
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({
|
function SendMediaForm({
|
||||||
cfg,
|
cfg,
|
||||||
@ -897,7 +895,7 @@ function SendMediaForm({
|
|||||||
|
|
||||||
const handleFile = useCallback(
|
const handleFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
if (file.size > FLOW_MEDIA_MAX_BYTES) {
|
if (file.size > MEDIA_MAX_BYTES) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
|
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
|
||||||
);
|
);
|
||||||
@ -905,49 +903,9 @@ function SendMediaForm({
|
|||||||
}
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
// Account-scoped upload (path `account-<id>/...`) — see
|
||||||
const {
|
// uploadAccountMedia + migration 020's flow-media RLS policy.
|
||||||
data: { user },
|
const { publicUrl } = await uploadAccountMedia(FLOW_MEDIA_BUCKET, file);
|
||||||
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);
|
|
||||||
// Patch all fields in one call so the form doesn't re-render
|
// Patch all fields in one call so the form doesn't re-render
|
||||||
// with a half-uploaded state.
|
// with a half-uploaded state.
|
||||||
onUpdateConfig({
|
onUpdateConfig({
|
||||||
|
|||||||
@ -1,13 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
import {
|
||||||
import { Send, LayoutTemplate } from "lucide-react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { GatedButton } from "@/components/ui/gated-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 { 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 { 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";
|
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 {
|
interface ReplyDraft {
|
||||||
/** Internal UUID of the message being replied to — sent back through onSend. */
|
/** Internal UUID of the message being replied to — sent back through onSend. */
|
||||||
id: string;
|
id: string;
|
||||||
@ -15,19 +72,56 @@ interface ReplyDraft {
|
|||||||
preview: string;
|
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 {
|
interface MessageComposerProps {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
sessionExpired: boolean;
|
sessionExpired: boolean;
|
||||||
onSend: (text: string, replyToId?: string) => void;
|
onSend: (text: string, replyToId?: string) => void;
|
||||||
|
onSendMedia: (payload: SendMediaPayload) => void;
|
||||||
onOpenTemplates: () => void;
|
onOpenTemplates: () => void;
|
||||||
replyTo?: ReplyDraft | null;
|
replyTo?: ReplyDraft | null;
|
||||||
onClearReply?: () => void;
|
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({
|
export function MessageComposer({
|
||||||
conversationId,
|
conversationId,
|
||||||
sessionExpired,
|
sessionExpired,
|
||||||
onSend,
|
onSend,
|
||||||
|
onSendMedia,
|
||||||
onOpenTemplates,
|
onOpenTemplates,
|
||||||
replyTo,
|
replyTo,
|
||||||
onClearReply,
|
onClearReply,
|
||||||
@ -35,11 +129,66 @@ export function MessageComposer({
|
|||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
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);
|
||||||
|
// 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<MediaDraft | null>(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<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.
|
// Viewers (read-only role) can browse the inbox but never send.
|
||||||
// For solo users this is always true — single-owner accounts pass
|
// For solo users this is always true — single-owner accounts pass
|
||||||
// every capability — so the disabled branch is a no-op there.
|
// every capability — so the disabled branch is a no-op there.
|
||||||
const canSend = useCan("send-messages");
|
const canSend = useCan("send-messages");
|
||||||
const readOnly = !canSend;
|
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 adjustHeight = useCallback(() => {
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
@ -83,6 +232,179 @@ export function MessageComposer({
|
|||||||
[adjustHeight]
|
[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 (
|
return (
|
||||||
<div className="border-t border-border bg-card p-3">
|
<div className="border-t border-border bg-card p-3">
|
||||||
{replyTo && (
|
{replyTo && (
|
||||||
@ -111,7 +433,120 @@ export function MessageComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hidden file inputs driven by the attach menu — only mounted when
|
||||||
|
the beta feature is on. */}
|
||||||
|
{chatMediaEnabled && (
|
||||||
|
<>
|
||||||
|
<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 = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft ? (
|
||||||
|
<MediaDraftPreview
|
||||||
|
draft={draft}
|
||||||
|
busy={busy}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onCaptionChange={setCaption}
|
||||||
|
onDiscard={discardDraft}
|
||||||
|
onSend={sendDraft}
|
||||||
|
/>
|
||||||
|
) : 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)} /{" "}
|
||||||
|
{formatDuration(MAX_RECORDING_SECONDS)}
|
||||||
|
</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">
|
<div className="flex items-end gap-2">
|
||||||
|
{/* Attach menu — photo / video / document / voice. Beta-gated:
|
||||||
|
hidden entirely for accounts not opted into 'chat_media'. */}
|
||||||
|
{chatMediaEnabled && (
|
||||||
|
<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
|
<GatedButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -159,13 +594,113 @@ export function MessageComposer({
|
|||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</GatedButton>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hint sits outside the flex row so its height doesn't push
|
{/* Hint sits outside the flex row so its height doesn't push
|
||||||
`items-end` buttons below the textarea. Indented to line up
|
`items-end` buttons below the textarea. Indented to line up
|
||||||
under the textarea left edge (w-9 button + gap-2 = 44px). */}
|
under the textarea left edge. */}
|
||||||
<p className="mt-1 pl-11 text-[10px] text-muted-foreground">
|
{!draft && !recording && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 text-[10px] text-muted-foreground",
|
||||||
|
// Line up under the textarea: one button (template) when the
|
||||||
|
// attach menu is hidden, two when the beta feature adds it.
|
||||||
|
chatMediaEnabled ? "pl-[5.5rem]" : "pl-11",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Type '/' for quick replies
|
Type '/' for quick replies
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staged-attachment preview with caption + send/discard. Declared at
|
||||||
|
* module scope (not nested in MessageComposer) so React keeps it mounted
|
||||||
|
* across the parent's re-renders — a nested component would remount the
|
||||||
|
* caption input on every keystroke and drop focus.
|
||||||
|
*/
|
||||||
|
function MediaDraftPreview({
|
||||||
|
draft,
|
||||||
|
busy,
|
||||||
|
readOnly,
|
||||||
|
onCaptionChange,
|
||||||
|
onDiscard,
|
||||||
|
onSend,
|
||||||
|
}: {
|
||||||
|
draft: MediaDraft;
|
||||||
|
busy: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
onCaptionChange: (caption: string) => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onSend: () => void;
|
||||||
|
}) {
|
||||||
|
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={onDiscard}
|
||||||
|
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}
|
||||||
|
maxLength={MEDIA_CAPTION_MAX}
|
||||||
|
onChange={(e) => onCaptionChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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={onSend}
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,12 @@ import {
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { MessageBubble } from "./message-bubble";
|
import { MessageBubble } from "./message-bubble";
|
||||||
import { MessageActions } from "./message-actions";
|
import { MessageActions } from "./message-actions";
|
||||||
import { MessageComposer } from "./message-composer";
|
import {
|
||||||
|
MessageComposer,
|
||||||
|
CHAT_MEDIA_BUCKET,
|
||||||
|
type SendMediaPayload,
|
||||||
|
} from "./message-composer";
|
||||||
|
import { deleteAccountMedia } from "@/lib/storage/upload-media";
|
||||||
import { TemplatePicker } from "./template-picker";
|
import { TemplatePicker } from "./template-picker";
|
||||||
import { buildReplyPreview } from "./reply-quote";
|
import { buildReplyPreview } from "./reply-quote";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -473,6 +478,72 @@ export function MessageThread({
|
|||||||
[conversation, onNewMessage, onUpdateMessage]
|
[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" });
|
||||||
|
// The upload never reached the recipient — GC the orphaned
|
||||||
|
// object rather than leaving it in the public bucket forever.
|
||||||
|
void deleteAccountMedia(CHAT_MEDIA_BUCKET, payload.path).catch(() => {});
|
||||||
|
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" });
|
||||||
|
void deleteAccountMedia(CHAT_MEDIA_BUCKET, payload.path).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conversation, onNewMessage, onUpdateMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const handleStatusChange = useCallback(
|
const handleStatusChange = useCallback(
|
||||||
async (status: ConversationStatus) => {
|
async (status: ConversationStatus) => {
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
@ -950,6 +1021,7 @@ export function MessageThread({
|
|||||||
conversationId={conversation.id}
|
conversationId={conversation.id}
|
||||||
sessionExpired={sessionInfo.expired}
|
sessionExpired={sessionInfo.expired}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
onSendMedia={handleSendMedia}
|
||||||
onOpenTemplates={handleOpenTemplates}
|
onOpenTemplates={handleOpenTemplates}
|
||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
onClearReply={() => setReplyTo(null)}
|
onClearReply={() => setReplyTo(null)}
|
||||||
|
|||||||
20
src/hooks/use-beta-feature.ts
Normal file
20
src/hooks/use-beta-feature.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { hasBetaFeature, type BetaFeature } from "@/lib/auth/beta-features";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean gate for a per-profile beta feature flag (see
|
||||||
|
* `@/lib/auth/beta-features`). Mirrors `useCan`: returns `false` while the
|
||||||
|
* profile is still loading so a not-yet-opted-in user never sees a flash
|
||||||
|
* of beta UI during the initial load window.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* const chatMedia = useBetaFeature(CHAT_MEDIA_BETA);
|
||||||
|
* {chatMedia && <AttachButton />}
|
||||||
|
*/
|
||||||
|
export function useBetaFeature(feature: BetaFeature): boolean {
|
||||||
|
const { profileLoading, profile } = useAuth();
|
||||||
|
if (profileLoading || !profile) return false;
|
||||||
|
return hasBetaFeature(profile.beta_features, feature);
|
||||||
|
}
|
||||||
19
src/lib/auth/beta-features.test.ts
Normal file
19
src/lib/auth/beta-features.test.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CHAT_MEDIA_BETA, hasBetaFeature } from "./beta-features";
|
||||||
|
|
||||||
|
describe("hasBetaFeature", () => {
|
||||||
|
it("is true when the feature key is present", () => {
|
||||||
|
expect(hasBetaFeature([CHAT_MEDIA_BETA], CHAT_MEDIA_BETA)).toBe(true);
|
||||||
|
expect(hasBetaFeature(["flows", CHAT_MEDIA_BETA], CHAT_MEDIA_BETA)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when the key is absent", () => {
|
||||||
|
expect(hasBetaFeature(["flows"], CHAT_MEDIA_BETA)).toBe(false);
|
||||||
|
expect(hasBetaFeature([], CHAT_MEDIA_BETA)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false (not throwing) for null / undefined lists", () => {
|
||||||
|
expect(hasBetaFeature(null, CHAT_MEDIA_BETA)).toBe(false);
|
||||||
|
expect(hasBetaFeature(undefined, CHAT_MEDIA_BETA)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/lib/auth/beta-features.ts
Normal file
31
src/lib/auth/beta-features.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Beta feature flags, stored per-profile in `profiles.beta_features`
|
||||||
|
* (a `TEXT[]`, migration 011). A feature is "on" for an account when its
|
||||||
|
* key is present in that array; the owner opts in by editing the column
|
||||||
|
* in Supabase Studio:
|
||||||
|
*
|
||||||
|
* UPDATE profiles
|
||||||
|
* SET beta_features = array_append(beta_features, 'chat_media')
|
||||||
|
* WHERE email = 'someone@example.com';
|
||||||
|
*
|
||||||
|
* This module is the single source of truth for the keys + the membership
|
||||||
|
* check so the client hook (`useBetaFeature`) and the server routes gate
|
||||||
|
* on exactly the same logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Agent-initiated media sends in the inbox composer (issue #213). */
|
||||||
|
export const CHAT_MEDIA_BETA = "chat_media";
|
||||||
|
|
||||||
|
export type BetaFeature = typeof CHAT_MEDIA_BETA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an opted-in feature list contains `feature`. Pure + null-safe so
|
||||||
|
* both the client (reading `profile.beta_features`) and the server
|
||||||
|
* (reading the row from Supabase) share one implementation.
|
||||||
|
*/
|
||||||
|
export function hasBetaFeature(
|
||||||
|
betaFeatures: readonly string[] | null | undefined,
|
||||||
|
feature: BetaFeature,
|
||||||
|
): boolean {
|
||||||
|
return betaFeatures?.includes(feature) ?? false;
|
||||||
|
}
|
||||||
@ -125,6 +125,10 @@ export const RATE_LIMITS = {
|
|||||||
* fidget with reactions and a single "swap" is actually two calls
|
* fidget with reactions and a single "swap" is actually two calls
|
||||||
* (remove + add) under the hood. */
|
* (remove + add) under the hood. */
|
||||||
react: { limit: 120, windowMs: 60_000 },
|
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
|
/** Invitation peek (public, per-IP). 30/min lets a forwarded link
|
||||||
* retry a handful of times under flaky connectivity without
|
* retry a handful of times under flaky connectivity without
|
||||||
* enabling brute-force token enumeration. With 256-bit tokens the
|
* enabling brute-force token enumeration. With 256-bit tokens the
|
||||||
|
|||||||
46
src/lib/storage/upload-media.test.ts
Normal file
46
src/lib/storage/upload-media.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildMediaPath, MEDIA_MAX_BYTES_BY_KIND } 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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MEDIA_MAX_BYTES_BY_KIND", () => {
|
||||||
|
it("caps images at Meta's tighter 5 MB limit", () => {
|
||||||
|
expect(MEDIA_MAX_BYTES_BY_KIND.image).toBe(5 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps video/audio/document at the 16 MB bucket limit", () => {
|
||||||
|
expect(MEDIA_MAX_BYTES_BY_KIND.video).toBe(16 * 1024 * 1024);
|
||||||
|
expect(MEDIA_MAX_BYTES_BY_KIND.audio).toBe(16 * 1024 * 1024);
|
||||||
|
expect(MEDIA_MAX_BYTES_BY_KIND.document).toBe(16 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
});
|
||||||
137
src/lib/storage/upload-media.ts
Normal file
137
src/lib/storage/upload-media.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-kind upload ceilings that mirror Meta's WhatsApp Cloud API caps so
|
||||||
|
* a file that the bucket would accept (≤16 MB) but Meta would reject is
|
||||||
|
* caught client-side BEFORE upload — otherwise it lands in storage as an
|
||||||
|
* orphan and the send fails with a confusing 400. Images are Meta's
|
||||||
|
* tightest cap at 5 MB; documents are held at the 16 MB bucket limit
|
||||||
|
* (Meta allows 100 MB, but the bucket — and shared-hosting upload UX —
|
||||||
|
* caps lower).
|
||||||
|
*/
|
||||||
|
export const MEDIA_MAX_BYTES_BY_KIND = {
|
||||||
|
image: 5 * 1024 * 1024,
|
||||||
|
video: 16 * 1024 * 1024,
|
||||||
|
audio: 16 * 1024 * 1024,
|
||||||
|
document: 16 * 1024 * 1024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a previously-uploaded object. Used to GC media that was staged
|
||||||
|
* (uploaded) but never sent — a cancelled draft or a failed Meta send —
|
||||||
|
* so abandoned attachments don't accumulate in the public bucket. The
|
||||||
|
* DELETE is gated by the same account-scoped RLS policy as the upload,
|
||||||
|
* so a caller can only remove objects under their own account folder.
|
||||||
|
*
|
||||||
|
* Best-effort: callers fire-and-forget and swallow errors (a missed
|
||||||
|
* delete is a storage nit, not something to surface to the user).
|
||||||
|
*/
|
||||||
|
export async function deleteAccountMedia(
|
||||||
|
bucket: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { error } = await supabase.storage.from(bucket).remove([path]);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
}
|
||||||
43
src/lib/whatsapp/audio-transcode.test.ts
Normal file
43
src/lib/whatsapp/audio-transcode.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/lib/whatsapp/audio-transcode.ts
Normal file
63
src/lib/whatsapp/audio-transcode.ts
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
79
src/lib/whatsapp/meta-api.media.test.ts
Normal file
79
src/lib/whatsapp/meta-api.media.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -259,7 +259,7 @@ export async function sendTextMessage(
|
|||||||
return { messageId: data.messages[0].id }
|
return { messageId: data.messages[0].id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaKind = 'image' | 'video' | 'document'
|
export type MediaKind = 'image' | 'video' | 'document' | 'audio'
|
||||||
|
|
||||||
export interface SendMediaMessageArgs {
|
export interface SendMediaMessageArgs {
|
||||||
phoneNumberId: string
|
phoneNumberId: string
|
||||||
@ -268,19 +268,24 @@ export interface SendMediaMessageArgs {
|
|||||||
kind: MediaKind
|
kind: MediaKind
|
||||||
/** Public URL Meta fetches at send time. */
|
/** Public URL Meta fetches at send time. */
|
||||||
link: string
|
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
|
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
|
filename?: string
|
||||||
contextMessageId?: 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
|
* Used by the Flows engine's `send_media` node and the inbox composer's
|
||||||
* `sendTextMessage` — single fetch, throws on non-2xx, returns Meta's
|
* agent-initiated media sends. Mirrors `sendTextMessage` — single fetch,
|
||||||
* message id.
|
* 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(
|
export async function sendMediaMessage(
|
||||||
args: SendMediaMessageArgs,
|
args: SendMediaMessageArgs,
|
||||||
@ -289,8 +294,11 @@ export async function sendMediaMessage(
|
|||||||
if (!link) throw new Error('sendMediaMessage requires a link.')
|
if (!link) throw new Error('sendMediaMessage requires a link.')
|
||||||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
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 }
|
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
|
if (kind === 'document' && filename) media.filename = filename
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
|
|||||||
7
src/types/ffmpeg-static.d.ts
vendored
Normal file
7
src/types/ffmpeg-static.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
122
supabase/migrations/023_chat_media.sql
Normal file
122
supabase/migrations/023_chat_media.sql
Normal 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]
|
||||||
|
)
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user