sistema creado v0.5.0
Some checks failed
build-and-push / deploy (push) Has been skipped
build-and-push / build (push) Failing after 6s

This commit is contained in:
2025-05-14 16:10:41 -06:00
parent b5e40cf4ac
commit 745168cf51
193 changed files with 7267 additions and 8789 deletions

28
agent/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# 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"]

16
agent/config.js Normal file
View File

@@ -0,0 +1,16 @@
/*───────────────────────────────────────────────────────────────*/
/* ⚙️ 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',
};

View File

@@ -0,0 +1,195 @@
{
"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 Normal file
View File

@@ -0,0 +1,120 @@
// 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: {} }]; // Searchasatool
}
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.';
}
}

51
agent/handlers.js Normal file
View File

@@ -0,0 +1,51 @@
// 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
/* 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 || '';
/* ----- comando @nucleo ----- */
if (/^@nucleo(\s|$)/i.test(text)) {
// Llama a la función importada
// await respuestaNormal(msg); // <- LLAMADA A LA FUNCIÓN EXTERNA
await respuestaMCP(msg); // <- LLAMADA A LA FUNCIÓN EXTERNA
} else {
// Lógica para otros mensajes (si aplica)
// log('debug', 'Mensaje recibido no es comando @nucleo:', text);
}
if (/^@nucleo.(\s|$)/i.test(text)) {
log('info', '🧠 Generando respuesta para @nucleo...');
const respuestaObjMCP = await respuestaBrave(msg); // <- LLAMADA A LA FUNCIÓN EXTERNA
log('info', 'Respuesta de MCP:', respuestaObjMCP);
sendText(msg.chatId, respuestaObjMCP);
} else {
// Lógica para otros mensajes (si aplica)
// log('debug', 'Mensaje recibido no es comando @nucleo:', text);
}
}

35
agent/index.js Normal file
View File

@@ -0,0 +1,35 @@
/* 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));
});

17
agent/logger.js Normal file
View File

@@ -0,0 +1,17 @@
/*───────────────────────────────────────────────────────────────*/
/* 🖨️ 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
);
}
}

20
agent/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"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"
}
}

View File

@@ -0,0 +1,128 @@
// /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);
}
}

View File

@@ -0,0 +1,127 @@
// /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);
}
}

View File

@@ -0,0 +1,100 @@
// /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 Normal file
View File

@@ -0,0 +1,200 @@
// 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 });
});

View File

@@ -0,0 +1,80 @@
// 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;
}
}

View File

@@ -0,0 +1,80 @@
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;
}

80
agent/utils/saveMedia.js Normal file
View File

@@ -0,0 +1,80 @@
// 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;
}
}

137
agent/whatsapp.js Normal file
View File

@@ -0,0 +1,137 @@
// 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);
}
}