feat(whatsapp): integracion de whitelist y filtro de chats en Appwrite y settings
This commit is contained in:
parent
d905e50496
commit
987fe8aef6
@ -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`.
|
||||
|
||||
|
||||
|
||||
|
||||
27
read_webhook.js
Normal file
27
read_webhook.js
Normal file
@ -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();
|
||||
44
setup_webhook.js
Normal file
44
setup_webhook.js
Normal file
@ -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();
|
||||
95
setup_whatsapp_chats_collection.js
Normal file
95
setup_whatsapp_chats_collection.js
Normal file
@ -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();
|
||||
277
src/app/actions/whatsapp-config.ts
Normal file
277
src/app/actions/whatsapp-config.ts
Normal file
@ -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<WhatsAppChatConfig> {
|
||||
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<Array<{ id: string; subject: string }>> {
|
||||
try {
|
||||
return await fetchAllWhatsAppGroups();
|
||||
} catch (error) {
|
||||
console.error('Error fetching live groups:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Gestor y Filtro de Chats Autorizados de WhatsApp */}
|
||||
<WhatsAppChatManager initialConfig={whatsappConfig} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
501
src/components/dashboard/WhatsAppChatManager.tsx
Normal file
501
src/components/dashboard/WhatsAppChatManager.tsx
Normal file
@ -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<WhatsAppChatConfig>(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<Array<{ id: string; subject: string }>>([]);
|
||||
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 (
|
||||
<div className="bg-slate-900 border border-slate-800 p-8 rounded-2xl shadow-2xl space-y-8 animate-fade-in">
|
||||
{/* Header & Global Switch */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-800 pb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.15)]">
|
||||
<MessageSquare size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-xl font-bold uppercase tracking-wider text-white">
|
||||
Filtro de Chats WhatsApp (IA)
|
||||
</h2>
|
||||
<span className="flex items-center gap-1 text-[10px] uppercase font-black px-2.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-400 border border-cyan-500/30">
|
||||
<Database size={10} /> Appwrite DB
|
||||
</span>
|
||||
<span className={`text-[10px] uppercase font-black px-2.5 py-0.5 rounded-full border ${
|
||||
config.filter_enabled
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
|
||||
: 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||
}`}>
|
||||
{config.filter_enabled ? 'Whitelist Activa' : 'Abierto a todos'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Registra y gestiona en Appwrite qué chats o clientes generan publicaciones automáticas con IA
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Toggle Switch */}
|
||||
<div className="flex items-center gap-3 bg-slate-950 p-3 rounded-xl border border-slate-800">
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-bold text-white block">
|
||||
{config.filter_enabled ? 'Filtrado Estricto' : 'Procesar Todo'}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{config.filter_enabled ? 'Solo autorizados en Appwrite' : 'Sin restricciones'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleGlobalFilter}
|
||||
disabled={isPending}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
||||
config.filter_enabled ? 'bg-emerald-600' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
config.filter_enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Metrics & Actions Bar */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-slate-950/60 border border-slate-800/80 p-4 rounded-xl flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-black tracking-widest text-slate-500 block">Total en Appwrite</span>
|
||||
<span className="text-2xl font-black font-mono text-white">{totalCount}</span>
|
||||
</div>
|
||||
<Layers className="text-slate-600" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/60 border border-slate-800/80 p-4 rounded-xl flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-black tracking-widest text-slate-500 block">Autorizados (Activos)</span>
|
||||
<span className="text-2xl font-black font-mono text-emerald-400">{activeCount}</span>
|
||||
</div>
|
||||
<CheckCircle2 className="text-emerald-500/50" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/60 border border-slate-800/80 p-4 rounded-xl flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-black tracking-widest text-slate-500 block">Pausados / Inactivos</span>
|
||||
<span className="text-2xl font-black font-mono text-slate-400">{totalCount - activeCount}</span>
|
||||
</div>
|
||||
<XCircle className="text-slate-600" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid: Add Chat Form + Allowed Chats List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
|
||||
{/* Column 1: Add New Chat Form (5 cols) */}
|
||||
<div className="lg:col-span-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-slate-200 flex items-center gap-2">
|
||||
<Plus size={16} className="text-emerald-400" />
|
||||
Registrar Chat o Cliente
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchEvolutionGroups}
|
||||
disabled={isLoadingGroups}
|
||||
className="text-xs font-semibold text-emerald-400 hover:text-emerald-300 flex items-center gap-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 px-2.5 py-1 rounded-lg border border-emerald-500/20 transition-all"
|
||||
title="Detectar nombres de grupos conectados en Evolution API"
|
||||
>
|
||||
<RefreshCw size={12} className={isLoadingGroups ? 'animate-spin' : ''} />
|
||||
Detectar Grupos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Groups Picker Dropdown Modal */}
|
||||
{showGroupsPicker && (
|
||||
<div className="bg-slate-950 border border-emerald-500/30 rounded-xl p-4 space-y-3 animate-fade-in shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-emerald-400 flex items-center gap-1.5">
|
||||
<Sparkles size={14} /> Grupos Detectados en WhatsApp ({liveGroups.length})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowGroupsPicker(false)}
|
||||
className="text-slate-500 hover:text-white text-xs px-1.5 py-0.5"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{liveGroups.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 italic py-2">No se encontraron grupos activos en la instancia.</p>
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
|
||||
{liveGroups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectEvolutionGroup(group)}
|
||||
className="w-full text-left p-2 rounded-lg bg-slate-900 hover:bg-emerald-950/40 border border-slate-800 hover:border-emerald-500/40 transition-all text-xs flex flex-col"
|
||||
>
|
||||
<span className="font-bold text-slate-200">{group.subject}</span>
|
||||
<span className="text-[10px] font-mono text-slate-500">{group.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddChat} className="bg-slate-950 border border-slate-800 p-5 rounded-xl space-y-4">
|
||||
{formError && (
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/30 rounded-lg text-rose-400 text-xs flex items-center gap-2">
|
||||
<ShieldAlert size={16} />
|
||||
<span>{formError}</span>
|
||||
</div>
|
||||
)}
|
||||
{formSuccess && (
|
||||
<div className="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-400 text-xs flex items-center gap-2">
|
||||
<ShieldCheck size={16} />
|
||||
<span>{formSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-black tracking-widest text-slate-400 uppercase block mb-1.5">
|
||||
Nombre Exacto del Chat o Grupo *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500 mt-1 block">
|
||||
Se almacena en Appwrite y se compara con el título del grupo o contacto en WhatsApp.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-black tracking-widest text-slate-400 uppercase block mb-1.5">
|
||||
JID de WhatsApp (Opcional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={jid}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-black tracking-widest text-slate-400 uppercase block mb-1.5">
|
||||
Notas / Descripción del Cliente
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notes}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full flex items-center justify-center gap-2 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white font-bold uppercase tracking-wider text-xs py-3 px-4 rounded-xl shadow-lg shadow-emerald-900/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Guardar en Appwrite</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="bg-slate-950/40 border border-slate-800/60 p-4 rounded-xl flex items-start gap-2.5 text-[11px] text-slate-400">
|
||||
<Info size={16} className="text-emerald-400 shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Los chats registrados se guardan en la base de datos de <strong>Appwrite</strong> en tiempo real y se sincronizan como respaldo en <code className="text-slate-300 bg-slate-900 px-1 py-0.5 rounded font-mono">src/config/whatsapp-chats.json</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Whitelist Management (7 cols) */}
|
||||
<div className="lg:col-span-7 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-slate-200 flex items-center gap-2">
|
||||
Chats Registrados ({filteredChats.length})
|
||||
<button
|
||||
onClick={refreshFromAppwrite}
|
||||
title="Refrescar desde Appwrite"
|
||||
className="text-slate-500 hover:text-emerald-400 transition-colors p-1"
|
||||
>
|
||||
<RefreshCw size={13} className={isPending ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
{/* Search filter */}
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className="bg-slate-950 border border-slate-800/80 p-8 rounded-xl text-center space-y-2">
|
||||
<MessageSquare size={32} className="mx-auto text-slate-600" />
|
||||
<p className="text-sm font-bold text-slate-400">No se encontraron chats autorizados en Appwrite</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{searchQuery ? 'Prueba con otro término de búsqueda.' : 'Registra el primer chat usando el formulario a la izquierda.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[520px] overflow-y-auto pr-1 scrollbar-thin">
|
||||
{filteredChats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`p-4 rounded-xl border transition-all ${
|
||||
chat.enabled
|
||||
? 'bg-slate-950 border-slate-800 hover:border-emerald-500/30'
|
||||
: 'bg-slate-950/40 border-slate-800/50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-sm text-white truncate">
|
||||
{chat.name}
|
||||
</span>
|
||||
<span className={`text-[9px] uppercase font-black px-2 py-0.5 rounded-md ${
|
||||
chat.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||
: 'bg-slate-800 text-slate-400 border border-slate-700'
|
||||
}`}>
|
||||
{chat.enabled ? 'Activo en Appwrite' : 'Pausado'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{chat.jid && (
|
||||
<div className="text-[11px] font-mono text-slate-500 truncate select-all">
|
||||
JID: {chat.jid}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chat.notes && (
|
||||
<p className="text-xs text-slate-400">
|
||||
{chat.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions: Toggle & Delete */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Active toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleChat(chat.id, chat.enabled)}
|
||||
disabled={isPending}
|
||||
title={chat.enabled ? 'Pausar procesamiento para este chat en Appwrite' : 'Reanudar procesamiento'}
|
||||
className={`p-2 rounded-lg border transition-colors ${
|
||||
chat.enabled
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||
: 'bg-slate-900 border-slate-800 text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{chat.enabled ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteChat(chat.id, chat.name)}
|
||||
disabled={isPending}
|
||||
title="Eliminar de Appwrite"
|
||||
className="p-2 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/20 text-rose-400 hover:text-rose-300 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/config/whatsapp-chats.json
Normal file
23
src/config/whatsapp-chats.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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<string, { subject: string, expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/**
|
||||
* Fetch all groups from Evolution API
|
||||
*/
|
||||
export async function fetchAllWhatsAppGroups(): Promise<Array<{ id: string, subject: string }>> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
207
src/lib/whatsapp-filter.ts
Normal file
207
src/lib/whatsapp-filter.ts
Normal file
@ -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<WhatsAppFilterResult> {
|
||||
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.`
|
||||
};
|
||||
}
|
||||
36
src/types/whatsapp.ts
Normal file
36
src/types/whatsapp.ts
Normal file
@ -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;
|
||||
}
|
||||
34
verify-remote-appwrite.js
Normal file
34
verify-remote-appwrite.js
Normal file
@ -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();
|
||||
Loading…
Reference in New Issue
Block a user