//// import type { Database } from 'bun:sqlite'; import { CommandService } from './services/command'; import { GroupSyncService } from './services/group-sync'; import { ResponseQueue } from './services/response-queue'; import { TaskService } from './tasks/service'; import { WebhookManager } from './services/webhook-manager'; import { normalizeWhatsAppId, isGroupId } from './utils/whatsapp'; import { ensureUserExists, db, initializeDatabase } from './db'; import { ContactsService } from './services/contacts'; // Bun is available globally when running under Bun runtime declare global { var Bun: typeof import('bun'); } export const REQUIRED_ENV = [ 'EVOLUTION_API_URL', 'EVOLUTION_API_KEY', 'EVOLUTION_API_INSTANCE', 'CHATBOT_PHONE_NUMBER', 'WEBHOOK_URL' ]; type WebhookPayload = { event: string; instance: string; data: any; // Other fields from Evolution API }; export class WebhookServer { static dbInstance: Database = db; private static getBaseUrl(request: Request): string { const proto = request.headers.get('x-forwarded-proto') || 'http'; const host = request.headers.get('x-forwarded-host') || request.headers.get('host'); return `${proto}://${host}`; } private static getMessageText(message: any): string { if (!message || typeof message !== 'object') return ''; const text = message.conversation || message?.extendedTextMessage?.text || message?.imageMessage?.caption || message?.videoMessage?.caption || ''; return typeof text === 'string' ? text.trim() : ''; } static async handleRequest(request: Request): Promise { // Health check endpoint const url = new URL(request.url); if (url.pathname.endsWith('/health')) { return new Response('OK', { status: 200 }); } if (process.env.NODE_ENV !== 'test') { console.log('ℹ️ Incoming webhook request:') } // 1. Method validation if (request.method !== 'POST') { return new Response('🚫 Method not allowed', { status: 405 }); } // 2. Content-Type validation const contentType = request.headers.get('content-type'); if (!contentType?.includes('application/json')) { return new Response('🚫 Invalid content type', { status: 400 }); } try { // 3. Parse and validate payload const payload = await request.json() as WebhookPayload; if (!payload.event || !payload.instance) { return new Response('🚫 Invalid payload', { status: 400 }); } // 4. Verify instance matches (skip in test environment unless TEST_VERIFY_INSTANCE is set) if ((process.env.NODE_ENV !== 'test' || process.env.TEST_VERIFY_INSTANCE) && payload.instance !== process.env.EVOLUTION_API_INSTANCE) { return new Response('🚫 Invalid instance', { status: 403 }); } // 5. Route events // console.log('ℹ️ Webhook event received:', { // event: payload.event, // instance: payload.instance, // data: payload.data ? '[...]' : null // }); // Normalize event name to handle different casing/format (e.g., MESSAGES_UPSERT) const evt = String(payload.event); const evtNorm = evt.toLowerCase().replace(/_/g, '.'); switch (evtNorm) { case 'messages.upsert': if (process.env.NODE_ENV !== 'test') { console.log('ℹ️ Handling message upsert:', { groupId: payload.data?.key?.remoteJid, message: payload.data?.message?.conversation, rawEvent: evt }); } await WebhookServer.handleMessageUpsert(payload.data); break; case 'contacts.update': case 'chats.update': if (process.env.NODE_ENV !== 'test') { console.log('ℹ️ Handling contacts/chats update event:', { rawEvent: evt }); } ContactsService.updateFromWebhook(payload.data); break; // Other events will be added later } return new Response('OK', { status: 200 }); } catch (error) { console.error('❌ Error processing webhook:', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, time: new Date().toISOString() }); return new Response('Invalid request', { status: 400 }); } } static async handleMessageUpsert(data: any) { if (!data?.key?.remoteJid || !data.message) { if (process.env.NODE_ENV !== 'test') { console.log('⚠️ Invalid message format - missing required fields'); console.log(data); } return; } const messageText = WebhookServer.getMessageText(data.message); if (!messageText) { if (process.env.NODE_ENV !== 'test') { console.log('⚠️ Empty or unsupported message content'); } return; } // Normalize sender ID for consistency and validation const normalizedSenderId = normalizeWhatsAppId(data.key.participant); if (!normalizedSenderId) { if (process.env.NODE_ENV !== 'test') { console.log('⚠️ Invalid sender ID, ignoring message'); } return; } // Ensure user exists in database (swallow DB errors to keep webhook 200) let userId: string | null = null; try { userId = ensureUserExists(data.key.participant, WebhookServer.dbInstance); } catch (e) { if (process.env.NODE_ENV !== 'test') { console.error('⚠️ Error ensuring user exists, ignoring message:', e); } return; } if (!userId) { if (process.env.NODE_ENV !== 'test') { console.log('⚠️ Failed to ensure user exists, ignoring message'); } return; } // Check if the group is active (allow DMs always) if (isGroupId(data.key.remoteJid) && !GroupSyncService.isGroupActive(data.key.remoteJid)) { if (process.env.NODE_ENV !== 'test') { console.log('⚠️ Group is not active, ignoring message'); } return; } // Forward to command service only if it's a text-ish message and starts with /t or /tarea const messageTextTrimmed = messageText.trim(); if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) { // Extraer menciones desde el mensaje (varios formatos) const mentions = data.message?.contextInfo?.mentionedJid || data.message?.extendedTextMessage?.contextInfo?.mentionedJid || data.message?.imageMessage?.contextInfo?.mentionedJid || data.message?.videoMessage?.contextInfo?.mentionedJid || []; // Asegurar que CommandService y TaskService usen la misma DB (tests/producción) (CommandService as any).dbInstance = WebhookServer.dbInstance; (TaskService as any).dbInstance = WebhookServer.dbInstance; // Delegar el manejo del comando const responses = await CommandService.handle({ sender: normalizedSenderId, groupId: data.key.remoteJid, message: messageText, mentions }); // Encolar respuestas si las hay if (responses.length > 0) { await ResponseQueue.add(responses); } } } static validateEnv() { console.log('ℹ️ Checking environment variables...'); console.log('EVOLUTION_API_URL:', process.env.EVOLUTION_API_URL ? '***' : 'MISSING'); console.log('EVOLUTION_API_INSTANCE:', process.env.EVOLUTION_API_INSTANCE || 'MISSING'); console.log('WEBHOOK_URL:', process.env.WEBHOOK_URL ? `${process.env.WEBHOOK_URL.substring(0, 20)}...` : 'NOT SET'); console.log('WHATSAPP_COMMUNITY_ID:', process.env.WHATSAPP_COMMUNITY_ID ? '***' : 'NOT SET (se mostrarán comunidades disponibles)'); const missing = REQUIRED_ENV.filter(v => !process.env[v]); if (missing.length) { console.error('❌ Missing required environment variables:'); missing.forEach(v => console.error(`- ${v}`)); console.error('Add these to your CapRover environment configuration'); process.exit(1); } if (process.env.CHATBOT_PHONE_NUMBER && !/^\d+$/.test(process.env.CHATBOT_PHONE_NUMBER)) { console.error('❌ CHATBOT_PHONE_NUMBER must contain only digits'); process.exit(1); } } static async start() { this.validateEnv(); // Ensure database schema and migrations are applied initializeDatabase(this.dbInstance); const PORT = process.env.PORT || '3007'; console.log('✅ Environment variables validated'); if (process.env.NODE_ENV !== 'test') { try { await WebhookManager.registerWebhook(); // Add small delay to allow webhook to propagate await new Promise(resolve => setTimeout(resolve, 1000)); const isActive = await WebhookManager.verifyWebhook(); if (!isActive) { console.error('❌ Webhook verification failed - retrying in 2 seconds...'); await new Promise(resolve => setTimeout(resolve, 2000)); const isActiveRetry = await WebhookManager.verifyWebhook(); if (!isActiveRetry) { console.error('❌ Webhook verification failed after retry'); process.exit(1); } } // Initialize groups - critical for operation await GroupSyncService.checkInitialGroups(); // Start response queue worker (background) try { await ResponseQueue.process(); console.log('✅ ResponseQueue worker started'); } catch (e) { console.error('❌ Failed to start ResponseQueue worker:', e); } } catch (error) { console.error('❌ Failed to setup webhook:', error instanceof Error ? error.message : error); process.exit(1); } } const server = Bun.serve({ port: parseInt(PORT), fetch: (request) => WebhookServer.handleRequest(request) }); console.log(`Server running on port ${PORT}`); return server; } }