import { NextResponse } from 'next/server'; import { ID, Query } from 'node-appwrite'; 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, fetchRecentMediaFromSameChat, getNotificationGroupJid } from '@/lib/evolution'; import { isWhatsAppChatAllowed } from '@/lib/whatsapp-filter'; // Función para obtener el último día del mes actual function getLastDayOfMonth(): string { const date = new Date(); const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0); return lastDay.toISOString().split('T')[0]; } function generateSlug(titulo: string): string { return titulo.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-').replace(/-+/g, '-').substring(0, 100); } function truncateString(str: string, length: number): string { if (!str) return ''; return str.length > length ? str.substring(0, length - 3) + '...' : str; } function htmlToWhatsAppText(html: string): string { if (!html) return ''; return html .replace(/]*>(.*?)<\/strong>/gi, '*$1*') .replace(/]*>(.*?)<\/b>/gi, '*$1*') .replace(/]*>(.*?)<\/em>/gi, '_$1_') .replace(/]*>(.*?)<\/i>/gi, '_$1_') .replace(/]*>(.*?)<\/li>/gi, '• $1\n') .replace(/<\/?(?:p|div|h[1-6]|ul|ol|table|tr|td)[^>]*>/gi, '\n') .replace(//gi, '\n') .replace(/<[^>]+>/g, '') // Eliminar cualquier etiqueta HTML remanente .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/\n{3,}/g, '\n\n') .trim(); } export async function POST(req: Request) { try { const payload = await req.json(); // Reenviar de forma segura a los webhooks de las tiendas de calzado (DripZone, Sneaky, etc.) const MULTISTORE_WEBHOOKS = [ 'https://drip-zone.online/api/webhook/whatsapp', 'https://dripzone.almeira.com.co/api/webhook/whatsapp', 'https://sneaky.almeira.com.co/api/webhook/whatsapp', 'https://zapply.almeira.com.co/api/webhook/whatsapp', 'https://store.almeira.com.co/api/webhook/whatsapp', ]; try { await Promise.allSettled( MULTISTORE_WEBHOOKS.map(targetUrl => fetch(targetUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(12000), }).catch(() => {}) ) ); } catch (_) {} const rawEvent = payload.event || ''; const eventType = rawEvent.toLowerCase().replace(/_/g, '.'); // Solo procesamos nuevos mensajes (messages.upsert, MESSAGES_UPSERT, etc.) if (!eventType.includes('messages.upsert') && !eventType.includes('messages.update') && !eventType.includes('send.message')) { return NextResponse.json({ success: true, message: 'Ignored event', event: rawEvent }, { status: 200 }); } const data = payload.data; const remoteJid = data?.key?.remoteJid; const fromMe = data?.key?.fromMe; const pushName = data?.pushName || data?.sender?.name || ''; const groupSubject = data?.groupSubject || data?.chatName || ''; if (!remoteJid) { return NextResponse.json({ success: true, message: 'No remoteJid found' }, { status: 200 }); } const messageData = data?.message || {}; const rawText = messageData.conversation || messageData.extendedTextMessage?.text || messageData.imageMessage?.caption || messageData.videoMessage?.caption || ''; // Ignorar mensajes generados por el bot para evitar bucle infinito, // pero permitir tests que el administrador envíe desde el mismo celular del bot. if (fromMe && ( rawText.includes('SOLICITUD DE APROBACIÓN') || rawText.includes('PUBLICACIÓN LISTA') || rawText.includes('Información Recibida') || rawText.includes('Procesando Publicación') || rawText.includes('Publicación Procesada') || rawText.includes('PUBLICACIÓN APROBADA') || rawText.includes('PUBLICACIÓN RECHAZADA') || rawText.includes('Error al publicar') || rawText.includes('Bot de Almeira') )) { return NextResponse.json({ success: true, message: 'Ignored bot self-message loop' }, { status: 200 }); } // Validar si el chat/grupo de WhatsApp está en la lista blanca de clientes autorizados en el repositorio const filterResult = await isWhatsAppChatAllowed({ remoteJid, pushName, groupSubject }); if (!filterResult.allowed) { console.log(`[WhatsApp Webhook] Mensaje descartado: ${filterResult.reason} (JID: ${remoteJid}, Nombre: ${filterResult.resolvedName})`); return NextResponse.json({ success: true, message: 'Ignored unauthorized chat', reason: filterResult.reason, chat: remoteJid, name: filterResult.resolvedName }, { status: 200 }); } console.log(`[WhatsApp Webhook] Chat autorizado detectado: "${filterResult.resolvedName}" (JID: ${remoteJid})`); // Extraer texto del mensaje const text = rawText; const lowerText = text.trim().toLowerCase(); const contextInfo = messageData.extendedTextMessage?.contextInfo; const isReply = !!contextInfo?.stanzaId; const quotedText = contextInfo?.quotedMessage?.conversation || contextInfo?.quotedMessage?.extendedTextMessage?.text || ''; const { databases, storage } = await createAdminClient(); // LOGIC 1: RESPUESTA DE APROBACIÓN O RECHAZO const isApprovalWord = lowerText.includes('aprobar') || lowerText.includes('publicar') || lowerText === 'si' || lowerText === 'sí' || lowerText === 'ok'; const isRejectionWord = lowerText.includes('rechazar') || lowerText.includes('descartar') || lowerText.includes('cancelar') || lowerText === 'no'; if (isApprovalWord || isRejectionWord) { // Intentar extraer el ID de la referencia en el mensaje citado o en el texto let postId: string | null = null; const refMatch = quotedText.match(/Ref:\s*([a-zA-Z0-9_-]+)/) || rawText.match(/Ref:\s*([a-zA-Z0-9_-]+)/); if (refMatch && refMatch[1]) { postId = refMatch[1]; } else { // Fallback: buscar el borrador más reciente try { const drafts = await databases.listDocuments( APPWRITE_ALMEIRA_CONFIG.databaseId, APPWRITE_ALMEIRA_CONFIG.collections.posts, [ Query.equal('estado', 'borrador'), Query.orderDesc('$createdAt'), Query.limit(1) ] ); if (drafts.documents.length > 0) { postId = drafts.documents[0].$id; } } catch (e) { console.warn('No se pudo consultar el borrador más reciente:', e); } } if (postId) { try { if (isApprovalWord) { const updatedPost = await databases.updateDocument( APPWRITE_ALMEIRA_CONFIG.databaseId, APPWRITE_ALMEIRA_CONFIG.collections.posts, postId, { estado: 'publicado' } ); const approvalMsg = `✅ *¡PUBLICACIÓN APROBADA Y EN VIVO!*\n\n` + `📌 *${updatedPost.titulo}*\n` + `🌐 *Ver en el portal:* https://almeira.com.co/${updatedPost.slug}\n\n` + `(ID: ${postId})`; await sendWhatsAppMessage(remoteJid, approvalMsg); return NextResponse.json({ success: true, message: 'Post aprobado y publicado' }, { status: 200 }); } else if (isRejectionWord) { await databases.updateDocument( APPWRITE_ALMEIRA_CONFIG.databaseId, APPWRITE_ALMEIRA_CONFIG.collections.posts, postId, { estado: 'archivado' } ); const rejectionMsg = `🚫 *PUBLICACIÓN RECHAZADA*\n\n` + `El borrador ha sido descartado y no será publicado en Almeira Web.\n\n` + `(ID: ${postId})`; await sendWhatsAppMessage(remoteJid, rejectionMsg); return NextResponse.json({ success: true, message: 'Post rechazado y archivado' }, { status: 200 }); } } catch (e: any) { console.error('Error al actualizar estado del post:', e); await sendWhatsAppMessage(remoteJid, `❌ Error al procesar tu decisión: ${e.message}`); return NextResponse.json({ success: false, error: e.message }, { status: 500 }); } } } // 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; } function detectExplicitPublicationType(inputText: string): 'noticia' | 'evento' | 'aviso' | null { if (!inputText) return null; const lower = inputText.toLowerCase(); // Detección de palabras clave explícitas enviadas por el usuario: // Ejemplos: "Noticia: ...", "Evento: ...", "Publicidad: ...", "#noticia", "publicar como evento", etc. if ( /^\s*(?:#|tipo:\s*|categoria:\s*|publicar\s+como:\s*|\[)?\s*noticia\b/i.test(lower) || /\b(?:publicar\s+como\s+noticia|es\s+una\s+noticia)\b/i.test(lower) ) { return 'noticia'; } if ( /^\s*(?:#|tipo:\s*|categoria:\s*|publicar\s+como:\s*|\[)?\s*evento\b/i.test(lower) || /\b(?:publicar\s+como\s+evento|es\s+un\s+evento|agenda\s+cultural|agenda\s+de\s+eventos)\b/i.test(lower) ) { return 'evento'; } if ( /^\s*(?:#|tipo:\s*|categoria:\s*|publicar\s+como:\s*|\[)?\s*(?:publicidad|aviso|clasificado|anuncio)\b/i.test(lower) || /\b(?:publicar\s+como\s+publicidad|publicar\s+como\s+aviso|publicar\s+como\s+clasificado|es\s+publicidad|es\s+un\s+aviso)\b/i.test(lower) ) { return 'aviso'; } return null; } // 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 >= 10); 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 }); } // Detectar si el usuario definió explícitamente el tipo en el texto (noticia, evento o publicidad/aviso) const explicitType = detectExplicitPublicationType(text); if (explicitType) { console.log(`[WhatsApp Webhook] Tipo de publicación explícito detectado: "${explicitType.toUpperCase()}"`); } // Descubrir el grupo central de Notificaciones y Aprobaciones (Notificaciones Almeira) const adminNotificationJid = await getNotificationGroupJid() || '120363428012905103@g.us'; const destinationJid = adminNotificationJid; // Notificación de Progreso 1: Enviada EXCLUSIVAMENTE a Notificaciones Almeira const typeInfoText = explicitType ? ` (${explicitType.toUpperCase()})` : ''; sendWhatsAppMessage( destinationJid, `📥 *Nueva Información Recibida${typeInfoText}*\n` + `📍 *Fuente:* ${filterResult.resolvedName}\n` + `_Almeira Web • Asistente Editorial con IA_\n\n` + `🤖 Recolectando lote de imágenes y estructurando 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 + álbum de fotos reciente del chat) const candidateMediaList: Array<{ base64: string; mimeType: string }> = []; if (messageData.imageMessage || messageData.videoMessage || messageData.documentMessage) { const media = await getBase64FromMediaMessage(data); if (media && media.base64) { candidateMediaList.push({ base64: media.base64, mimeType: media.mimetype }); } } // 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, 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))) { 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 del álbum con Gemini (Tipo forzado: ${explicitType || 'Auto'})...`); // Llamar a Gemini para estructurar la publicación y seleccionar la mejor imagen respetando el tipo explícito si existe const parsed = await processWhatsAppMessage(text, candidateMediaList, undefined, explicitType); // Determinar dimensiones según el tipo de publicación // Noticias: 640x360 (16:9) | Clasificados y Eventos: 640x640 (1:1) // Determinar gravedad de encuadre y dimensiones según análisis de IA const gravity = parsed.crop_focus === 'top' ? 'top' : parsed.crop_focus === 'bottom' ? 'bottom' : 'center'; // Si es un afiche/flyer con texto integrado o evento/aviso, usar formato cuadrado 640x640 para evitar mutilar texto const isSquare = parsed.tipo === 'evento' || parsed.tipo === 'aviso' || Boolean(parsed.has_text_overlay || parsed.crop_focus === 'contain'); const imgHeight = isSquare ? 640 : 360; // Notificación de Progreso 2: Estructuración completa y registro (asíncrona) const mediaStatus = candidateMediaList.length > 0 ? `🖼️ *Imagen:* Optimizando foto destacada (${isSquare ? 'Formato 640x640' : 'Formato 640x360'} • Gravedad: ${gravity})` : `📄 *Formato:* Publicación en texto`; const progressMsg = `⚙️ *Procesando Publicación*\n` + `📍 *Fuente:* ${filterResult.resolvedName}\n\n` + `📌 *Titular:* ${parsed.titulo}\n` + `📂 *Categoría:* ${parsed.tipo.toUpperCase()}\n` + `${mediaStatus}\n\n` + `💾 Registrando en la biblioteca de medios y guardando borrador en la web...`; sendWhatsAppMessage(destinationJid, progressMsg).catch(e => console.warn('[WhatsApp Webhook] No se pudo enviar notificación de progreso:', e.message)); 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]; try { 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); const uploadedFile = await storage.createFile( APPWRITE_ALMEIRA_CONFIG.mediaBucketId, ID.unique(), file ); storageFileId = uploadedFile.$id; // Crear registro en la Biblioteca de Medios (Colección 'media') const mediaDoc = await databases.createDocument( APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', ID.unique(), { file_id: storageFileId, bucket_id: APPWRITE_ALMEIRA_CONFIG.mediaBucketId, titulo: truncateString(parsed.titulo, 240), alt_text: truncateString(parsed.titulo, 240), descripcion_seo: truncateString(parsed.seo_descripcion, 450), tipo: bestMedia.mimeType.startsWith('video') ? 'video' : 'imagen' } ); featuredMediaDocId = mediaDoc.$id; 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 seleccionada a Appwrite:', err.message); } } let ogImageUrl = undefined; if (storageFileId) { ogImageUrl = `${APPWRITE_ALMEIRA_CONFIG.endpoint}/storage/buckets/${APPWRITE_ALMEIRA_CONFIG.mediaBucketId}/files/${storageFileId}/view?project=${APPWRITE_ALMEIRA_CONFIG.projectId}&width=640&height=${imgHeight}&gravity=${gravity}&quality=85`; } // Determinar fecha de vencimiento let fechaVencimiento = parsed.fecha_vencimiento; if ((parsed.tipo === 'evento' || parsed.tipo === 'aviso') && !fechaVencimiento) { fechaVencimiento = getLastDayOfMonth(); } // Mapeo seguro de tipo_publicacion_id requerido por el esquema de Appwrite const DEFAULT_PUBLICATION_TYPES: Record = { noticia: '69c2b91400362d5d52b6', evento: '69c2b9150030f3d1288c', aviso: '69c2b916002aeec66684' }; let tipoPublicacionId = DEFAULT_PUBLICATION_TYPES[parsed.tipo] || '69c2b91400362d5d52b6'; try { const types = await databases.listDocuments( APPWRITE_ALMEIRA_CONFIG.databaseId, APPWRITE_ALMEIRA_CONFIG.collections.publicationTypes, [Query.limit(20)] ); const targetCode = parsed.tipo === 'evento' ? 'EVENTO' : parsed.tipo === 'aviso' ? 'CLASIFICADO' : 'NOTICIA'; const matched = types.documents.find((d: any) => d.codigo === targetCode) || types.documents.find((d: any) => d.codigo === 'NOTICIA') || types.documents[0]; if (matched) { tipoPublicacionId = matched.$id; } } catch (e) { console.warn('No se pudo obtener el tipo de publicación en Appwrite, usando fallback:', e); } // Crear Post en Appwrite (Vinculando la imagen destacada de la biblioteca de medios) const newPost = await databases.createDocument( APPWRITE_ALMEIRA_CONFIG.databaseId, APPWRITE_ALMEIRA_CONFIG.collections.posts, ID.unique(), { titulo: truncateString(parsed.titulo, 70), slug: generateSlug(parsed.titulo) + '-' + Date.now().toString().slice(-4), tipo: parsed.tipo, tipo_publicacion_id: tipoPublicacionId, resumen_redes: truncateString(parsed.seo_descripcion, 155), seo_titulo: truncateString(parsed.titulo, 60), seo_descripcion: truncateString(parsed.seo_descripcion, 155), contenido: parsed.contenido, estado: 'borrador', // Requiere aprobación fecha_publicacion: new Date().toISOString(), ...(fechaVencimiento && { fecha_finalizacion: new Date(fechaVencimiento).toISOString() }), ...(featuredMediaDocId && { imagen_destacada_id: featuredMediaDocId }), ...(ogImageUrl && { og_image_url: ogImageUrl }) } ); // Formatear contenido limpio para WhatsApp (sin etiquetas HTML) const cleanContent = htmlToWhatsAppText(parsed.contenido); // Estado de la imagen destacada con análisis de encuadre let imageNotice = 'No se adjuntó imagen'; const focusLabel = parsed.crop_focus === 'top' ? 'Enfoque superior (preserva titulares/rostros)' : parsed.crop_focus === 'contain' ? 'Formato completo (afiche/texto sin cortes)' : parsed.crop_focus === 'bottom' ? 'Enfoque inferior' : 'Enfoque centrado equilibrado'; if (candidateMediaList.length > 1) { const chosenNum = (parsed.selected_image_index ?? 0) + 1; imageNotice = `Foto #${chosenNum} de ${candidateMediaList.length} analizadas` + (parsed.image_selection_reason ? ` (${parsed.image_selection_reason})` : '') + `\n 📐 _${focusLabel}_`; } else if (candidateMediaList.length === 1) { imageNotice = `1 foto optimizada (${isSquare ? '640x640' : '640x360'})\n 📐 _${focusLabel}_`; } const requestText = `🔔 *PUBLICACIÓN LISTA - SOLICITUD DE APROBACIÓN*\n\n` + `📍 *Fuente:* ${filterResult.resolvedName}\n\n` + `📌 *Titular:*\n${parsed.titulo}\n\n` + `🖼️ *Imagen Destacada:*\n${imageNotice}\n\n` + `📝 *Resumen:*\n${parsed.seo_descripcion}\n\n` + `📄 *Contenido:*\n${cleanContent}\n\n` + `━━━━━━━━━━━━━━━━━━━━━━\n` + `¿Autorizas la publicación en el portal?\n\n` + `👉 Responde a este mensaje con:\n` + `✅ *Aprobar* (para publicar en vivo en la web)\n` + `❌ *Rechazar* (para descartar el borrador)\n\n` + `(Ref: ${newPost.$id})`; await sendWhatsAppMessage(destinationJid, requestText); return NextResponse.json({ success: true, message: 'Mensaje procesado con notificaciones de progreso' }, { status: 200 }); } catch (error: any) { console.error('Error in WhatsApp Webhook:', error); return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } }