diff --git a/docs/2025-11-01-plan-refactor-tecnico.md b/docs/2025-11-01-plan-refactor-tecnico.md
index 8073b28..ef74fad 100644
--- a/docs/2025-11-01-plan-refactor-tecnico.md
+++ b/docs/2025-11-01-plan-refactor-tecnico.md
@@ -438,35 +438,301 @@ Con estos 3 archivos podremos definir cambios mínimos para tener un typecheck l
## Fase D — Documentación (nueva)
-- Principios:
- - Markdown en /docs como fuente de verdad; sin dependencias nuevas; build opcional más adelante.
- - Verificación simple en CI para comprobar “completitud” (p. ej., grep de variables de entorno usadas vs documentadas).
-- PR D0: Estructura y guía de estilo
- - docs/README (mapa de contenidos), guía de estilo y convenciones.
- - Índice propuesto: Quickstart, Instalación, Configuración (ENV), Uso web, Uso por chat/comandos, Operaciones, Arquitectura, Seguridad, Contribución, Troubleshooting/FAQ.
- - Aceptación: esqueleto aprobado y navegación clara.
-- PR D1: Referencia de variables de entorno
- - Tabla completa: nombre, descripción, tipo, por defecto, requerido/optativo, ámbito (core/web), sensibilidad, recarga dinámica.
- - Generar .env.example coherente con la tabla.
- - Aceptación: 100% de variables de entorno documentadas.
-- PR D2: Quickstart e instalación
- - Pasos desde cero a “todo funcionando” (core + web).
- - Matriz de comandos: typecheck core/web, dev, build, test, coverage.
- - Aceptación: reproducible en máquina limpia.
-- PR D3: Uso (web + comandos)
- - Catálogo de comandos (nueva, tomar, soltar, ver, web…): expectativas y ejemplos.
- - Guía de la interfaz web (navegación, calendarios ICS, preferencias).
- - Aceptación: ejemplos probados manualmente.
-- PR D4: Operaciones y despliegue
- - Endpoints /health y /metrics, migraciones, copias de seguridad, rotación de claves, runbooks de incidencias.
- - Aceptación: runbooks accionables.
-- PR D5: Arquitectura y DB
- - Visión por capas (core, servicios, web), flujos (webhook → colas → tareas), esquema SQLite principal.
- - Aceptación: suficiente para nuevos contribuidores.
-- PR D6: Contribución y ADRs
- - Cómo proponer cambios, estándares TS/UTC/ICS, política de versiones.
- - ADRs para decisiones clave (UTC, DB locator, ICS all-day).
- - Aceptación: plantilla de PR y guía de contribución listas.
+El objetivo de esta fase es frenar la “entropía documental” actual, ofrecer una narración clara para quien llega nuevo y, a la vez, dejar una base sólida para seguir evolucionando el sistema sin añadir deuda técnica.
+
+### Principios
+
+- Markdown en /docs como fuente de verdad; sin dependencias nuevas; build opcional más adelante.
+- Documentación estructurada por **qué quiere hacer la persona lectora** (evaluar, desplegar, operar, contribuir), no por “lista de archivos existentes”.
+- README raíz orientado a adopción (pitch, casos de uso, requisitos), con enlaces claros a la documentación detallada.
+- Documentación “v2” claramente separada de planes históricos y documentos de trabajo.
+- Verificación simple en CI para comprobar “completitud” (p. ej., grep de variables de entorno usadas vs documentadas).
+
+### D0: Reset estructural y guía de estilo
+
+Objetivo: redefinir la estructura de documentación y marcar qué es canónico vs histórico, sin perder información.
+
+Cambios:
+
+- **README raíz (del repo)**:
+ - Reescribirlo con foco en:
+ - Tagline y pitch corto (qué resuelve, para quién).
+ - Casos de uso y “no es para ti si…”.
+ - Requisitos para desplegarlo (Evolution API, hosting, etc.).
+ - Vista de alto nivel de cómo funciona.
+ - Enlace claro a `docs/` (Quickstart, Guía de uso, Arquitectura, etc.).
+
+- **Definir documentos canónicos en `docs/`** (columna vertebral “v2”):
+ - `docs/README.md` — índice general y guía de estilo.
+ - `docs/overview.md` — visión general y flujos clave (evaluación).
+ - `docs/quickstart.md` — de cero a bot funcionando (dev/prod).
+ - `docs/config/env.md` — referencia única de variables de entorno.
+ - `docs/usage/commands.md` — uso por WhatsApp (comandos).
+ - `docs/usage/web.md` — uso de la interfaz web.
+ - `docs/architecture.md` — arquitectura lógica y DB locator.
+ - `docs/operations.md` — operación, métricas, backups, health/metrics.
+ - `docs/contributing.md` — cómo tocar el código sin romper convenciones.
+
+- **Crear un área de documentación histórica**:
+ - Nuevo directorio: `docs/legacy/` (o similar).
+ - Mover allí:
+ - Planes antiguos (`plan-*.md`, `*-plan.md`) que ya no encajen con el estado actual.
+ - Documentos de trabajo y notas de diseño que han quedado superadas por este plan.
+ - Añadir `docs/legacy/README.md` explicando que:
+ - Son documentos históricos, útiles como contexto, pero **no** representan el estado actual ni la decisión vigente.
+
+- **Guía de estilo mínima en `docs/README.md`**:
+ - Idioma principal: español para texto narrativo; identificadores y nombres técnicos en inglés.
+ - Convenciones de nombres de archivos (kebab-case, sufijos como `-guide`, etc.).
+ - Expectativas por documento (público objetivo y nivel técnico).
+
+Métricas de aceptación:
+
+- README raíz reescrito y enlazando a la nueva estructura.
+- `docs/README.md` actualizado con el índice “v2” y la guía de estilo.
+- Todos los planes/documentos obsoletos movidos a `docs/legacy/` y etiquetados como históricos.
+
+### D1: Referencia de variables de entorno
+
+Objetivo: consolidar una tabla única y fiable de configuración por entorno.
+
+Cambios:
+
+- Construir un inventario completo:
+ - Partir del `git grep process.env` (ya recogido en este documento).
+ - Complementar con las variables definidas en `.env.example`.
+ - Identificar:
+ - variables usadas en código pero ausentes en `.env.example`,
+ - variables presentes en `.env.example` que ya no se usan.
+
+- Crear `docs/config/env.md`:
+ - Tabla con columnas:
+ - **VARIABLE** | **Descripción** | **Tipo** | **Por defecto** | **Obligatoria** | **Ámbito (core/web)** | **Sensible (sí/no)**.
+ - Sección explicando:
+ - cómo se cargan (ficheros `.env` vs entorno),
+ - qué variables son imprescindibles para arrancar,
+ - particularidades en test (variables que los tests fuerzan o suponen).
+
+- Sincronizar `.env.example` con la tabla:
+ - Mismas variables.
+ - Comentarios alineados con la descripción de `docs/config/env.md`.
+ - README raíz solo enumera un subconjunto (“configuración esencial”) y enlaza a este documento para el resto.
+
+Métricas de aceptación:
+
+- `docs/config/env.md` cubre el 100% de las variables utilizadas en código.
+- `.env.example` sin variables huérfanas ni faltantes respecto al inventario.
+- Potencial check de CI: script simple que compare nombres de variables entre código, `env.md` y `.env.example`.
+
+### D2: Quickstart e instalación
+
+Objetivo: permitir que alguien nuevo pase de “clonar el repo” a “tengo el bot funcionando” sin leer todo el resto.
+
+Cambios:
+
+- Crear/actualizar `docs/quickstart.md`:
+ - Requisitos:
+ - Bun/Node versión recomendada.
+ - Instancia de Evolution API.
+ - Almacenamiento para SQLite.
+ - Pasos:
+ - Clonar repositorio e instalar dependencias.
+ - Copiar `.env.example` y rellenar variables mínimas.
+ - Arrancar en desarrollo (core + web).
+ - Arrancar en producción (comandos de build + start).
+ - “Happy path”:
+ - ejemplo de mensaje de WhatsApp para crear una tarea,
+ - cómo verla en la web (/app),
+ - cómo confirmar que /health y /metrics responden.
+
+- Alinear README raíz con este quickstart:
+ - Sección “Instalación rápida” condensando los pasos principales.
+ - Enlace explícito a `docs/quickstart.md` para detalles.
+
+Métricas de aceptación:
+
+- Una persona que no conoce el proyecto puede seguir `docs/quickstart.md` en una máquina limpia y terminar con:
+ - webhook recibiendo eventos,
+ - tareas visibles en /app,
+ - métricas en /metrics.
+
+### D3: Uso (web + comandos)
+
+Objetivo: documentar claramente la experiencia de uso, separando canales (WhatsApp vs web) y evitando duplicidad.
+
+Cambios:
+
+- Reorganizar la documentación de uso actual en dos archivos:
+
+ - `docs/usage/commands.md`:
+ - Basado en `docs/USER_GUIDE.md` y `docs/commands-inventory.md`.
+ - Contenido:
+ - prefijo de comandos y principios (DM vs grupo, fechas, rate limit),
+ - catálogo de comandos con alias, parámetros y ejemplos,
+ - notas sobre:
+ - interpretación de fechas (“hoy”, “mañana”, YYYY-MM-DD),
+ - menciones reales vs tokens `@número`,
+ - comportamiento en grupo vs DM,
+ - sección de administración (/admin…).
+
+ - `docs/usage/web.md`:
+ - Basado en `plan-diseno-web.md`, `plan-web-fases.md` y el estado actual de la UI.
+ - Contenido:
+ - estructura de `/app` (lista de tareas, grupos, preferencias),
+ - flujo típico de uso desde web (reclamar/soltar, editar, completar),
+ - relación entre lo que ves en WhatsApp y lo que ves en la web,
+ - vistas de calendario/ICS y cómo se consumen.
+
+- Dejar `docs/USER_GUIDE.md` como entrada que redirige:
+ - Puede convertirse en un índice breve que apunte a `usage/commands.md` y `usage/web.md`, o bien moverse a `docs/legacy/` cuando la nueva estructura esté madura.
+
+Métricas de aceptación:
+
+- No hay duplicación significativa de contenido entre README, `USER_GUIDE`, `usage/commands` y `usage/web`.
+- Los ejemplos de comandos están actualizados y probados manualmente (al menos los principales).
+
+### D4: Operaciones y despliegue
+
+Objetivo: facilitar la vida a quien opera Taskbot en producción (observabilidad, mantenimiento, CI/CD).
+
+Cambios:
+
+- Crear/actualizar `docs/operations.md`:
+ - Basado en `operations.md`, `metrics-plan.md`, `CI-CD-PLAN.md` y `docs/grafana/`.
+ - Contenido:
+ - **Despliegue**:
+ - opciones típicas (proceso único vs supervisado por systemd, contenedores, etc.),
+ - recomendaciones de recursos y almacenamiento,
+ - interacción con Evolution API (timeouts, health checks).
+ - **Migraciones y base de datos**:
+ - cómo y cuándo se ejecutan migraciones,
+ - política de WAL y PRAGMAs más importantes,
+ - estrategias de backup/restore de SQLite.
+ - **Observabilidad**:
+ - uso de `/health` (qué comprueba, señales de fallo),
+ - uso de `/metrics` (Prometheus/JSON, flags de configuración),
+ - referencia al dashboard de Grafana (`docs/grafana/taskbot-metrics.json`): qué gráficos hay y qué significan.
+ - **Schedulers y tareas periódicas**:
+ - sincronización de grupos,
+ - recordatorios,
+ - limpieza de cola de respuestas,
+ - cómo ajustar intervalos y qué implicaciones tienen.
+
+- Conectar con variables de entorno:
+ - Enlazar a `docs/config/env.md` para cada opción relevante (p. ej. `GROUP_SYNC_INTERVAL_MS`, `RQ_*`, `METRICS_*`).
+
+Métricas de aceptación:
+
+- Operador externo puede entender:
+ - qué mirar en caso de problemas (health/metrics/logs),
+ - cómo hacer backup/restore sin corromper la DB,
+ - cómo cambiar la frecuencia de tareas periódicas con seguridad.
+
+### D5: Arquitectura y DB
+
+Objetivo: ofrecer una visión clara de cómo está organizado el sistema internamente, especialmente tras el refactor (servicios extraídos, DB locator, ICS central).
+
+Cambios:
+
+- Consolidar `docs/architecture.md`:
+ - Integrar/actualizar contenido de `architecture.md`, `overview.md`, `database.md` y este propio plan.
+ - Contenido:
+ - **Componentes principales**:
+ - servidor HTTP (WebhookServer y handlers /webhook, /health, /metrics),
+ - servicios de dominio (TaskService, ResponseQueue, GroupSync, Reminders, Admin…),
+ - cola de respuestas y cliente Evolution,
+ - app web SvelteKit.
+ - **DB Locator**:
+ - motivación,
+ - API básica (`getDb`/`setDb`/`withDb`),
+ - cómo se usa en servicios y tests.
+ - **Flujos clave**:
+ - mensaje entrante → comando → creación/actualización de tarea → encolado de respuesta/reacción,
+ - sincronización de grupos y membresías,
+ - recordatorios.
+ - **Base de datos**:
+ - tablas principales (tasks, task_assignments, groups, group_members, response_queue, user_preferences…),
+ - invariantes importantes,
+ - decisiones de diseño (WAL, migraciones up-only).
+
+- Mantener `docs/database.md` como detalle de esquema:
+ - Puede centrarse en:
+ - descripción tabla a tabla,
+ - índices relevantes,
+ - ejemplos de consultas típicas,
+ - enlazado desde `docs/architecture.md`.
+
+Métricas de aceptación:
+
+- Contributors nuevos pueden entender dónde “colgar” código nuevo (por ejemplo, un nuevo servicio, una nueva tabla o un nuevo comando).
+- Este plan de refactor técnico se percibe como coherente con la arquitectura documentada (no como un documento separado y divergente).
+
+### D6: Contribución y ADRs
+
+Objetivo: ayudar a futuras personas contribuidoras (incluido tú mismo “del futuro”) a cambiar el sistema sin romper las convenciones ni repetir debates ya resueltos.
+
+Cambios:
+
+- Crear/actualizar `docs/contributing.md`:
+ - Contenido:
+ - cómo montar entorno de desarrollo (dependencias, comandos básicos),
+ - cómo ejecutar:
+ - typecheck core (`bun run typecheck:core`),
+ - typecheck web (`bun run typecheck:web`),
+ - tests y cobertura (`bun test --coverage`),
+ - convenciones de código:
+ - UTC y formato de timestamps en SQLite,
+ - uso de helpers centralizados (datetime, crypto, helpers de test),
+ - tipos estrictos en TS (flags activados y expectativas),
+ - uso del DB locator.
+ - criterios para abrir PRs:
+ - tests verdes,
+ - sin cambios funcionales en lotes que se declaran “refactor internos”,
+ - cuándo y cómo añadir un ADR.
+ - referencia rápida a how-tos existentes:
+ - `how-to/adding-command.md`,
+ - `how-to/adding-migration.md`,
+ - `how-to/adjusting-group-sync.md`,
+ - `how-to/adding-env.md`.
+
+- Consolidar ADRs:
+ - Mantener `docs/adr/*.md` como registro de decisiones de arquitectura.
+ - Añadir ADRs nuevos cuando:
+ - se tomen decisiones de largo plazo (p. ej., i18n, políticas de rate limiting, cambios de almacenamiento).
+ - Desde `docs/contributing.md`, explicar:
+ - cuándo merece la pena un ADR,
+ - cómo estructurarlo (título, contexto, decisión, consecuencias).
+
+Métricas de aceptación:
+
+- Contributors pueden:
+ - saber qué comandos ejecutar antes de abrir PR,
+ - entender las decisiones ya tomadas (UTC, DB locator, ICS, estructura de servicios),
+ - no tener que “descubrirlas” leyendo código o este plan.
+
+### Orden recomendado de ejecución para Fase D
+
+1. Ejecutar **D0** (reset estructural):
+ - nuevo README raíz orientado a adopción,
+ - índice v2 en `docs/README.md`,
+ - movimiento de documentos antiguos a `docs/legacy/`.
+
+2. Ejecutar **D1** (env) y **D2** (quickstart) en paralelo:
+ - cerrar la historia de “cómo configuro esto” y “cómo lo echo a andar”.
+
+3. Completar **D3** (uso commands/web) apoyándose en la guía de usuario actual, reorganizando sin perder contenido útil.
+
+4. Redondear con **D4** (operaciones) y **D5** (arquitectura/DB) usando este plan de refactor como referencia.
+
+5. Cerrar con **D6** (contribución+ADRs), conectando:
+ - decisiones técnicas ya tomadas,
+ - how-tos existentes,
+ - y el pipeline de typecheck/tests.
+
+Esta Fase D se coordina con los lotes técnicos previo/posteriores:
+
+- depende de que el estado del core/web esté relativamente estable (Lotes 0–6),
+- y sirve como preparación explícita antes de acometer cambios más invasivos (i18n, nuevos comandos, cambios de esquema), reduciendo el riesgo de deuda técnica futura.
## Fase I — Internacionalización (nueva)
diff --git a/package.json b/package.json
index 167ffe3..8c4458d 100644
--- a/package.json
+++ b/package.json
@@ -1,17 +1,18 @@
{
- "name": "task-whatsapp",
- "module": "index.ts",
- "type": "module",
- "private": true,
- "scripts": {
- "typecheck:web": "cd apps/web && bunx svelte-kit sync && bunx tsc --noEmit --pretty false",
- "typecheck:core": "bunx tsc -p tsconfig.core.json --noEmit --pretty false"
- },
- "devDependencies": {
- "@types/bun": "latest",
- "bun-types": "^1.3.3"
- },
- "peerDependencies": {
- "typescript": "^5.8.2"
- }
+ "name": "task-whatsapp",
+ "module": "index.ts",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "typecheck:web": "cd apps/web && bunx svelte-kit sync && bunx tsc --noEmit --pretty false",
+ "typecheck:core": "bunx tsc -p tsconfig.core.json --noEmit --pretty false",
+ "test": "for i in $(fd . |rg test.ts); do bun test $i || break ; done"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "bun-types": "^1.3.3"
+ },
+ "peerDependencies": {
+ "typescript": "^5.8.2"
+ }
}
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/src/types/shims.d.ts b/src/types/shims.d.ts
index 6fd80db..8446a9d 100644
--- a/src/types/shims.d.ts
+++ b/src/types/shims.d.ts
@@ -8,6 +8,8 @@ declare global {
toJSON?: any;
count?: any;
getAll?: any;
+ // En Bun y en el estándar, entries existe siempre; no la marcamos como opcional
+ entries(): any;
}
// Añadir timeout soportado por Bun.fetch en algunos usos y permitir httpVersion usado en algunos fetch
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,