All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m10s
- 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
1138 lines
38 KiB
TypeScript
1138 lines
38 KiB
TypeScript
/**
|
|
* 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<string, string> = {
|
|
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<string, any>): Promise<McpResult> {
|
|
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')
|
|
}
|
|
}
|