feat(whatsapp): integracion de whitelist y filtro de chats en Appwrite y settings

This commit is contained in:
AI Bot 2026-08-24 12:06:22 -05:00
parent d905e50496
commit 987fe8aef6
13 changed files with 1350 additions and 8 deletions

View File

@ -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
View 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
View 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();

View 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();

View 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 [];
}
}

View File

@ -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;

View File

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

View 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>
);
}

View 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"
}
]
}

View File

@ -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
View 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
View 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
View 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();