From 7abad2b6c36d4577629c701b0c933cf9e4c6b9d6 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Thu, 4 Dec 2025 14:05:27 -0600 Subject: [PATCH] Feat: Agregar MCP Server para agentes IA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Endpoint JSON-RPC 2.0 en /api/mcp - 8 herramientas: list_instances, list_chats, get_chat, search_chats, send_message, get_messages, react_message, get_guide - Soporte para todos los tipos de mensaje (text, image, video, audio, document, sticker, contact, poll, event) - Guías de uso integradas con ejemplos JSON - Autenticación via NUXT_MASTER_API_KEY --- README.md | 59 ++ server/api/mcp/index.post.ts | 127 ++++ server/utils/mcp.ts | 1137 ++++++++++++++++++++++++++++++++++ 3 files changed, 1323 insertions(+) create mode 100644 README.md create mode 100644 server/api/mcp/index.post.ts create mode 100644 server/utils/mcp.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ff4c3b --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# WhatsApp Nucleo + +Sistema de gestión centralizada de múltiples instancias de WhatsApp para Nucleo V3. + +## MCP Server para Claude Code + +Agregar el MCP a tu proyecto (usa tu NUXT_MASTER_API_KEY): + +```bash +claude mcp add --transport http whatsapp https://whatsapp.nucleoriofrio.com/api/mcp --header "Authorization: Bearer " +``` + +--- + +## Setup + +```bash +npm install +``` + +## Development + +```bash +npm run dev +``` + +## Production + +```bash +npm run build +node .output/server/index.mjs +``` + +--- + +## API Endpoints + +### Instancias +- `GET /api/instances` - Lista instancias +- `POST /api/instances` - Crear instancia +- `POST /api/instances/:id/connect` - Conectar instancia +- `POST /api/instances/:id/disconnect` - Desconectar + +### Mensajes +- `POST /api/messages/:instanceId/:chatId/send` - Enviar mensaje +- `GET /api/messages/:instanceId/:chatId` - Obtener mensajes +- `POST /api/messages/:instanceId/react` - Reaccionar a mensaje + +### MCP +- `POST /api/mcp` - Endpoint JSON-RPC para agentes IA + +--- + +## Stack + +- **Frontend:** Nuxt 3, Vue 3, Nuxt UI, TailwindCSS +- **Backend:** Nitro, PostgreSQL +- **WhatsApp:** Baileys v6.7.9 +- **Auth:** Authentik + Traefik diff --git a/server/api/mcp/index.post.ts b/server/api/mcp/index.post.ts new file mode 100644 index 0000000..15be1b5 --- /dev/null +++ b/server/api/mcp/index.post.ts @@ -0,0 +1,127 @@ +// MCP Server Endpoint - JSON-RPC 2.0 over HTTP +// Protocolo MCP para agentes de IA - WhatsApp Nucleo + +import { MCP_TOOLS, handleToolCall } from '../../utils/mcp' + +interface JsonRpcRequest { + jsonrpc: '2.0' + id: string | number + method: string + params?: any +} + +interface JsonRpcResponse { + jsonrpc: '2.0' + id: string | number | null + result?: any + error?: { + code: number + message: string + data?: any + } +} + +export default defineEventHandler(async (event) => { + // Validar token de autenticación (usa la misma API key del sistema) + const authHeader = getHeader(event, 'Authorization') + const expectedToken = process.env.NUXT_MASTER_API_KEY + + if (expectedToken) { + const providedToken = authHeader?.replace('Bearer ', '') + if (!providedToken || providedToken !== expectedToken) { + setResponseStatus(event, 401) + return createJsonRpcError(null, -32000, 'Unauthorized: Invalid or missing token') + } + } + + try { + const body = await readBody(event) as JsonRpcRequest + + // Validar JSON-RPC + if (body.jsonrpc !== '2.0') { + return createJsonRpcError(body.id, -32600, 'Invalid Request: jsonrpc must be "2.0"') + } + + if (!body.method) { + return createJsonRpcError(body.id, -32600, 'Invalid Request: method is required') + } + + // Manejar métodos MCP + switch (body.method) { + case 'initialize': { + // Handshake inicial del protocolo MCP + return createJsonRpcResponse(body.id, { + protocolVersion: '2024-11-05', + capabilities: { + tools: {} + }, + serverInfo: { + name: 'whatsapp-nucleo-mcp', + version: '1.0.0' + } + }) + } + + case 'initialized': { + // Notificación de que el cliente está listo + return createJsonRpcResponse(body.id, {}) + } + + case 'tools/list': { + // Listar todas las tools disponibles + return createJsonRpcResponse(body.id, { + tools: MCP_TOOLS + }) + } + + case 'tools/call': { + // Ejecutar una tool + const { name, arguments: args } = body.params || {} + + if (!name) { + return createJsonRpcError(body.id, -32602, 'Invalid params: tool name is required') + } + + // Verificar que la tool existe + const tool = MCP_TOOLS.find(t => t.name === name) + if (!tool) { + return createJsonRpcError(body.id, -32602, `Tool not found: ${name}`) + } + + // Ejecutar la tool + const result = await handleToolCall(name, args || {}) + return createJsonRpcResponse(body.id, result) + } + + case 'ping': { + return createJsonRpcResponse(body.id, {}) + } + + default: + return createJsonRpcError(body.id, -32601, `Method not found: ${body.method}`) + } + } catch (err: any) { + console.error('MCP Error:', err) + return createJsonRpcError(null, -32603, `Internal error: ${err.message}`) + } +}) + +function createJsonRpcResponse(id: string | number | null, result: any): JsonRpcResponse { + return { + jsonrpc: '2.0', + id: id ?? null, + result + } +} + +function createJsonRpcError(id: string | number | null, code: number, message: string, data?: any): JsonRpcResponse { + return { + jsonrpc: '2.0', + id: id ?? null, + error: { + code, + message, + ...(data && { data }) + } + } +} diff --git a/server/utils/mcp.ts b/server/utils/mcp.ts new file mode 100644 index 0000000..e0c9d48 --- /dev/null +++ b/server/utils/mcp.ts @@ -0,0 +1,1137 @@ +/** + * MCP Tools and Handlers for WhatsApp Nucleo + * Permite a agentes de IA manejar conversaciones de WhatsApp + */ +import { query } from './database' +import { baileysManager } from '../services/baileys/manager' +import { generateWAMessageFromContent, proto } from '@whiskeysockets/baileys' + +// ============================================================================ +// GUÍAS DE USO +// ============================================================================ + +export const MCP_GUIDES: Record = { + message_types: ` +╔══════════════════════════════════════════════════════════════╗ +║ TIPOS DE MENSAJE SOPORTADOS ║ +╠══════════════════════════════════════════════════════════════╣ +║ text - Mensaje de texto plano ║ +║ image - Imagen con caption opcional ║ +║ video - Video con caption opcional ║ +║ audio - Audio o nota de voz (PTT) ║ +║ document - Documento con nombre de archivo ║ +║ sticker - Sticker (se convierte automáticamente a WebP) ║ +║ contact - Contacto vCard ║ +║ poll - Encuesta (máx 12 opciones) ║ +║ event - Evento con fecha/hora y ubicación opcional ║ +╚══════════════════════════════════════════════════════════════╝ + +NOTAS IMPORTANTES: +- Para media (image, video, audio, document, sticker) se requiere mediaUrl +- Los archivos se descargan automáticamente desde la URL proporcionada +- El sticker se convierte automáticamente a WebP +- Los límites de tamaño son: imagen 16MB, video 64MB, audio 16MB, documento 100MB +`, + + chat_ids: ` +╔══════════════════════════════════════════════════════════════╗ +║ FORMATO DE CHAT ID ║ +╠══════════════════════════════════════════════════════════════╣ +║ Contacto individual: "5491155551234@s.whatsapp.net" ║ +║ Grupo: "123456789012345678@g.us" ║ +╚══════════════════════════════════════════════════════════════╝ + +REGLAS: +- Usar código de país SIN el símbolo + (ej: 54 para Argentina) +- No incluir guiones ni espacios +- Los grupos tienen un ID numérico largo terminado en @g.us + +EJEMPLOS: +- Argentina: 5491155551234@s.whatsapp.net +- México: 5215512345678@s.whatsapp.net +- España: 34612345678@s.whatsapp.net +- Grupo: 120363123456789012@g.us +`, + + media: ` +╔══════════════════════════════════════════════════════════════╗ +║ ENVÍO DE ARCHIVOS MULTIMEDIA ║ +╠══════════════════════════════════════════════════════════════╣ +║ type: "image" → mediaUrl + caption opcional ║ +║ type: "video" → mediaUrl + caption opcional ║ +║ type: "audio" → mediaUrl + ptt (true para nota de voz) ║ +║ type: "document" → mediaUrl + fileName ║ +║ type: "sticker" → mediaUrl (se convierte a WebP auto) ║ +╚══════════════════════════════════════════════════════════════╝ + +LÍMITES DE TAMAÑO: +- Imagen: 16 MB +- Video: 64 MB +- Audio: 16 MB +- Documento: 100 MB +- Sticker: 500 KB (antes de conversión) + +FORMATOS SOPORTADOS: +- Imagen: JPG, PNG, WebP, GIF +- Video: MP4, 3GP, MOV +- Audio: MP3, OGG, WAV, M4A, OPUS +- Documento: Cualquier tipo de archivo +- Sticker: JPG, PNG, WebP (se convierte a WebP 512x512) +`, + + reactions: ` +╔══════════════════════════════════════════════════════════════╗ +║ REACCIONES A MENSAJES ║ +╠══════════════════════════════════════════════════════════════╣ +║ Usar whatsapp_react_message con: ║ +║ - messageId: ID del mensaje a reaccionar ║ +║ - emoji: El emoji de reacción ║ +╚══════════════════════════════════════════════════════════════╝ + +EMOJIS COMUNES: +- 👍 Pulgar arriba +- ❤️ Corazón +- 😂 Risa +- 😮 Sorpresa +- 😢 Tristeza +- 🙏 Gracias + +QUITAR REACCIÓN: +Enviar emoji vacío "" para quitar la reacción +`, + + polls: ` +╔══════════════════════════════════════════════════════════════╗ +║ ENCUESTAS (POLLS) ║ +╠══════════════════════════════════════════════════════════════╣ +║ pollName: Pregunta de la encuesta ║ +║ pollOptions: Array de opciones (mín 2, máx 12) ║ +║ pollSelectMultiple: true/false (default: false) ║ +╚══════════════════════════════════════════════════════════════╝ + +EJEMPLO: +{ + "type": "poll", + "pollName": "¿Qué día prefieres para la reunión?", + "pollOptions": ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes"], + "pollSelectMultiple": false +} + +NOTA: Las encuestas solo se pueden enviar a grupos o chats individuales +`, + + events: ` +╔══════════════════════════════════════════════════════════════╗ +║ EVENTOS ║ +╠══════════════════════════════════════════════════════════════╣ +║ eventName: Nombre del evento (requerido) ║ +║ eventDescription: Descripción (opcional) ║ +║ eventStartTime: Fecha/hora en formato ISO 8601 (requerido) ║ +║ eventLocation: Ubicación del evento (opcional) ║ +╚══════════════════════════════════════════════════════════════╝ + +FORMATO DE FECHA: +- ISO 8601: "2025-01-15T14:00:00Z" +- Con zona horaria: "2025-01-15T14:00:00-03:00" + +EJEMPLO: +{ + "type": "event", + "eventName": "Reunión de equipo", + "eventDescription": "Revisión semanal del proyecto", + "eventStartTime": "2025-01-15T14:00:00Z", + "eventLocation": "Oficina central, Piso 3" +} +`, + + contacts: ` +╔══════════════════════════════════════════════════════════════╗ +║ ENVÍO DE CONTACTOS ║ +╠══════════════════════════════════════════════════════════════╣ +║ contacts: Array de objetos con: ║ +║ - displayName: Nombre a mostrar ║ +║ - phones: Array de números de teléfono ║ +╚══════════════════════════════════════════════════════════════╝ + +EJEMPLO: +{ + "type": "contact", + "contacts": [ + { + "displayName": "Juan Pérez", + "phones": ["+54 9 11 5555-1234", "+54 9 11 5555-5678"] + }, + { + "displayName": "María García", + "phones": ["+54 9 11 4444-1234"] + } + ] +} + +NOTA: Se pueden enviar múltiples contactos en un solo mensaje +` +} + +// ============================================================================ +// DEFINICIÓN DE HERRAMIENTAS MCP +// ============================================================================ + +export const MCP_TOOLS = [ + // ======================== INSTANCIAS (solo lectura) ======================== + { + name: 'whatsapp_list_instances', + description: `Lista todas las instancias de WhatsApp disponibles. + +Retorna información básica de cada instancia para que puedas identificar +qué instancia usar para enviar mensajes. + +RESPUESTA: +- id: ID único de la instancia +- name: Nombre descriptivo +- status: Estado de conexión (connected, disconnected, connecting) +- phoneNumber: Número de WhatsApp asociado (si está conectado) + +IMPORTANTE: Usa el 'id' de la instancia en las demás herramientas.`, + inputSchema: { + type: 'object', + properties: {}, + required: [] + } + }, + + // ======================== CHATS ======================== + { + name: 'whatsapp_list_chats', + description: `Lista los chats de una instancia de WhatsApp. + +Retorna los chats ordenados por última actividad. + +PARÁMETROS: +- instanceId: ID de la instancia (obtener con whatsapp_list_instances) +- limit: Cantidad máxima de chats (default: 50, max: 100) + +RESPUESTA por cada chat: +- id: ID del chat (usar en whatsapp_send_message y whatsapp_get_messages) +- jid: JID de WhatsApp (número@s.whatsapp.net o grupo@g.us) +- name: Nombre del chat o contacto +- isGroup: true si es un grupo +- unreadCount: Cantidad de mensajes no leídos +- lastMessageAt: Fecha del último mensaje +- lastMessageType: Tipo del último mensaje`, + inputSchema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'ID de la instancia de WhatsApp' + }, + limit: { + type: 'number', + description: 'Cantidad máxima de chats a retornar (default: 50, max: 100)' + } + }, + required: ['instanceId'] + } + }, + + { + name: 'whatsapp_get_chat', + description: `Obtiene información detallada de un chat específico. + +PARÁMETROS: +- instanceId: ID de la instancia +- chatId: ID del chat (obtenido de whatsapp_list_chats) + +RESPUESTA: +- id: ID del chat +- jid: JID de WhatsApp +- name: Nombre del chat +- isGroup: true si es un grupo +- unreadCount: Mensajes no leídos +- lastMessageAt: Último mensaje +- participants: Lista de participantes (solo grupos)`, + inputSchema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'ID de la instancia de WhatsApp' + }, + chatId: { + type: 'string', + description: 'ID del chat' + } + }, + required: ['instanceId', 'chatId'] + } + }, + + { + name: 'whatsapp_search_chats', + description: `Busca chats por nombre o número de teléfono. + +PARÁMETROS: +- instanceId: ID de la instancia +- query: Texto a buscar (nombre o número) +- limit: Cantidad máxima de resultados (default: 20) + +RESPUESTA: Lista de chats que coinciden con la búsqueda`, + inputSchema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'ID de la instancia de WhatsApp' + }, + query: { + type: 'string', + description: 'Texto a buscar (nombre o número)' + }, + limit: { + type: 'number', + description: 'Cantidad máxima de resultados (default: 20)' + } + }, + required: ['instanceId', 'query'] + } + }, + + // ======================== MENSAJES ======================== + { + name: 'whatsapp_send_message', + description: `Envía un mensaje de WhatsApp. Soporta múltiples tipos de contenido. + +╔══════════════════════════════════════════════════════════════╗ +║ TIPOS DE MENSAJE SOPORTADOS ║ +╠══════════════════════════════════════════════════════════════╣ +║ text - Mensaje de texto plano ║ +║ image - Imagen con caption opcional ║ +║ video - Video con caption opcional ║ +║ audio - Audio o nota de voz (PTT) ║ +║ document - Documento con nombre de archivo ║ +║ sticker - Sticker (se convierte automáticamente a WebP) ║ +║ contact - Contacto vCard ║ +║ poll - Encuesta (máx 12 opciones) ║ +║ event - Evento con fecha/hora y ubicación opcional ║ +╚══════════════════════════════════════════════════════════════╝ + +IMPORTANTE - CÓMO ESPECIFICAR EL DESTINATARIO: +Puedes usar CUALQUIERA de estos métodos: +1. chatId: ID del chat (obtenido de whatsapp_list_chats) +2. jid: JID directo del destinatario (número@s.whatsapp.net o grupo@g.us) + +FORMATO DE JID: +- Contacto individual: "5491155551234@s.whatsapp.net" +- Grupo: "123456789012345678@g.us" +- Usar número con código de país SIN el + inicial + +EJEMPLOS POR TIPO: + +📝 TEXTO: +{ + "instanceId": "inst_abc123", + "jid": "5491155551234@s.whatsapp.net", + "type": "text", + "text": "Hola! Este es un mensaje de texto" +} + +🖼️ IMAGEN (con URL): +{ + "instanceId": "inst_abc123", + "jid": "5491155551234@s.whatsapp.net", + "type": "image", + "mediaUrl": "https://example.com/imagen.jpg", + "caption": "Descripción opcional de la imagen" +} + +📹 VIDEO: +{ + "instanceId": "inst_abc123", + "jid": "5491155551234@s.whatsapp.net", + "type": "video", + "mediaUrl": "https://example.com/video.mp4", + "caption": "Video de ejemplo" +} + +🎵 AUDIO (nota de voz): +{ + "instanceId": "inst_abc123", + "jid": "5491155551234@s.whatsapp.net", + "type": "audio", + "mediaUrl": "https://example.com/audio.ogg", + "ptt": true +} + +📄 DOCUMENTO: +{ + "instanceId": "inst_abc123", + "jid": "5491155551234@s.whatsapp.net", + "type": "document", + "mediaUrl": "https://example.com/archivo.pdf", + "fileName": "reporte.pdf" +} + +👤 CONTACTO: +{ + "instanceId": "inst_abc123", + "jid": "5491155551234@s.whatsapp.net", + "type": "contact", + "contacts": [ + { + "displayName": "Juan Pérez", + "phones": ["+54 9 11 5555-1234"] + } + ] +} + +📊 ENCUESTA (POLL): +{ + "instanceId": "inst_abc123", + "jid": "5491155551234@s.whatsapp.net", + "type": "poll", + "pollName": "¿Qué día prefieres?", + "pollOptions": ["Lunes", "Martes", "Miércoles"], + "pollSelectMultiple": false +} + +📅 EVENTO: +{ + "instanceId": "inst_abc123", + "jid": "5491155551234@s.whatsapp.net", + "type": "event", + "eventName": "Reunión de equipo", + "eventDescription": "Revisión semanal del proyecto", + "eventStartTime": "2025-01-15T14:00:00Z", + "eventLocation": "Oficina central" +} + +RESPONDER A UN MENSAJE (quoted): +Agregar "quotedMessageId" con el ID del mensaje a citar: +{ + "instanceId": "inst_abc123", + "jid": "5491155551234@s.whatsapp.net", + "type": "text", + "text": "Esta es mi respuesta", + "quotedMessageId": "3EB0A1B2C3D4E5F6" +}`, + inputSchema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'ID de la instancia de WhatsApp' + }, + chatId: { + type: 'string', + description: 'ID del chat (alternativa a jid, obtenido de whatsapp_list_chats)' + }, + jid: { + type: 'string', + description: 'JID directo del destinatario (alternativa a chatId). Formato: número@s.whatsapp.net o grupo@g.us' + }, + type: { + type: 'string', + enum: ['text', 'image', 'video', 'audio', 'document', 'sticker', 'contact', 'poll', 'event'], + description: 'Tipo de mensaje a enviar' + }, + text: { + type: 'string', + description: 'Texto del mensaje (para type=text)' + }, + mediaUrl: { + type: 'string', + description: 'URL del archivo multimedia (para image/video/audio/document/sticker)' + }, + caption: { + type: 'string', + description: 'Descripción del medio (para image/video/document)' + }, + fileName: { + type: 'string', + description: 'Nombre del archivo (para document)' + }, + ptt: { + type: 'boolean', + description: 'true para nota de voz (para audio)' + }, + contacts: { + type: 'array', + description: 'Lista de contactos (para type=contact)', + items: { + type: 'object', + properties: { + displayName: { type: 'string', description: 'Nombre del contacto' }, + phones: { + type: 'array', + items: { type: 'string' }, + description: 'Números de teléfono' + } + }, + required: ['displayName', 'phones'] + } + }, + pollName: { + type: 'string', + description: 'Pregunta de la encuesta (para type=poll)' + }, + pollOptions: { + type: 'array', + items: { type: 'string' }, + description: 'Opciones de la encuesta (mín 2, máx 12)' + }, + pollSelectMultiple: { + type: 'boolean', + description: 'Permitir selección múltiple en encuesta' + }, + eventName: { + type: 'string', + description: 'Nombre del evento (para type=event)' + }, + eventDescription: { + type: 'string', + description: 'Descripción del evento' + }, + eventStartTime: { + type: 'string', + description: 'Fecha/hora del evento en formato ISO 8601' + }, + eventLocation: { + type: 'string', + description: 'Ubicación del evento' + }, + quotedMessageId: { + type: 'string', + description: 'ID del mensaje a citar (para respuestas)' + } + }, + required: ['instanceId', 'type'] + } + }, + + { + name: 'whatsapp_get_messages', + description: `Obtiene los mensajes de un chat. + +PARÁMETROS: +- instanceId: ID de la instancia +- chatId: ID del chat +- limit: Cantidad de mensajes (default: 50, max: 100) +- before: Timestamp para paginación (obtener mensajes anteriores a esta fecha) + +RESPUESTA por cada mensaje: +- id: ID interno del mensaje +- messageId: ID de WhatsApp del mensaje +- fromMe: true si lo envié yo +- type: Tipo de mensaje (text, image, video, audio, document, sticker, contact, location, poll, event) +- content: Contenido del mensaje (texto o descripción) +- caption: Caption de media +- media: Info de archivo multimedia +- timestamp: Fecha/hora del mensaje +- status: Estado (sent, delivered, read) +- participant: JID del participante (en grupos) +- pushName: Nombre del remitente +- quoted: Mensaje citado (si existe)`, + inputSchema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'ID de la instancia de WhatsApp' + }, + chatId: { + type: 'string', + description: 'ID del chat' + }, + limit: { + type: 'number', + description: 'Cantidad de mensajes (default: 50, max: 100)' + }, + before: { + type: 'string', + description: 'Timestamp ISO para paginación' + } + }, + required: ['instanceId', 'chatId'] + } + }, + + { + name: 'whatsapp_react_message', + description: `Envía una reacción (emoji) a un mensaje. + +PARÁMETROS: +- instanceId: ID de la instancia +- messageId: ID del mensaje a reaccionar (obtenido de whatsapp_get_messages) +- emoji: El emoji de reacción (ej: "👍", "❤️", "😂") + +PARA QUITAR UNA REACCIÓN: +Enviar emoji como string vacío "" + +EMOJIS COMUNES: +👍 ❤️ 😂 😮 😢 🙏 🎉 🔥 👏 💯 + +EJEMPLO: +{ + "instanceId": "inst_abc123", + "messageId": "3EB0A1B2C3D4E5F6", + "emoji": "👍" +}`, + inputSchema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'ID de la instancia de WhatsApp' + }, + messageId: { + type: 'string', + description: 'ID del mensaje a reaccionar' + }, + emoji: { + type: 'string', + description: 'Emoji de reacción (string vacío para quitar)' + } + }, + required: ['instanceId', 'messageId', 'emoji'] + } + }, + + // ======================== GUÍA ======================== + { + name: 'whatsapp_get_guide', + description: `Obtiene una guía de uso detallada. + +GUÍAS DISPONIBLES: +- message_types: Todos los tipos de mensaje con descripciones +- chat_ids: Formato de IDs de chat (@s.whatsapp.net, @g.us) +- media: Cómo enviar imágenes, videos, audios, documentos +- reactions: Emojis soportados para reacciones +- polls: Crear encuestas (límites y formato) +- events: Crear eventos con fecha/ubicación +- contacts: Enviar contactos vCard + +EJEMPLO: +{ + "topic": "message_types" +}`, + inputSchema: { + type: 'object', + properties: { + topic: { + type: 'string', + enum: ['message_types', 'chat_ids', 'media', 'reactions', 'polls', 'events', 'contacts'], + description: 'Tema de la guía' + } + }, + required: ['topic'] + } + } +] + +// ============================================================================ +// HANDLERS DE HERRAMIENTAS +// ============================================================================ + +interface McpResult { + content: Array<{ type: 'text'; text: string }> + isError?: boolean +} + +function mcpSuccess(data: any): McpResult { + return { + content: [{ + type: 'text', + text: JSON.stringify(data, null, 2) + }] + } +} + +function mcpError(message: string): McpResult { + return { + content: [{ + type: 'text', + text: JSON.stringify({ ok: false, error: message }) + }], + isError: true + } +} + +export async function handleToolCall(toolName: string, args: Record): Promise { + try { + switch (toolName) { + // ==================== INSTANCIAS ==================== + case 'whatsapp_list_instances': { + const result = await query( + `SELECT id, name, status, phone_number + FROM instances + ORDER BY name` + ) + + return mcpSuccess({ + ok: true, + instances: result.rows.map(row => ({ + id: row.id, + name: row.name, + status: row.status, + phoneNumber: row.phone_number + })) + }) + } + + // ==================== CHATS ==================== + case 'whatsapp_list_chats': { + const { instanceId, limit = 50 } = args + + if (!instanceId) { + return mcpError('instanceId es requerido') + } + + const result = await query( + `SELECT id, jid, name, is_group, unread_count, last_message_at, last_message_type + FROM chats + WHERE instance_id = $1 + ORDER BY last_message_at DESC NULLS LAST + LIMIT $2`, + [instanceId, Math.min(limit, 100)] + ) + + return mcpSuccess({ + ok: true, + chats: result.rows.map(row => ({ + id: row.id, + jid: row.jid, + name: row.name, + isGroup: row.is_group, + unreadCount: row.unread_count || 0, + lastMessageAt: row.last_message_at, + lastMessageType: row.last_message_type + })) + }) + } + + case 'whatsapp_get_chat': { + const { instanceId, chatId } = args + + if (!instanceId || !chatId) { + return mcpError('instanceId y chatId son requeridos') + } + + const result = await query( + `SELECT id, jid, name, is_group, unread_count, last_message_at, last_message_type + FROM chats + WHERE id = $1 AND instance_id = $2`, + [chatId, instanceId] + ) + + if (result.rows.length === 0) { + return mcpError('Chat no encontrado') + } + + const chat = result.rows[0] + return mcpSuccess({ + ok: true, + chat: { + id: chat.id, + jid: chat.jid, + name: chat.name, + isGroup: chat.is_group, + unreadCount: chat.unread_count || 0, + lastMessageAt: chat.last_message_at, + lastMessageType: chat.last_message_type + } + }) + } + + case 'whatsapp_search_chats': { + const { instanceId, query: searchQuery, limit = 20 } = args + + if (!instanceId || !searchQuery) { + return mcpError('instanceId y query son requeridos') + } + + const result = await query( + `SELECT id, jid, name, is_group, unread_count, last_message_at + FROM chats + WHERE instance_id = $1 + AND (name ILIKE $2 OR jid ILIKE $2) + ORDER BY last_message_at DESC NULLS LAST + LIMIT $3`, + [instanceId, `%${searchQuery}%`, Math.min(limit, 50)] + ) + + return mcpSuccess({ + ok: true, + chats: result.rows.map(row => ({ + id: row.id, + jid: row.jid, + name: row.name, + isGroup: row.is_group, + unreadCount: row.unread_count || 0, + lastMessageAt: row.last_message_at + })) + }) + } + + // ==================== MENSAJES ==================== + case 'whatsapp_send_message': { + const { instanceId, chatId, jid: directJid, type } = args + + if (!instanceId) { + return mcpError('instanceId es requerido') + } + + if (!type) { + return mcpError('type es requerido') + } + + // Determinar el JID del destinatario + let targetJid: string + + if (directJid) { + // Usar JID directo si se proporciona + targetJid = directJid + } else if (chatId) { + // Buscar JID en la base de datos + const chatResult = await query( + 'SELECT jid FROM chats WHERE id = $1 AND instance_id = $2', + [chatId, instanceId] + ) + + if (chatResult.rows.length === 0) { + return mcpError('Chat no encontrado') + } + + targetJid = chatResult.rows[0].jid + } else { + return mcpError('Se requiere chatId o jid') + } + + // Obtener socket + const socket = baileysManager.getSocket(instanceId) + if (!socket) { + return mcpError('Instancia no conectada') + } + + // Obtener mensaje citado si existe + let quotedMessage = null + if (args.quotedMessageId) { + const quotedResult = await query( + 'SELECT raw_message FROM messages WHERE message_id = $1 AND instance_id = $2', + [args.quotedMessageId, instanceId] + ) + if (quotedResult.rows.length > 0) { + quotedMessage = quotedResult.rows[0].raw_message + } + } + + const options: any = {} + if (quotedMessage) { + options.quoted = quotedMessage + } + + // Enviar según tipo + let result: any + let messageType = type + + switch (type) { + case 'text': { + if (!args.text?.trim()) { + return mcpError('text es requerido para mensajes de texto') + } + result = await socket.sendMessage(targetJid, { text: args.text }, options) + break + } + + case 'image': + case 'video': + case 'audio': + case 'document': + case 'sticker': { + if (!args.mediaUrl) { + return mcpError('mediaUrl es requerido para mensajes multimedia') + } + + // Descargar archivo desde URL + const response = await fetch(args.mediaUrl) + if (!response.ok) { + return mcpError(`Error descargando archivo: ${response.status}`) + } + + const buffer = Buffer.from(await response.arrayBuffer()) + const contentType = response.headers.get('content-type') || 'application/octet-stream' + + let content: any + + if (type === 'image') { + content = { image: buffer, caption: args.caption, mimetype: contentType } + } else if (type === 'video') { + content = { video: buffer, caption: args.caption, mimetype: contentType } + } else if (type === 'audio') { + content = { audio: buffer, ptt: args.ptt || false, mimetype: contentType } + } else if (type === 'document') { + content = { + document: buffer, + fileName: args.fileName || 'document', + caption: args.caption, + mimetype: contentType + } + } else if (type === 'sticker') { + // Para stickers, importar y convertir + const { convertToSticker } = await import('../services/media/sticker-processor') + const stickerResult = await convertToSticker(buffer, contentType) + content = { sticker: stickerResult.buffer } + } + + result = await socket.sendMessage(targetJid, content, options) + break + } + + case 'contact': { + if (!args.contacts || args.contacts.length === 0) { + return mcpError('contacts es requerido para mensajes de contacto') + } + + const vcards = args.contacts.map((contact: any) => { + const phoneNumber = contact.phones?.[0] || '' + const lines = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `FN:${contact.displayName}`, + `TEL;type=CELL;waid=${phoneNumber.replace(/\D/g, '')}:${phoneNumber}`, + 'END:VCARD' + ] + return lines.join('\n') + }) + + const content = { + contacts: { + displayName: args.contacts.length === 1 + ? args.contacts[0].displayName + : `${args.contacts.length} contactos`, + contacts: vcards.map((vcard: string) => ({ vcard })) + } + } + + result = await socket.sendMessage(targetJid, content, options) + break + } + + case 'poll': { + if (!args.pollName?.trim()) { + return mcpError('pollName es requerido para encuestas') + } + if (!args.pollOptions || args.pollOptions.length < 2) { + return mcpError('Se requieren al menos 2 opciones para encuestas') + } + if (args.pollOptions.length > 12) { + return mcpError('Máximo 12 opciones permitidas') + } + + const content = { + poll: { + name: args.pollName, + values: args.pollOptions, + selectableCount: args.pollSelectMultiple ? args.pollOptions.length : 1 + } + } + + result = await socket.sendMessage(targetJid, content, options) + break + } + + case 'event': { + if (!args.eventName?.trim()) { + return mcpError('eventName es requerido para eventos') + } + if (!args.eventStartTime) { + return mcpError('eventStartTime es requerido para eventos') + } + + const eventMessage: proto.Message.IEventMessage = { + name: args.eventName, + startTime: Math.floor(new Date(args.eventStartTime).getTime() / 1000), + description: args.eventDescription || undefined, + extraGuestsAllowed: true + } + + if (args.eventLocation) { + // Location simple como texto en description si no hay coordenadas + if (!eventMessage.description) { + eventMessage.description = `Ubicación: ${args.eventLocation}` + } else { + eventMessage.description += `\nUbicación: ${args.eventLocation}` + } + } + + const messageContent: proto.IMessage = { eventMessage } + const userJid = socket.user?.id + + const generatedMessage = generateWAMessageFromContent( + targetJid, + messageContent, + { userJid, quoted: options.quoted, timestamp: new Date() } + ) + + await socket.relayMessage(targetJid, generatedMessage.message!, { + messageId: generatedMessage.key.id + }) + + result = generatedMessage + break + } + + default: + return mcpError(`Tipo de mensaje no soportado: ${type}`) + } + + // Guardar en DB y actualizar chat + if (result?.key?.id) { + // Obtener o crear chatId + let dbChatId = chatId + if (!dbChatId) { + const chatResult = await query( + `INSERT INTO chats (instance_id, jid, name, is_group) + VALUES ($1, $2, $3, $4) + ON CONFLICT (instance_id, jid) DO UPDATE SET name = EXCLUDED.name + RETURNING id`, + [instanceId, targetJid, targetJid.split('@')[0], targetJid.endsWith('@g.us')] + ) + dbChatId = chatResult.rows[0].id + } + + await query( + `INSERT INTO messages ( + instance_id, chat_id, message_id, from_jid, from_me, + message_type, content, timestamp, status, raw_message, quoted_message_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) + ON CONFLICT (instance_id, message_id) DO NOTHING`, + [ + instanceId, + dbChatId, + result.key.id, + 'me', + true, + messageType, + args.text || args.caption || args.pollName || args.eventName || null, + 'sent', + JSON.stringify(result), + args.quotedMessageId || null + ] + ) + + await query( + `UPDATE chats SET last_message_at = NOW(), last_message_type = $1 WHERE id = $2`, + [messageType, dbChatId] + ) + } + + return mcpSuccess({ + ok: true, + messageId: result?.key?.id, + type: messageType, + jid: targetJid + }) + } + + case 'whatsapp_get_messages': { + const { instanceId, chatId, limit = 50, before } = args + + if (!instanceId || !chatId) { + return mcpError('instanceId y chatId son requeridos') + } + + let messagesQuery = ` + SELECT id, message_id, from_jid, from_me, message_type, + content, caption, timestamp, status, participant_jid, push_name + FROM messages + WHERE chat_id = $1 + ` + const params: any[] = [chatId] + + if (before) { + messagesQuery += ` AND timestamp < $${params.length + 1}` + params.push(before) + } + + messagesQuery += ` ORDER BY timestamp DESC LIMIT $${params.length + 1}` + params.push(Math.min(limit, 100)) + + const result = await query(messagesQuery, params) + + return mcpSuccess({ + ok: true, + messages: result.rows.map(row => ({ + id: row.id, + messageId: row.message_id, + fromMe: row.from_me, + type: row.message_type || 'text', + content: row.content, + caption: row.caption, + timestamp: row.timestamp, + status: row.status, + participant: row.participant_jid, + pushName: row.push_name + })) + }) + } + + case 'whatsapp_react_message': { + const { instanceId, messageId, emoji } = args + + if (!instanceId || !messageId || emoji === undefined) { + return mcpError('instanceId, messageId y emoji son requeridos') + } + + // Obtener el mensaje para encontrar el JID + const msgResult = await query( + `SELECT raw_message FROM messages WHERE instance_id = $1 AND message_id = $2`, + [instanceId, messageId] + ) + + if (msgResult.rows.length === 0) { + return mcpError('Mensaje no encontrado') + } + + const rawMessage = msgResult.rows[0].raw_message + const jid = rawMessage?.key?.remoteJid + + if (!jid) { + return mcpError('No se pudo determinar el JID del mensaje') + } + + await baileysManager.sendReaction(instanceId, jid, messageId, emoji) + + return mcpSuccess({ + ok: true, + messageId, + emoji + }) + } + + // ==================== GUÍA ==================== + case 'whatsapp_get_guide': { + const { topic } = args + + if (!topic || !MCP_GUIDES[topic]) { + return mcpSuccess({ + ok: true, + availableTopics: Object.keys(MCP_GUIDES), + message: 'Especifica un topic válido' + }) + } + + return mcpSuccess({ + ok: true, + topic, + guide: MCP_GUIDES[topic] + }) + } + + default: + return mcpError(`Herramienta no encontrada: ${toolName}`) + } + } catch (error: any) { + console.error(`[MCP] Error en ${toolName}:`, error) + return mcpError(error.message || 'Error interno') + } +}