almeira-web/src/app/api/webhooks/whatsapp/route.ts

391 lines
19 KiB
TypeScript

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[^>]*>(.*?)<\/strong>/gi, '*$1*')
.replace(/<b[^>]*>(.*?)<\/b>/gi, '*$1*')
.replace(/<em[^>]*>(.*?)<\/em>/gi, '_$1_')
.replace(/<i[^>]*>(.*?)<\/i>/gi, '_$1_')
.replace(/<li[^>]*>(.*?)<\/li>/gi, '• $1\n')
.replace(/<\/?(?:p|div|h[1-6]|ul|ol|table|tr|td)[^>]*>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '') // Eliminar cualquier etiqueta HTML remanente
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export async function POST(req: Request) {
try {
const payload = await req.json();
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 });
}
}
}
// 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 });
}
// 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
sendWhatsAppMessage(
destinationJid,
`📥 *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...`
).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)
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 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);
// Determinar dimensiones según el tipo de publicación
// Noticias: 640x360 (16:9) | Clasificados y Eventos: 640x640 (1:1)
const isSquare = parsed.tipo === 'evento' || parsed.tipo === 'aviso';
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'})`
: `📄 *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}/preview?project=${APPWRITE_ALMEIRA_CONFIG.projectId}&width=640&height=${imgHeight}&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<string, string> = {
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
let imageNotice = 'No se adjuntó imagen';
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})` : '');
} else if (candidateMediaList.length === 1) {
imageNotice = `1 foto procesada (${isSquare ? '640x640' : '640x360'})`;
}
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 });
}
}