Merge pull request #14 from ArnasDon/fix/media-messages-pipeline

fix: media messages (image/video/audio/document/sticker) now render
This commit is contained in:
Arnas Donauskas 2026-04-16 21:43:59 +03:00 committed by GitHub
commit 03b40e0e8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 } video?: { id: string; mime_type: string; caption?: string }
document?: { id: string; mime_type: string; filename?: string; caption?: string } document?: { id: string; mime_type: string; filename?: string; caption?: string }
audio?: { id: string; mime_type: string } audio?: { id: string; mime_type: string }
sticker?: { id: string; mime_type: string }
location?: { latitude: number; longitude: number; name?: string; address?: string } location?: { latitude: number; longitude: number; name?: string; address?: string }
reaction?: { message_id: string; emoji: 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 // column; the MIME type is only used to construct the proxy URL during
// parseMessageContent. Silence the unused-var warning: // parseMessageContent. Silence the unused-var warning:
void mediaType 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({ const { error: msgError } = await supabaseAdmin().from('messages').insert({
conversation_id: conversation.id, conversation_id: conversation.id,
sender_type: 'customer', sender_type: 'customer',
content_type: message.type, content_type: contentType,
content_text: contentText, content_text: contentText,
media_url: mediaUrl, media_url: mediaUrl,
message_id: message.id, message_id: message.id,
@ -275,6 +290,25 @@ async function parseMessageContent(
mediaUrl: string | null mediaUrl: string | null
mediaType: 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) { switch (message.type) {
case 'text': case 'text':
return { return {
@ -284,61 +318,55 @@ async function parseMessageContent(
} }
case 'image': case 'image':
if (message.image) { if (message.image?.id) {
try { return {
await getMediaUrl(accessToken, message.image.id) contentText: message.image.caption || null,
return { mediaUrl: await verifyAndBuildUrl(message.image.id),
contentText: message.image.caption || null, mediaType: message.image.mime_type,
mediaUrl: `/api/whatsapp/media/${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': case 'video':
if (message.video) { if (message.video?.id) {
try { return {
await getMediaUrl(accessToken, message.video.id) contentText: message.video.caption || null,
return { mediaUrl: await verifyAndBuildUrl(message.video.id),
contentText: message.video.caption || null, mediaType: message.video.mime_type,
mediaUrl: `/api/whatsapp/media/${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': case 'document':
if (message.document) { if (message.document?.id) {
try { return {
await getMediaUrl(accessToken, message.document.id) contentText:
return { message.document.caption || message.document.filename || null,
contentText: message.document.caption || message.document.filename || null, mediaUrl: await verifyAndBuildUrl(message.document.id),
mediaUrl: `/api/whatsapp/media/${message.document.id}`, mediaType: message.document.mime_type,
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': case 'audio':
if (message.audio) { if (message.audio?.id) {
try { return {
await getMediaUrl(accessToken, message.audio.id) contentText: null,
return { mediaUrl: await verifyAndBuildUrl(message.audio.id),
contentText: null, mediaType: message.audio.mime_type,
mediaUrl: `/api/whatsapp/media/${message.audio.id}`, }
mediaType: message.audio.mime_type, }
} return { contentText: null, mediaUrl: null, mediaType: null }
} catch (error) {
console.error('Error verifying audio media:', error) 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 } 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 }) { function MediaImage({ url, alt }: { url: string; alt: string }) {
const [src, setSrc] = useState<string | null>(null); const [src, setSrc] = useState<string | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
@ -111,8 +120,10 @@ function MessageContent({ message }: { message: Message }) {
case "image": case "image":
return ( return (
<div> <div>
{message.media_url && ( {message.media_url ? (
<MediaImage url={message.media_url} alt="Shared image" /> <MediaImage url={message.media_url} alt="Shared image" />
) : (
<MediaUnavailable label="Image" />
)} )}
{message.content_text && ( {message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm"> <p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -125,12 +136,14 @@ function MessageContent({ message }: { message: Message }) {
case "video": case "video":
return ( return (
<div> <div>
{message.media_url && ( {message.media_url ? (
<video <video
src={message.media_url} src={message.media_url}
controls controls
className="max-h-64 max-w-60 rounded-lg" className="max-h-64 max-w-60 rounded-lg"
/> />
) : (
<MediaUnavailable label="Video" />
)} )}
{message.content_text && ( {message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm"> <p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -143,16 +156,21 @@ function MessageContent({ message }: { message: Message }) {
case "audio": case "audio":
return ( return (
<div> <div>
{message.media_url && ( {message.media_url ? (
<audio src={message.media_url} controls className="max-w-60" /> <audio src={message.media_url} controls className="max-w-60" />
) : (
<MediaUnavailable label="Audio" />
)} )}
</div> </div>
); );
case "document": case "document":
if (!message.media_url) {
return <MediaUnavailable label={message.content_text || "Document"} />;
}
return ( return (
<a <a
href={message.media_url ?? "#"} href={message.media_url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" className="flex items-center gap-2 rounded-lg bg-slate-700/50 px-3 py-2 text-sm hover:bg-slate-700"