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,