primer commit

This commit is contained in:
2025-05-02 13:09:42 -06:00
commit 42793abc7a
21 changed files with 5872 additions and 0 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# .env.example
# 🔐 Clave privada para autenticar las llamadas a la API
WA_API_KEY=poné_una_clave_segura_aquí
# 🆔 Nombre de la sesión de WhatsApp
WA_SESSION_ID=planillas
# ⚙️ Desactivar spinners molestos en logs (opcional)
WA_DISABLE_SPINS=true
# 🌐 Webhook opcional para recibir mensajes entrantes
# Formato: http://nombre-servicio:puerto/ruta
WA_WEBHOOK=http://core-hr:3000/webhooks/whatsapp
# 🛡️ (Opcional) Limitar IPs que pueden conectarse
# WA_ALLOW_IPS=10.0.0.0/24

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Ignorar archivos sensibles
.env
# Archivos de sesión de WhatsApp (para no perder la sesión si borrás el contenedor)
sessions/
whatsapp_sessions/
# Docker artefactos
*.pid
*.log
# Node.js (por si después metés algún microservicio o extensión)
node_modules/
npm-debug.log
yarn-error.log
# General basura del sistema
.DS_Store
Thumbs.db

34
Makefile Normal file
View File

@@ -0,0 +1,34 @@
# Makefile para WhatsApp Bot - Planillas Río Frío
# Nombre del archivo docker-compose
COMPOSE_FILE=docker-compose.yml
# Servicio principal
SERVICE_NAME=whatsapp-bot
# Comandos
up:
docker compose -f $(COMPOSE_FILE) up -d
down:
docker compose -f $(COMPOSE_FILE) down
restart:
docker compose -f $(COMPOSE_FILE) restart $(SERVICE_NAME)
logs:
docker compose -f $(COMPOSE_FILE) logs -f $(SERVICE_NAME)
ps:
docker compose -f $(COMPOSE_FILE) ps
pull:
docker compose -f $(COMPOSE_FILE) pull
build:
docker compose -f $(COMPOSE_FILE) build
qr:
docker compose -f $(COMPOSE_FILE) logs -f $(SERVICE_NAME) | grep -i "SCAN"

90
README.md Normal file
View File

@@ -0,0 +1,90 @@
# WhatsApp Bot - Planillas Río Frío
Este proyecto levanta un bot de WhatsApp utilizando [Open-WA](https://openwa.dev/) en Docker para integrar mensajes entrantes y salientes en el sistema de planillas de Beneficio Río Frío.
---
## 🚀 Cómo levantar el bot
1. **Clonar el repositorio**
```bash
git clone https://tu-repo.git
cd whatsapp-bot-planillas
```
2. **Configurar el entorno**
Crear un archivo `.env` basado en el ejemplo:
```bash
cp .env.example .env
```
Editá `.env` para definir tu `WA_API_KEY` y demás variables.
3. **Desplegar con Docker Compose**
```bash
docker compose -f docker-compose.whatsapp.yml up -d
```
4. **Escanear el QR**
- Ver los logs del contenedor.
- Escanear el código QR usando la app de WhatsApp Business.
5. **Probar conexión**
```bash
curl -H "x-api-key:TU_API_KEY" http://localhost:8080/chats
```
---
## 📦 Estructura del proyecto
```
whatsapp-bot-planillas/
├── docker-compose.whatsapp.yml
├── .env
├── .gitignore
└── README.md
```
---
## ⚙️ Variables de Entorno
| Variable | Descripción |
|------------------|---------------------------------------------------|
| WA_API_KEY | Clave privada para autenticación API |
| WA_SESSION_ID | Nombre de la sesión (por default `planillas`) |
| WA_DISABLE_SPINS | Evita spinners molestos en logs (`true`) |
| WA_WEBHOOK | URL para recibir mensajes entrantes (opcional) |
---
## 🔒 Seguridad
- **No publiques tu `.env**` ni los archivos de sesión (`sessions/`).
- Configurá Nginx Proxy Manager o Authentik para proteger tu endpoint.
---
## 📚 Recursos útiles
- [Documentación oficial Open-WA](https://openwa.dev/)
- [Referencia API Open-WA](https://openwa.dev/docs/api)
---
## 🛠️ Próximos pasos
- Conectar el webhook a `attendance-svc` o `core-hr`.
- Procesar mensajes y registrar asistencia automáticamente.
- Automatizar respuestas básicas via WhatsApp.
---
Hecho con ☕ y dedicación en Río Frío, Honduras.

41
docker-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
# docker-compose.whatsapp.yml
version: "3.9"
services:
whatsapp-bot:
image: openwa/wa-automate:latest
container_name: whatsapp-bot
hostname: whatsapp-bot
init: true # evita zombies
restart: on-failure
environment:
## Identificá tu sesión y ajustá flags según necesidad
WA_SESSION_ID: "planillas" # cambia el nombre si querés varias instancias
WA_DISABLE_SPINS: "true" # quita el spinner del log
WA_API_KEY: ${WA_API_KEY} # token para llamadas REST
WA_WEBHOOK: "http://core-hr:3000/webhooks/whatsapp" # ejemplo de webhook interno
# WA_ALLOW_IPS: "10.0.0.0/24" # opcional: restringí IPs
volumes:
- whatsapp_sessions:/sessions # persiste el login QR
ports:
- "8080:8080" # API REST y socket
networks:
- principal # tu red docker ya existente
nucleo-bot:
build: ./nucleo-bot
depends_on:
- whatsapp-bot # nombre del servicio WA existente
environment:
BOT_API_URL: http://whatsapp-bot:3000 # mismo puerto expuesto por openwa
GROUP_ID: 120363203056794284@g.us
REPLY_MSG: "que pedos" # cámbialo cuando quieras
networks:
- default # o la red que use tu stack
volumes:
whatsapp_sessions:
networks:
principal:
external: true

19
nucleo-bot/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# Dockerfile actualizado
FROM node:23-slim
WORKDIR /app
# 1) Copiás sólo package.json (y package-lock.json si existe) para aprovechar cache
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
# 2) Copiás el resto del código (todos los .js y módulos separados)
COPY . .
# 3) Variables y puerto
ENV PORT=4000
EXPOSE 4000
# 4) Arranque
CMD ["node", "index.js"]

16
nucleo-bot/config.js Normal file
View File

@@ -0,0 +1,16 @@
/*───────────────────────────────────────────────────────────────*/
/* ⚙️ Variables de entorno */
/*───────────────────────────────────────────────────────────────*/
export const config = {
VERSION : '0.5.14',
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
nucleo-bot/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.';
}
}

83
nucleo-bot/handlers.js Normal file
View File

@@ -0,0 +1,83 @@
// handlers.js
import fs from 'fs/promises';
import { log } from './logger.js';
import {
sendText,
fetchChatMessages,
setTypingStatus
} from './whatsapp.js';
import { askGemini } from './gemini.js';
import { processMessage } from './utils/processMessage.js';
import { saveMedia } from './utils/saveMedia.js'; // ← NUEVO
/* carpeta raíz donde saveMedia deja todo */
const MEDIA_DIR = '/media';
await fs.mkdir(MEDIA_DIR, { recursive: true });
/* Quita campos pesados antes de mandar a Gemini */
const cleanForGemini = ({ media, reactions, preview, ...rest }) => rest;
export async function processIncoming(raw) {
if (raw.type !== 'chat') await saveMedia(raw);
const msg = processMessage(raw);
const text = msg.text || '';
/* ----- comando @nucleo ----- */
if (/^@nucleo(\s|$)/i.test(text)) {
setTypingStatus(msg.chatId, true);
log('info', '🧠 Generando respuesta…');
/* 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);
}
/* 3) prompt */
const prompt = [
`Eres el asistente del grupo "${context[0]?.chatName ?? 'chat'}".`,
`Pregunta del usuario: ${text}`
, ...context];
/* 4) procesa medias y respeta el límite de 20 MB */
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;
const [msgId, obj] = Object.entries(r.value)[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;
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);
setTypingStatus(msg.chatId, false);
log('info', '🧠 Respuesta enviada');
}
}

35
nucleo-bot/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));
});

185
nucleo-bot/indexv1.js Normal file
View File

@@ -0,0 +1,185 @@
import express from 'express';
import axios from 'axios';
import morgan from 'morgan';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js'; // ⬅OJO: “.js” si usás ESM puro
dayjs.extend(utc);
// ───────────────────────────────────────────
// ⚙️ Variables de entorno (con defaults)
const VERSION = '0.3.21';
const API_URL = process.env.BOT_API_URL ?? 'http://whatsapp-bot:8002';
const GROUP_ID = process.env.GROUP_ID ?? '120363203056794284@g.us';
const REPLY_MSG = process.env.REPLY_MSG ?? 'que pedos';
const PORT = +(process.env.PORT ?? 4000);
const LOG_LEVEL = process.env.LOG_LEVEL ?? 'debug';
const RETRY_MS = +(process.env.RETRY_MS ?? 5_000); // 5 s
const MAX_ATTEMPTS = +(process.env.MAX_ATTEMPTS ?? 60);// 5 min máx
// ───────────────────────────────────────────
// ► Logger interno
function log(level, ...args) {
const levels = ['debug', 'info', 'warn', 'error'];
if (levels.indexOf(level) >= levels.indexOf(LOG_LEVEL)) {
console[level === 'debug' ? 'log' : level](
`[${dayjs().utc().format()}]`, level.toUpperCase(), ...args
);
}
}
// ───────────────────────────────────────────
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(morgan('[:date[iso]] :method :url :status - :response-time ms'));
// ► Util para enviar texto
async function sendText(to, content) {
log('info', `Enviando mensaje a ${to}: "${content}"`);
const { data } = await axios.post(`${API_URL}/sendText`, { args: { to, content } });
log('debug', 'Respuesta de /sendText →', data);
return data;
}
// ► Procesar mensajes entrantes
async function processIncoming(msg) {
const text = msg.body ?? msg.text ?? '';
// 🧠 Extraemos info útil
const info = {
idMensaje: msg.id ?? msg.mId ?? null,
texto: text,
esDeGrupo: msg.isGroupMsg ?? false,
grupoId: msg.chatId ?? null,
nombreGrupo: msg.chat?.name ?? msg.chat?.formattedTitle ?? null,
participantesGrupo: msg.chat?.participantsCount ?? null,
esPrivado: !(msg.isGroupMsg ?? false),
autorId: msg.sender?.id ?? msg.author ?? null,
autorNombre: msg.sender?.name ?? null,
autorPushName: msg.sender?.pushname ?? null,
autorEsContacto: msg.sender?.isMyContact ?? false,
reenviado: msg.isForwarded ?? false,
menciones: msg.mentionedJidList ?? [],
citandoMensaje: msg.isQuotedMsgAvailable ?? false,
tieneReaccion: msg.hasReaction ?? false,
tipo: msg.type ?? 'chat',
timestamp: msg.timestamp ?? null,
fecha: msg.timestamp ? dayjs.unix(msg.timestamp).format('YYYY-MM-DD HH:mm:ss') : null,
};
// 📋 Logueamos limpio
log('debug', '↪︎ Mensaje IN procesado', info);
// 🚀 Acción si menciona al bot
if (/@nucleo/i.test(info.texto)) {
await sendText(GROUP_ID, REPLY_MSG);
}
// 🚀 Acción si pide repetir
if (/@nucleoRepeti/i.test(info.texto)) {
const partes = info.texto.split(/@nucleoRepeti/i);
const contenido = (partes[1]?.trim() || 'vacio');
log('info', `📢 Reenviando: "${contenido}" al chat ${info.grupoId}`);
await sendText(info.grupoId, contenido);
}
}
// ──────── ENDPOINTS ─────────────────────────
app.post('/webhook', async (req, res) => {
const { event, data } = req.body;
log('debug', `📩 Webhook event "${event}"`);
if (event === 'onMessage' || event === 'onAnyMessage') await processIncoming(data);
res.sendStatus(200);
});
app.get('/debug/scan', async (_req, res) => {
const { data } = await axios.post(`${API_URL}/loadAndGetAllMessagesInChat`, {
args: { chatId: 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 });
});
app.get('/debug/send', async (req, res) => {
const text = req.query.msg ?? REPLY_MSG;
const resp = await sendText(GROUP_ID, text);
res.json({ ok: true, resp });
});
app.get('/debug/version', (_req, res) => {
res.json({ version: VERSION });
});
// ────────── INICIALIZACIÓN ──────────────────
async function waitForGateway() {
for (let i = 1; i <= MAX_ATTEMPTS; i++) {
try {
await axios.get(`${API_URL}/api-docs`); // endpoint de salud de open-wa
log('info', '🟢 whatsapp-gateway listo');
return;
} catch {
log('warn', `Gateway no responde (intento ${i}/${MAX_ATTEMPTS})…`);
await new Promise(r => setTimeout(r, RETRY_MS));
}
}
throw new Error('whatsapp-gateway no respondió a tiempo');
}
async function clearWebhooks() {
try {
const { data } = await axios.post(`${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(`${API_URL}/removeWebhook`, { args: { webhookId: h.id } }))
// ⬆️ NOTA: ahora es "webhookId", no "id"
);
results.forEach((result, idx) => {
const id = hooks[idx].id;
if (result.status === 'fulfilled' && result.value?.data?.response === true) {
log('debug', `✔️ Eliminado webhook ${id}`);
} else {
log('warn', `⚠️ Falló eliminar webhook ${id}`);
}
});
const okCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.response === true).length;
log('info', `Limpieza OK (${okCount}/${hooks.length} eliminados)`);
} catch (e) {
log('error', 'Fallo limpiando webhooks:', e.response?.data ?? e.message);
}
}
async function registerWebhook() {
const url = `http://nucleo-bot:${PORT}/webhook`;
const { data } = await axios.post(`${API_URL}/registerWebhook`, {
args: { url, events: ['onAnyMessage'], id: 'nucleo-bot' }
});
log('info', '✔️ Webhook registrado:', data);
}
async function bootstrap() {
await waitForGateway();
await clearWebhooks();
await registerWebhook();
}
app.listen(PORT, () => {
log('info', `🪪 Versión del bot: ${VERSION}`);
log('info', `🚀 nucleobot escuchando en :${PORT}`);
bootstrap().catch(err => log('error', 'Error en bootstrap:', err.message));
});

17
nucleo-bot/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
);
}
}

15
nucleo-bot/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "nucleo-bot",
"version": "0.4.0",
"type": "module",
"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"
}
}

200
nucleo-bot/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;
}

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
nucleo-bot/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);
}
}

4409
sad Normal file

File diff suppressed because it is too large Load Diff