fix: media messages (image/video/audio/document/sticker) now render

Incoming photos showed up as empty bubbles in the Inbox (only the
timestamp, no image). Investigation found three compounding bugs in
the media pipeline:

1. Swapped getMediaUrl args in webhook parseMessageContent
   The helper signature is getMediaUrl(mediaId, accessToken), but every
   media case (image/video/document/audio) called it as
   getMediaUrl(accessToken, message.X.id). Meta got requests for
     GET /v21.0/<ACCESS_TOKEN>
   with Authorization: Bearer <MEDIA_ID>
   Every verification failed → the catch block returned mediaUrl: null
   → the message was stored in the DB with no media_url → MessageBubble
   had nothing to render.

   Refactored to a single verifyAndBuildUrl(mediaId) helper called per
   media type, with the arguments in the correct order.

2. No fallback UI when media_url is null
   MessageBubble's image/video/audio/document cases all did
   `{message.media_url && <render />}` with no else branch. So a media
   message without a URL rendered an empty <div> — the user saw only
   the timestamp.

   Added a <MediaUnavailable label="..."> component that shows the icon
   + "Image/Video/Audio/Document unavailable" text when media_url is
   null. Applied to all four media types.

3. Sticker type would fail the CHECK constraint on insert
   WhatsApp sends image stickers as type: "sticker", but the
   messages.content_type CHECK constraint only allows:
     text, image, document, audio, video, location, template
   Inserting content_type: 'sticker' silently failed.

   processMessage now validates content_type against an allow-list
   before insert. Stickers are mapped to 'image' (they ARE images).
   Reactions and any other unknown type are mapped to 'text'.

Also added the sticker media handling to parseMessageContent so sticker
images actually render (with the verified proxy URL).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-16 21:43:07 +03:00
parent 641031fd5c
commit 870e25f0ba
No known key found for this signature in database
2 changed files with 94 additions and 48 deletions

View File

@ -27,6 +27,7 @@ interface WhatsAppMessage {
video?: { id: string; mime_type: string; caption?: string }
document?: { id: string; mime_type: string; filename?: string; caption?: string }
audio?: { id: string; mime_type: string }
sticker?: { id: string; mime_type: string }
location?: { latitude: number; longitude: number; name?: string; address?: string }
reaction?: { message_id: string; emoji: string }
}
@ -235,10 +236,24 @@ async function processMessage(
// column; the MIME type is only used to construct the proxy URL during
// parseMessageContent. Silence the unused-var warning:
void mediaType
// The messages.content_type CHECK constraint only allows:
// text, image, document, audio, video, location, template
// Map incoming WhatsApp types that aren't in that list to the closest
// allowed value so the INSERT doesn't fail with a constraint error.
const ALLOWED_CONTENT_TYPES = new Set([
'text', 'image', 'document', 'audio', 'video', 'location', 'template',
])
const contentType = ALLOWED_CONTENT_TYPES.has(message.type)
? message.type
: message.type === 'sticker'
? 'image' // stickers are images
: 'text' // reaction, unknown → text fallback
const { error: msgError } = await supabaseAdmin().from('messages').insert({
conversation_id: conversation.id,
sender_type: 'customer',
content_type: message.type,
content_type: contentType,
content_text: contentText,
media_url: mediaUrl,
message_id: message.id,
@ -275,6 +290,25 @@ async function parseMessageContent(
mediaUrl: string | null
mediaType: string | null
}> {
// getMediaUrl signature is (mediaId, accessToken) — earlier code had
// the args swapped, so every verification hit an invalid Meta URL and
// fell through to the catch block, leaving mediaUrl as null. That's
// why images showed up as empty bubbles in the inbox.
const verifyAndBuildUrl = async (
mediaId: string
): Promise<string | null> => {
try {
await getMediaUrl(mediaId, accessToken)
return `/api/whatsapp/media/${mediaId}`
} catch (error) {
console.error(
`Failed to verify media ${mediaId} with Meta:`,
error instanceof Error ? error.message : error
)
return null
}
}
switch (message.type) {
case 'text':
return {
@ -284,61 +318,55 @@ async function parseMessageContent(
}
case 'image':
if (message.image) {
try {
await getMediaUrl(accessToken, message.image.id)
if (message.image?.id) {
return {
contentText: message.image.caption || null,
mediaUrl: `/api/whatsapp/media/${message.image.id}`,
mediaUrl: await verifyAndBuildUrl(message.image.id),
mediaType: message.image.mime_type,
}
} catch (error) {
console.error('Error verifying image media:', error)
}
}
return { contentText: message.image?.caption || null, mediaUrl: null, mediaType: null }
return { contentText: null, mediaUrl: null, mediaType: null }
case 'video':
if (message.video) {
try {
await getMediaUrl(accessToken, message.video.id)
if (message.video?.id) {
return {
contentText: message.video.caption || null,
mediaUrl: `/api/whatsapp/media/${message.video.id}`,
mediaUrl: await verifyAndBuildUrl(message.video.id),
mediaType: message.video.mime_type,
}
} catch (error) {
console.error('Error verifying video media:', error)
}
}
return { contentText: message.video?.caption || null, mediaUrl: null, mediaType: null }
return { contentText: null, mediaUrl: null, mediaType: null }
case 'document':
if (message.document) {
try {
await getMediaUrl(accessToken, message.document.id)
if (message.document?.id) {
return {
contentText: message.document.caption || message.document.filename || null,
mediaUrl: `/api/whatsapp/media/${message.document.id}`,
contentText:
message.document.caption || message.document.filename || null,
mediaUrl: await verifyAndBuildUrl(message.document.id),
mediaType: message.document.mime_type,
}
} catch (error) {
console.error('Error verifying document media:', error)
}
}
return { contentText: message.document?.filename || null, mediaUrl: null, mediaType: null }
return { contentText: null, mediaUrl: null, mediaType: null }
case 'audio':
if (message.audio) {
try {
await getMediaUrl(accessToken, message.audio.id)
if (message.audio?.id) {
return {
contentText: null,
mediaUrl: `/api/whatsapp/media/${message.audio.id}`,
mediaUrl: await verifyAndBuildUrl(message.audio.id),
mediaType: message.audio.mime_type,
}
} catch (error) {
console.error('Error verifying audio media:', error)
}
return { contentText: null, mediaUrl: null, mediaType: null }
case 'sticker':
// Stickers are images under the hood. Treat them as such so the
// MessageBubble renders the <img>. The caller maps the DB
// content_type to 'image' for the CHECK constraint.
if (message.sticker?.id) {
return {
contentText: null,
mediaUrl: await verifyAndBuildUrl(message.sticker.id),
mediaType: message.sticker.mime_type,
}
}
return { contentText: null, mediaUrl: null, mediaType: null }

View File

@ -36,6 +36,15 @@ function StatusIcon({ status }: { status: Message["status"] }) {
}
}
function MediaUnavailable({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 rounded-lg bg-slate-700/40 px-3 py-2 text-xs text-slate-300">
<ImageOff className="h-4 w-4 shrink-0 text-slate-500" />
<span>{label} unavailable</span>
</div>
);
}
function MediaImage({ url, alt }: { url: string; alt: string }) {
const [src, setSrc] = useState<string | null>(null);
const [error, setError] = useState(false);
@ -111,8 +120,10 @@ function MessageContent({ message }: { message: Message }) {
case "image":
return (
<div>
{message.media_url && (
{message.media_url ? (
<MediaImage url={message.media_url} alt="Shared image" />
) : (
<MediaUnavailable label="Image" />
)}
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -125,12 +136,14 @@ function MessageContent({ message }: { message: Message }) {
case "video":
return (
<div>
{message.media_url && (
{message.media_url ? (
<video
src={message.media_url}
controls
className="max-h-64 max-w-60 rounded-lg"
/>
) : (
<MediaUnavailable label="Video" />
)}
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -143,16 +156,21 @@ function MessageContent({ message }: { message: Message }) {
case "audio":
return (
<div>
{message.media_url && (
{message.media_url ? (
<audio src={message.media_url} controls className="max-w-60" />
) : (
<MediaUnavailable label="Audio" />
)}
</div>
);
case "document":
if (!message.media_url) {
return <MediaUnavailable label={message.content_text || "Document"} />;
}
return (
<a
href={message.media_url ?? "#"}
href={message.media_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-slate-700/50 px-3 py-2 text-sm hover:bg-slate-700"