From f020c809ec4015c2ccbaaa3710c3cc4f7eecdaa5 Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 20 Oct 2025 21:40:58 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20a=C3=B1ade=20plan=20detallado=20de=20re?= =?UTF-8?q?acciones=20del=20bot=20de=20tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- docs/plan-reacciones-bot.md | 231 ++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/plan-reacciones-bot.md diff --git a/docs/plan-reacciones-bot.md b/docs/plan-reacciones-bot.md new file mode 100644 index 0000000..1a84a8f --- /dev/null +++ b/docs/plan-reacciones-bot.md @@ -0,0 +1,231 @@ +# Plan de reacciones del bot de tareas (WhatsApp) + +Objetivo: añadir un “ack” visual de bajo ruido en grupos, usando reacciones a los mensajes con comandos `/t`. Alcance inicial: +- Reaccionar 1 sola vez por comando: + - Éxito (comando procesado): 🤖 + - Error (uso inválido, permisos, no encontrada…): ⚠️ +- Plus opcional sin mucha complejidad: si el comando creó una tarea y esta se completa dentro de un TTL (7–14 días), reaccionar con ✅ al mensaje origen del comando. + +No se añaden mensajes al chat; solo reacciones. Por defecto solo en grupos. Todo detrás de “feature flags”. + +--- + +## 1) UX y reglas + +- Ámbito: + - Grupos permitidos (AllowedGroups) por defecto (REACTIONS_SCOPE=groups). + - No reaccionar en DMs salvo que se configure explícitamente (REACTIONS_SCOPE=all). +- Una reacción por comando (no usar “procesando”/“pensando”). +- No borrar/reemplazar reacciones anteriores; simplemente añadir la correspondiente (🤖/⚠️) y, si aplica, luego ✅. +- TTL para marcar ✅ tras completar: 14 días por defecto (configurable 7–14). + +Emojis: +- Éxito de procesamiento: 🤖 +- Error: ⚠️ +- Tarea completada (tardío): ✅ + +--- + +## 2) Flags/entorno + +Añadir variables de entorno: +- REACTIONS_ENABLED=true|false (default: false) +- REACTIONS_TTL_DAYS=14 (admisible 7–14) +- REACTIONS_SCOPE=groups|all (default: groups) +- (Opcional) RQ_REACTIONS_MAX_ATTEMPTS=3 para limitar reintentos de jobs de reacción + +Se reutilizan: +- GROUP_GATING_MODE (off|discover|enforce) +- AllowedGroups.isAllowed para coherencia con el gating. + +--- + +## 3) Persistencia: nueva tabla `task_origins` (migración v17) + +Objetivo: vincular una tarea creada con el mensaje de WhatsApp que originó el comando para poder reaccionar con ✅ al completarse. + +Esquema: +- task_id INTEGER PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE +- chat_id TEXT NOT NULL // JID completo del grupo (p. ej. 123@g.us) +- message_id TEXT NOT NULL // id del mensaje del comando +- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')) + +Índices: +- CREATE INDEX IF NOT EXISTS idx_task_origins_task ON task_origins(task_id); +- (Opcional) CREATE INDEX IF NOT EXISTS idx_task_origins_chat_msg ON task_origins(chat_id, message_id); + +Notas: +- 1 fila por tarea (PK = task_id). Suficiente para nuestro caso. +- No toca esquemas existentes. + +--- + +## 4) Cola: soporte de “jobs de reacción” en ResponseQueue + +Formato del job (reutilizamos `response_queue`, sin cambiar esquema): +- recipient: usar `chatId` (JID) para cumplir NOT NULL. +- message: puede estar vacío (no se usa para reactions). +- metadata (JSON): + ``` + { + "kind": "reaction", + "emoji": "🤖" | "⚠️" | "✅", + "chatId": "", + "messageId": "" + } + ``` + +Envío (Evolution API): +- POST {EVOLUTION_API_URL}/message/sendReaction/{instance} +- Headers: { apikey, Content-Type: application/json } +- Body: + ``` + { + "key": { "remoteJid": "", "fromMe": true, "id": "" }, + "reaction": "" + } + ``` + +Reintentos: +- Backoff existente. +- Opcional: limitar reacciones con `RQ_REACTIONS_MAX_ATTEMPTS` (p. ej. 3). 4xx → fallo definitivo; 5xx/red → reintentos. + +Idempotencia: +- Antes de insertar, consultar si ya existe (status IN queued|processing|sent) un job con metadata idéntica (mismo chatId, messageId, emoji) en las últimas 24h; si existe, no insertar otro. +- Mantener JSON canónico (mismas claves/orden) al construir metadata para hacer la comparación fiable o parsear JSON en la consulta. + +--- + +## 5) Cambios por fichero (implementación por fases) + +Fase 1 — Infra y reacción final por comando +- src/services/response-queue.ts + - Detectar `metadata.kind === 'reaction'`. + - Construir y enviar POST a `/message/sendReaction/{instance}` con el payload anterior. + - Opcional: `RQ_REACTIONS_MAX_ATTEMPTS` para jobs de reacción. +- src/server.ts (WebhookServer) + - Capturar `messageId = data.key.id`. + - Pasar `messageId` en el `CommandContext`. + - Tras ejecutar el comando, decidir emoji: + - Si REACTIONS_ENABLED=false → no hacer nada. + - Si REACTIONS_SCOPE=groups y no es grupo → no hacer nada. + - Si GROUP_GATING_MODE='enforce' y el grupo no está allowed → no hacer nada. + - Determinar outcome: + - Recomendado: usar un wrapper `handleWithOutcome` en CommandService que devuelva `{ responses, ok: boolean, createdTaskIds?: number[] }`. + - Alternativa mínima (temporal): heurística sobre texto (mensajes que empiezan con “ℹ️ Uso: …”, “No puedes …”, “no encontrada”, “Acción … no reconocida”, “⚠️ …” → error). + - Encolar job con emoji = ok ? 🤖 : ⚠️, `chatId=remoteJid`, `messageId`. + - Idempotencia: consulta previa antes de insertar. +- src/services/command.ts + - Ampliar `CommandContext` con `messageId: string`. + - En la rama `/t nueva`, tras crear la tarea: + - Si `isGroupId(context.groupId)` y `context.messageId`, insertar fila en `task_origins (task_id, chat_id, message_id)`. + - (Recomendado) Añadir `handleWithOutcome` para clasificar ok/error sin depender del texto. +- src/db/migrations/index.ts + - Añadir migración v17 con `task_origins` e índices. + +Fase 2 — Reacción tardía (✅) al completar +- src/tasks/service.ts + - En `completeTask`, cuando `status === 'updated'`: + - Buscar `task_origins` por `taskId`. + - Si no existe, salir. + - Comprobar TTL: `now - created_at <= REACTIONS_TTL_DAYS`. + - Flags/política: `REACTIONS_ENABLED` y, si `REACTIONS_SCOPE=groups`, que `chat_id` termine en `@g.us`. + - (Opcional) En modo enforce, verificar AllowedGroups.isAllowed(chat_id). + - Encolar job `kind:'reaction', emoji:'✅', chatId, messageId`. + - Idempotencia: mismo check previo antes de insertar. + +--- + +## 6) Flujo E2E (grupo permitido) + +1) Usuario envía mensaje con `/t nueva …` en un grupo. +2) WebhookServer: + - Obtiene `remoteJid`, `messageId`. + - Construye `CommandContext` con `sender`, `groupId`, `message`, `mentions`, `messageId`. +3) CommandService: + - Procesa el comando. + - Si crea tarea: inserta fila en `task_origins`. +4) WebhookServer: + - Clasifica outcome (ok/err). + - Si aplica, encola una reacción (🤖 o ⚠️) usando ResponseQueue. +5) Más tarde, alguien completa la tarea: + - TaskService.completeTask → si dentro del TTL, encola ✅ apuntando al `messageId` original. +6) ResponseQueue: + - Consume jobs `kind:'reaction'` y llama a Evolution `/message/sendReaction`. + +--- + +## 7) Idempotencia, límites y gating + +- Idempotencia: + - No duplicar reacciones para el mismo (chatId, messageId, emoji) gracias a la consulta previa en `response_queue`. + - Completar varias veces → solo 1 job ✅ (misma idempotencia). +- Gating: + - Respetar `GROUP_GATING_MODE='enforce'`: no reaccionar en grupos no permitidos. + - No reaccionar en DMs salvo `REACTIONS_SCOPE=all`. +- Límites: + - RateLimiter de comandos ya limita frecuencia. + - Reintentos de reacciones limitados para evitar ruido prolongado. + +--- + +## 8) Errores previstos y manejo + +- Mensaje borrado / bot expulsado / permisos → error 4xx → marcar `failed` sin reintentos excesivos. +- Errores de red/5xx → reintentos con backoff hasta `RQ_REACTIONS_MAX_ATTEMPTS` (si definido) o los globales. +- Falta de `messageId` en el evento → omitir reacciones y `task_origins` (no romper el flujo). + +--- + +## 9) Pruebas a añadir + +Unitarias: +- Reacción final: + - Grupo allowed, `REACTIONS_ENABLED=true`, `/t nueva …` → se encola 🤖 (1 job con metadata.kind='reaction', emoji='🤖', chatId=grupo, messageId capturado). + - Comando inválido (p. ej. `/t x` sin IDs) → se encola ⚠️. + - DM con `REACTIONS_SCOPE=groups` → no se encola. + - `REACTIONS_ENABLED=false` → no se encola. +- task_origins: + - Tras `/t nueva` en grupo, existe `task_origins(task_id, chat_id, message_id)`. +- Completar → ✅: + - Dentro de TTL → se encola ✅ con el `messageId` de origen. + - Fuera de TTL → no se encola. + - Completar dos veces → solo 1 job ✅ (idempotencia). +- ResponseQueue: + - Jobs `kind:'reaction'` llaman a `/message/sendReaction…` (no a sendText). + - Manejo de 4xx/5xx conforme a política de reintentos. + +Integración simulada: +- Flujo feliz: `/t nueva` → 🤖; `completeTask` → ✅. +- Error: comando desconocido o “Uso:” → ⚠️. +- Grupo bloqueado en enforce → no reacción. + +--- + +## 10) Despliegue y configuración + +- Añadir flags al entorno: + - `REACTIONS_ENABLED=false` (arranque en “off”). + - `REACTIONS_TTL_DAYS=14`. + - `REACTIONS_SCOPE=groups`. + - (Opcional) `RQ_REACTIONS_MAX_ATTEMPTS=3`. +- Aplicar migraciones (incluye v17: `task_origins`). +- Activar `REACTIONS_ENABLED` gradualmente y monitorizar efectos. + +--- + +## 11) Consideraciones + +- Notificaciones: algunos usuarios reciben notificación por reacciones; una sola por comando minimiza ruido. +- Privacidad: no se envían datos nuevos; solo reacciones en el mismo chat. +- Observabilidad: se puede añadir contadores de métricas (opcional): + - `reactions_enqueued_total{emoji=…}`, `reactions_sent_total`, `reactions_failed_total`. + +--- + +## 12) Trabajos futuros (opcional) + +- Debounce de “procesando” (⏳) >1–2s y reemplazo por 🤖. +- Opt-out por grupo (preferencia guardada en DB). +- Cambio de reacción previa (quitar ⚠️/🤖 y dejar solo ✅) — requiere leer/gestionar estado de reacciones y añade complejidad. +- Reaccionar a otros comandos (tomar/soltar) con emojis específicos.