From 987fe8aef62c2fb82c332093ba8de576cd7d275e Mon Sep 17 00:00:00 2001 From: AI Bot Date: Mon, 24 Aug 2026 12:06:22 -0500 Subject: [PATCH] feat(whatsapp): integracion de whitelist y filtro de chats en Appwrite y settings --- docs/developer_context.md | 10 + read_webhook.js | 27 + setup_webhook.js | 44 ++ setup_whatsapp_chats_collection.js | 95 ++++ src/app/actions/whatsapp-config.ts | 277 ++++++++++ src/app/api/webhooks/whatsapp/route.ts | 34 +- src/app/dashboard/settings/page.tsx | 8 + .../dashboard/WhatsAppChatManager.tsx | 501 ++++++++++++++++++ src/config/whatsapp-chats.json | 23 + src/lib/evolution.ts | 62 +++ src/lib/whatsapp-filter.ts | 207 ++++++++ src/types/whatsapp.ts | 36 ++ verify-remote-appwrite.js | 34 ++ 13 files changed, 1350 insertions(+), 8 deletions(-) create mode 100644 read_webhook.js create mode 100644 setup_webhook.js create mode 100644 setup_whatsapp_chats_collection.js create mode 100644 src/app/actions/whatsapp-config.ts create mode 100644 src/components/dashboard/WhatsAppChatManager.tsx create mode 100644 src/config/whatsapp-chats.json create mode 100644 src/lib/whatsapp-filter.ts create mode 100644 src/types/whatsapp.ts create mode 100644 verify-remote-appwrite.js diff --git a/docs/developer_context.md b/docs/developer_context.md index f012c96..4b22aac 100644 --- a/docs/developer_context.md +++ b/docs/developer_context.md @@ -84,3 +84,13 @@ A lo largo del desarrollo de la plataforma, se han resuelto varios problemas cr * **Problema**: Caídas constantes del servidor en Hostinger debido a limitaciones de memoria y errores 503 en despliegues tradicionales de Node.js. * **Solución**: Se migró el entorno de despliegue a **Coolify**, una plataforma de self-hosting que gestiona contenedores Docker. Se configuró el enrutamiento para soportar múltiples orígenes y dominios sin errores de cabeceras CORS en el backend, mejorando la disponibilidad global de la web de Almeira. +### 5. Automatización de Publicaciones vía WhatsApp con IA y Whitelist en Appwrite +* **Arquitectura**: Se integró Evolution API con Google Gemini (`gemini-3.6-flash`) y Appwrite para procesar mensajes y fotos enviados por clientes o salas de prensa directamente en WhatsApp. +* **Almacenamiento en Base de Datos Appwrite**: Los chats y clientes autorizados se registran y gestionan directamente en la base de datos de **Appwrite** (`whatsapp_messages` con estado `whitelist_active`/`whitelist_paused`), permitiendo añadir, pausar o eliminar clientes en tiempo real desde el Dashboard sin necesidad de redesplegar o reiniciar el servidor. +* **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**: Chat autorizado envía mensaje/multimedia -> Gemini estructura título, categoría, SEO y contenido limpio -> 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`. + + + diff --git a/read_webhook.js b/read_webhook.js new file mode 100644 index 0000000..90db180 --- /dev/null +++ b/read_webhook.js @@ -0,0 +1,27 @@ +// Using native fetch + +async function readWebhook() { + const apiUrl = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; + const apiKey = process.env.EVOLUTION_API_KEY || 'mgXxu2eAwyhWNnptFEouUE6qLkvwVm6K'; + const instance = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; + + console.log("Leyendo el Webhook en Evolution API..."); + + try { + const response = await fetch(`${apiUrl}/webhook/find/${instance}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'apikey': apiKey + } + }); + + const data = await response.json(); + console.log("Respuesta:", JSON.stringify(data, null, 2)); + + } catch (e) { + console.error("Fallo:", e.message); + } +} + +readWebhook(); diff --git a/setup_webhook.js b/setup_webhook.js new file mode 100644 index 0000000..88e0394 --- /dev/null +++ b/setup_webhook.js @@ -0,0 +1,44 @@ +// Using native fetch + +async function setWebhook() { + const apiUrl = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; + const apiKey = process.env.EVOLUTION_API_KEY || 'mgXxu2eAwyhWNnptFEouUE6qLkvwVm6K'; + const instance = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; + + // El destino de donde escuchará Evolution las respuestas + const webhookTargetUrl = 'https://news.almeira.com.co/api/webhooks/whatsapp'; + + const payload = { + webhook: { + enabled: true, + url: webhookTargetUrl, + webhookByEvents: false, + base64: false, + events: [ + "MESSAGES_UPSERT" + ] + } + }; + + console.log("Configurando el Webhook en Evolution API..."); + + try { + const response = await fetch(`${apiUrl}/webhook/set/${instance}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': apiKey + }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + console.log(`Estado: HTTP ${response.status}`); + console.log("Respuesta:", JSON.stringify(data, null, 2)); + + } catch (e) { + console.error("Fallo:", e.message); + } +} + +setWebhook(); diff --git a/setup_whatsapp_chats_collection.js b/setup_whatsapp_chats_collection.js new file mode 100644 index 0000000..389aaf3 --- /dev/null +++ b/setup_whatsapp_chats_collection.js @@ -0,0 +1,95 @@ +const sdk = require('node-appwrite'); +require('dotenv').config({ path: '.env.local' }); + +const client = new sdk.Client() + .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) + .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID) + .setKey(process.env.APPWRITE_API_KEY); + +const databases = new sdk.Databases(client); +const DB_ID = process.env.APPWRITE_DATABASE_ID || 'almeira'; +const COLLECTION_ID = 'whatsapp_chats'; + +const delay = ms => new Promise(res => setTimeout(res, ms)); + +async function setupWhatsAppChatsCollection() { + try { + console.log("Creando o verificando colección 'whatsapp_chats' en Appwrite..."); + + let collectionExists = false; + try { + await databases.getCollection(DB_ID, COLLECTION_ID); + collectionExists = true; + console.log("✓ Colección 'whatsapp_chats' ya existe."); + } catch (e) { + // Create collection + await databases.createCollection( + DB_ID, + COLLECTION_ID, + 'Chats Autorizados WhatsApp', + [ + sdk.Permission.read(sdk.Role.any()), + sdk.Permission.write(sdk.Role.any()) + ], + false + ); + console.log("✓ Colección 'whatsapp_chats' creada exitosamente."); + } + + await delay(1000); + + // Attributes + const attributes = [ + { name: 'name', type: 'string', size: 255, required: true }, + { name: 'jid', type: 'string', size: 255, required: false }, + { name: 'enabled', type: 'boolean', required: true, default: true }, + { name: 'notes', type: 'string', size: 1000, required: false } + ]; + + for (const attr of attributes) { + try { + if (attr.type === 'string') { + await databases.createStringAttribute(DB_ID, COLLECTION_ID, attr.name, attr.size, attr.required, undefined, false); + } else if (attr.type === 'boolean') { + await databases.createBooleanAttribute(DB_ID, COLLECTION_ID, attr.name, attr.required, attr.default, false); + } + console.log(`✓ Atributo '${attr.name}' creado.`); + await delay(600); + } catch (err) { + console.log(`> Atributo '${attr.name}' ya existe.`); + } + } + + // Wait a bit for Appwrite schema to be ready + console.log("Esperando que Appwrite procese los atributos..."); + await delay(2500); + + // Seed initial documents if empty + const existingDocs = await databases.listDocuments(DB_ID, COLLECTION_ID); + if (existingDocs.total === 0) { + console.log("Poblando chats iniciales autorizados..."); + await databases.createDocument(DB_ID, COLLECTION_ID, sdk.ID.unique(), { + name: 'Publicaciones Almeira', + jid: '120363419329355281@g.us', + enabled: true, + notes: 'Grupo oficial de publicaciones de Almeira' + }); + + await databases.createDocument(DB_ID, COLLECTION_ID, sdk.ID.unique(), { + name: 'Medios y Prensa Gob Cauca', + jid: '573134848203-1408546044@g.us', + enabled: true, + notes: 'Boletines de prensa y noticias de la Gobernación del Cauca' + }); + console.log("✓ Documentos iniciales creados."); + } else { + console.log(`✓ Colección ya cuenta con ${existingDocs.total} chats registrados.`); + } + + console.log("¡Todo listo en Appwrite!"); + } catch (e) { + console.error("Error en setup:", e.message); + } +} + +setupWhatsAppChatsCollection(); diff --git a/src/app/actions/whatsapp-config.ts b/src/app/actions/whatsapp-config.ts new file mode 100644 index 0000000..0bfc7c7 --- /dev/null +++ b/src/app/actions/whatsapp-config.ts @@ -0,0 +1,277 @@ +'use server'; + +import fs from 'fs'; +import path from 'path'; +import { revalidatePath } from 'next/cache'; +import { ID, Query } from 'node-appwrite'; +import { createAdminClient } from '@/lib/appwrite.server'; +import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; +import { WhatsAppChatConfig, WhatsAppAllowedChat } from '@/types/whatsapp'; +import { fetchAllWhatsAppGroups } from '@/lib/evolution'; + +const CONFIG_PATH = path.join(process.cwd(), 'src', 'config', 'whatsapp-chats.json'); + +/** + * Helper to read repository JSON config as fallback or sync mirror + */ +function readLocalJsonConfig(): WhatsAppChatConfig { + try { + if (fs.existsSync(CONFIG_PATH)) { + const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); + return JSON.parse(raw) as WhatsAppChatConfig; + } + } catch (error) { + console.error('Error reading local whatsapp-chats.json:', error); + } + + return { + description: 'Lista blanca de chats y grupos autorizados para procesar publicaciones automáticamente con IA', + filter_enabled: true, + allowed_chats: [ + { + id: 'chat_almeira_pub', + name: 'Publicaciones Almeira', + jid: '120363419329355281@g.us', + enabled: true, + notes: 'Grupo oficial de publicaciones de Almeira' + } + ] + }; +} + +/** + * Helper to write repository JSON config mirror + */ +function writeLocalJsonConfig(config: WhatsAppChatConfig) { + try { + const dir = path.dirname(CONFIG_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8'); + } catch (err) { + console.warn('Could not write local config mirror:', err); + } +} + +/** + * Gets the current WhatsApp Chat configuration directly from Appwrite database, + * falling back to or synchronizing with `src/config/whatsapp-chats.json`. + */ +export async function getWhatsAppConfigAction(): Promise { + const localConfig = readLocalJsonConfig(); + + try { + const { databases } = await createAdminClient(); + const response = await databases.listDocuments( + APPWRITE_ALMEIRA_CONFIG.databaseId, + APPWRITE_ALMEIRA_CONFIG.collections.whatsappMessages, + [ + Query.equal('status', ['whitelist_active', 'whitelist_paused']), + Query.limit(100) + ] + ); + + if (response.total > 0) { + const appwriteChats: WhatsAppAllowedChat[] = response.documents.map(doc => ({ + id: doc.$id, + name: doc.sender_name, + jid: doc.phone_number || undefined, + notes: doc.content || undefined, + enabled: doc.status === 'whitelist_active', + createdAt: doc.timestamp || doc.$createdAt + })); + + const mergedConfig: WhatsAppChatConfig = { + description: localConfig.description, + filter_enabled: localConfig.filter_enabled !== false, + allowed_chats: appwriteChats, + last_updated: new Date().toISOString() + }; + + // Keep local repository file in sync with Appwrite + writeLocalJsonConfig(mergedConfig); + return mergedConfig; + } else if (localConfig.allowed_chats.length > 0) { + // Seed Appwrite from local config if Appwrite has no whitelist records yet + console.log('[WhatsApp Config] Seeding Appwrite database from repository config...'); + for (const chat of localConfig.allowed_chats) { + try { + await databases.createDocument( + APPWRITE_ALMEIRA_CONFIG.databaseId, + APPWRITE_ALMEIRA_CONFIG.collections.whatsappMessages, + ID.unique(), + { + sender_name: chat.name, + phone_number: chat.jid || '', + content: chat.notes || '', + status: chat.enabled ? 'whitelist_active' : 'whitelist_paused', + timestamp: new Date().toISOString() + } + ); + } catch (e) { + console.warn(`Could not seed chat ${chat.name} to Appwrite:`, e); + } + } + } + } catch (error) { + console.error('Error fetching WhatsApp whitelist from Appwrite:', error); + } + + return localConfig; +} + +/** + * Adds a new allowed chat/client directly in Appwrite database and syncs repository file. + */ +export async function addWhatsAppChatAction(chat: { + name: string; + jid?: string; + notes?: string; + enabled?: boolean; +}): Promise<{ success: boolean; error?: string; documentId?: string }> { + try { + if (!chat.name || !chat.name.trim()) { + return { success: false, error: 'El nombre del chat es obligatorio.' }; + } + + const cleanName = chat.name.trim(); + const cleanJid = chat.jid?.trim() || ''; + const isEnabled = chat.enabled !== false; + + const { databases } = await createAdminClient(); + + // Verify if already exists in Appwrite + const existing = await databases.listDocuments( + APPWRITE_ALMEIRA_CONFIG.databaseId, + APPWRITE_ALMEIRA_CONFIG.collections.whatsappMessages, + [ + Query.equal('status', ['whitelist_active', 'whitelist_paused']), + Query.limit(100) + ] + ); + + const duplicate = existing.documents.find( + d => d.sender_name.toLowerCase() === cleanName.toLowerCase() || + (cleanJid && d.phone_number && d.phone_number.toLowerCase() === cleanJid.toLowerCase()) + ); + + if (duplicate) { + return { success: false, error: `Ya existe una regla en Appwrite para "${cleanName}".` }; + } + + // Create in Appwrite + const doc = await databases.createDocument( + APPWRITE_ALMEIRA_CONFIG.databaseId, + APPWRITE_ALMEIRA_CONFIG.collections.whatsappMessages, + ID.unique(), + { + sender_name: cleanName, + phone_number: cleanJid, + content: chat.notes?.trim() || '', + status: isEnabled ? 'whitelist_active' : 'whitelist_paused', + timestamp: new Date().toISOString() + } + ); + + // Sync local repo mirror + const localConfig = readLocalJsonConfig(); + localConfig.allowed_chats.push({ + id: doc.$id, + name: cleanName, + jid: cleanJid || undefined, + notes: chat.notes?.trim() || undefined, + enabled: isEnabled, + createdAt: new Date().toISOString() + }); + writeLocalJsonConfig(localConfig); + + revalidatePath('/dashboard/settings'); + return { success: true, documentId: doc.$id }; + } catch (error: any) { + console.error('Error adding WhatsApp chat in Appwrite:', error); + return { success: false, error: error.message || 'Error guardando en Appwrite' }; + } +} + +/** + * Toggles a chat active/paused state in Appwrite database. + */ +export async function toggleWhatsAppChatAction(chatId: string, enabled: boolean): Promise<{ success: boolean; error?: string }> { + try { + const { databases } = await createAdminClient(); + await databases.updateDocument( + APPWRITE_ALMEIRA_CONFIG.databaseId, + APPWRITE_ALMEIRA_CONFIG.collections.whatsappMessages, + chatId, + { + status: enabled ? 'whitelist_active' : 'whitelist_paused' + } + ); + + // Sync local repo mirror + const localConfig = readLocalJsonConfig(); + const item = localConfig.allowed_chats.find(c => c.id === chatId); + if (item) item.enabled = enabled; + writeLocalJsonConfig(localConfig); + + revalidatePath('/dashboard/settings'); + return { success: true }; + } catch (error: any) { + console.error('Error toggling WhatsApp chat in Appwrite:', error); + return { success: false, error: error.message }; + } +} + +/** + * Removes a chat from Appwrite database and syncs repository file. + */ +export async function deleteWhatsAppChatAction(chatId: string): Promise<{ success: boolean; error?: string }> { + try { + const { databases } = await createAdminClient(); + await databases.deleteDocument( + APPWRITE_ALMEIRA_CONFIG.databaseId, + APPWRITE_ALMEIRA_CONFIG.collections.whatsappMessages, + chatId + ); + + // Sync local repo mirror + const localConfig = readLocalJsonConfig(); + localConfig.allowed_chats = localConfig.allowed_chats.filter(c => c.id !== chatId); + writeLocalJsonConfig(localConfig); + + revalidatePath('/dashboard/settings'); + return { success: true }; + } catch (error: any) { + console.error('Error deleting WhatsApp chat from Appwrite:', error); + return { success: false, error: error.message }; + } +} + +/** + * Toggles the global filter mode (strict whitelist vs open). + */ +export async function toggleGlobalFilterAction(enabled: boolean): Promise<{ success: boolean; error?: string }> { + try { + const localConfig = readLocalJsonConfig(); + localConfig.filter_enabled = enabled; + writeLocalJsonConfig(localConfig); + + revalidatePath('/dashboard/settings'); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +/** + * Fetches active groups from Evolution API to provide live suggestions in the UI. + */ +export async function getLiveEvolutionGroupsAction(): Promise> { + try { + return await fetchAllWhatsAppGroups(); + } catch (error) { + console.error('Error fetching live groups:', error); + return []; + } +} diff --git a/src/app/api/webhooks/whatsapp/route.ts b/src/app/api/webhooks/whatsapp/route.ts index eb239bd..253d1ad 100644 --- a/src/app/api/webhooks/whatsapp/route.ts +++ b/src/app/api/webhooks/whatsapp/route.ts @@ -5,9 +5,7 @@ import { createAdminClient } from '@/lib/appwrite.server'; import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; import { processWhatsAppMessage } from '@/lib/gemini'; import { sendWhatsAppMessage, getBase64FromMediaMessage } from '@/lib/evolution'; - -// Grupo de publicaciones (tomado del test) o puedes dejarlo abierto a todos los grupos -const TARGET_GROUP_JID = '573134848203-1408546044@g.us'; +import { isWhatsAppChatAllowed } from '@/lib/whatsapp-filter'; // Función para obtener el último día del mes actual function getLastDayOfMonth(): string { @@ -38,6 +36,12 @@ export async function POST(req: Request) { 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 || ''; @@ -48,11 +52,25 @@ export async function POST(req: Request) { return NextResponse.json({ success: true, message: 'Ignored bot self-message loop' }, { status: 200 }); } - // Filtrar estrictamente para que SOLO procese mensajes del grupo 'Publicaciones Almeira' - // (A petición del usuario se desactiva para procesar todos los chats y filtrar en la fase de aprobación) - // if (remoteJid !== TARGET_GROUP_JID) { - // return NextResponse.json({ success: true, message: 'Ignored other chat' }, { 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 (ya guardado en rawText, lo mantenemos como text para retrocompatibilidad interna) const text = rawText; diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 22cf678..e1b8334 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -4,11 +4,14 @@ import { getEntity, updateEntity } from '@/app/actions/entity'; import { revalidatePath } from 'next/cache'; import { Settings as SettingsIcon, Save, Clock, Menu, Building, Image as ImageIcon, Link as LinkIcon, Share2 } from 'lucide-react'; import { Settings as SettingsInterface } from '@/types/settings'; +import { WhatsAppChatManager } from '@/components/dashboard/WhatsAppChatManager'; +import { getWhatsAppConfigAction } from '@/app/actions/whatsapp-config'; export default async function SettingsPage() { let settings: SettingsInterface = { ticker_count: 10, rotation_seconds: 5 }; let docId = ''; let entity: any = null; + let whatsappConfig = await getWhatsAppConfigAction(); try { const sessionClient = await createSessionClient(); @@ -29,6 +32,7 @@ export default async function SettingsPage() { console.error("No se pudo cargar config:", e); } + async function saveSettingsServer(formData: FormData) { 'use server'; const sessionClient = await createSessionClient(); @@ -168,7 +172,11 @@ export default async function SettingsPage() { + + {/* Gestor y Filtro de Chats Autorizados de WhatsApp */} + ); } + diff --git a/src/components/dashboard/WhatsAppChatManager.tsx b/src/components/dashboard/WhatsAppChatManager.tsx new file mode 100644 index 0000000..dbdc306 --- /dev/null +++ b/src/components/dashboard/WhatsAppChatManager.tsx @@ -0,0 +1,501 @@ +'use client'; + +import React, { useState, useTransition } from 'react'; +import { + MessageSquare, + Plus, + Trash2, + CheckCircle2, + XCircle, + RefreshCw, + ShieldCheck, + ShieldAlert, + Sparkles, + Info, + Layers, + Database, + Search +} from 'lucide-react'; +import { WhatsAppChatConfig, WhatsAppAllowedChat } from '@/types/whatsapp'; +import { + addWhatsAppChatAction, + deleteWhatsAppChatAction, + toggleWhatsAppChatAction, + toggleGlobalFilterAction, + getLiveEvolutionGroupsAction, + getWhatsAppConfigAction +} from '@/app/actions/whatsapp-config'; + +interface Props { + initialConfig: WhatsAppChatConfig; +} + +export function WhatsAppChatManager({ initialConfig }: Props) { + const [config, setConfig] = useState(initialConfig); + const [isPending, startTransition] = useTransition(); + const [searchQuery, setSearchQuery] = useState(''); + + // Form state for adding chat + const [name, setName] = useState(''); + const [jid, setJid] = useState(''); + const [notes, setNotes] = useState(''); + const [formError, setFormError] = useState(''); + const [formSuccess, setFormSuccess] = useState(''); + + // Live Evolution groups detection + const [liveGroups, setLiveGroups] = useState>([]); + const [isLoadingGroups, setIsLoadingGroups] = useState(false); + const [showGroupsPicker, setShowGroupsPicker] = useState(false); + + // Filter list by search + const filteredChats = (config.allowed_chats || []).filter(c => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (c.notes && c.notes.toLowerCase().includes(searchQuery.toLowerCase())) || + (c.jid && c.jid.toLowerCase().includes(searchQuery.toLowerCase())) + ); + + const activeCount = (config.allowed_chats || []).filter(c => c.enabled).length; + const totalCount = (config.allowed_chats || []).length; + + const refreshFromAppwrite = async () => { + startTransition(async () => { + const latest = await getWhatsAppConfigAction(); + setConfig(latest); + }); + }; + + const handleToggleGlobalFilter = async () => { + const nextState = !config.filter_enabled; + setConfig(prev => ({ ...prev, filter_enabled: nextState })); + startTransition(async () => { + const res = await toggleGlobalFilterAction(nextState); + if (!res.success) { + setConfig(prev => ({ ...prev, filter_enabled: !nextState })); + setFormError(res.error || 'Error al cambiar filtro'); + } + }); + }; + + const handleToggleChat = (chatId: string, currentEnabled: boolean) => { + const nextState = !currentEnabled; + setConfig(prev => ({ + ...prev, + allowed_chats: prev.allowed_chats.map(c => c.id === chatId ? { ...c, enabled: nextState } : c) + })); + + startTransition(async () => { + const res = await toggleWhatsAppChatAction(chatId, nextState); + if (!res.success) { + // Revert on error + setConfig(prev => ({ + ...prev, + allowed_chats: prev.allowed_chats.map(c => c.id === chatId ? { ...c, enabled: currentEnabled } : c) + })); + setFormError(res.error || 'Error al actualizar en Appwrite'); + } + }); + }; + + const handleDeleteChat = (chatId: string, chatName: string) => { + if (!window.confirm(`¿Estás seguro de eliminar el chat "${chatName}" de Appwrite?`)) { + return; + } + + const previousChats = [...config.allowed_chats]; + setConfig(prev => ({ + ...prev, + allowed_chats: prev.allowed_chats.filter(c => c.id !== chatId) + })); + + startTransition(async () => { + const res = await deleteWhatsAppChatAction(chatId); + if (!res.success) { + setConfig(prev => ({ ...prev, allowed_chats: previousChats })); + setFormError(res.error || 'Error al eliminar en Appwrite'); + } + }); + }; + + const handleAddChat = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(''); + setFormSuccess(''); + + if (!name.trim()) { + setFormError('El nombre del chat es obligatorio.'); + return; + } + + startTransition(async () => { + const res = await addWhatsAppChatAction({ + name: name.trim(), + jid: jid.trim() || undefined, + notes: notes.trim() || undefined, + enabled: true + }); + + if (res.success) { + setFormSuccess(`¡Chat "${name.trim()}" registrado en Appwrite exitosamente!`); + setName(''); + setJid(''); + setNotes(''); + + // Refresh directly from Appwrite + const latest = await getWhatsAppConfigAction(); + setConfig(latest); + setTimeout(() => setFormSuccess(''), 4000); + } else { + setFormError(res.error || 'Error al guardar en Appwrite'); + } + }); + }; + + const handleFetchEvolutionGroups = async () => { + setIsLoadingGroups(true); + try { + const groups = await getLiveEvolutionGroupsAction(); + setLiveGroups(groups); + setShowGroupsPicker(true); + } catch (err) { + setFormError('No se pudieron obtener los grupos de Evolution API.'); + } finally { + setIsLoadingGroups(false); + } + }; + + const handleSelectEvolutionGroup = (group: { id: string; subject: string }) => { + setName(group.subject); + setJid(group.id); + setNotes(`Grupo detectado automáticamente: ${group.subject}`); + setShowGroupsPicker(false); + }; + + return ( +
+ {/* Header & Global Switch */} +
+
+
+ +
+
+
+

+ Filtro de Chats WhatsApp (IA) +

+ + Appwrite DB + + + {config.filter_enabled ? 'Whitelist Activa' : 'Abierto a todos'} + +
+

+ Registra y gestiona en Appwrite qué chats o clientes generan publicaciones automáticas con IA +

+
+
+ + {/* Global Toggle Switch */} +
+
+ + {config.filter_enabled ? 'Filtrado Estricto' : 'Procesar Todo'} + + + {config.filter_enabled ? 'Solo autorizados en Appwrite' : 'Sin restricciones'} + +
+ +
+
+ + {/* Summary Metrics & Actions Bar */} +
+
+
+ Total en Appwrite + {totalCount} +
+ +
+ +
+
+ Autorizados (Activos) + {activeCount} +
+ +
+ +
+
+ Pausados / Inactivos + {totalCount - activeCount} +
+ +
+
+ + {/* Main Grid: Add Chat Form + Allowed Chats List */} +
+ + {/* Column 1: Add New Chat Form (5 cols) */} +
+
+

+ + Registrar Chat o Cliente +

+ +
+ + {/* Groups Picker Dropdown Modal */} + {showGroupsPicker && ( +
+
+ + Grupos Detectados en WhatsApp ({liveGroups.length}) + + +
+ + {liveGroups.length === 0 ? ( +

No se encontraron grupos activos en la instancia.

+ ) : ( +
+ {liveGroups.map((group) => ( + + ))} +
+ )} +
+ )} + +
+ {formError && ( +
+ + {formError} +
+ )} + {formSuccess && ( +
+ + {formSuccess} +
+ )} + +
+ + setName(e.target.value)} + placeholder="Ej. Medios y Prensa Gob Cauca" + required + className="w-full bg-slate-900 border border-slate-800 rounded-lg px-3.5 py-2.5 text-sm text-white placeholder-slate-600 focus:border-emerald-500 focus:outline-none transition-all" + /> + + Se almacena en Appwrite y se compara con el título del grupo o contacto en WhatsApp. + +
+ +
+ + setJid(e.target.value)} + placeholder="Ej. 120363419329355281@g.us" + className="w-full bg-slate-900 border border-slate-800 rounded-lg px-3.5 py-2.5 text-xs font-mono text-white placeholder-slate-600 focus:border-emerald-500 focus:outline-none transition-all" + /> +
+ +
+ + setNotes(e.target.value)} + placeholder="Ej. Cliente institucional / Pauta 2026" + className="w-full bg-slate-900 border border-slate-800 rounded-lg px-3.5 py-2.5 text-sm text-white placeholder-slate-600 focus:border-emerald-500 focus:outline-none transition-all" + /> +
+ + +
+ +
+ +

+ Los chats registrados se guardan en la base de datos de Appwrite en tiempo real y se sincronizan como respaldo en src/config/whatsapp-chats.json. +

+
+
+ + {/* Column 2: Whitelist Management (7 cols) */} +
+
+

+ Chats Registrados ({filteredChats.length}) + +

+ + {/* Search filter */} +
+ + setSearchQuery(e.target.value)} + placeholder="Buscar chat o cliente..." + className="w-full bg-slate-950 border border-slate-800 rounded-lg pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-600 focus:border-emerald-500 focus:outline-none transition-all" + /> +
+
+ + {filteredChats.length === 0 ? ( +
+ +

No se encontraron chats autorizados en Appwrite

+

+ {searchQuery ? 'Prueba con otro término de búsqueda.' : 'Registra el primer chat usando el formulario a la izquierda.'} +

+
+ ) : ( +
+ {filteredChats.map((chat) => ( +
+
+
+
+ + {chat.name} + + + {chat.enabled ? 'Activo en Appwrite' : 'Pausado'} + +
+ + {chat.jid && ( +
+ JID: {chat.jid} +
+ )} + + {chat.notes && ( +

+ {chat.notes} +

+ )} +
+ + {/* Actions: Toggle & Delete */} +
+ {/* Active toggle */} + + + {/* Delete */} + +
+
+
+ ))} +
+ )} +
+ +
+
+ ); +} diff --git a/src/config/whatsapp-chats.json b/src/config/whatsapp-chats.json new file mode 100644 index 0000000..3fb8dd7 --- /dev/null +++ b/src/config/whatsapp-chats.json @@ -0,0 +1,23 @@ +{ + "description": "Lista blanca de chats y grupos autorizados para procesar publicaciones automáticamente con IA", + "filter_enabled": true, + "last_updated": "2026-08-24T12:00:00.000Z", + "allowed_chats": [ + { + "id": "chat_almeira_pub", + "name": "Publicaciones Almeira", + "jid": "120363419329355281@g.us", + "enabled": true, + "notes": "Grupo oficial de publicaciones de Almeira", + "createdAt": "2026-08-24T12:00:00.000Z" + }, + { + "id": "chat_gob_cauca", + "name": "Medios y Prensa Gob Cauca", + "jid": "573134848203-1408546044@g.us", + "enabled": true, + "notes": "Boletines de prensa y noticias de la Gobernación del Cauca", + "createdAt": "2026-08-24T12:00:00.000Z" + } + ] +} diff --git a/src/lib/evolution.ts b/src/lib/evolution.ts index 8990caa..7ec2457 100644 --- a/src/lib/evolution.ts +++ b/src/lib/evolution.ts @@ -38,3 +38,65 @@ export async function getBase64FromMediaMessage(messagePayload: any): Promise<{ } return null; } + +// In-memory cache for group subjects: jid -> { subject, expiresAt } +const groupCache = new Map(); +const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** + * Fetch all groups from Evolution API + */ +export async function fetchAllWhatsAppGroups(): Promise> { + if (!EVOLUTION_API_URL || !EVOLUTION_INSTANCE_NAME) { + return []; + } + + try { + const url = `${EVOLUTION_API_URL}/group/fetchAllGroups/${EVOLUTION_INSTANCE_NAME}?getParticipants=false`; + const response = await axios.get(url, { headers, timeout: 8000 }); + + if (Array.isArray(response.data)) { + const groups = response.data.map((g: any) => ({ + id: g.id || g.jid || '', + subject: g.subject || g.name || '' + })).filter((g: any) => Boolean(g.id)); + + // Update cache + const now = Date.now(); + for (const group of groups) { + if (group.id && group.subject) { + groupCache.set(group.id, { subject: group.subject, expiresAt: now + CACHE_TTL_MS }); + } + } + + return groups; + } + } catch (err: any) { + console.warn('Warning: Could not fetch WhatsApp groups from Evolution API:', err?.response?.data || err.message); + } + + return []; +} + +/** + * Resolve WhatsApp group subject by JID using in-memory cache and Evolution API fallback + */ +export async function getWhatsAppGroupSubject(groupJid: string): Promise { + if (!groupJid) return null; + + const now = Date.now(); + const cached = groupCache.get(groupJid); + if (cached && cached.expiresAt > now) { + return cached.subject; + } + + // If not cached, fetch all groups to refresh cache + const groups = await fetchAllWhatsAppGroups(); + const found = groups.find(g => g.id === groupJid); + if (found && found.subject) { + return found.subject; + } + + return null; +} + diff --git a/src/lib/whatsapp-filter.ts b/src/lib/whatsapp-filter.ts new file mode 100644 index 0000000..24c07b4 --- /dev/null +++ b/src/lib/whatsapp-filter.ts @@ -0,0 +1,207 @@ +import fs from 'fs'; +import path from 'path'; +import { Query } from 'node-appwrite'; +import { createAdminClient } from '@/lib/appwrite.server'; +import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; +import { WhatsAppChatConfig, WhatsAppFilterResult, WhatsAppAllowedChat } from '@/types/whatsapp'; +import { getWhatsAppGroupSubject } from './evolution'; + +const CONFIG_PATH = path.join(process.cwd(), 'src', 'config', 'whatsapp-chats.json'); + +// In-memory cache for Appwrite active whitelist chats: { chats, expiresAt } +let activeChatsCache: { chats: WhatsAppAllowedChat[]; expiresAt: number } | null = null; +const CACHE_TTL_MS = 60 * 1000; // 1 minute cache + +/** + * Normalizes text for robust comparison: + * Lowercases, strips accents, removes extra spaces and punctuation/emojis. + */ +function normalizeText(text: string): string { + if (!text) return ''; + return text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // remove diacritics + .replace(/[^\w\s]/gi, '') // remove special symbols/emojis + .trim() + .replace(/\s+/g, ' '); +} + +/** + * Reads fallback configuration from the repository JSON file. + */ +export function getStoredWhatsAppConfig(): WhatsAppChatConfig { + try { + if (fs.existsSync(CONFIG_PATH)) { + const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); + return JSON.parse(raw) as WhatsAppChatConfig; + } + } catch (error) { + console.error('Error reading whatsapp-chats.json config:', error); + } + + return { + description: 'Lista blanca de chats y grupos autorizados para procesar publicaciones automáticamente con IA', + filter_enabled: true, + allowed_chats: [ + { + id: 'chat_almeira_pub', + name: 'Publicaciones Almeira', + jid: '120363419329355281@g.us', + enabled: true, + notes: 'Grupo oficial de publicaciones de Almeira' + } + ] + }; +} + +/** + * Retrieves the list of actively allowed chats directly from Appwrite database + * (with in-memory cache and local file fallback). + */ +export async function getActiveAllowedChatsFromAppwrite(): Promise<{ + filter_enabled: boolean; + chats: WhatsAppAllowedChat[]; +}> { + const localConfig = getStoredWhatsAppConfig(); + + // If local config has filter disabled, respect it + if (!localConfig.filter_enabled) { + return { filter_enabled: false, chats: [] }; + } + + const now = Date.now(); + if (activeChatsCache && activeChatsCache.expiresAt > now) { + return { filter_enabled: true, chats: activeChatsCache.chats }; + } + + try { + const { databases } = await createAdminClient(); + const response = await databases.listDocuments( + APPWRITE_ALMEIRA_CONFIG.databaseId, + APPWRITE_ALMEIRA_CONFIG.collections.whatsappMessages, + [ + Query.equal('status', 'whitelist_active'), + Query.limit(100) + ] + ); + + if (response.documents.length > 0) { + const chats: WhatsAppAllowedChat[] = response.documents.map(doc => ({ + id: doc.$id, + name: doc.sender_name, + jid: doc.phone_number || undefined, + notes: doc.content || undefined, + enabled: true, + createdAt: doc.timestamp || doc.$createdAt + })); + + activeChatsCache = { + chats, + expiresAt: now + CACHE_TTL_MS + }; + + return { filter_enabled: true, chats }; + } + } catch (error) { + console.warn('Appwrite query for whitelist chats fallback to local config:', error); + } + + // Fallback to local config active chats + const fallbackChats = (localConfig.allowed_chats || []).filter(c => c.enabled); + return { filter_enabled: localConfig.filter_enabled, chats: fallbackChats }; +} + +/** + * Invalidates the in-memory cache when chats are added, updated, or removed. + */ +export function invalidateWhatsAppFilterCache() { + activeChatsCache = null; +} + +/** + * Evaluates whether an incoming WhatsApp message should be processed based on whitelist rules in Appwrite. + */ +export async function isWhatsAppChatAllowed(params: { + remoteJid: string; + pushName?: string; + groupSubject?: string; +}): Promise { + const { remoteJid, pushName } = params; + let { groupSubject } = params; + + const { filter_enabled, chats: activeAllowedChats } = await getActiveAllowedChatsFromAppwrite(); + + // If filter is explicitly turned off, accept all messages + if (!filter_enabled) { + return { + allowed: true, + chatIdentifier: remoteJid, + resolvedName: groupSubject || pushName || remoteJid, + reason: 'El filtro de chats está desactivado (modo abierto).' + }; + } + + const isGroup = remoteJid.endsWith('@g.us'); + + // If it's a group and groupSubject wasn't provided, try resolving from Evolution API + if (isGroup && !groupSubject) { + try { + const resolved = await getWhatsAppGroupSubject(remoteJid); + if (resolved) { + groupSubject = resolved; + } + } catch (e) { + console.warn(`Could not resolve group subject for ${remoteJid}:`, e); + } + } + + // 1. Direct match by JID in Appwrite records + const matchByJid = activeAllowedChats.find( + c => c.jid && c.jid.trim().toLowerCase() === remoteJid.trim().toLowerCase() + ); + + if (matchByJid) { + return { + allowed: true, + matchedChat: matchByJid, + chatIdentifier: remoteJid, + resolvedName: groupSubject || matchByJid.name, + reason: `Coincidencia en Appwrite por JID: ${matchByJid.name}` + }; + } + + // 2. Match by Group Subject / Chat Name in Appwrite records + const candidateName = groupSubject || pushName || ''; + const normalizedCandidate = normalizeText(candidateName); + + if (normalizedCandidate) { + for (const chat of activeAllowedChats) { + const normalizedAllowedName = normalizeText(chat.name); + if (!normalizedAllowedName) continue; + + // Exact normalized match or full inclusion + if ( + normalizedCandidate === normalizedAllowedName || + normalizedCandidate.includes(normalizedAllowedName) || + normalizedAllowedName.includes(normalizedCandidate) + ) { + return { + allowed: true, + matchedChat: chat, + chatIdentifier: remoteJid, + resolvedName: candidateName, + reason: `Coincidencia en Appwrite por nombre: "${candidateName}" coincide con regla "${chat.name}"` + }; + } + } + } + + // No match found + return { + allowed: false, + chatIdentifier: remoteJid, + resolvedName: candidateName || remoteJid, + reason: `El chat "${candidateName || remoteJid}" no está registrado como autorizado en Appwrite.` + }; +} diff --git a/src/types/whatsapp.ts b/src/types/whatsapp.ts new file mode 100644 index 0000000..0b70d17 --- /dev/null +++ b/src/types/whatsapp.ts @@ -0,0 +1,36 @@ +/** + * @file whatsapp.ts + * @description Types and interfaces for WhatsApp chat whitelist and webhook management. + */ + +export interface WhatsAppAllowedChat { + id: string; // Unique identifier for config management + name: string; // Exact or friendly chat/group/client name + jid?: string; // WhatsApp JID (e.g. 120363419329355281@g.us or 573001234567@s.whatsapp.net) + enabled: boolean; // Whether processing is active for this chat + notes?: string; // Optional client description or metadata + createdAt?: string; +} + +export interface WhatsAppChatConfig { + description: string; + filter_enabled: boolean; + allowed_chats: WhatsAppAllowedChat[]; + last_updated?: string; +} + +export interface WhatsAppGroupInfo { + id: string; + subject: string; + owner?: string; + creation?: number; + participantsCount?: number; +} + +export interface WhatsAppFilterResult { + allowed: boolean; + matchedChat?: WhatsAppAllowedChat; + chatIdentifier: string; + resolvedName?: string; + reason: string; +} diff --git a/verify-remote-appwrite.js b/verify-remote-appwrite.js new file mode 100644 index 0000000..a7b76f8 --- /dev/null +++ b/verify-remote-appwrite.js @@ -0,0 +1,34 @@ +const { Client, Databases, Query } = require('node-appwrite'); +require('dotenv').config({ path: '.env.local' }); + +const client = new Client() + .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) + .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID) + .setKey(process.env.APPWRITE_API_KEY); + +const databases = new Databases(client); + +async function checkDatabase() { + console.log("Revisando los últimos posts creados..."); + try { + const response = await databases.listDocuments( + process.env.APPWRITE_DATABASE_ID, + 'posts', + [ + Query.orderDesc('$createdAt'), + Query.limit(2) + ] + ); + + for (const doc of response.documents) { + console.log(`[${doc.$createdAt}] Título: ${doc.titulo}`); + console.log(`Estado: ${doc.estado} | Tipo: ${doc.tipo}`); + console.log(`Slug Generado: ${doc.slug}`); + console.log("--------------"); + } + } catch (e) { + console.error("Error consultando la base de datos:", e.message); + } +} + +checkDatabase();