fix: ajustar tipos Bun y Request para limpiar typecheck core

Co-authored-by: aider (openrouter/openai/gpt-5.1) <aider@aider.chat>
main
borja 3 weeks ago
parent 199863df00
commit b0397d24d0

@ -22,7 +22,7 @@ function buildForwardHeaders(req: Request): Headers {
Bun.serve({ Bun.serve({
port: Number(process.env.PORT || process.env.ROUTER_PORT || 3000), port: Number(process.env.PORT || process.env.ROUTER_PORT || 3000),
fetch: async (req) => { fetch: async (req: Request) => {
const url = new URL(req.url); const url = new URL(req.url);
// Health local para el contenedor (evita 404 en healthcheck) // Health local para el contenedor (evita 404 en healthcheck)
@ -36,12 +36,14 @@ Bun.serve({
const headers = buildForwardHeaders(req); const headers = buildForwardHeaders(req);
if (!routeToBot) { if (!routeToBot) {
try { headers.set('accept-encoding', 'identity'); } catch {} try {
headers.set('accept-encoding', 'identity');
} catch {}
} }
const init: RequestInit = { const init: RequestInit = {
method: req.method, method: req.method,
headers, headers,
redirect: 'manual', redirect: 'manual'
}; };
if (req.method !== 'GET' && req.method !== 'HEAD' && req.body !== null) { if (req.method !== 'GET' && req.method !== 'HEAD' && req.body !== null) {
(init as any).body = req.body as any; (init as any).body = req.body as any;
@ -52,7 +54,11 @@ Bun.serve({
const res = await fetch(targetUrl, init); const res = await fetch(targetUrl, init);
const ms = Date.now() - started; const ms = Date.now() - started;
try { 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 {} } catch {}
// Devuelve la respuesta (incluye Set-Cookie, Location, etc.), asegurando Content-Type en assets por si faltase // Devuelve la respuesta (incluye Set-Cookie, Location, etc.), asegurando Content-Type en assets por si faltase
const passthroughHeaders = new Headers(res.headers); const passthroughHeaders = new Headers(res.headers);
@ -71,14 +77,23 @@ Bun.serve({
} catch {} } catch {}
} }
if (!passthroughHeaders.get('content-type')) { if (!passthroughHeaders.get('content-type')) {
if (url.pathname.endsWith('.js')) passthroughHeaders.set('content-type', 'application/javascript; charset=utf-8'); if (url.pathname.endsWith('.js')) {
if (url.pathname.endsWith('.css')) passthroughHeaders.set('content-type', 'text/css; charset=utf-8'); 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 }); return new Response(res.body, { status: res.status, headers: passthroughHeaders });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(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 }); return new Response(`Proxy error: ${msg}\n`, { status: 502 });
} }
}, }
}); });

@ -1,4 +1,4 @@
//// <reference types="bun-types" /> /// <reference types="bun-types" />
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { GroupSyncService } from './services/group-sync'; import { GroupSyncService } from './services/group-sync';
import { ContactsService } from './services/contacts'; import { ContactsService } from './services/contacts';
@ -11,216 +11,241 @@ import { handleHealthRequest } from './http/health';
import { startServices } from './http/bootstrap'; import { startServices } from './http/bootstrap';
import { handleMessageUpsert as handleMessageUpsertFn } from './http/webhook-handler'; 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 = [ export const REQUIRED_ENV = [
'EVOLUTION_API_URL', 'EVOLUTION_API_URL',
'EVOLUTION_API_KEY', 'EVOLUTION_API_KEY',
'EVOLUTION_API_INSTANCE', 'EVOLUTION_API_INSTANCE',
'CHATBOT_PHONE_NUMBER', 'CHATBOT_PHONE_NUMBER',
'WEBHOOK_URL' 'WEBHOOK_URL'
]; ];
type WebhookPayload = { type WebhookPayload = {
event: string; event: string;
instance: string; instance: string;
data: any; data: any;
// Other fields from Evolution API // Other fields from Evolution API
}; };
export class WebhookServer { export class WebhookServer {
static dbInstance: Database = db; static dbInstance: Database = db;
private static getBaseUrl(request: Request): string { private static getBaseUrl(request: Request): string {
const proto = request.headers.get('x-forwarded-proto') || 'http'; const proto = request.headers.get('x-forwarded-proto') || 'http';
const host = request.headers.get('x-forwarded-host') || request.headers.get('host'); const host =
return `${proto}://${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 ''; private static getMessageText(message: any): string {
const text = if (!message || typeof message !== 'object') return '';
message.conversation || const text =
message?.extendedTextMessage?.text || message.conversation ||
message?.imageMessage?.caption || message?.extendedTextMessage?.text ||
message?.videoMessage?.caption || message?.imageMessage?.caption ||
''; message?.videoMessage?.caption ||
return typeof text === 'string' ? text.trim() : ''; '';
} return typeof text === 'string' ? text.trim() : '';
}
static async handleRequest(request: Request): Promise<Response> {
// Health check endpoint y métricas static async handleRequest(request: Request): Promise<Response> {
const url = new URL(request.url); // Health check endpoint y métricas
if (url.pathname.endsWith('/metrics')) { const url = new URL(request.url);
return await handleMetricsRequest(request, WebhookServer.dbInstance); if (url.pathname.endsWith('/metrics')) {
} return await handleMetricsRequest(request, WebhookServer.dbInstance);
if (url.pathname.endsWith('/health')) { }
return await handleHealthRequest(url, WebhookServer.dbInstance); if (url.pathname.endsWith('/health')) {
} return await handleHealthRequest(url, WebhookServer.dbInstance);
}
if (process.env.NODE_ENV !== 'test') {
console.log(' Incoming webhook request:') if (process.env.NODE_ENV !== 'test') {
} console.log(' Incoming webhook request:');
}
// 1. Method validation
if (request.method !== 'POST') { // 1. Method validation
return new Response('🚫 Method not allowed', { status: 405 }); if (request.method !== 'POST') {
} return new Response('🚫 Method not allowed', { status: 405 });
}
// 2. Content-Type validation
const contentType = request.headers.get('content-type'); // 2. Content-Type validation
if (!contentType?.includes('application/json')) { const contentType = request.headers.get('content-type');
return new Response('🚫 Invalid content type', { status: 400 }); if (!contentType?.includes('application/json')) {
} return new Response('🚫 Invalid content type', { status: 400 });
}
try {
// 3. Parse and validate payload try {
const payload = await request.json() as WebhookPayload; // 3. Parse and validate payload
const payload = (await request.json()) as WebhookPayload;
if (!payload.event || !payload.instance) {
return new Response('🚫 Invalid payload', { status: 400 }); 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) && // 4. Verify instance matches (skip in test environment unless TEST_VERIFY_INSTANCE is set)
payload.instance !== process.env.EVOLUTION_API_INSTANCE) { if (
return new Response('🚫 Invalid instance', { status: 403 }); (process.env.NODE_ENV !== 'test' || process.env.TEST_VERIFY_INSTANCE) &&
} payload.instance !== process.env.EVOLUTION_API_INSTANCE
) {
// 5. Route events return new Response('🚫 Invalid instance', { status: 403 });
// console.log(' Webhook event received:', { }
// event: payload.event,
// instance: payload.instance, // 5. Route events
// data: payload.data ? '[...]' : null // console.log(' Webhook event received:', {
// }); // event: payload.event,
// instance: payload.instance,
// Normalize event name to handle different casing/format (e.g., MESSAGES_UPSERT) // data: payload.data ? '[...]' : null
const evt = String(payload.event); // });
const evtNorm = evt.toLowerCase().replace(/_/g, '.');
// Normalize event name to handle different casing/format (e.g., MESSAGES_UPSERT)
// Contabilizar evento de webhook por tipo const evt = String(payload.event);
try { const evtNorm = evt.toLowerCase().replace(/_/g, '.');
Metrics.inc(`webhook_events_total_${evtNorm.replace(/\./g, '_')}`);
} catch {} // Contabilizar evento de webhook por tipo
try {
switch (evtNorm) { Metrics.inc(`webhook_events_total_${evtNorm.replace(/\./g, '_')}`);
case 'messages.upsert': } catch {}
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling message upsert:', { switch (evtNorm) {
groupId: payload.data?.key?.remoteJid, case 'messages.upsert':
message: payload.data?.message?.conversation, if (process.env.NODE_ENV !== 'test') {
rawEvent: evt console.log(' Handling message upsert:', {
}); groupId: payload.data?.key?.remoteJid,
} message: payload.data?.message?.conversation,
await WebhookServer.handleMessageUpsert(payload.data); rawEvent: evt
break; });
case 'contacts.update': }
case 'chats.update': await WebhookServer.handleMessageUpsert(payload.data);
if (process.env.NODE_ENV !== 'test') { break;
console.log(' Handling contacts/chats update event:', { rawEvent: evt }); case 'contacts.update':
} case 'chats.update':
ContactsService.updateFromWebhook(payload.data); if (process.env.NODE_ENV !== 'test') {
break; console.log(' Handling contacts/chats update event:', {
case 'groups.upsert': rawEvent: evt
if (process.env.NODE_ENV !== 'test') { });
console.log(' Handling groups upsert event:', { rawEvent: evt }); }
} ContactsService.updateFromWebhook(payload.data);
try { break;
const res = await GroupSyncService.syncGroups(); case 'groups.upsert':
GroupSyncService.refreshActiveGroupsCache(); if (process.env.NODE_ENV !== 'test') {
const changed = GroupSyncService.getLastChangedActive(); console.log(' Handling groups upsert event:', { rawEvent: evt });
if (changed.length > 0) { }
await GroupSyncService.syncMembersForGroups(changed); try {
} else { const res = await GroupSyncService.syncGroups();
await GroupSyncService.syncMembersForActiveGroups(); GroupSyncService.refreshActiveGroupsCache();
} const changed = GroupSyncService.getLastChangedActive();
} catch (e) { if (changed.length > 0) {
console.error('❌ Error handling groups.upsert:', e); await GroupSyncService.syncMembersForGroups(changed);
} } else {
break; await GroupSyncService.syncMembersForActiveGroups();
// Other events will be added later }
} } catch (e) {
console.error('❌ Error handling groups.upsert:', e);
return new Response('OK', { status: 200 }); }
} catch (error) { break;
console.error('❌ Error processing webhook:', { // Other events will be added later
error: error instanceof Error ? error.message : String(error), }
stack: error instanceof Error ? error.stack : undefined,
time: new Date().toISOString() return new Response('OK', { status: 200 });
}); } catch (error) {
try { Metrics.inc('webhook_errors_total'); } catch {} console.error('❌ Error processing webhook:', {
return new Response('Invalid request', { status: 400 }); error: error instanceof Error ? error.message : String(error),
} stack: error instanceof Error ? error.stack : undefined,
} time: new Date().toISOString()
});
static async handleMessageUpsert(data: any) { try {
return await handleMessageUpsertFn(data, WebhookServer.dbInstance); Metrics.inc('webhook_errors_total');
} } catch {}
return new Response('Invalid request', { status: 400 });
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'); static async handleMessageUpsert(data: any) {
console.log('WEBHOOK_URL:', process.env.WEBHOOK_URL ? `${process.env.WEBHOOK_URL.substring(0, 20)}...` : 'NOT SET'); return await handleMessageUpsertFn(data, WebhookServer.dbInstance);
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]); static validateEnv() {
if (missing.length) { console.log(' Checking environment variables...');
console.error('❌ Missing required environment variables:'); console.log(
missing.forEach(v => console.error(`- ${v}`)); 'EVOLUTION_API_URL:',
console.error('Add these to your CapRover environment configuration'); process.env.EVOLUTION_API_URL ? '***' : 'MISSING'
process.exit(1); );
} console.log(
'EVOLUTION_API_INSTANCE:',
if (process.env.CHATBOT_PHONE_NUMBER && process.env.EVOLUTION_API_INSTANCE || 'MISSING'
!/^\d+$/.test(process.env.CHATBOT_PHONE_NUMBER)) { );
console.error('❌ CHATBOT_PHONE_NUMBER must contain only digits'); console.log(
process.exit(1); 'WEBHOOK_URL:',
} process.env.WEBHOOK_URL
} ? `${process.env.WEBHOOK_URL.substring(0, 20)}...`
: 'NOT SET'
static async start() { );
this.validateEnv(); console.log(
'WHATSAPP_COMMUNITY_ID:',
// Run database migrations (up-only) before starting services process.env.WHATSAPP_COMMUNITY_ID
await Migrator.migrateToLatest(this.dbInstance); ? '***'
: 'NOT SET (se mostrarán comunidades disponibles)'
// Etapa 7: seed inicial de grupos permitidos desde ALLOWED_GROUPS (best-effort) );
try { AllowedGroups.seedFromEnv(); } catch {}
const missing = REQUIRED_ENV.filter(v => !process.env[v]);
const PORT = process.env.PORT || '3007'; if (missing.length) {
console.log('✅ Environment variables validated'); console.error('❌ Missing required environment variables:');
// A0: pre-crear contadores para que aparezcan en /metrics missing.forEach(v => console.error(`- ${v}`));
try { console.error('Add these to your CapRover environment configuration');
Metrics.inc('onboarding_prompts_sent_total', 0); process.exit(1);
Metrics.inc('onboarding_prompts_skipped_total', 0); }
Metrics.inc('onboarding_assign_failures_total', 0);
if (
// Precalentar métricas de reacciones por emoji process.env.CHATBOT_PHONE_NUMBER &&
for (const emoji of ['robot', 'warn', 'check', 'other']) { !/^\d+$/.test(process.env.CHATBOT_PHONE_NUMBER)
Metrics.inc('reactions_enqueued_total', 0, { emoji }); ) {
Metrics.inc('reactions_sent_total', 0, { emoji }); console.error('❌ CHATBOT_PHONE_NUMBER must contain only digits');
Metrics.inc('reactions_failed_total', 0, { emoji }); process.exit(1);
} }
} catch {} }
if (process.env.NODE_ENV !== 'test') { static async start() {
try { this.validateEnv();
await startServices(this.dbInstance);
} catch (error) { // Run database migrations (up-only) before starting services
console.error('❌ Failed to setup webhook:', error instanceof Error ? error.message : error); await Migrator.migrateToLatest(this.dbInstance);
process.exit(1);
} // Etapa 7: seed inicial de grupos permitidos desde ALLOWED_GROUPS (best-effort)
} try {
AllowedGroups.seedFromEnv();
const server = Bun.serve({ } catch {}
port: parseInt(PORT),
fetch: (request) => WebhookServer.handleRequest(request) const PORT = process.env.PORT || '3007';
}); console.log('✅ Environment variables validated');
console.log(`Server running on port ${PORT}`); // A0: pre-crear contadores para que aparezcan en /metrics
return server; 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;
}
} }

@ -2,7 +2,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"types": ["bun-types"], "types": ["bun-types"],
"lib": ["esnext"], "lib": ["esnext", "dom"],
"strict": false, "strict": false,
"strictNullChecks": true, "strictNullChecks": true,
"noImplicitAny": true, "noImplicitAny": true,

Loading…
Cancel
Save