/// import { CommandService } from './services/command'; import { GroupSyncService } from './services/group-sync'; import { ResponseQueue } from './services/response-queue'; import { WebhookManager } from './services/webhook-manager'; import { normalizeWhatsAppId } from './utils/whatsapp'; // 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 { 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}`; } 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 // }); switch (payload.event) { case 'messages.upsert': if (process.env.NODE_ENV !== 'test') { console.log('ℹ️ Handling message upsert:', { groupId: payload.data?.key?.remoteJid, message: payload.data?.message?.conversation }); } await WebhookServer.handleMessageUpsert(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 || !data.message.conversation) { if (process.env.NODE_ENV !== 'test') { console.log('⚠️ Invalid message format - missing required fields'); console.log(data); } 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; } // Forward to command service only if: // 1. It's a text message (has conversation field) // 2. Starts with /tarea command const messageText = data.message.conversation; if (typeof messageText === 'string' && messageText.trim().startsWith('/tarea')) { // Parse command components const commandParts = messageText.trim().split(/\s+/); const command = commandParts[0].toLowerCase(); if (commandParts.length < 2) { console.log('⚠️ Invalid /tarea command - missing action'); return; } const action = commandParts[1].toLowerCase(); let descriptionParts = []; let dueDate = ''; // Process remaining parts const dateCandidates = []; // First collect all valid future dates for (let i = 2; i < commandParts.length; i++) { const part = commandParts[i]; // Check for date (YYYY-MM-DD) if (/^\d{4}-\d{2}-\d{2}$/.test(part)) { const date = new Date(part); const today = new Date(); today.setHours(0, 0, 0, 0); if (date >= today) { dateCandidates.push({ index: i, date, part }); } } } // Use the last valid future date as due date if (dateCandidates.length > 0) { const lastDate = dateCandidates[dateCandidates.length - 1]; dueDate = lastDate.part; // Add all parts except the last date to description for (let i = 2; i < commandParts.length; i++) { // Skip the due date part if (i === lastDate.index) continue; // Add to description if it's a date candidate but not selected or a regular part const part = commandParts[i]; descriptionParts.push(part); } } else { // No valid future dates, add all parts to description descriptionParts = commandParts.slice(2); } const description = descriptionParts.join(' ').trim(); console.log('🔍 Detected /tarea command:', { timestamp: new Date().toISOString(), from: data.key.participant, group: data.key.remoteJid, rawMessage: messageText }); const mentions = data.message?.contextInfo?.mentionedJid || []; console.log('✅ Successfully parsed command:', { action, description, dueDate: dueDate || 'none', mentionCount: mentions.length }); const responses = await CommandService.handle({ sender: data.key.participant, groupId: data.key.remoteJid, message: messageText, mentions }); // Queue responses for sending 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)}...` : 'MISSING'); 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(); 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(); } 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; } }