/** * 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 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') } }