diff --git a/proxy.ts b/proxy.ts index 48d33a2..c477246 100644 --- a/proxy.ts +++ b/proxy.ts @@ -22,7 +22,7 @@ function buildForwardHeaders(req: Request): Headers { Bun.serve({ port: Number(process.env.PORT || process.env.ROUTER_PORT || 3000), - fetch: async (req) => { + fetch: async (req: Request) => { const url = new URL(req.url); // Health local para el contenedor (evita 404 en healthcheck) @@ -36,12 +36,14 @@ Bun.serve({ const headers = buildForwardHeaders(req); if (!routeToBot) { - try { headers.set('accept-encoding', 'identity'); } catch {} + try { + headers.set('accept-encoding', 'identity'); + } catch {} } const init: RequestInit = { method: req.method, headers, - redirect: 'manual', + redirect: 'manual' }; if (req.method !== 'GET' && req.method !== 'HEAD' && req.body !== null) { (init as any).body = req.body as any; @@ -52,7 +54,11 @@ Bun.serve({ const res = await fetch(targetUrl, init); const ms = Date.now() - started; try { - console.log(`[proxy] ${req.method} ${url.pathname}${url.search} -> ${routeToBot ? 'bot' : 'web'} ${res.status} (${ms}ms)`); + console.log( + `[proxy] ${req.method} ${url.pathname}${url.search} -> ${ + routeToBot ? 'bot' : 'web' + } ${res.status} (${ms}ms)` + ); } catch {} // Devuelve la respuesta (incluye Set-Cookie, Location, etc.), asegurando Content-Type en assets por si faltase const passthroughHeaders = new Headers(res.headers); @@ -71,14 +77,23 @@ Bun.serve({ } catch {} } if (!passthroughHeaders.get('content-type')) { - if (url.pathname.endsWith('.js')) passthroughHeaders.set('content-type', 'application/javascript; charset=utf-8'); - if (url.pathname.endsWith('.css')) passthroughHeaders.set('content-type', 'text/css; charset=utf-8'); + if (url.pathname.endsWith('.js')) { + passthroughHeaders.set( + 'content-type', + 'application/javascript; charset=utf-8' + ); + } + if (url.pathname.endsWith('.css')) { + passthroughHeaders.set('content-type', 'text/css; charset=utf-8'); + } } return new Response(res.body, { status: res.status, headers: passthroughHeaders }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.error(`[proxy] ${req.method} ${url.pathname}${url.search} -> ERROR: ${msg}`); + console.error( + `[proxy] ${req.method} ${url.pathname}${url.search} -> ERROR: ${msg}` + ); return new Response(`Proxy error: ${msg}\n`, { status: 502 }); } - }, + } }); diff --git a/src/server.ts b/src/server.ts index 32b821b..e6b11e3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -//// +/// import type { Database } from 'bun:sqlite'; import { GroupSyncService } from './services/group-sync'; import { ContactsService } from './services/contacts'; @@ -11,216 +11,241 @@ import { handleHealthRequest } from './http/health'; import { startServices } from './http/bootstrap'; import { handleMessageUpsert as handleMessageUpsertFn } from './http/webhook-handler'; -// 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' + '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 + 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 y métricas - const url = new URL(request.url); - if (url.pathname.endsWith('/metrics')) { - return await handleMetricsRequest(request, WebhookServer.dbInstance); - } - if (url.pathname.endsWith('/health')) { - return await handleHealthRequest(url, WebhookServer.dbInstance); - } - - 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, '.'); - - // Contabilizar evento de webhook por tipo - try { - Metrics.inc(`webhook_events_total_${evtNorm.replace(/\./g, '_')}`); - } catch {} - - 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; - case 'groups.upsert': - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Handling groups upsert event:', { rawEvent: evt }); - } - try { - const res = await GroupSyncService.syncGroups(); - GroupSyncService.refreshActiveGroupsCache(); - const changed = GroupSyncService.getLastChangedActive(); - if (changed.length > 0) { - await GroupSyncService.syncMembersForGroups(changed); - } else { - await GroupSyncService.syncMembersForActiveGroups(); - } - } catch (e) { - console.error('❌ Error handling groups.upsert:', e); - } - 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() - }); - try { Metrics.inc('webhook_errors_total'); } catch {} - return new Response('Invalid request', { status: 400 }); - } - } - - static async handleMessageUpsert(data: any) { - return await handleMessageUpsertFn(data, WebhookServer.dbInstance); - } - - 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(); - - // Run database migrations (up-only) before starting services - await Migrator.migrateToLatest(this.dbInstance); - - // Etapa 7: seed inicial de grupos permitidos desde ALLOWED_GROUPS (best-effort) - try { AllowedGroups.seedFromEnv(); } catch {} - - const PORT = process.env.PORT || '3007'; - console.log('✅ Environment variables validated'); - // A0: pre-crear contadores para que aparezcan en /metrics - try { - Metrics.inc('onboarding_prompts_sent_total', 0); - Metrics.inc('onboarding_prompts_skipped_total', 0); - Metrics.inc('onboarding_assign_failures_total', 0); - - // Precalentar métricas de reacciones por emoji - for (const emoji of ['robot', 'warn', 'check', 'other']) { - Metrics.inc('reactions_enqueued_total', 0, { emoji }); - Metrics.inc('reactions_sent_total', 0, { emoji }); - Metrics.inc('reactions_failed_total', 0, { emoji }); - } - } catch {} - - if (process.env.NODE_ENV !== 'test') { - try { - await startServices(this.dbInstance); - } 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; - } + 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 y métricas + const url = new URL(request.url); + if (url.pathname.endsWith('/metrics')) { + return await handleMetricsRequest(request, WebhookServer.dbInstance); + } + if (url.pathname.endsWith('/health')) { + return await handleHealthRequest(url, WebhookServer.dbInstance); + } + + 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, '.'); + + // Contabilizar evento de webhook por tipo + try { + Metrics.inc(`webhook_events_total_${evtNorm.replace(/\./g, '_')}`); + } catch {} + + 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; + case 'groups.upsert': + if (process.env.NODE_ENV !== 'test') { + console.log('ℹ️ Handling groups upsert event:', { rawEvent: evt }); + } + try { + const res = await GroupSyncService.syncGroups(); + GroupSyncService.refreshActiveGroupsCache(); + const changed = GroupSyncService.getLastChangedActive(); + if (changed.length > 0) { + await GroupSyncService.syncMembersForGroups(changed); + } else { + await GroupSyncService.syncMembersForActiveGroups(); + } + } catch (e) { + console.error('❌ Error handling groups.upsert:', e); + } + 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() + }); + try { + Metrics.inc('webhook_errors_total'); + } catch {} + return new Response('Invalid request', { status: 400 }); + } + } + + static async handleMessageUpsert(data: any) { + return await handleMessageUpsertFn(data, WebhookServer.dbInstance); + } + + 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(); + + // Run database migrations (up-only) before starting services + await Migrator.migrateToLatest(this.dbInstance); + + // Etapa 7: seed inicial de grupos permitidos desde ALLOWED_GROUPS (best-effort) + try { + AllowedGroups.seedFromEnv(); + } catch {} + + const PORT = process.env.PORT || '3007'; + console.log('✅ Environment variables validated'); + // A0: pre-crear contadores para que aparezcan en /metrics + try { + Metrics.inc('onboarding_prompts_sent_total', 0); + Metrics.inc('onboarding_prompts_skipped_total', 0); + Metrics.inc('onboarding_assign_failures_total', 0); + + // Precalentar métricas de reacciones por emoji + for (const emoji of ['robot', 'warn', 'check', 'other']) { + Metrics.inc('reactions_enqueued_total', 0, { emoji }); + Metrics.inc('reactions_sent_total', 0, { emoji }); + Metrics.inc('reactions_failed_total', 0, { emoji }); + } + } catch {} + + if (process.env.NODE_ENV !== 'test') { + try { + await startServices(this.dbInstance); + } 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: Request) => WebhookServer.handleRequest(request) + }); + console.log(`Server running on port ${PORT}`); + return server; + } } diff --git a/tsconfig.core.json b/tsconfig.core.json index fff44bd..1fb9268 100644 --- a/tsconfig.core.json +++ b/tsconfig.core.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "types": ["bun-types"], - "lib": ["esnext"], + "lib": ["esnext", "dom"], "strict": false, "strictNullChecks": true, "noImplicitAny": true,