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.
|
* **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.
|
* **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 { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||||
import { processWhatsAppMessage } from '@/lib/gemini';
|
import { processWhatsAppMessage } from '@/lib/gemini';
|
||||||
import { sendWhatsAppMessage, getBase64FromMediaMessage } from '@/lib/evolution';
|
import { sendWhatsAppMessage, getBase64FromMediaMessage } from '@/lib/evolution';
|
||||||
|
import { isWhatsAppChatAllowed } from '@/lib/whatsapp-filter';
|
||||||
// Grupo de publicaciones (tomado del test) o puedes dejarlo abierto a todos los grupos
|
|
||||||
const TARGET_GROUP_JID = '573134848203-1408546044@g.us';
|
|
||||||
|
|
||||||
// Función para obtener el último día del mes actual
|
// Función para obtener el último día del mes actual
|
||||||
function getLastDayOfMonth(): string {
|
function getLastDayOfMonth(): string {
|
||||||
@ -38,6 +36,12 @@ export async function POST(req: Request) {
|
|||||||
const data = payload.data;
|
const data = payload.data;
|
||||||
const remoteJid = data?.key?.remoteJid;
|
const remoteJid = data?.key?.remoteJid;
|
||||||
const fromMe = data?.key?.fromMe;
|
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 messageData = data?.message || {};
|
||||||
const rawText = messageData.conversation || messageData.extendedTextMessage?.text || messageData.imageMessage?.caption || messageData.videoMessage?.caption || '';
|
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 });
|
return NextResponse.json({ success: true, message: 'Ignored bot self-message loop' }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrar estrictamente para que SOLO procese mensajes del grupo 'Publicaciones Almeira'
|
// Validar si el chat/grupo de WhatsApp está en la lista blanca de clientes autorizados en el repositorio
|
||||||
// (A petición del usuario se desactiva para procesar todos los chats y filtrar en la fase de aprobación)
|
const filterResult = await isWhatsAppChatAllowed({
|
||||||
// if (remoteJid !== TARGET_GROUP_JID) {
|
remoteJid,
|
||||||
// return NextResponse.json({ success: true, message: 'Ignored other chat' }, { status: 200 });
|
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)
|
// Extraer texto del mensaje (ya guardado en rawText, lo mantenemos como text para retrocompatibilidad interna)
|
||||||
const text = rawText;
|
const text = rawText;
|
||||||
|
|||||||
@ -4,11 +4,14 @@ import { getEntity, updateEntity } from '@/app/actions/entity';
|
|||||||
import { revalidatePath } from 'next/cache';
|
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 SettingsIcon, Save, Clock, Menu, Building, Image as ImageIcon, Link as LinkIcon, Share2 } from 'lucide-react';
|
||||||
import { Settings as SettingsInterface } from '@/types/settings';
|
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() {
|
export default async function SettingsPage() {
|
||||||
let settings: SettingsInterface = { ticker_count: 10, rotation_seconds: 5 };
|
let settings: SettingsInterface = { ticker_count: 10, rotation_seconds: 5 };
|
||||||
let docId = '';
|
let docId = '';
|
||||||
let entity: any = null;
|
let entity: any = null;
|
||||||
|
let whatsappConfig = await getWhatsAppConfigAction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionClient = await createSessionClient();
|
const sessionClient = await createSessionClient();
|
||||||
@ -29,6 +32,7 @@ export default async function SettingsPage() {
|
|||||||
console.error("No se pudo cargar config:", e);
|
console.error("No se pudo cargar config:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function saveSettingsServer(formData: FormData) {
|
async function saveSettingsServer(formData: FormData) {
|
||||||
'use server';
|
'use server';
|
||||||
const sessionClient = await createSessionClient();
|
const sessionClient = await createSessionClient();
|
||||||
@ -168,7 +172,11 @@ export default async function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Gestor y Filtro de Chats Autorizados de WhatsApp */}
|
||||||
|
<WhatsAppChatManager initialConfig={whatsappConfig} />
|
||||||
</div>
|
</div>
|
||||||
</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;
|
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