diff --git a/src/app/api/webhooks/whatsapp/route.ts b/src/app/api/webhooks/whatsapp/route.ts index 0479149..a9e77b5 100644 --- a/src/app/api/webhooks/whatsapp/route.ts +++ b/src/app/api/webhooks/whatsapp/route.ts @@ -184,10 +184,44 @@ export async function POST(req: Request) { } } - // LOGIC 2: PROCESAR NUEVO MENSAJE PARA GEMINI - // Evitámos tratar de procesar respuestas cortas o mensajes vacíos - if (!text && !messageData.imageMessage && !messageData.videoMessage && !messageData.documentMessage) { - return NextResponse.json({ success: true, message: 'Ignored empty message' }, { status: 200 }); +// Cache en memoria para evitar procesar mensajes duplicados que WhatsApp retransmite en ráfaga +const processedMessagesCache = new Map(); + +function isDuplicateMessage(chatId: string, text: string): boolean { + const key = `${chatId}:${text.trim().substring(0, 100)}`; + const now = Date.now(); + const lastSeen = processedMessagesCache.get(key); + + // Limpieza de claves viejas (> 2 minutos) + for (const [k, time] of processedMessagesCache.entries()) { + if (now - time > 120000) { + processedMessagesCache.delete(k); + } + } + + if (lastSeen && (now - lastSeen) < 45000) { + return true; + } + + processedMessagesCache.set(key, now); + return false; +} + + // LOGIC 2: PROCESAR PUBLICACIÓN ÚNICA + // En WhatsApp, los álbumes de fotos (ej. 25 fotos + 1 texto) llegan como 26 eventos separados. + // Las fotos individuales sin texto sustancial NO deben generar publicaciones independientes; + // se recolectan como recursos para la publicación principal que contiene el texto de la noticia. + const hasSubstantialText = Boolean(text && text.trim().length >= 15); + + if (!hasSubstantialText) { + console.log(`[WhatsApp Webhook] Mensaje multimedia sin texto editorial (${text?.length || 0} chars). Registrado para lote de fotos.`); + return NextResponse.json({ success: true, message: 'Media registered for album batch' }, { status: 200 }); + } + + // Evitar duplicación si WhatsApp envía el mismo texto repetido en la ráfaga + if (isDuplicateMessage(remoteJid, text)) { + console.log(`[WhatsApp Webhook] Mensaje de texto duplicado en ráfaga ignorado.`); + return NextResponse.json({ success: true, message: 'Ignored duplicate text in burst' }, { status: 200 }); } // Descubrir el grupo central de Notificaciones y Aprobaciones (Notificaciones Almeira) @@ -200,10 +234,10 @@ export async function POST(req: Request) { `📥 *Nueva Información Recibida*\n` + `📍 *Fuente:* ${filterResult.resolvedName}\n` + `_Almeira Web • Asistente Editorial con IA_\n\n` + - `🤖 Iniciando análisis con IA, estructuración y optimización de contenido...` + `🤖 Recolectando lote de imágenes y analizando contenido...` ).catch(e => console.warn('[WhatsApp Webhook] No se pudo enviar notificación de inicio:', e.message)); - // Recolectamos todas las imágenes/multimedia candidatas (mensaje actual + lote reciente del mismo chat) + // Recolectamos todas las imágenes/multimedia candidatas (mensaje actual + álbum de fotos reciente del chat) const candidateMediaList: Array<{ base64: string; mimeType: string }> = []; if (messageData.imageMessage || messageData.videoMessage || messageData.documentMessage) { @@ -213,9 +247,9 @@ export async function POST(req: Request) { } } - // Consultamos si llegaron otras imágenes en el mismo grupo o chat en los últimos 2 minutos (álbum / ráfaga) + // Consultamos todas las fotos que llegaron en el mismo grupo en los últimos 4 minutos (álbum completo de hasta 30 fotos) try { - const recentMedia = await fetchRecentMediaFromSameChat(remoteJid, 4, 120); + const recentMedia = await fetchRecentMediaFromSameChat(remoteJid, 30, 240); 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))) { @@ -226,7 +260,7 @@ export async function POST(req: Request) { console.warn('[WhatsApp Webhook] No se pudo consultar lote multimedia reciente:', e); } - console.log(`[WhatsApp Webhook] Procesando ${candidateMediaList.length} imágenes candidatas con Gemini...`); + console.log(`[WhatsApp Webhook] Procesando ${candidateMediaList.length} imágenes del álbum con Gemini...`); // Llamar a Gemini para estructurar la publicación y seleccionar la mejor imagen const parsed = await processWhatsAppMessage(text, candidateMediaList); diff --git a/src/lib/evolution.ts b/src/lib/evolution.ts index bdea0ad..f609f45 100644 --- a/src/lib/evolution.ts +++ b/src/lib/evolution.ts @@ -44,25 +44,25 @@ export async function getBase64FromMediaMessage(messagePayload: any): Promise<{ */ export async function fetchRecentMediaFromSameChat( remoteJid: string, - maxItems = 3, - windowSeconds = 90 + maxItems: number = 25, + windowSeconds: number = 180 ): Promise> { if (!EVOLUTION_API_URL || !EVOLUTION_INSTANCE_NAME || !remoteJid) { return []; } try { - const url = `${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=5`; + const url = `${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=35`; const response = await axios.post( url, { where: { "key": { "remoteJid": remoteJid } } }, - { headers, timeout: 3000 } + { headers, timeout: 6000 } ); const records = response.data?.messages?.records || []; const nowSec = Math.floor(Date.now() / 1000); - // Strict check: only messages created within the last `windowSeconds` (90s) + // Strict check: only messages created within the last `windowSeconds` (180s) const mediaRecords = records.filter((r: any) => { const isMedia = Boolean(r.message?.imageMessage || r.message?.videoMessage); if (!isMedia || !r.messageTimestamp) return false;