Files
whatsappNucleo/server/utils/mcp.ts
josedario87 08964ec18f
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m10s
Feat: Agregar sistema de alias para chats
- Agregar campo alias a tabla chats con migración 003
- Crear endpoint PUT /api/messages/:instanceId/:chatId/alias
- Modificar MCP para priorizar alias sobre nombres automáticos
- Crear modal ChatAliasModal para editar alias desde UI
- Agregar botón de editar alias en ChatItem
- Integrar modal en página de mensajes

El alias permite asignar nombres personalizados a chats que tienen
prioridad sobre los nombres de WhatsApp tanto en la interfaz como
en el MCP para agentes IA.
2025-12-04 15:06:46 -06:00

1171 lines
40 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
c.id, c.jid, c.is_group, c.unread_count, c.last_message_at, c.last_message_type, c.alias,
CASE
WHEN c.is_group THEN COALESCE(c.alias, gm.subject, c.name, c.jid)
ELSE COALESCE(c.alias, ct.name, ct.push_name, c.name, SPLIT_PART(c.jid, '@', 1))
END as name
FROM chats c
LEFT JOIN contacts ct ON c.instance_id = ct.instance_id AND c.jid = ct.jid
LEFT JOIN group_metadata gm ON c.instance_id = gm.instance_id AND c.jid = gm.jid
WHERE c.instance_id = $1
ORDER BY c.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,
alias: row.alias,
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
c.id, c.jid, c.is_group, c.unread_count, c.last_message_at, c.last_message_type, c.alias,
CASE
WHEN c.is_group THEN COALESCE(c.alias, gm.subject, c.name, c.jid)
ELSE COALESCE(c.alias, ct.name, ct.push_name, c.name, SPLIT_PART(c.jid, '@', 1))
END as name,
gm.participants as group_participants
FROM chats c
LEFT JOIN contacts ct ON c.instance_id = ct.instance_id AND c.jid = ct.jid
LEFT JOIN group_metadata gm ON c.instance_id = gm.instance_id AND c.jid = gm.jid
WHERE c.id = $1 AND c.instance_id = $2`,
[chatId, instanceId]
)
if (result.rows.length === 0) {
return mcpError('Chat no encontrado')
}
const chat = result.rows[0]
const response: any = {
id: chat.id,
jid: chat.jid,
name: chat.name,
alias: chat.alias,
isGroup: chat.is_group,
unreadCount: chat.unread_count || 0,
lastMessageAt: chat.last_message_at,
lastMessageType: chat.last_message_type
}
// Include participants for groups
if (chat.is_group && chat.group_participants) {
response.participants = chat.group_participants
}
return mcpSuccess({
ok: true,
chat: response
})
}
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
c.id, c.jid, c.is_group, c.unread_count, c.last_message_at, c.alias,
CASE
WHEN c.is_group THEN COALESCE(c.alias, gm.subject, c.name, c.jid)
ELSE COALESCE(c.alias, ct.name, ct.push_name, c.name, SPLIT_PART(c.jid, '@', 1))
END as name
FROM chats c
LEFT JOIN contacts ct ON c.instance_id = ct.instance_id AND c.jid = ct.jid
LEFT JOIN group_metadata gm ON c.instance_id = gm.instance_id AND c.jid = gm.jid
WHERE c.instance_id = $1
AND (c.alias ILIKE $2 OR c.name ILIKE $2 OR c.jid ILIKE $2 OR ct.name ILIKE $2 OR ct.push_name ILIKE $2 OR gm.subject ILIKE $2)
ORDER BY c.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,
alias: row.alias,
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
const fromMe = rawMessage?.key?.fromMe || false
if (!jid) {
return mcpError('No se pudo determinar el JID del mensaje')
}
await baileysManager.sendReaction(instanceId, jid, messageId, emoji, fromMe)
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')
}
}