mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
641031fd5c
commit
870e25f0ba
@ -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 {
|
|
||||||
await getMediaUrl(accessToken, message.image.id)
|
|
||||||
return {
|
return {
|
||||||
contentText: message.image.caption || null,
|
contentText: message.image.caption || null,
|
||||||
mediaUrl: `/api/whatsapp/media/${message.image.id}`,
|
mediaUrl: await verifyAndBuildUrl(message.image.id),
|
||||||
mediaType: message.image.mime_type,
|
mediaType: message.image.mime_type,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying image media:', error)
|
|
||||||
}
|
}
|
||||||
}
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
||||||
return { contentText: message.image?.caption || null, mediaUrl: null, mediaType: null }
|
|
||||||
|
|
||||||
case 'video':
|
case 'video':
|
||||||
if (message.video) {
|
if (message.video?.id) {
|
||||||
try {
|
|
||||||
await getMediaUrl(accessToken, message.video.id)
|
|
||||||
return {
|
return {
|
||||||
contentText: message.video.caption || null,
|
contentText: message.video.caption || null,
|
||||||
mediaUrl: `/api/whatsapp/media/${message.video.id}`,
|
mediaUrl: await verifyAndBuildUrl(message.video.id),
|
||||||
mediaType: message.video.mime_type,
|
mediaType: message.video.mime_type,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying video media:', error)
|
|
||||||
}
|
}
|
||||||
}
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
||||||
return { contentText: message.video?.caption || null, mediaUrl: null, mediaType: null }
|
|
||||||
|
|
||||||
case 'document':
|
case 'document':
|
||||||
if (message.document) {
|
if (message.document?.id) {
|
||||||
try {
|
|
||||||
await getMediaUrl(accessToken, message.document.id)
|
|
||||||
return {
|
return {
|
||||||
contentText: message.document.caption || message.document.filename || null,
|
contentText:
|
||||||
mediaUrl: `/api/whatsapp/media/${message.document.id}`,
|
message.document.caption || message.document.filename || null,
|
||||||
|
mediaUrl: await verifyAndBuildUrl(message.document.id),
|
||||||
mediaType: message.document.mime_type,
|
mediaType: message.document.mime_type,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying document media:', error)
|
|
||||||
}
|
}
|
||||||
}
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
||||||
return { contentText: message.document?.filename || null, mediaUrl: null, mediaType: null }
|
|
||||||
|
|
||||||
case 'audio':
|
case 'audio':
|
||||||
if (message.audio) {
|
if (message.audio?.id) {
|
||||||
try {
|
|
||||||
await getMediaUrl(accessToken, message.audio.id)
|
|
||||||
return {
|
return {
|
||||||
contentText: null,
|
contentText: null,
|
||||||
mediaUrl: `/api/whatsapp/media/${message.audio.id}`,
|
mediaUrl: await verifyAndBuildUrl(message.audio.id),
|
||||||
mediaType: message.audio.mime_type,
|
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 }
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user