feat(whatsapp): seleccion multimodal de la mejor imagen destacada con IA y resoluciones 640x360 y 640x640

This commit is contained in:
AI Bot 2026-08-24 12:38:27 -05:00
parent ca09d22b01
commit 886db53cab
4 changed files with 135 additions and 34 deletions

View File

@ -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`.

View File

@ -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})`;

View File

@ -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<Array<{ base64: string, mimeType: string, messageId: string }>> {
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<string, { subject: string, expiresAt: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
@ -99,4 +149,3 @@ export async function getWhatsAppGroupSubject(groupJid: string): Promise<string
return null;
}

View File

@ -2,25 +2,43 @@ import { GoogleGenAI, Type } from '@google/genai';
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '' });
export interface MediaInput {
base64: string;
mimeType: string;
id?: string;
}
export interface ProcessedPublication {
titulo: string;
tipo: 'noticia' | 'evento' | 'aviso';
seo_descripcion: string;
contenido: string;
fecha_vencimiento: string | null;
selected_image_index?: number;
image_selection_reason?: string;
}
export async function processWhatsAppMessage(
text: string,
base64Media?: string,
mimeType?: string
mediaParam?: string | MediaInput[] | MediaInput,
mimeTypeParam?: string
): Promise<ProcessedPublication> {
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 <p>, <strong>, <ul>/<li> 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"]
}