diff --git a/.env.example b/.env.example index 152a1b5..7adfb76 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,10 @@ ONBOARDING_FALLBACK_MIN_DIGITS=8 # A2: longitud mínima para conservar núme # ONBOARDING_PROMPTS_ENABLED=true # Permite publicación durante tests específicos # ONBOARDING_ENABLE_IN_TEST=false +# +# Onboarding A4 (DM JIT y palabra clave) +# Palabra clave de alta por DM: activar +# En tests, los prompts JIT (A4) y los mensajes al grupo (A3) solo se envían si ONBOARDING_ENABLE_IN_TEST=true # Umbral de cobertura (publica si coverage < threshold). Por defecto 1.0 # ONBOARDING_COVERAGE_THRESHOLD=1 # Periodo de gracia tras la última verificación de miembros (segundos). Por defecto 90 diff --git a/docs/plan-onboarding-usuarios.md b/docs/plan-onboarding-usuarios.md index 561740e..514d538 100644 --- a/docs/plan-onboarding-usuarios.md +++ b/docs/plan-onboarding-usuarios.md @@ -18,7 +18,7 @@ Estrategia general - Exprimir al máximo el aprendizaje automático que ya tenemos (participants y contacts). - Ajustar CommandService para no descartar menciones válidas con números reales. - Medir cobertura y publicar un único mensaje por grupo con wa.me únicamente si faltan usuarios por resolver; con cooldown. -- DM “hola” como último recurso confiable para cerrar huecos de usuarios silenciosos; considerar “código por grupo” solo como fallback extremo si una instancia no correlaciona jamás sin token. +- DM “activar” como último recurso confiable para cerrar huecos de usuarios silenciosos; considerar “código por grupo” solo como fallback extremo si una instancia no correlaciona jamás sin token. Fases @@ -53,18 +53,18 @@ Fase A3 — Mensaje único por grupo con wa.me (Completada) - Tras A1 y un breve grace period (≈1–2 min) para permitir contacts/chats updates, calcular alias_coverage_ratio{group_id}. - Si cobertura = 100% → NO publicar. - Si cobertura < 100% → publicar UNA vez un mensaje por grupo con el texto: - - “Para poder asignarte tareas y acceder a la web, envía ‘hola’ al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/” + - “Para poder asignarte tareas y acceder a la web, envía ‘activar’ al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/” - Control de ruido: - Persistir timestamp de último envío por grupo (cooldown, p. ej., 7 días) y re-publicar solo si entran nuevos miembros sin resolver tras el cooldown. - Fallback extremo (probablemente no necesario con tu stack): - Si en una instancia concreta el DM “hola” no basta para correlacionar, usar “código por grupo” como texto pre-rellenado en wa.me: “alta XYZ123” (único por grupo, nunca por-usuario), almacenado con caducidad. Aplicarlo solo si la métrica demuestra que el caso existe. -Fase A4 — Asistentes “just-in-time” y UX mínima +Fase A4 — Asistentes “just-in-time” y UX mínima (Completada) - Si una asignación falla por mención no resoluble: - - Enviar DM al asignador (ResponseQueue) con: “No puedo asignar a X aún. Pídele que toque este enlace y diga ‘hola’: https://wa.me/”. -- Primer DM “hola” de un usuario: + - Enviar DM al asignador (ResponseQueue) con: “No puedo asignar a X aún. Pídele que toque este enlace y diga ‘activar’: https://wa.me/”. +- Primer DM “activar” de un usuario: - Asegurar ensureUserExists y responder con: “Listo, ya puedes reclamar/ser responsable en: …”. -- Opcional web: si el usuario llega sin estar identificado, mostrar banner con botón a wa.me “hola”. +- Opcional web: si el usuario llega sin estar identificado, mostrar banner con botón a wa.me “activar”. Fase A5 — Optimizaciones post-A1 (pendiente) - Optimizar encadenado tras groups.upsert para sincronizar solo los grupos afectados cuando el payload lo permita. diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index eb6f7b2..d9ddde6 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -747,7 +747,7 @@ export class GroupSyncService { } // Encolar mensaje en la cola persistente y marcar timestamp en groups - const msg = `Para poder asignarte tareas y acceder a la web, envía 'hola' al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/${bot}`; + const msg = `Para poder asignarte tareas y acceder a la web, envía 'activar' al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/${bot}`; this.dbInstance.transaction(() => { this.dbInstance.prepare(` INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at) diff --git a/tests/unit/server.onboarding-activar.test.ts b/tests/unit/server.onboarding-activar.test.ts new file mode 100644 index 0000000..bada311 --- /dev/null +++ b/tests/unit/server.onboarding-activar.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../src/db'; +import { WebhookServer } from '../../src/server'; +import { ResponseQueue } from '../../src/services/response-queue'; + +describe('WebhookServer - DM "activar" (A4)', () => { + let memdb: Database; + + beforeAll(() => { + memdb = new Database(':memory:'); + initializeDatabase(memdb); + (WebhookServer as any).dbInstance = memdb; + (ResponseQueue as any).dbInstance = memdb; + }); + + beforeEach(() => { + process.env.NODE_ENV = 'test'; + memdb.exec('DELETE FROM response_queue'); + memdb.exec('DELETE FROM users'); + }); + + function rowCount(): number { + const r = memdb.query("SELECT COUNT(*) AS c FROM response_queue").get() as any; + return Number(r?.c || 0); + } + + it('al recibir "activar" por DM, asegura usuario y encola confirmación', async () => { + const data = { + key: { remoteJid: '7001@s.whatsapp.net', fromMe: false }, + message: { conversation: 'activar' } + }; + await WebhookServer.handleMessageUpsert(data); + + expect(rowCount()).toBe(1); + const row = memdb.query("SELECT recipient, message FROM response_queue ORDER BY id").get() as any; + expect(row.recipient).toBe('7001'); + expect(String(row.message).toLowerCase()).toContain('listo'); + }); + + it('es idempotente: si envía "activar" de nuevo, se vuelve a encolar', async () => { + const data = { + key: { remoteJid: '8002@s.whatsapp.net', fromMe: false }, + message: { conversation: 'activar' } + }; + await WebhookServer.handleMessageUpsert(data); + await WebhookServer.handleMessageUpsert(data); + + expect(rowCount()).toBe(2); + }); +}); diff --git a/tests/unit/services/command.onboarding-jit.test.ts b/tests/unit/services/command.onboarding-jit.test.ts new file mode 100644 index 0000000..2f47cd6 --- /dev/null +++ b/tests/unit/services/command.onboarding-jit.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { TaskService } from '../../../src/tasks/service'; +import { CommandService } from '../../../src/services/command'; +import { Metrics } from '../../../src/services/metrics'; + +describe('CommandService - A4 JIT DM al asignador', () => { + let memdb: Database; + + beforeAll(() => { + memdb = new Database(':memory:'); + initializeDatabase(memdb); + TaskService.dbInstance = memdb as any; + CommandService.dbInstance = memdb as any; + }); + + beforeEach(() => { + process.env.NODE_ENV = 'test'; + process.env.METRICS_ENABLED = 'true'; + process.env.ONBOARDING_ENABLE_IN_TEST = 'true'; + process.env.CHATBOT_PHONE_NUMBER = '555111222'; + Metrics.reset(); + + memdb.exec(` + DELETE FROM task_assignments; + DELETE FROM tasks; + DELETE FROM users; + `); + }); + + it('envía un único DM JIT al asignador con la lista y enlace wa.me cuando hay tokens no resolubles', async () => { + const res = await CommandService.handle({ + sender: '111', + groupId: '', // DM + message: '/t n Mixta @34600123456 @lid-opaque', + mentions: [], + }); + + // Debe existir al menos el ACK y el JIT + expect(res.length).toBeGreaterThan(0); + + const toSender = res.filter(r => r.recipient === '111'); + expect(toSender.length).toBeGreaterThan(0); + + const jit = toSender.find(r => r.message.includes('activar')); + expect(jit).toBeTruthy(); + expect(jit!.message).toContain('lid-opaque'); + expect(jit!.message).toContain('https://wa.me/555111222'); + + const prom = Metrics.render('prom'); + expect(prom).toContain('onboarding_prompts_sent_total'); + expect(prom).toContain('source="jit_assignee_failure"'); + }); + + it('no envía JIT si falta CHATBOT_PHONE_NUMBER y contabiliza skipped:missing_bot_number', async () => { + process.env.CHATBOT_PHONE_NUMBER = ''; + + const res = await CommandService.handle({ + sender: '222', + groupId: '', + message: '/t n Solo opaco @alias-xyz', + mentions: [], + }); + + const anyJit = res.find(r => r.recipient === '222' && r.message.includes('activar')); + expect(anyJit).toBeUndefined(); + + const prom = Metrics.render('prom'); + expect(prom).toContain('onboarding_prompts_skipped_total'); + expect(prom).toContain('reason="missing_bot_number"'); + }); +});