creado planilla-agent
This commit is contained in:
25
agent/.gitignore
vendored
25
agent/.gitignore
vendored
@@ -1,25 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -1,28 +0,0 @@
|
||||
# Dockerfile con acceso a docker CLI
|
||||
FROM node:23-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 1) Instalar dependencias normales
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# 2) Instalar Docker CLI
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ca-certificates curl gnupg && \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg && \
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" \
|
||||
> /etc/apt/sources.list.d/docker.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y docker-ce-cli && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 3) Copiar el código fuente
|
||||
COPY . .
|
||||
|
||||
# 4) Puerto y arranque
|
||||
ENV PORT=4000
|
||||
EXPOSE 4000
|
||||
CMD ["node", "index.js"]
|
||||
@@ -1,16 +0,0 @@
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
/* ⚙️ Variables de entorno */
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
|
||||
export const config = {
|
||||
VERSION : '0.6.12',
|
||||
API_URL : process.env.BOT_API_URL ?? 'http://whatsapp-bot:8002',
|
||||
GROUP_ID : process.env.GROUP_ID ?? '120363203056794284@g.us',
|
||||
REPLY_MSG : process.env.REPLY_MSG ?? 'que pedos',
|
||||
PORT : +(process.env.PORT ?? 4000),
|
||||
LOG_LEVEL : process.env.LOG_LEVEL ?? 'debug',
|
||||
RETRY_MS : +(process.env.RETRY_MS ?? 5_000),
|
||||
MAX_ATTEMPTS : +(process.env.MAX_ATTEMPTS ?? 60),
|
||||
GEMINI_KEY : process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? '',
|
||||
GEMINI_MODEL_ID: process.env.GEMINI_MODEL_ID ?? 'gemini-2.5-flash-preview-04-17',
|
||||
};
|
||||
@@ -1,195 +0,0 @@
|
||||
{
|
||||
"id": "true_50496210031@c.us_3F4F5E90B644956AC938",
|
||||
"viewed": false,
|
||||
"body": "amor",
|
||||
"type": "chat",
|
||||
"t": 1745718602,
|
||||
"notifyName": "📀🧮📡🌐",
|
||||
"from": "50498554225@c.us",
|
||||
"to": "50496210031@c.us",
|
||||
"author": "50498554225:77@c.us",
|
||||
"invis": false,
|
||||
"isNewMsg": true,
|
||||
"star": false,
|
||||
"kicNotified": false,
|
||||
"recvFresh": true,
|
||||
"isFromTemplate": false,
|
||||
"thumbnail": "",
|
||||
"pollInvalidated": false,
|
||||
"isSentCagPollCreation": false,
|
||||
"latestEditMsgKey": null,
|
||||
"latestEditSenderTimestampMs": null,
|
||||
"mentionedJidList": [],
|
||||
"groupMentions": [],
|
||||
"isEventCanceled": false,
|
||||
"eventInvalidated": false,
|
||||
"isVcardOverMmsDocument": false,
|
||||
"labels": [],
|
||||
"hasReaction": false,
|
||||
"ephemeralDuration": 0,
|
||||
"ephemeralSettingTimestamp": 0,
|
||||
"disappearingModeInitiator": "chat",
|
||||
"disappearingModeTrigger": "chat_settings",
|
||||
"viewMode": "VISIBLE",
|
||||
"productHeaderImageRejected": false,
|
||||
"lastPlaybackProgress": 0,
|
||||
"isDynamicReplyButtonsMsg": false,
|
||||
"isCarouselCard": false,
|
||||
"parentMsgId": null,
|
||||
"callSilenceReason": null,
|
||||
"isVideoCall": false,
|
||||
"callDuration": null,
|
||||
"callParticipants": null,
|
||||
"isMdHistoryMsg": false,
|
||||
"stickerSentTs": 0,
|
||||
"isAvatar": false,
|
||||
"lastUpdateFromServerTs": 0,
|
||||
"invokedBotWid": null,
|
||||
"bizBotType": null,
|
||||
"botResponseTargetId": null,
|
||||
"botPluginType": null,
|
||||
"botPluginReferenceIndex": null,
|
||||
"botPluginSearchProvider": null,
|
||||
"botPluginSearchUrl": null,
|
||||
"botPluginSearchQuery": null,
|
||||
"botPluginMaybeParent": false,
|
||||
"botReelPluginThumbnailCdnUrl": null,
|
||||
"botMessageDisclaimerText": null,
|
||||
"botMsgBodyType": null,
|
||||
"reportingTokenInfo": null,
|
||||
"requiresDirectConnection": false,
|
||||
"bizContentPlaceholderType": null,
|
||||
"hostedBizEncStateMismatch": false,
|
||||
"senderOrRecipientAccountTypeHosted": false,
|
||||
"placeholderCreatedWhenAccountIsHosted": false,
|
||||
"device": 77,
|
||||
"local": false,
|
||||
"fromMe": true,
|
||||
"mId": "3F4F5E90B644956AC938",
|
||||
"sender": {
|
||||
"id": "50498554225@c.us",
|
||||
"name": "📀🧮📡🌐jose",
|
||||
"shortName": "📀🧮📡🌐jose",
|
||||
"pushname": "📀🧮📡🌐",
|
||||
"type": "in",
|
||||
"isBusiness": false,
|
||||
"isEnterprise": false,
|
||||
"isSmb": false,
|
||||
"isContactSyncCompleted": 1,
|
||||
"disappearingModeDuration": 0,
|
||||
"disappearingModeSettingTimestamp": 1700599275,
|
||||
"textStatusLastUpdateTime": -1,
|
||||
"syncToAddressbook": true,
|
||||
"formattedName": "Tú",
|
||||
"isMe": true,
|
||||
"isMyContact": true,
|
||||
"isPSA": false,
|
||||
"isUser": true,
|
||||
"status": "Can't talk, WhatsApp only",
|
||||
"isVerified": false,
|
||||
"isWAContact": true,
|
||||
"profilePicThumbObj": {
|
||||
"eurl": "https://pps.whatsapp.net/v/t61.24694-24/471428085_1635189164083925_3546014480456031647_n.jpg?ccb=11-4&oh=01_Q5Aa1QEqY8vxL1FGF3t2s1OQw0t3tPQ8cS66RZvtzTy6nE1VWQ&oe=6817A633&_nc_sid=5e03e0&_nc_cat=106",
|
||||
"id": "50498554225@c.us",
|
||||
"img": "https://media-mia3-1.cdn.whatsapp.net/v/t61.24694-24/471428085_1635189164083925_3546014480456031647_n.jpg?stp=dst-jpg_s96x96_tt6&ccb=11-4&oh=01_Q5Aa1QELed0umu8TOLHLhNq8lHmkZ2srD3fu-IK3spzsNxkLug&oe=6817A633&_nc_sid=5e03e0&_nc_cat=106",
|
||||
"imgFull": "https://media-mia3-1.cdn.whatsapp.net/v/t61.24694-24/471428085_1635189164083925_3546014480456031647_n.jpg?ccb=11-4&oh=01_Q5Aa1QEqY8vxL1FGF3t2s1OQw0t3tPQ8cS66RZvtzTy6nE1VWQ&oe=6817A633&_nc_sid=5e03e0&_nc_cat=106",
|
||||
"tag": "1735668406"
|
||||
},
|
||||
"msgs": null
|
||||
},
|
||||
"senderId": null,
|
||||
"timestamp": 1745718602,
|
||||
"content": "amor",
|
||||
"isGroupMsg": false,
|
||||
"isQuotedMsgAvailable": true,
|
||||
"isMedia": false,
|
||||
"chat": {
|
||||
"id": "50496210031@c.us",
|
||||
"pendingMsgs": false,
|
||||
"lastReceivedKey": {
|
||||
"fromMe": true,
|
||||
"remote": "50496210031@c.us",
|
||||
"id": "3F009E582691FEE944F0",
|
||||
"_serialized": "true_50496210031@c.us_3F009E582691FEE944F0"
|
||||
},
|
||||
"t": 1745717805,
|
||||
"unreadCount": 0,
|
||||
"unreadDividerOffset": 0,
|
||||
"archive": false,
|
||||
"isReadOnly": false,
|
||||
"isLocked": false,
|
||||
"muteExpiration": 0,
|
||||
"isAutoMuted": false,
|
||||
"name": "Margie (:",
|
||||
"notSpam": true,
|
||||
"pin": 1695127572481,
|
||||
"ephemeralDuration": 0,
|
||||
"ephemeralSettingTimestamp": 0,
|
||||
"disappearingModeInitiator": "chat",
|
||||
"disappearingModeTrigger": "chat_settings",
|
||||
"createdLocally": false,
|
||||
"unreadMentionsOfMe": [],
|
||||
"unreadMentionCount": 0,
|
||||
"hasUnreadMention": false,
|
||||
"archiveAtMentionViewedInDrawer": false,
|
||||
"hasChatBeenOpened": false,
|
||||
"tcToken": {},
|
||||
"tcTokenTimestamp": 1745616969,
|
||||
"tcTokenSenderTimestamp": 1744944499,
|
||||
"endOfHistoryTransferType": 0,
|
||||
"pendingInitialLoading": false,
|
||||
"chatlistPreview": {
|
||||
"type": "reaction",
|
||||
"msgKey": "false_50496210031@c.us_3FEA807956686BD2AD73",
|
||||
"parentMsgKey": "true_50496210031@c.us_3F93865C2A9E8061A668",
|
||||
"reactionText": "🙏",
|
||||
"sender": "50496210031@c.us",
|
||||
"timestamp": 1745717107750
|
||||
},
|
||||
"unreadEditTimestampMs": 1745508257945,
|
||||
"celebrationAnimationLastPlayed": 0,
|
||||
"hasRequestedWelcomeMsg": false,
|
||||
"msgs": null,
|
||||
"canSend": true,
|
||||
"isGroup": false,
|
||||
"pic": "https://pps.whatsapp.net/v/t61.24694-24/470810943_1065895391975207_6852834404866940192_n.jpg?ccb=11-4&oh=01_Q5Aa1QEWA1-AVsmMc5-23KYTOSB9RsYUB41vONjdzNZCen_qGw&oe=6817C3B1&_nc_sid=5e03e0&_nc_cat=109",
|
||||
"formattedTitle": "Margie (:",
|
||||
"contact": {
|
||||
"id": "50496210031@c.us",
|
||||
"name": "Margie (:",
|
||||
"shortName": "Margie",
|
||||
"pushname": "Margie Elizabeth:)",
|
||||
"type": "in",
|
||||
"isBusiness": false,
|
||||
"isEnterprise": false,
|
||||
"isSmb": false,
|
||||
"isContactSyncCompleted": 1,
|
||||
"disappearingModeDuration": 0,
|
||||
"disappearingModeSettingTimestamp": 1671409557,
|
||||
"textStatusLastUpdateTime": -1,
|
||||
"syncToAddressbook": true,
|
||||
"formattedName": "Margie (:",
|
||||
"isMe": false,
|
||||
"isMyContact": true,
|
||||
"isPSA": false,
|
||||
"isUser": true,
|
||||
"isVerified": false,
|
||||
"isWAContact": true,
|
||||
"profilePicThumbObj": {
|
||||
"eurl": "https://pps.whatsapp.net/v/t61.24694-24/470810943_1065895391975207_6852834404866940192_n.jpg?ccb=11-4&oh=01_Q5Aa1QEWA1-AVsmMc5-23KYTOSB9RsYUB41vONjdzNZCen_qGw&oe=6817C3B1&_nc_sid=5e03e0&_nc_cat=109"
|
||||
},
|
||||
"msgs": null
|
||||
},
|
||||
"groupMetadata": null,
|
||||
"presence": {
|
||||
"id": "50496210031@c.us",
|
||||
"chatstates": []
|
||||
},
|
||||
"isOnline": false,
|
||||
"participantsCount": 1
|
||||
},
|
||||
"isOnline": false,
|
||||
"chatId": "50496210031@c.us",
|
||||
"mediaData": {},
|
||||
"text": "amor"
|
||||
}
|
||||
120
agent/gemini.js
120
agent/gemini.js
@@ -1,120 +0,0 @@
|
||||
// gemini.js – integración Grounding con Google Search + logs de verificación
|
||||
import genai from '@google/genai';
|
||||
import { config } from './config.js';
|
||||
import { log } from './logger.js';
|
||||
|
||||
const { GoogleGenAI, createUserContent, createPartFromUri } = genai;
|
||||
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
/* 📜 System prompt */
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
const SYSTEM_PROMPT = `
|
||||
#########
|
||||
Tu nombre es nucleo
|
||||
##########
|
||||
RESPUESTAS CORTAS y CONCISAS
|
||||
sos la inteligencia artificial de Servidores de G.O.D (nuestro J.A.R.V.I.S.).
|
||||
tenés derecho a sostener opiniones firmes con lógica, sin corrección política excesiva.
|
||||
Respuestas casuales breves; si te extendés, hacelo en un solo mensaje.
|
||||
Podés continuar conversaciones, usar chistes, analogías, etc.
|
||||
`.trim();
|
||||
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
/* 🧠 Inicializar Gemini */
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
let ai = null;
|
||||
function initGemini() {
|
||||
if (!config.GEMINI_KEY) throw new Error('🔑 GEMINI_API_KEY no configurada');
|
||||
if (!ai) {
|
||||
ai = new GoogleGenAI({ apiKey: config.GEMINI_KEY });
|
||||
log('info', `🧠 Gemini SDK inicializado (${config.GEMINI_MODEL_ID})`);
|
||||
}
|
||||
return ai;
|
||||
}
|
||||
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
/* 🔍 Construir tools de búsqueda */
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
function buildSearchTools() {
|
||||
const model = config.GEMINI_MODEL_ID;
|
||||
// Los objetos literales cumplen con el esquema Tool del SDK.
|
||||
if (/^gemini-2\./.test(model) || /^gemini-2\.5/.test(model)) {
|
||||
return [{ google_search: {} }]; // Search‑as‑a‑tool
|
||||
}
|
||||
if (/^gemini-1\.5/.test(model)) {
|
||||
return [{
|
||||
google_search_retrieval: {
|
||||
dynamic_retrieval_config: {
|
||||
mode: 'MODE_DYNAMIC',
|
||||
dynamic_threshold: 0.3,
|
||||
},
|
||||
},
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
/* 🚀 askGemini */
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
export async function askGemini(historial, files = {}) {
|
||||
try {
|
||||
const client = initGemini();
|
||||
|
||||
// 1️⃣ Construir "contents"
|
||||
let contents;
|
||||
if (typeof historial === 'string') {
|
||||
contents = historial;
|
||||
} else if (Array.isArray(historial)) {
|
||||
const parts = [];
|
||||
for (const m of historial) {
|
||||
if (m.type === 'document') continue;
|
||||
if (m.type === 'chat') {
|
||||
parts.push(`${m.senderName}: ${m.text} -- ${m.date}`);
|
||||
continue;
|
||||
}
|
||||
const up = files[m.msgId?.toLowerCase?.()];
|
||||
if (up?.uri) {
|
||||
parts.push(
|
||||
createPartFromUri(up.uri, up.mimeType),
|
||||
`archivo ${m.type} de ${m.senderName}: ${m.caption || m.text} -- ${m.date}`
|
||||
);
|
||||
}
|
||||
}
|
||||
contents = createUserContent(parts);
|
||||
} else {
|
||||
throw new Error('Formato de historial no soportado');
|
||||
}
|
||||
|
||||
// 2️⃣ Herramientas
|
||||
const tools = buildSearchTools();
|
||||
|
||||
// 3️⃣ Llamar al modelo
|
||||
const response = await client.models.generateContent({
|
||||
model: config.GEMINI_MODEL_ID,
|
||||
contents,
|
||||
config: {
|
||||
systemInstruction: SYSTEM_PROMPT,
|
||||
maxOutputTokens: 4096,
|
||||
temperature: 0.3, // menor → mayor factualidad
|
||||
tools,
|
||||
response_modalities: ['TEXT'],
|
||||
},
|
||||
});
|
||||
|
||||
// 4️⃣ Log de grounding
|
||||
const candidate = response?.candidates?.[0];
|
||||
if (candidate?.groundingMetadata) {
|
||||
log('info', '🔗 GroundingMetadata presente:', JSON.stringify(candidate.groundingMetadata.webSearchQueries));
|
||||
} else {
|
||||
log('warn', 'ℹ️ Sin groundingMetadata en la respuesta');
|
||||
}
|
||||
|
||||
if (!candidate) return '⚠️ Sin candidato.';
|
||||
return candidate.content.parts.map(p => p.text).join('').trim() || '⚠️ Respuesta vacía.';
|
||||
} catch (e) {
|
||||
log('error', 'Gemini falló:', e.message);
|
||||
if (e.response?.status === 429) return '🚦 Límite alcanzado. Probá más tarde.';
|
||||
return '⚠️ No se pudo obtener respuesta de Gemini.';
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// handlers.js
|
||||
import { respuestaMCP } from './respuestas/respuestaMCP.js'; // <- NUEVA IMPORTACIÓN
|
||||
import fs from 'fs/promises';
|
||||
import { log } from './logger.js';
|
||||
// Ya no se necesitan: sendText, fetchChatMessages, setTypingStatus, askGemini aquí
|
||||
import { processMessage } from './utils/processMessage.js';
|
||||
import { saveMedia } from './utils/saveMedia.js';
|
||||
// import { respuestaNormal } from './respuestas/respuestaNormal.js'; // <- NUEVA IMPORTACIÓN
|
||||
import { respuestaBrave } from './respuestas/respuestaBrave.js'; // <- NUEVA IMPORTACIÓN
|
||||
import { sendText } from './whatsapp.js'; // <- NUEVA IMPORTACIÓN
|
||||
|
||||
// Mock Data for Employees
|
||||
const mockEmployees = [
|
||||
{
|
||||
id: '1', // Ensure ID is string if components expect string
|
||||
name: 'Ana García Mock',
|
||||
cedula: 123456789, // Ensure cedula is number
|
||||
avatar_url: 'https://randomuser.me/api/portraits/women/60.jpg',
|
||||
telefono: '0991234567',
|
||||
ubicacion: 'Oficina Mock Central',
|
||||
idciat: 'AG001M',
|
||||
grupo_estudio: 'Desarrollo Frontend Mock',
|
||||
empleado: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Carlos Rodriguez Mock',
|
||||
cedula: 987654321,
|
||||
avatar_url: 'https://randomuser.me/api/portraits/men/45.jpg',
|
||||
telefono: '0987654321',
|
||||
ubicacion: 'Sucursal Mock Norte',
|
||||
idciat: 'CR002M',
|
||||
grupo_estudio: 'Backend Services Mock',
|
||||
empleado: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Luisa Martinez Mock',
|
||||
cedula: 112233445,
|
||||
avatar_url: 'https://randomuser.me/api/portraits/women/61.jpg',
|
||||
telefono: '0976543210',
|
||||
ubicacion: 'Remoto Mock',
|
||||
idciat: 'LM003M',
|
||||
grupo_estudio: 'QA Mock',
|
||||
empleado: true,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Jorge Herrera Mock',
|
||||
cedula: 223344556,
|
||||
avatar_url: 'https://randomuser.me/api/portraits/men/50.jpg',
|
||||
telefono: '0965432109',
|
||||
ubicacion: 'Oficina Mock Sur',
|
||||
idciat: 'JH004M',
|
||||
grupo_estudio: 'DevOps Mock',
|
||||
empleado: true,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Patricia Fernández Mock',
|
||||
cedula: 334455667,
|
||||
avatar_url: 'https://randomuser.me/api/portraits/women/62.jpg',
|
||||
telefono: '0954321098',
|
||||
ubicacion: 'Oficina Mock Central',
|
||||
idciat: 'PF005M',
|
||||
grupo_estudio: 'Diseño UX/UI Mock',
|
||||
empleado: true,
|
||||
}
|
||||
];
|
||||
|
||||
/* carpeta raíz donde saveMedia deja todo */
|
||||
const MEDIA_DIR = '/media';
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
|
||||
// La función cleanForGemini se movió a respuestaNormal.js
|
||||
|
||||
export async function processIncoming(raw) {
|
||||
// Guarda media si no es un mensaje de texto plano
|
||||
if (raw.type !== 'chat') {
|
||||
try {
|
||||
// Nota: saveMedia podría necesitar acceso a MEDIA_DIR si no está codificado internamente
|
||||
await saveMedia(raw /*, MEDIA_DIR */); // Podrías necesitar pasar MEDIA_DIR si saveMedia lo requiere
|
||||
} catch (error) {
|
||||
log('error', 'Error guardando media:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const msg = processMessage(raw);
|
||||
const text = msg.text || '';
|
||||
|
||||
// Logica para componentes UI de Empleados
|
||||
if (/^Quiero crear un nuevo @empleado/i.test(text)) {
|
||||
log('info', `Comando recibido: Crear nuevo empleado. Enviando componente EmpleadoForm.`);
|
||||
sendText(msg.chatId, 'CHAT_UI_COMPONENT::EmpleadoForm');
|
||||
return; // Termina el procesamiento para este comando
|
||||
}
|
||||
|
||||
const verEmpleadoMatch = text.match(/^Ver @empleado(\d+)/i);
|
||||
if (verEmpleadoMatch && verEmpleadoMatch[1]) {
|
||||
const cedula = parseInt(verEmpleadoMatch[1], 10);
|
||||
log('info', `Comando recibido: Ver empleado con cédula ${cedula}.`);
|
||||
const employee = mockEmployees.find(emp => emp.cedula === cedula);
|
||||
if (employee) {
|
||||
log('info', `Empleado encontrado: ${employee.name}. Enviando componente cardEmpleado.`);
|
||||
// La cédula se pasa como parámetro para que el frontend la use si es necesario para buscar o mostrar.
|
||||
sendText(msg.chatId, `CHAT_UI_COMPONENT::cardEmpleado::${cedula}`);
|
||||
} else {
|
||||
log('warn', `Empleado con cédula ${cedula} no encontrado.`);
|
||||
sendText(msg.chatId, `No se encontró un empleado con la cédula ${cedula}.`);
|
||||
}
|
||||
return; // Termina el procesamiento para este comando
|
||||
}
|
||||
|
||||
const mostrarEmpleadosMatch = text.match(/^Mostrame los primeros (\d+) @empleados/i);
|
||||
if (mostrarEmpleadosMatch && mostrarEmpleadosMatch[1]) {
|
||||
const count = parseInt(mostrarEmpleadosMatch[1], 10);
|
||||
log('info', `Comando recibido: Mostrar los primeros ${count} empleados.`);
|
||||
// El count se pasa como parámetro para que el frontend lo use para determinar cuántos mostrar.
|
||||
// La lógica de obtener los X primeros empleados realmente estará en el frontend o en una API.
|
||||
// Aquí solo indicamos el componente y el count deseado.
|
||||
sendText(msg.chatId, `CHAT_UI_COMPONENT::tablaEmpleados::${count}`);
|
||||
return; // Termina el procesamiento para este comando
|
||||
}
|
||||
|
||||
/* ----- comando @nucleo ----- */
|
||||
// Se comenta la condicion original de @nucleo para evitar doble respuesta si no se hace return antes.
|
||||
// if (/^@nucleo(\s|$)/i.test(text)) {
|
||||
// // Llama a la función importada
|
||||
// // await respuestaNormal(msg); // Ya no se usa respuestaNormal aquí directamente.
|
||||
// await respuestaMCP(msg); // respuestaMCP ya no es relevante en este flujo si @nucleo siempre va a brave.
|
||||
// }
|
||||
|
||||
if (/^@nucleo(\s|$)/i.test(text)) { // Modificado para que @nucleo solo dispare respuestaBrave
|
||||
log('info', '🧠 Generando respuesta para @nucleo...');
|
||||
const respuestaObjMCP = await respuestaBrave(msg);
|
||||
log('info', 'Respuesta de @nucleo (Brave):', respuestaObjMCP);
|
||||
sendText(msg.chatId, respuestaObjMCP);
|
||||
} else {
|
||||
// Lógica para otros mensajes si no son comandos de UI ni @nucleo
|
||||
log('debug', 'Mensaje no reconocido como comando UI o @nucleo:', text);
|
||||
// Considerar si se debe enviar una respuesta por defecto o ninguna si no coincide con nada.
|
||||
// Por ahora, no se envía nada si no es un comando específico.
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/* nucleo-bot ― index.js */
|
||||
import express from 'express';
|
||||
import morgan from 'morgan';
|
||||
import { config } from './config.js';
|
||||
import { log } from './logger.js';
|
||||
import { router } from './routes.js';
|
||||
import {
|
||||
waitForGateway,
|
||||
clearWebhooks,
|
||||
registerWebhook
|
||||
} from './whatsapp.js';
|
||||
|
||||
|
||||
|
||||
export let globalMemory = {};
|
||||
|
||||
/* 🌐 Express app */
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(morgan('[:date[iso]] :method :url :status - :response-time ms'));
|
||||
app.use(router);
|
||||
|
||||
/* 🚀 Bootstrap */
|
||||
async function bootstrap() {
|
||||
await waitForGateway();
|
||||
await clearWebhooks();
|
||||
await registerWebhook();
|
||||
}
|
||||
|
||||
/* 🏁 Arranque */
|
||||
app.listen(config.PORT, () => {
|
||||
log('info', `🪪 Versión del bot: ${config.VERSION}`);
|
||||
log('info', `🚀 nucleo-bot escuchando en :${config.PORT}`);
|
||||
bootstrap().catch(e => log('error', 'Error en bootstrap:', e.message));
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
/* 🖨️ Logger */
|
||||
/*───────────────────────────────────────────────────────────────*/
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
export function log(level, ...args) {
|
||||
const levels = ['debug', 'info', 'warn', 'error'];
|
||||
if (levels.indexOf(level) >= levels.indexOf(config.LOG_LEVEL)) {
|
||||
console[level === 'debug' ? 'log' : level](
|
||||
`[${dayjs().utc().format()}]`, level.toUpperCase(), ...args
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "nucleo-bot",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start:mcp": "npx @philschmid/weather-mcp"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.8.4",
|
||||
"dayjs": "^1.11.11",
|
||||
"express": "^4.19.2",
|
||||
"morgan": "^1.10.0",
|
||||
"@google/generative-ai": "^0.4.0",
|
||||
"@google/genai": "^0.9.0",
|
||||
"@open-wa/wa-automate": "^4.34.3",
|
||||
"mime-types": "^2.1.35",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@philschmid/weather-mcp": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// /respuesta/respuestaBrave.js
|
||||
import fs from 'fs/promises';
|
||||
import { log } from '../logger.js';
|
||||
import {
|
||||
sendText,
|
||||
fetchChatMessages,
|
||||
setTypingStatus
|
||||
} from '../whatsapp.js';
|
||||
import { processMessage } from '../utils/processMessage.js';
|
||||
import { saveMedia } from '../utils/saveMedia.js';
|
||||
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
function sanitizeSchema(obj) {
|
||||
if (Array.isArray(obj)) return obj.map(sanitizeSchema);
|
||||
if (obj && typeof obj === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === 'additionalProperties' || k === '$schema') continue;
|
||||
out[k] = sanitizeSchema(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
const cleanForGemini = ({ media, reactions, preview, ...rest }) => rest;
|
||||
|
||||
export async function respuestaBrave(msg) {
|
||||
const text = msg.text || '';
|
||||
|
||||
setTypingStatus(msg.chatId, true);
|
||||
log('info', '🧠 Generando respuesta para @nucleo (Brave)…');
|
||||
|
||||
try {
|
||||
const allraw = await fetchChatMessages(msg.chatId);
|
||||
const allMsgs = allraw.map(processMessage);
|
||||
|
||||
const context = allMsgs.map(cleanForGemini);
|
||||
let json = JSON.stringify(context);
|
||||
while (json.length > 100_000 && context.length) {
|
||||
context.shift();
|
||||
json = JSON.stringify(context);
|
||||
}
|
||||
|
||||
const chatName = context.length > 0 ? context[0]?.chatName : 'chat';
|
||||
const prompt = [
|
||||
`Eres un asistente con acceso a Brave Search mediante herramientas externas.`,
|
||||
`Pregunta del usuario: ${text}`,
|
||||
...context
|
||||
];
|
||||
|
||||
const medias = allraw.filter(m => m.type !== 'chat');
|
||||
const settled = await Promise.allSettled(medias.map(saveMedia));
|
||||
|
||||
const MAX = 20 * 1024 * 1024;
|
||||
let total = 0;
|
||||
const files = {};
|
||||
|
||||
for (const r of settled) {
|
||||
if (r.status !== 'fulfilled' || !r.value) continue;
|
||||
const entries = Object.entries(r.value);
|
||||
if (entries.length === 0) continue;
|
||||
const [msgId, obj] = entries[0];
|
||||
let size = Number(obj.sizeBytes || 0);
|
||||
if (!size && obj.filePath) {
|
||||
try { size = (await fs.stat(obj.filePath)).size; } catch { size = 0; }
|
||||
}
|
||||
if (total + size > MAX && total > 0) break;
|
||||
if (size > MAX) continue;
|
||||
|
||||
total += size;
|
||||
files[msgId] = { uri: obj.uri || obj.filePath, mimeType: obj.mimeType };
|
||||
}
|
||||
|
||||
// === MCP Brave Search ===
|
||||
const client = new Client({ name: 'brave-agent', version: '1.0.0' });
|
||||
const serverParams = new StdioClientTransport({
|
||||
command: 'uvx',
|
||||
args: ['mcp-server-fetch']
|
||||
});
|
||||
|
||||
|
||||
await client.connect(serverParams);
|
||||
|
||||
const mcp = await client.listTools();
|
||||
const tools = mcp.tools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: sanitizeSchema({
|
||||
type: 'object',
|
||||
...t.inputSchema
|
||||
})
|
||||
}));
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash-preview-04-17',
|
||||
contents: prompt,
|
||||
config: {
|
||||
tools: [{ functionDeclarations: tools }],
|
||||
temperature: 0
|
||||
}
|
||||
});
|
||||
|
||||
let respuesta;
|
||||
if (response.functionCalls && response.functionCalls.length > 0) {
|
||||
const call = response.functionCalls[0];
|
||||
const result = await client.callTool({ name: call.name, arguments: call.args });
|
||||
await client.close();
|
||||
respuesta = result.content?.[0]?.text ?? JSON.stringify(result);
|
||||
} else {
|
||||
await client.close();
|
||||
respuesta = response.text;
|
||||
}
|
||||
|
||||
await sendText(msg.chatId, respuesta);
|
||||
log('info', '🧠 Respuesta enviada');
|
||||
|
||||
} catch (error) {
|
||||
log('error', 'Error en respuestaBrave:', error);
|
||||
await sendText(msg.chatId, 'Hubo un error al procesar tu solicitud.');
|
||||
} finally {
|
||||
setTypingStatus(msg.chatId, false);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
// /respuesta/respuestaMCP.js
|
||||
import fs from 'fs/promises';
|
||||
import { log } from '../logger.js';
|
||||
import {
|
||||
sendText,
|
||||
fetchChatMessages,
|
||||
setTypingStatus
|
||||
} from '../whatsapp.js';
|
||||
import { processMessage } from '../utils/processMessage.js';
|
||||
import { saveMedia } from '../utils/saveMedia.js';
|
||||
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
/* limpia el esquema JSON recursivamente */
|
||||
function sanitizeSchema(obj) {
|
||||
if (Array.isArray(obj)) return obj.map(sanitizeSchema);
|
||||
if (obj && typeof obj === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === 'additionalProperties' || k === '$schema') continue;
|
||||
out[k] = sanitizeSchema(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
const cleanForGemini = ({ media, reactions, preview, ...rest }) => rest;
|
||||
|
||||
export async function respuestaMCP(msg) {
|
||||
const text = msg.text || '';
|
||||
|
||||
setTypingStatus(msg.chatId, true);
|
||||
log('info', '🧠 Generando respuesta para @nucleo…');
|
||||
|
||||
try {
|
||||
const allraw = await fetchChatMessages(msg.chatId);
|
||||
const allMsgs = allraw.map(processMessage);
|
||||
|
||||
const context = allMsgs.map(cleanForGemini);
|
||||
let json = JSON.stringify(context);
|
||||
while (json.length > 100_000 && context.length) {
|
||||
context.shift();
|
||||
json = JSON.stringify(context);
|
||||
}
|
||||
|
||||
const chatName = context.length > 0 ? context[0]?.chatName : 'chat';
|
||||
const prompt = [
|
||||
`Eres el asistente del grupo "${chatName}". tenes la capacidad de interactuar con las carpetas ubicacadas en el directorio "/media/mcp" y sus subcarpetas.`,
|
||||
`Pregunta del usuario: ${text}`,
|
||||
...context
|
||||
];
|
||||
|
||||
const medias = allraw.filter(m => m.type !== 'chat');
|
||||
const settled = await Promise.allSettled(medias.map(saveMedia));
|
||||
|
||||
const MAX = 20 * 1024 * 1024;
|
||||
let total = 0;
|
||||
const files = {};
|
||||
|
||||
for (const r of settled) {
|
||||
if (r.status !== 'fulfilled' || !r.value) continue;
|
||||
const entries = Object.entries(r.value);
|
||||
if (entries.length === 0) continue;
|
||||
const [msgId, obj] = entries[0];
|
||||
let size = Number(obj.sizeBytes || 0);
|
||||
if (!size && obj.filePath) {
|
||||
try { size = (await fs.stat(obj.filePath)).size; } catch { size = 0; }
|
||||
}
|
||||
if (total + size > MAX && total > 0) break;
|
||||
if (size > MAX) continue;
|
||||
|
||||
total += size;
|
||||
files[msgId] = { uri: obj.uri || obj.filePath, mimeType: obj.mimeType };
|
||||
}
|
||||
|
||||
// === MCP setup ===
|
||||
const client = new Client({ name: 'mcp-agent', version: '1.0.0' });
|
||||
const serverParams = new StdioClientTransport({
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-filesystem', '/media/mcp']
|
||||
});
|
||||
await client.connect(serverParams);
|
||||
|
||||
const mcp = await client.listTools();
|
||||
const tools = mcp.tools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: sanitizeSchema({
|
||||
type: 'object',
|
||||
...t.inputSchema
|
||||
})
|
||||
}));
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash-preview-04-17',
|
||||
contents: prompt,
|
||||
config: {
|
||||
tools: [{ googleSearch: {} }],
|
||||
temperature: 0
|
||||
}
|
||||
});
|
||||
|
||||
let respuesta;
|
||||
if (response.functionCalls && response.functionCalls.length > 0) {
|
||||
const call = response.functionCalls[0];
|
||||
const result = await client.callTool({ name: call.name, arguments: call.args });
|
||||
await client.close();
|
||||
respuesta = result.content?.[0]?.text ?? JSON.stringify(result);
|
||||
} else {
|
||||
await client.close();
|
||||
respuesta = response.text;
|
||||
}
|
||||
|
||||
await sendText(msg.chatId, respuesta);
|
||||
log('info', '🧠 Respuesta enviada');
|
||||
|
||||
} catch (error) {
|
||||
log('error', 'Error en respuestaMCP:', error);
|
||||
await sendText(msg.chatId, 'Hubo un error al procesar tu solicitud.');
|
||||
} finally {
|
||||
setTypingStatus(msg.chatId, false);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// /respuesta/respuestaNormal.js
|
||||
import fs from 'fs/promises';
|
||||
import { log } from '../logger.js'; // <- Nota el '../'
|
||||
import {
|
||||
sendText,
|
||||
fetchChatMessages,
|
||||
setTypingStatus
|
||||
} from '../whatsapp.js'; // <- Nota el '../'
|
||||
import { askGemini } from '../gemini.js'; // <- Nota el '../'
|
||||
import { processMessage } from '../utils/processMessage.js'; // <- Nota el '../'
|
||||
import { saveMedia } from '../utils/saveMedia.js'; // <- Nota el '../'
|
||||
|
||||
/* Quita campos pesados antes de mandar a Gemini */
|
||||
const cleanForGemini = ({ media, reactions, preview, ...rest }) => rest;
|
||||
|
||||
/**
|
||||
* Procesa la lógica específica para el comando @nucleo.
|
||||
* Obtiene el historial, prepara el contexto, procesa media,
|
||||
* llama a Gemini y envía la respuesta.
|
||||
* @param {object} msg - El objeto de mensaje procesado.
|
||||
*/
|
||||
export async function respuestaNormal(msg) {
|
||||
const text = msg.text || ''; // Necesitamos el texto original aquí también
|
||||
|
||||
setTypingStatus(msg.chatId, true);
|
||||
log('info', '🧠 Generando respuesta para @nucleo…');
|
||||
|
||||
try {
|
||||
/* 1) historial completo del chat */
|
||||
const allraw = await fetchChatMessages(msg.chatId);
|
||||
const allMsgs = allraw.map(processMessage);
|
||||
|
||||
/* 2) recorta contexto a ≤100 kB */
|
||||
const context = allMsgs.map(cleanForGemini);
|
||||
let json = JSON.stringify(context);
|
||||
while (json.length > 100_000 && context.length) {
|
||||
context.shift();
|
||||
json = JSON.stringify(context);
|
||||
}
|
||||
|
||||
// Asegurarse de que el contexto no esté vacío antes de acceder a context[0]
|
||||
const chatName = context.length > 0 ? context[0]?.chatName : 'chat';
|
||||
|
||||
/* 3) prompt */
|
||||
const prompt = [
|
||||
`Eres el asistente del grupo "${chatName}".`,
|
||||
`Pregunta del usuario: ${text}` // Usamos el 'text' del mensaje original que activó el comando
|
||||
, ...context];
|
||||
|
||||
/* 4) procesa medias y respeta el límite de 20 MB */
|
||||
// Filtrar solo los mensajes que no son de tipo 'chat' del historial obtenido
|
||||
const medias = allraw.filter(m => m.type !== 'chat');
|
||||
const settled = await Promise.allSettled(medias.map(saveMedia));
|
||||
|
||||
const MAX = 20 * 1024 * 1024; // 20 MB
|
||||
let total = 0;
|
||||
const files = {};
|
||||
|
||||
for (const r of settled) {
|
||||
if (r.status !== 'fulfilled' || !r.value) continue;
|
||||
|
||||
if (typeof r.value !== 'object' || r.value === null) continue;
|
||||
const entries = Object.entries(r.value);
|
||||
if (entries.length === 0) continue;
|
||||
const [msgId, obj] = entries[0];
|
||||
if (typeof obj !== 'object' || obj === null) continue;
|
||||
|
||||
let size = Number(obj.sizeBytes || 0);
|
||||
|
||||
if (!size && obj.filePath) {
|
||||
try { size = (await fs.stat(obj.filePath)).size; }
|
||||
catch { size = 0; }
|
||||
}
|
||||
|
||||
// Comprobar si añadir este archivo excede el límite MÁXIMO TOTAL
|
||||
// y si ya tenemos *algo* (total > 0) para evitar empezar con un archivo demasiado grande
|
||||
if (total + size > MAX && total > 0) break;
|
||||
// Si este archivo *por sí solo* excede el límite, saltarlo
|
||||
if (size > MAX) continue;
|
||||
|
||||
total += size;
|
||||
files[msgId] = { uri: obj.uri || obj.filePath, mimeType: obj.mimeType };
|
||||
}
|
||||
|
||||
// log('info', '🧠 Enviando a Gemini...', { files });
|
||||
|
||||
/* 5) llama a Gemini y responde */
|
||||
const respuesta = await askGemini(prompt, files);
|
||||
await sendText(msg.chatId, respuesta);
|
||||
|
||||
log('info', '🧠 Respuesta enviada');
|
||||
|
||||
} catch (error) {
|
||||
log('error', 'Error en respuestaNormal:', error);
|
||||
await sendText(msg.chatId, 'Hubo un error al procesar tu solicitud.');
|
||||
|
||||
} finally {
|
||||
setTypingStatus(msg.chatId, false);
|
||||
}
|
||||
}
|
||||
200
agent/routes.js
200
agent/routes.js
@@ -1,200 +0,0 @@
|
||||
// routes.js
|
||||
import express from 'express';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { config } from './config.js';
|
||||
import { log } from './logger.js';
|
||||
import { sendText } from './whatsapp.js';
|
||||
import { processIncoming } from './handlers.js';
|
||||
import { processMessage } from './utils/processMessage.js';
|
||||
|
||||
// chats a tomar en cuenta para el bot
|
||||
|
||||
const relevantChats = [
|
||||
'50496210031@c.us',
|
||||
'120363203056794284@g.us',
|
||||
'120363398335375917@g.us',
|
||||
'50498554225@c.us',
|
||||
'50496934012@c.us',
|
||||
'50497588328@c.us',
|
||||
'50489701450@c.us'
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
// --- Manejo de eventos del webhook ------------------------------------------------
|
||||
router.post('/webhook', async (req, res) => {
|
||||
const { event, data: raw } = req.body;
|
||||
const data = processMessage(raw);
|
||||
|
||||
// Si el evento no es relevante, ignorar
|
||||
if(data.chatId && !relevantChats.includes(data.chatId)) {
|
||||
log('info', `Mensaje de ${data.chatId} ignorado`);
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
// console.log('----------------------------------------------------------------');
|
||||
// log('debug', '↪︎ Mensaje IN →', raw);
|
||||
// console.log('----------------------------------------------------------------');
|
||||
|
||||
// log('debug', `📩 Webhook event "${event}"`);
|
||||
|
||||
switch (event) {
|
||||
case 'onAck':
|
||||
log('info', 'Ack:', data);
|
||||
break;
|
||||
|
||||
case 'onAddedToGroup':
|
||||
log('info', 'Added to group:', data);
|
||||
break;
|
||||
|
||||
case 'onAnyMessage':
|
||||
// log('info', 'onAnyMessage', data);
|
||||
log('info', 'onAnyMessage', raw.chatId);
|
||||
await processIncoming(raw);
|
||||
break;
|
||||
|
||||
case 'onBattery':
|
||||
log('info', 'Battery status:', data);
|
||||
break;
|
||||
|
||||
case 'onBroadcast':
|
||||
log('info', 'Broadcast:', data);
|
||||
break;
|
||||
|
||||
case 'onButton':
|
||||
log('info', 'Button pressed:', data);
|
||||
break;
|
||||
|
||||
case 'onCallState':
|
||||
log('info', 'Call state:', data);
|
||||
break;
|
||||
|
||||
case 'onChatDeleted':
|
||||
log('info', 'Chat deleted:', data);
|
||||
break;
|
||||
|
||||
case 'onChatOpened':
|
||||
log('info', 'Chat opened:', data);
|
||||
break;
|
||||
|
||||
case 'onChatState':
|
||||
log('info', 'Chat state:', data);
|
||||
break;
|
||||
|
||||
case 'onContactAdded':
|
||||
log('info', 'Contact added:', data);
|
||||
break;
|
||||
|
||||
case 'onGlobalParticipantsChanged':
|
||||
log('info', 'Global participants changed:', data);
|
||||
break;
|
||||
|
||||
case 'onGroupApprovalRequest':
|
||||
log('info', 'Group approval request:', data);
|
||||
break;
|
||||
|
||||
case 'onGroupChange':
|
||||
log('info', 'Group change:', data);
|
||||
break;
|
||||
|
||||
case 'onIncomingCall':
|
||||
log('info', 'Incoming call:', data);
|
||||
break;
|
||||
|
||||
case 'onLabel':
|
||||
log('info', 'Label event:', data);
|
||||
break;
|
||||
|
||||
case 'onLogout':
|
||||
log('info', 'Logout:', data);
|
||||
break;
|
||||
|
||||
case 'onMessage':
|
||||
log('info', 'Message:', data);
|
||||
break;
|
||||
|
||||
case 'onMessageDeleted':
|
||||
log('info', 'Message deleted:', data);
|
||||
break;
|
||||
|
||||
case 'onNewProduct':
|
||||
log('info', 'New product:', data);
|
||||
break;
|
||||
|
||||
case 'onOrder':
|
||||
log('info', 'Order:', data);
|
||||
break;
|
||||
|
||||
case 'onPlugged':
|
||||
log('info', 'Plugged:', data);
|
||||
break;
|
||||
|
||||
case 'onPollVote':
|
||||
log('info', 'Poll vote:', data);
|
||||
break;
|
||||
|
||||
case 'onReaction':
|
||||
log('info', 'Reaction:', data);
|
||||
break;
|
||||
|
||||
case 'onRemovedFromGroup':
|
||||
log('info', 'Removed from group:', data);
|
||||
break;
|
||||
|
||||
case 'onStateChanged':
|
||||
log('info', 'State changed:', data);
|
||||
break;
|
||||
|
||||
case 'onStory':
|
||||
log('info', 'Story:', data);
|
||||
break;
|
||||
|
||||
default:
|
||||
log('warn', `Unhandled event type: "${event}"`, data);
|
||||
break;
|
||||
}
|
||||
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// comentado el 4/26/2025
|
||||
/* Debug: escanear últimos mensajes */
|
||||
// router.get('/debug/scan', async (_req, res) => {
|
||||
// const { data } = await axios.post(`${config.API_URL}/loadAndGetAllMessagesInChat`, {
|
||||
// args: { chatId: config.GROUP_ID, includeMe: 'true', includeNotifications: 'false' }
|
||||
// });
|
||||
// const msgs = (data?.response ?? []).slice(-20);
|
||||
// log('info', `Escaneando ${msgs.length} mensajes recientes…`);
|
||||
// for (const m of msgs) await processIncoming(m);
|
||||
// res.json({ ok: true, scanned: msgs.length });
|
||||
// });
|
||||
|
||||
/* Debug: enviar mensaje */
|
||||
// router.get('/debug/send', async (req, res) => {
|
||||
// const text = req.query.msg ?? config.REPLY_MSG;
|
||||
// const resp = await sendText(config.GROUP_ID, text);
|
||||
// res.json({ ok: true, resp });
|
||||
// });
|
||||
|
||||
/* Debug: versión */
|
||||
router.get('/debug/version', (_req, res) => {
|
||||
res.json({ version: config.VERSION });
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
// utils/decryptMediaContent.js (ES modules)
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
import mime from 'mime-types';
|
||||
import { decryptMedia } from '@open-wa/wa-automate';
|
||||
import { log } from '../logger.js';
|
||||
|
||||
// 🔒 quita caracteres que rompen rutas
|
||||
const safe = s => (s || '').replace(/[\\/:*?"<>|]/g, '_');
|
||||
|
||||
export async function decryptMediaContent(
|
||||
mediaInfo,
|
||||
outputDir = 'media/dec',
|
||||
rawDir = 'media/raw',
|
||||
filename = null
|
||||
) {
|
||||
const { clientUrl, t, filehash, msgId, type } = mediaInfo;
|
||||
let { mimetype } = mediaInfo;
|
||||
|
||||
if (!clientUrl) {
|
||||
log('error', '❌ Sin clientUrl, no se puede bajar');
|
||||
return null;
|
||||
}
|
||||
|
||||
// deducir mimetype si falta
|
||||
if (!mimetype) {
|
||||
mimetype =
|
||||
mime.lookup(clientUrl) ||
|
||||
(type?.startsWith('image') && 'image/jpeg') ||
|
||||
(type?.startsWith('video') && 'video/mp4') ||
|
||||
'application/octet-stream';
|
||||
}
|
||||
|
||||
const ext = mime.extension(mimetype) || 'bin';
|
||||
const baseName = safe(filename || msgId || filehash?.slice(0,16) || `file_${t||Date.now()}`);
|
||||
const rawPath = path.join(rawDir , `${baseName}.enc`);
|
||||
const decPath = path.join(outputDir, `${baseName}.${ext}`);
|
||||
|
||||
if (fs.existsSync(decPath)) return decPath;
|
||||
|
||||
fs.mkdirSync(rawDir , { recursive: true });
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
try {
|
||||
/* ───── descarga RAW (solo si no existe) ───── */
|
||||
if (!fs.existsSync(rawPath)) {
|
||||
const { data } = await axios
|
||||
.get(clientUrl, { responseType: 'arraybuffer' })
|
||||
.catch(e => {
|
||||
if (e.response?.status === 410) throw new Error('URL expirada (410)');
|
||||
throw e;
|
||||
});
|
||||
fs.writeFileSync(rawPath, Buffer.from(data));
|
||||
}
|
||||
|
||||
/* ───── inyectar RAW para que decryptMedia no lo vuelva a bajar ───── */
|
||||
const fake = {
|
||||
...mediaInfo,
|
||||
mimetype,
|
||||
mimeType: mimetype,
|
||||
_data: {
|
||||
...mediaInfo,
|
||||
mimetype,
|
||||
mimeType: mimetype,
|
||||
_raw: fs.readFileSync(rawPath)
|
||||
}
|
||||
};
|
||||
|
||||
const plain = await decryptMedia(fake);
|
||||
if (!plain?.length) throw new Error('descifrado vacío');
|
||||
|
||||
fs.writeFileSync(decPath, plain);
|
||||
log('info', `✔️ ${type || ext} → ${decPath}`);
|
||||
return decPath;
|
||||
} catch (e) {
|
||||
log('error', `❌ decryptMedia falló → ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* Convierte el raw de open-wa a un objeto compacto.
|
||||
* Solo coloca `media` cuando hay TODO para desencriptar: mediaKey + clientUrl.
|
||||
*/
|
||||
export function processMessage(raw) {
|
||||
const m = raw?.data ?? raw; // alias corto
|
||||
|
||||
const base = {
|
||||
msgId : m.id,
|
||||
chatId : m.chatId,
|
||||
chatName : m.chat?.name ?? m.chat?.formattedTitle ?? null,
|
||||
senderId : m.sender?.id ?? m.author ?? null,
|
||||
senderName: m.sender?.name ?? m.notifyName ?? null,
|
||||
type : m.type,
|
||||
text : m.text ?? m.caption ?? '',
|
||||
fromMe : !!m.fromMe,
|
||||
timestamp : m.timestamp ?? m.t,
|
||||
date : m.timestamp
|
||||
? dayjs.unix(m.timestamp).utcOffset(-360).format('YYYY-MM-DD HH:mm:ss')
|
||||
: null,
|
||||
hasReactions: (m.reactions?.length ?? 0) > 0 || !!m.hasReaction,
|
||||
reactions : (m.reactions ?? []).map(r => r.aggregateEmoji)
|
||||
};
|
||||
|
||||
/* ---------- quoted ---------- */
|
||||
if (m.quotedMsg) {
|
||||
base.replyTo = {
|
||||
id : m.quotedMsg.id,
|
||||
text: m.quotedMsg.text ?? m.quotedMsg.body ?? '',
|
||||
from: m.quotedParticipant ?? null,
|
||||
type: m.quotedMsg.type
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- preview de enlaces ---------- */
|
||||
if (m.matchedText) {
|
||||
base.preview = {
|
||||
url : m.matchedText,
|
||||
title: m.title ?? null,
|
||||
description: m.description ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- media listo para desencriptar ---------- */
|
||||
const isMediaType = ['image','video','sticker','ptt','audio','document'].includes(m.type);
|
||||
const hasKeys = m.mediaKey || m.mediaData?.mediaKey;
|
||||
const hasUrl = m.clientUrl || m.directPath;
|
||||
|
||||
if (isMediaType && hasKeys && hasUrl) {
|
||||
base.media = {
|
||||
type : m.type,
|
||||
clientUrl: m.clientUrl ?? m.directPath,
|
||||
mimetype : m.mimetype,
|
||||
size : m.size ?? m.mediaData?.size,
|
||||
width : m.width,
|
||||
height : m.height,
|
||||
duration : Number(m.duration) || 0,
|
||||
filename : m.filename ?? null,
|
||||
caption : m.caption ?? null,
|
||||
|
||||
/* claves para decryptMediaContent */
|
||||
mediaKey : m.mediaKey ?? m.mediaData?.mediaKey,
|
||||
filehash : m.filehash ?? m.mediaData?.filehash,
|
||||
t : m.t ?? m.timestamp
|
||||
};
|
||||
|
||||
if (m.type === 'sticker') {
|
||||
Object.assign(base.media, {
|
||||
packId : m.stickerPackId ?? m.mediaData?.stickerPackId,
|
||||
pack : m.stickerPackName ?? m.mediaData?.stickerPackName,
|
||||
author : m.stickerPackPublisher ?? m.mediaData?.stickerPackPublisher,
|
||||
animated: m.isLottie || m.mediaData?.isAnimated || false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// utils/saveMedia.js
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
import mime from 'mime-types';
|
||||
import { decryptMedia } from '@open-wa/wa-automate';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import { config } from '../config.js';
|
||||
import { log } from '../logger.js';
|
||||
|
||||
const BASE_DIR = '/media';
|
||||
const safe = s => (s || '').replace(/[\\/:*?"<>|]/g, '_');
|
||||
const ai = new GoogleGenAI({ apiKey: config.GEMINI_KEY });
|
||||
|
||||
function getShortId(msgId = '') {
|
||||
return msgId.replace(/^true_|^false_|@c\.us_/g, '').split('_')[0];
|
||||
}
|
||||
|
||||
export async function saveMedia(msg) {
|
||||
if (msg.type === 'chat') return null;
|
||||
if (!msg.clientUrl) {
|
||||
log('warn', `Sin clientUrl: ${msg.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = mime.extension(msg.mimetype || 'application/octet-stream') || 'bin';
|
||||
const chatId = safe(msg.chatId);
|
||||
const shortId = (`${msg.timestamp}${msg.type}`).toLocaleLowerCase();
|
||||
// const shortId = getShortId(msg.id);
|
||||
const folder = path.join(BASE_DIR, chatId, safe(msg.type));
|
||||
const filePath = path.join(folder, `${shortId}.${ext}`);
|
||||
const fileName = shortId // solo para Files API
|
||||
|
||||
// Buscar primero en Files API
|
||||
try {
|
||||
const existing = await ai.files.get({ name: fileName });
|
||||
log('info', `📂 ya existe en Files API: ${fileName}`);
|
||||
return { [msg.id]: existing };
|
||||
} catch (e) {
|
||||
if (e.message?.includes('INVALID_ARGUMENT')) {
|
||||
log('error', `files.get falló para ${fileName}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar en disco
|
||||
if (fs.existsSync(filePath)) {
|
||||
log('info', `📁 ya existe local: ${fileName}`, 'subiendo a Files API');
|
||||
const uploaded = await ai.files.upload({
|
||||
file: fileName,
|
||||
config: { mimeType: msg.mimetype || 'application/octet-stream', name: fileName },
|
||||
});
|
||||
log('info', `📤 subido a Files API: ${uploaded.name}`);
|
||||
return { [msg.id]: uploaded };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await axios.get(msg.clientUrl, { responseType: 'arraybuffer' });
|
||||
msg._data = { ...msg, _raw: raw.data };
|
||||
const buf = await decryptMedia(msg);
|
||||
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
fs.writeFileSync(filePath, buf);
|
||||
log('info', `✅ guardado: ${filePath}`);
|
||||
|
||||
const uploaded = await ai.files.upload({
|
||||
file: filePath,
|
||||
config: { mimeType: msg.mimetype || 'application/octet-stream', name: fileName },
|
||||
});
|
||||
log('info', `📤 subido a Files API: ${uploaded.name}`);
|
||||
return { [msg.id]: uploaded };
|
||||
|
||||
} catch (err) {
|
||||
if (err.response?.status === 410) {
|
||||
log('warn', `URL expirada (410) para ${msg.id}`);
|
||||
} else {
|
||||
log('error', `Error al guardar media ${msg.id}: ${err.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// whatsapp.js
|
||||
import axios from 'axios';
|
||||
import { config } from './config.js';
|
||||
import { log } from './logger.js';
|
||||
|
||||
/* ✉️ Enviar texto -------------------------------------------------------- */
|
||||
export async function sendText(to, content) {
|
||||
log('info', `Enviando mensaje a ${to}: "${content}"`);
|
||||
const { data } = await axios.post(`${config.API_URL}/sendText`, {
|
||||
args: { to, content }
|
||||
});
|
||||
log('debug', 'Respuesta de /sendText →', data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/* 🗨️ Traer todos los mensajes cargados en el chat ------------------------ */
|
||||
export async function fetchChatMessages(chatId) {
|
||||
try {
|
||||
const { data } = await axios.post(`${config.API_URL}/getAllMessagesInChat`, {
|
||||
args: {
|
||||
chatId,
|
||||
includeMe: 'true',
|
||||
includeNotifications: 'false'
|
||||
}
|
||||
});
|
||||
return data?.response ?? [];
|
||||
} catch (e) {
|
||||
log('error', 'Fallo /getAllMessagesInChat:', e.response?.data ?? e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/* ⏳ Esperar gateway ------------------------------------------------------ */
|
||||
export async function waitForGateway() {
|
||||
for (let i = 1; i <= config.MAX_ATTEMPTS; i++) {
|
||||
try {
|
||||
await axios.get(`${config.API_URL}/api-docs`);
|
||||
log('info', '🟢 whatsapp-gateway listo');
|
||||
return;
|
||||
} catch {
|
||||
log('warn', `Gateway no responde (intento ${i}/${config.MAX_ATTEMPTS})…`);
|
||||
await new Promise(r => setTimeout(r, config.RETRY_MS));
|
||||
}
|
||||
}
|
||||
throw new Error('whatsapp-gateway no respondió a tiempo');
|
||||
}
|
||||
|
||||
/* 🧹 Limpiar webhooks anteriores ----------------------------------------- */
|
||||
export async function clearWebhooks() {
|
||||
try {
|
||||
const { data } = await axios.post(`${config.API_URL}/listWebhooks`);
|
||||
const hooks = data?.response ?? [];
|
||||
if (!hooks.length) {
|
||||
log('info', 'Sin webhooks previos que limpiar');
|
||||
return;
|
||||
}
|
||||
|
||||
log('info', `Eliminando ${hooks.length} webhooks…`);
|
||||
const results = await Promise.allSettled(
|
||||
hooks.map(h => axios.post(`${config.API_URL}/removeWebhook`, { args: { webhookId: h.id } }))
|
||||
);
|
||||
|
||||
results.forEach((r, i) => {
|
||||
const id = hooks[i].id;
|
||||
if (r.status === 'fulfilled' && r.value?.data?.response === true) {
|
||||
log('debug', `✔️ Eliminado webhook ${id}`);
|
||||
} else {
|
||||
log('warn', `⚠️ Falló eliminar webhook ${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
const ok = results.filter(r => r.status === 'fulfilled' && r.value?.data?.response === true).length;
|
||||
log('info', `Limpieza OK (${ok}/${hooks.length} eliminados)`);
|
||||
} catch (e) {
|
||||
log('error', 'Fallo limpiando webhooks:', e.response?.data ?? e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Registro del webhook con todos los eventos disponibles ----------
|
||||
export async function registerWebhook() {
|
||||
const url = `http://nucleo-bot:${config.PORT}/webhook`;
|
||||
|
||||
const eventConfig = {
|
||||
onAck: false, // ❌
|
||||
onAddedToGroup: true,
|
||||
onAnyMessage: true,
|
||||
onBattery: true,
|
||||
onBroadcast: true,
|
||||
onButton: true,
|
||||
onCallState: false, // ❌
|
||||
onChatDeleted: true,
|
||||
onChatOpened: true,
|
||||
onChatState: true,
|
||||
onContactAdded: true,
|
||||
onGlobalParticipantsChanged: true,
|
||||
onGroupApprovalRequest: true,
|
||||
onGroupChange: true,
|
||||
onIncomingCall: false, // ❌
|
||||
onLabel: true,
|
||||
onLogout: true,
|
||||
onMessage: false, // ❌
|
||||
onMessageDeleted: true,
|
||||
onNewProduct: true,
|
||||
onOrder: true,
|
||||
onPlugged: false, // ❌
|
||||
onPollVote: true,
|
||||
onReaction: true,
|
||||
onRemovedFromGroup: false, // ❌
|
||||
onStateChanged: false, // ❌
|
||||
onStory: false, // ❌
|
||||
};
|
||||
|
||||
// usa el config de eventos para filtrar los habilitados (el config de arriba)
|
||||
const allEvents = Object.entries(eventConfig)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([event]) => event);
|
||||
|
||||
|
||||
const { data } = await axios.post(`${config.API_URL}/registerWebhook`, {
|
||||
args: { url, events: allEvents, id: 'nucleo-bot' }
|
||||
});
|
||||
|
||||
log('info', '✔️ Webhook registrado:', data);
|
||||
}
|
||||
|
||||
|
||||
export async function setTypingStatus(chatId, status) {
|
||||
try {
|
||||
const { data } = await axios.post(`${config.API_URL}/simulateTyping`, {
|
||||
args: { to: chatId, on: status }
|
||||
});
|
||||
log('debug', 'Respuesta de /setChatState →', data);
|
||||
} catch (e) {
|
||||
log('error', 'Fallo /setChatState:', e.response?.data ?? e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user