From 886db53cab5fdae25aa9c1e0accf872d2ae87556 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Mon, 24 Aug 2026 12:38:27 -0500 Subject: [PATCH] feat(whatsapp): seleccion multimodal de la mejor imagen destacada con IA y resoluciones 640x360 y 640x640 --- docs/developer_context.md | 2 +- src/app/api/webhooks/whatsapp/route.ts | 69 ++++++++++++++++++-------- src/lib/evolution.ts | 57 +++++++++++++++++++-- src/lib/gemini.ts | 41 +++++++++++---- 4 files changed, 135 insertions(+), 34 deletions(-) diff --git a/docs/developer_context.md b/docs/developer_context.md index 18f7277..4a7c812 100644 --- a/docs/developer_context.md +++ b/docs/developer_context.md @@ -90,7 +90,7 @@ A lo largo del desarrollo de la plataforma, se han resuelto varios problemas cr * **Sincronización con Repositorio**: Cada modificación realizada en Appwrite se sincroniza automáticamente como respaldo en `src/config/whatsapp-chats.json`. * **Resolución Dinámica de Nombres**: Al recibir un evento en `/api/webhooks/whatsapp`, el webhook resuelve el nombre exacto del grupo o remitente consultando Evolution API (con caché en memoria) y valida coincidencias en tiempo real contra los registros activos de Appwrite en `src/lib/whatsapp-filter.ts`. * **Gestión Visual en Dashboard**: En `/dashboard/settings`, los administradores pueden añadir nuevos clientes por nombre exacto, pausar/activar chats, o sincronizar automáticamente los grupos detectados en Evolution API con persistencia inmediata en Appwrite. -* **Flujo Editorial y Biblioteca de Medios**: Chat autorizado envía mensaje/multimedia -> Gemini estructura título, categoría, SEO y contenido limpio -> Si contiene imagen o video, el webhook sube el archivo a Appwrite Storage (`media_bucket`) y crea inmediatamente el documento en la **Biblioteca de Medios** (`media`), vinculándolo como `imagen_destacada_id` en el post -> Se calculan las dimensiones y previsualizaciones exactas (**640x360 px / 16:9** para Noticias; **640x640 px / 1:1** para Eventos y Clasificados) -> Se crea post en Appwrite en estado `borrador` -> El bot responde al grupo solicitando aprobación -> Administrador responde "Aprobar" -> El post pasa a estado `publicado`. +* **Flujo Editorial y Biblioteca de Medios**: Chat autorizado envía mensaje/multimedia -> Si se reciben múltiples imágenes (álbum o ráfaga), el webhook recopila las imágenes candidatas y **Google Gemini analiza visualmente cada una para seleccionar la más nítida, profesional y representativa** (`selected_image_index` y `image_selection_reason`) -> La imagen destacada elegida se sube a Appwrite Storage (`media_bucket`) y se registra en la **Biblioteca de Medios** (`media`), vinculándola como `imagen_destacada_id` en el post -> Se calculan las dimensiones y previsualizaciones exactas (**640x360 px / 16:9** para Noticias; **640x640 px / 1:1** para Eventos y Clasificados) -> Se crea post en Appwrite en estado `borrador` -> El bot responde al grupo solicitando aprobación e indicando qué foto fue seleccionada -> Administrador responde "Aprobar" -> El post pasa a estado `publicado`. diff --git a/src/app/api/webhooks/whatsapp/route.ts b/src/app/api/webhooks/whatsapp/route.ts index b172055..5a71a79 100644 --- a/src/app/api/webhooks/whatsapp/route.ts +++ b/src/app/api/webhooks/whatsapp/route.ts @@ -4,7 +4,7 @@ import { InputFile } from 'node-appwrite/file'; import { createAdminClient } from '@/lib/appwrite.server'; import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; import { processWhatsAppMessage } from '@/lib/gemini'; -import { sendWhatsAppMessage, getBase64FromMediaMessage } from '@/lib/evolution'; +import { sendWhatsAppMessage, getBase64FromMediaMessage, fetchRecentMediaFromSameChat } from '@/lib/evolution'; import { isWhatsAppChatAllowed } from '@/lib/whatsapp-filter'; // Función para obtener el último día del mes actual @@ -114,28 +114,48 @@ export async function POST(req: Request) { return NextResponse.json({ success: true, message: 'Ignored empty message' }, { status: 200 }); } - // Verificamos si tiene multimedia directa o en mensaje reciente - let base64Media = undefined; - let mimeType = undefined; - let storageFileId = undefined; - let featuredMediaDocId = undefined; + // Recolectamos todas las imágenes/multimedia candidatas (mensaje actual + lote reciente del mismo chat) + const candidateMediaList: Array<{ base64: string; mimeType: string }> = []; if (messageData.imageMessage || messageData.videoMessage || messageData.documentMessage) { const media = await getBase64FromMediaMessage(data); - if (media) { - base64Media = media.base64; - mimeType = media.mimetype; + if (media && media.base64) { + candidateMediaList.push({ base64: media.base64, mimeType: media.mimetype }); } } - // Llamar a Gemini para estructurar la publicación - const parsed = await processWhatsAppMessage(text, base64Media, mimeType); + // Consultamos si llegaron otras imágenes en el mismo grupo o chat en los últimos 2 minutos (álbum / ráfaga) + try { + const recentMedia = await fetchRecentMediaFromSameChat(remoteJid, 4, 120); + for (const rm of recentMedia) { + // Evitar duplicar la misma imagen exacta + if (!candidateMediaList.some(c => c.base64.slice(0, 80) === rm.base64.slice(0, 80))) { + candidateMediaList.push({ base64: rm.base64, mimeType: rm.mimeType }); + } + } + } catch (e) { + console.warn('[WhatsApp Webhook] No se pudo consultar lote multimedia reciente:', e); + } + + console.log(`[WhatsApp Webhook] Procesando ${candidateMediaList.length} imágenes candidatas con Gemini...`); + + // Llamar a Gemini para estructurar la publicación y seleccionar la mejor imagen + const parsed = await processWhatsAppMessage(text, candidateMediaList); + + let storageFileId = undefined; + let featuredMediaDocId = undefined; + + // Si tenemos imágenes candidatas, seleccionamos la indicada por la IA (o la primera por defecto) + if (candidateMediaList.length > 0) { + const selectedIdx = (typeof parsed.selected_image_index === 'number' && parsed.selected_image_index >= 0 && parsed.selected_image_index < candidateMediaList.length) + ? parsed.selected_image_index + : 0; + + const bestMedia = candidateMediaList[selectedIdx] || candidateMediaList[0]; - // Si tenemos multimedia, subimos a Storage y creamos el registro en la Biblioteca de Medios (Colección 'media') - if (base64Media && mimeType) { try { - const buffer = Buffer.from(base64Media, 'base64'); - const extension = mimeType.split('/')[1] || 'jpg'; + const buffer = Buffer.from(bestMedia.base64, 'base64'); + const extension = bestMedia.mimeType.split('/')[1] || 'jpg'; const fileName = `wa_${parsed.tipo}_${Date.now()}.${extension}`; const file = InputFile.fromBuffer(buffer, fileName); @@ -157,13 +177,13 @@ export async function POST(req: Request) { titulo: truncateString(parsed.titulo, 240), alt_text: truncateString(parsed.titulo, 240), descripcion_seo: truncateString(parsed.seo_descripcion, 450), - tipo: mimeType.startsWith('video') ? 'video' : 'imagen' + tipo: bestMedia.mimeType.startsWith('video') ? 'video' : 'imagen' } ); featuredMediaDocId = mediaDoc.$id; - console.log(`[WhatsApp Webhook] Imagen subida a Biblioteca de Medios (ID: ${featuredMediaDocId}, Storage: ${storageFileId})`); + console.log(`[WhatsApp Webhook] Imagen #${selectedIdx + 1} seleccionada y subida a Biblioteca de Medios (ID: ${featuredMediaDocId}, Storage: ${storageFileId})`); } catch (err: any) { - console.error('[WhatsApp Webhook] Error subiendo multimedia a Appwrite:', err.message); + console.error('[WhatsApp Webhook] Error subiendo multimedia seleccionada a Appwrite:', err.message); } } @@ -224,11 +244,20 @@ export async function POST(req: Request) { } ); - // Notificar al grupo de WhatsApp pidiendo aprobación + let imageNotice = ''; + if (candidateMediaList.length > 1) { + const chosenNum = (parsed.selected_image_index ?? 0) + 1; + imageNotice = `*Imagen Destacada:* Foto #${chosenNum} de ${candidateMediaList.length} analizadas` + + (parsed.image_selection_reason ? ` (${parsed.image_selection_reason})\n` : '\n'); + } else if (candidateMediaList.length === 1) { + imageNotice = `*Imagen Destacada:* 1 foto procesada (${isSquare ? 'Formato 640x640' : 'Formato 640x360'})\n`; + } + const requestText = `📰 *Publicación Procesada (Pendiente de Aprobación)*\n\n` + `*Título:* ${parsed.titulo}\n` - + `*Tipo:* ${parsed.tipo.toUpperCase()}\n` + + `*Tipo:* ${parsed.tipo.toUpperCase()} (${isSquare ? 'Cuadrado 640x640' : 'Panorámico 640x360'})\n` + + (imageNotice ? `${imageNotice}` : '') + `*SEO:* ${parsed.seo_descripcion}\n\n` + `Para aprobar esta publicación, responde a este mismo mensaje con la palabra "Aprobar".\n\n` + `(Ref: ${newPost.$id})`; diff --git a/src/lib/evolution.ts b/src/lib/evolution.ts index 7ec2457..c077cd5 100644 --- a/src/lib/evolution.ts +++ b/src/lib/evolution.ts @@ -25,20 +25,70 @@ export async function getBase64FromMediaMessage(messagePayload: any): Promise<{ const url = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE_NAME}`; const response = await axios.post(url, { message: messagePayload - }, { headers }); + }, { headers, timeout: 15000 }); if (response.data && response.data.base64) { return { base64: response.data.base64, mimetype: response.data.mimetype || 'image/jpeg' - }; // sometimes mimetype might be missing depending on version, fallback to jpeg. + }; } } catch (err: any) { - console.error('Error in getBase64FromMediaMessage:', err?.response?.data || err.message); + console.warn('Warning in getBase64FromMediaMessage:', err?.response?.data || err.message); } return null; } +/** + * Fetches recent media messages from the same chat (within a time window) to collect multiple candidate images. + */ +export async function fetchRecentMediaFromSameChat( + remoteJid: string, + maxItems = 4, + windowSeconds = 120 +): Promise> { + if (!EVOLUTION_API_URL || !EVOLUTION_INSTANCE_NAME || !remoteJid) { + return []; + } + + try { + const url = `${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=8`; + const response = await axios.post( + url, + { where: { "key": { "remoteJid": remoteJid } } }, + { headers, timeout: 8000 } + ); + + const records = response.data?.messages?.records || []; + const nowSec = Math.floor(Date.now() / 1000); + + // Filter media messages sent within the time window + const mediaRecords = records.filter((r: any) => { + const isMedia = Boolean(r.message?.imageMessage || r.message?.videoMessage); + const isRecent = r.messageTimestamp ? (nowSec - r.messageTimestamp <= windowSeconds) : true; + return isMedia && isRecent; + }).slice(0, maxItems); + + const results: Array<{ base64: string, mimeType: string, messageId: string }> = []; + + for (const record of mediaRecords) { + const media = await getBase64FromMediaMessage(record); + if (media && media.base64) { + results.push({ + base64: media.base64, + mimeType: media.mimetype, + messageId: record.key?.id || String(Date.now()) + }); + } + } + + return results; + } catch (err: any) { + console.warn('Could not fetch recent media from chat:', err?.message); + return []; + } +} + // In-memory cache for group subjects: jid -> { subject, expiresAt } const groupCache = new Map(); const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes @@ -99,4 +149,3 @@ export async function getWhatsAppGroupSubject(groupJid: string): Promise { if (!process.env.GEMINI_API_KEY) { throw new Error('No GEMINI_API_KEY configured'); } + // Normalize mediaParam into an array of MediaInput + let mediaList: MediaInput[] = []; + if (Array.isArray(mediaParam)) { + mediaList = mediaParam; + } else if (typeof mediaParam === 'string' && mimeTypeParam) { + mediaList = [{ base64: mediaParam, mimeType: mimeTypeParam }]; + } else if (mediaParam && typeof mediaParam === 'object' && 'base64' in mediaParam) { + mediaList = [mediaParam as MediaInput]; + } + const systemInstruction = ` - Eres un asistente editorial experto de Almeira Web. + Eres un asistente editorial experto y editor gráfico de Almeira Web. Analiza los mensajes y recursos multimedia recibidos desde WhatsApp para estructurarlos como publicaciones del portal. Debes estructurar el mensaje en base a lo siguiente: 1. titulo: Asigna un título claro, profesional y conciso (máx. 70 caracteres). @@ -31,21 +49,24 @@ export async function processWhatsAppMessage( 3. seo_descripcion: Un resumen conciso de máximo 150 caracteres optimizado para redes y motores de búsqueda. 4. contenido: El texto detallado de la publicación en HTML válido y limpio (usa

, ,

    /
  • si es apropiado), preservando nombres, fechas, contactos y enlaces originales. 5. fecha_vencimiento: Si el texto indica fecha límite del evento o del aviso (Ej. "hasta el 30 de noviembre", o "el próximo sábado"), devuélvela en formato YYYY-MM-DD. Si no aplica o es una noticia general, pasa null. + 6. selected_image_index: Si se proporcionaron múltiples imágenes candidatas (#1, #2, #3...), selecciona el índice base 0 de la imagen MÁS ADECUADA y de mayor calidad periodística/visual para ser la IMAGEN DESTACADA (la más nítida, bien compuesta, representativa del tema central y sin recortes desfavorables). Si solo hay 1 imagen, devuelve 0. Si no hay imágenes, pasa null. + 7. image_selection_reason: Breve explicación (máximo 1 línea) del motivo por el cual esa imagen es la más adecuada para portada. `; // Prepara los contenidos const contents: any[] = [ - { text: `Mensaje de WhatsApp: \n\n${text}` } + { text: `Mensaje de WhatsApp: \n\n${text || '(Publicación basada en las imágenes adjuntas)'}` } ]; - if (base64Media && mimeType) { + mediaList.forEach((media, idx) => { + contents.push({ text: `\n[Candidata a Imagen Destacada #${idx + 1}]` }); contents.push({ inlineData: { - data: base64Media, - mimeType: mimeType + data: media.base64, + mimeType: media.mimeType } }); - } + }); const response = await ai.models.generateContent({ model: 'gemini-3.6-flash', @@ -60,7 +81,9 @@ export async function processWhatsAppMessage( tipo: { type: Type.STRING, enum: ["noticia", "evento", "aviso"] }, seo_descripcion: { type: Type.STRING, description: "Máximo 150 caracteres" }, contenido: { type: Type.STRING, description: "Contenido en HTML" }, - fecha_vencimiento: { type: Type.STRING, description: "Fecha opcional YYYY-MM-DD", nullable: true } + fecha_vencimiento: { type: Type.STRING, description: "Fecha opcional YYYY-MM-DD", nullable: true }, + selected_image_index: { type: Type.INTEGER, description: "Índice base 0 de la mejor imagen seleccionada", nullable: true }, + image_selection_reason: { type: Type.STRING, description: "Motivo de selección de imagen", nullable: true } }, required: ["titulo", "tipo", "seo_descripcion", "contenido"] }