creado planilla-agent
This commit is contained in:
6
Makefile
6
Makefile
@@ -38,6 +38,10 @@ sync-to-gitea:
|
|||||||
|
|
||||||
# Declaramos el target como PHONY ya que no corresponde a un archivo real (opcional pero recomendado)
|
# Declaramos el target como PHONY ya que no corresponde a un archivo real (opcional pero recomendado)
|
||||||
|
|
||||||
.PHONY: UI
|
.PHONY: UI agent
|
||||||
UI:
|
UI:
|
||||||
cd ui && ( if not exist node_modules npm install ) && npm run dev
|
cd ui && ( if not exist node_modules npm install ) && npm run dev
|
||||||
|
|
||||||
|
agent:
|
||||||
|
cd agent && npm install && npm run dev
|
||||||
|
|
||||||
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
2
conversation-layer-agent/.dockerignore
Normal file
2
conversation-layer-agent/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
17
conversation-layer-agent/Dockerfile
Normal file
17
conversation-layer-agent/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# ---------- Build stage ----------
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---------- Production stage ----------
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
ENV PORT=8001
|
||||||
|
EXPOSE 8001
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
2426
conversation-layer-agent/package-lock.json
generated
Normal file
2426
conversation-layer-agent/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
conversation-layer-agent/package.json
Normal file
24
conversation-layer-agent/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "conversation-layer-agent",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "nodemon --watch src --ext ts --exec \"ts-node src/index.ts\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"@google/genai": "^1.4.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
|
"dotenv": "^16.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"@types/node": "^20.11.19",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
185
conversation-layer-agent/src/index.ts
Normal file
185
conversation-layer-agent/src/index.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { GoogleGenAI, mcpToTool } from '@google/genai';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
interface Participant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isMe: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Msg {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
ts: number;
|
||||||
|
type: 'chat' | 'image' | 'audio' | 'sticker' | 'doc';
|
||||||
|
text?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
mentions?: string[];
|
||||||
|
meta: {
|
||||||
|
ack: number;
|
||||||
|
hasReaction: boolean;
|
||||||
|
isQuoted: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
chatId: string;
|
||||||
|
title: string;
|
||||||
|
isGroup: boolean;
|
||||||
|
unreadCount: number;
|
||||||
|
participants: Participant[];
|
||||||
|
messages: Msg[];
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT) || 8001;
|
||||||
|
const API_KEY = process.env.GEMINI_API_KEY || '';
|
||||||
|
console.log(`Using Gemini API key: ${API_KEY}`);
|
||||||
|
|
||||||
|
const genAI = API_KEY ? new GoogleGenAI({ apiKey: API_KEY }) : null;
|
||||||
|
|
||||||
|
const MCP_URL = process.env.MCP_URL || 'http://planilla.interno.com/mcp';
|
||||||
|
let mcpClient: Client | undefined;
|
||||||
|
let mcpTransport: StreamableHTTPClientTransport | undefined;
|
||||||
|
|
||||||
|
async function getMcpClient(): Promise<Client> {
|
||||||
|
if (!mcpClient) {
|
||||||
|
mcpClient = new Client({ name: 'planilla-client', version: '1.0.0' });
|
||||||
|
mcpTransport = new StreamableHTTPClientTransport(new URL(MCP_URL));
|
||||||
|
await mcpClient.connect(mcpTransport);
|
||||||
|
}
|
||||||
|
return mcpClient;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Descripción de alto nivel para que cualquier agente (humano o LLM) entienda y
|
||||||
|
* trabaje con el repositorio sin perder tiempo buscando contexto.
|
||||||
|
*/
|
||||||
|
const repoInfo = `
|
||||||
|
📦 RESUMEN
|
||||||
|
Este repo orquesta tres servicios complementarios:
|
||||||
|
1. whatsapp-router → Recibe webhooks de OpenWA y re-expide los mensajes al agente o la UI.
|
||||||
|
2. conversation-layer-agent → LLM que responde dudas sobre el código y ejecuta acciones.
|
||||||
|
3. chat-ui → Interfaz web mínima que conversa con el agente.
|
||||||
|
|
||||||
|
🗂 ESTRUCTURA CLAVE
|
||||||
|
│
|
||||||
|
├─ docker-compose.yml # Levanta todo el stack
|
||||||
|
├─ whatsapp-router/
|
||||||
|
│ ├─ src/chatHandlers.ts # Mapeo chatId → handler; ¡editá aquí para nuevos agentes!
|
||||||
|
│ └─ … # Lógica de ruteo y validaciones
|
||||||
|
├─ conversation-layer-agent/
|
||||||
|
│ ├─ src/index.ts # Entrada principal del agente
|
||||||
|
│ └─ prompts/system.ts # Prompt base; importa repoInfo
|
||||||
|
└─ chat-ui/ # Frontend Vite + React (TypeScript)
|
||||||
|
└─ …
|
||||||
|
|
||||||
|
⚙️ VARIABLES DE ENTORNO (ejemplo .env)
|
||||||
|
OPEN_WA_URL=http://openwa:8080
|
||||||
|
LLM_AGENT_URL=http://conversation-layer-agent:8000
|
||||||
|
PORT=3001 # Puerto del router
|
||||||
|
NODE_ENV=development # Cambiá a production para desactivar logs verbosos
|
||||||
|
|
||||||
|
🚀 CÓMO LEVANTAR
|
||||||
|
1) cp .env.example .env && edítalo según tu entorno
|
||||||
|
2) docker-compose up -d --build
|
||||||
|
3) Visita http://localhost:3000 (UI) o revisá logs con \`docker-compose logs -f\`.
|
||||||
|
|
||||||
|
🔄 FLUJO DE MENSAJES
|
||||||
|
OpenWA → whatsapp-router (/webhook) → handler ↔ conversation-layer-agent ↔ chat-ui
|
||||||
|
|
||||||
|
🤖 GUÍA RÁPIDA PARA AGENTES LLM
|
||||||
|
- Responde corto, en el tono del usuario (“vos”, español hondureño).
|
||||||
|
- Usa SOLO la info del objeto \`Conversation\`; no mantengas estado entre turnos.
|
||||||
|
- Si falta contexto, pedilo en una línea.
|
||||||
|
- No repitas instrucciones ni digas que sos IA.
|
||||||
|
- Devuelve \`{ reply: string, actions?: any[] }\` (JSON puro) para facilitar parsing.
|
||||||
|
|
||||||
|
🔧 COMANDOS ÚTILES
|
||||||
|
npm run dev # Hot-reload del router
|
||||||
|
npm test # Ejecuta los tests
|
||||||
|
docker exec -it openwa sh # Shell dentro del contenedor OpenWA
|
||||||
|
git remote -v # Confirma remotos (origin: Gitea, github: mirror)
|
||||||
|
|
||||||
|
🔐 SEGURIDAD
|
||||||
|
- Los tokens/API keys van en variables de entorno; nunca los subas al repo.
|
||||||
|
- Usa certificados válidos o \`NODE_TLS_REJECT_UNAUTHORIZED=0\` SOLO en dev.
|
||||||
|
|
||||||
|
✍️ CONTRIBUCIONES
|
||||||
|
Push a rama feature/* → CI/CD en Gitea valida lint, tests y build.
|
||||||
|
Crea PR para revisión; no mezcles cambios de lógica y formato en el mismo commit.
|
||||||
|
|
||||||
|
📜 LICENCIA
|
||||||
|
GPL-3.0 — libre de usar, modificar y redistribuir mientras mantengas la misma licencia.
|
||||||
|
|
||||||
|
¡Listo! Con esto cualquier agente debería orientarse y empezar a trabajar sin drama.
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.post('/', async (req, res) => {
|
||||||
|
const conversation = req.body?.conversation as Conversation | undefined;
|
||||||
|
if (!conversation) return res.status(400).json({ error: 'Missing conversation' });
|
||||||
|
const lastMsg = conversation.messages[conversation.messages.length - 1];
|
||||||
|
const message = lastMsg?.text || '';
|
||||||
|
|
||||||
|
const context = conversation.messages
|
||||||
|
.slice(-10)
|
||||||
|
.map((m) => {
|
||||||
|
const sender =
|
||||||
|
conversation.participants.find((p) => p.id === m.from)?.name || m.from;
|
||||||
|
const content = m.text || `[${m.type}]`;
|
||||||
|
return `${sender}: ${content}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
if (!genAI) {
|
||||||
|
return res.json({ reply: repoInfo });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contents = `Repo information: ${repoInfo}\nConversation:\n${context}\n`;
|
||||||
|
const config: any = {};
|
||||||
|
// if (message.toLowerCase().includes('/planilla')) {
|
||||||
|
if (true) {
|
||||||
|
console.log('Using Model Context Protocol tools ', MCP_URL);
|
||||||
|
const client = await getMcpClient();
|
||||||
|
config.tools = [mcpToTool(client)];
|
||||||
|
}
|
||||||
|
const result = await genAI.models.generateContent({
|
||||||
|
model: 'gemini-2.0-flash',
|
||||||
|
contents,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
const reply = (result.text || '').trim();
|
||||||
|
res.json({ reply });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Gemini error', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to generate reply' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.send(`
|
||||||
|
<h1>Conversation Layer Agent</h1>
|
||||||
|
<p>This service answers questions about the repository.</p>
|
||||||
|
<p>Send a POST request to / with a JSON body containing {"conversation": {...}}</p>
|
||||||
|
<p>Example: {"conversation": {"chatId": "123@c.us", "title": "Chat", "isGroup": false, "unreadCount": 0, "participants": [{"id": "123@c.us", "name": "Alice", "isMe": false}], "messages": [{"id": "m1", "from": "123@c.us", "to": "me@c.us", "ts": 0, "type": "chat", "text": "hello", "meta": {"ack":0,"hasReaction":false,"isQuoted":false}}]}}</p>
|
||||||
|
<p>It will respond with a JSON object containing {"reply": "the answer"}</p>
|
||||||
|
|
||||||
|
<p>Repository info: ${repoInfo}</p>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`conversation-layer-agent listening on ${PORT}`);
|
||||||
|
});
|
||||||
14
conversation-layer-agent/tsconfig.json
Normal file
14
conversation-layer-agent/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["es2020"],
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -2,10 +2,17 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
agent:
|
agent:
|
||||||
|
container_name: planilla-agent
|
||||||
image: gitea.interno.com/nucleo000/planilla-agent:latest
|
image: gitea.interno.com/nucleo000/planilla-agent:latest
|
||||||
build: ./agent
|
build: ./agent
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- PORT=8012
|
||||||
|
- GEMINI_API_KEY= AIzaSyA9fI1mron-NVgghygu7B4sco7t6raXB8M
|
||||||
|
- MCP_URL= http:planilla-mcp:5000/mcp
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks: [planilla]
|
networks: [planilla, principal]
|
||||||
|
|
||||||
api:
|
api:
|
||||||
container_name: planilla-api
|
container_name: planilla-api
|
||||||
|
|||||||
Reference in New Issue
Block a user