feat: alinea copy A3/A4 a activar y añade tests; actualiza env y docs

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent b9252f4c49
commit 07bfa0f419

@ -25,6 +25,10 @@ ONBOARDING_FALLBACK_MIN_DIGITS=8 # A2: longitud mínima para conservar núme
# ONBOARDING_PROMPTS_ENABLED=true # ONBOARDING_PROMPTS_ENABLED=true
# Permite publicación durante tests específicos # Permite publicación durante tests específicos
# ONBOARDING_ENABLE_IN_TEST=false # 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 # Umbral de cobertura (publica si coverage < threshold). Por defecto 1.0
# ONBOARDING_COVERAGE_THRESHOLD=1 # ONBOARDING_COVERAGE_THRESHOLD=1
# Periodo de gracia tras la última verificación de miembros (segundos). Por defecto 90 # Periodo de gracia tras la última verificación de miembros (segundos). Por defecto 90

@ -18,7 +18,7 @@ Estrategia general
- Exprimir al máximo el aprendizaje automático que ya tenemos (participants y contacts). - 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. - 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. - 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 Fases
@ -53,18 +53,18 @@ Fase A3 — Mensaje único por grupo con wa.me (Completada)
- Tras A1 y un breve grace period (≈12 min) para permitir contacts/chats updates, calcular alias_coverage_ratio{group_id}. - Tras A1 y un breve grace period (≈12 min) para permitir contacts/chats updates, calcular alias_coverage_ratio{group_id}.
- Si cobertura = 100% → NO publicar. - Si cobertura = 100% → NO publicar.
- Si cobertura < 100% publicar UNA vez un mensaje por grupo con el texto: - 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/<NUM_BOT> - “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/<NUM_BOT>
- Control de ruido: - 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. - 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): - 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. - 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: - 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/<NUM_BOT>”. - 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/<NUM_BOT>”.
- Primer DM “hola” de un usuario: - Primer DM “activar” de un usuario:
- Asegurar ensureUserExists y responder con: “Listo, ya puedes reclamar/ser responsable en: …”. - 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) Fase A5 — Optimizaciones post-A1 (pendiente)
- Optimizar encadenado tras groups.upsert para sincronizar solo los grupos afectados cuando el payload lo permita. - Optimizar encadenado tras groups.upsert para sincronizar solo los grupos afectados cuando el payload lo permita.

@ -747,7 +747,7 @@ export class GroupSyncService {
} }
// Encolar mensaje en la cola persistente y marcar timestamp en groups // 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.transaction(() => {
this.dbInstance.prepare(` this.dbInstance.prepare(`
INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at) INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at)

@ -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);
});
});

@ -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"');
});
});
Loading…
Cancel
Save