From e3ec82037ba72b5c8f2d3c7ab70ebead1aac8695 Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 10 Nov 2025 13:49:10 +0100 Subject: [PATCH] refactor: extraer display_code y complete-reaction; ajustar TaskService Co-authored-by: aider (openrouter/openai/gpt-5) --- src/tasks/complete-reaction.ts | 74 ++++++++++++++++++++++++++ src/tasks/display-code.ts | 40 ++++++++++++++ src/tasks/service.ts | 95 ++-------------------------------- 3 files changed, 118 insertions(+), 91 deletions(-) create mode 100644 src/tasks/complete-reaction.ts create mode 100644 src/tasks/display-code.ts diff --git a/src/tasks/complete-reaction.ts b/src/tasks/complete-reaction.ts new file mode 100644 index 0000000..73a7f8b --- /dev/null +++ b/src/tasks/complete-reaction.ts @@ -0,0 +1,74 @@ +import type { Database } from 'bun:sqlite'; +import { isGroupId } from '../utils/whatsapp'; +import { AllowedGroups } from '../services/allowed-groups'; +import { ResponseQueue } from '../services/response-queue'; + +/** + * Publica una reacción ✅ al mensaje origen de la tarea si: + * - REACTIONS_ENABLED está activado, + * - scope permite (all o solo grupos), + * - está dentro del TTL configurado (por defecto 14 días), + * - y pasa el gating de grupos en modo 'enforce'. + * + * No lanza errores (no debe bloquear el flujo de completado). + */ +export function enqueueCompletionReactionIfEligible(db: Database, taskId: number): void { + try { + const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); + const enabled = ['true', '1', 'yes', 'on'].includes(rxEnabled); + if (!enabled) return; + + let origin: any = null; + try { + origin = db.prepare(` + SELECT chat_id, message_id, created_at, participant, from_me + FROM task_origins + WHERE task_id = ? + `).get(taskId) as { chat_id?: string; message_id?: string; created_at?: string; participant?: string | null; from_me?: number | boolean | null } | undefined; + } catch { + origin = db.prepare(` + SELECT chat_id, message_id, created_at + FROM task_origins + WHERE task_id = ? + `).get(taskId) as { chat_id?: string; message_id?: string; created_at?: string } | undefined; + } + + if (!origin || !origin.chat_id || !origin.message_id) return; + + const chatId = String(origin.chat_id); + const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase(); + if (!(scope === 'all' || isGroupId(chatId))) return; + + // TTL desde REACTIONS_TTL_DAYS (default 14 si inválido) + const ttlDaysEnv = Number(process.env.REACTIONS_TTL_DAYS); + const ttlDays = Number.isFinite(ttlDaysEnv) && ttlDaysEnv > 0 ? ttlDaysEnv : 14; + const maxAgeMs = ttlDays * 24 * 60 * 60 * 1000; + + const createdRaw = String(origin.created_at || ''); + const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z'); + const createdMs = Date.parse(createdIso); + const withinTtl = Number.isFinite(createdMs) ? (Date.now() - createdMs <= maxAgeMs) : false; + if (!withinTtl) return; + + // Gating 'enforce' para grupos + if (isGroupId(chatId)) { + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode === 'enforce') { + let allowed = true; + try { allowed = AllowedGroups.isAllowed(chatId); } catch { allowed = true; } + if (!allowed) return; + } + } + + // Encolar reacción ✅ con idempotencia; no bloquear si falla + const participant = origin && origin.participant ? String(origin.participant) : undefined; + const fromMe = (origin && (origin.from_me === 1 || origin.from_me === true)) ? true : undefined; + const rxOpts: { participant?: string; fromMe?: boolean } = {}; + if (participant !== undefined) rxOpts.participant = participant; + if (typeof fromMe === 'boolean') rxOpts.fromMe = fromMe; + + ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅', rxOpts).catch(() => {}); + } catch { + // no-op + } +} diff --git a/src/tasks/display-code.ts b/src/tasks/display-code.ts new file mode 100644 index 0000000..118f637 --- /dev/null +++ b/src/tasks/display-code.ts @@ -0,0 +1,40 @@ +import type { Database } from 'bun:sqlite'; + +/** + * Calcula el siguiente display_code disponible entre tareas activas + * (y tareas completadas en las últimas 24h), empezando en 1 hasta 9999, + * respetando huecos. + */ +export function pickNextDisplayCode(db: Database): number { + const MAX_DISPLAY_CODE = 9999; + + const rows = db + .prepare(` + SELECT display_code + FROM tasks + WHERE display_code IS NOT NULL + AND ( + COALESCE(completed, 0) = 0 + OR (completed_at IS NOT NULL AND completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')) + ) + ORDER BY display_code ASC + `) + .all() as Array<{ display_code: number }>; + + let expect = 1; + for (const r of rows) { + const dc = Number(r.display_code || 0); + if (dc < expect) continue; + if (dc === expect) { + expect++; + if (expect > MAX_DISPLAY_CODE) break; + continue; + } + // encontrado hueco + break; + } + if (expect > MAX_DISPLAY_CODE) { + throw new Error('No hay códigos disponibles (límite alcanzado)'); + } + return expect; +} diff --git a/src/tasks/service.ts b/src/tasks/service.ts index 5280652..f5adfa1 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -2,8 +2,8 @@ import type { Database } from 'bun:sqlite'; import { db, ensureUserExists } from '../db'; import { AllowedGroups } from '../services/allowed-groups'; import { isGroupId } from '../utils/whatsapp'; -import { ResponseQueue } from '../services/response-queue'; -import { Metrics } from '../services/metrics'; +import { pickNextDisplayCode } from './display-code'; +import { enqueueCompletionReactionIfEligible } from './complete-reaction'; type CreateTaskInput = { description: string; @@ -21,39 +21,7 @@ export class TaskService { static dbInstance: Database = db; static createTask(task: CreateTaskInput, assignments: CreateAssignmentInput[] = []): number { - const MAX_DISPLAY_CODE = 9999; - const runTx = this.dbInstance.transaction(() => { - const pickNextDisplayCode = (): number => { - const rows = this.dbInstance - .prepare(` - SELECT display_code - FROM tasks - WHERE display_code IS NOT NULL - AND ( - COALESCE(completed, 0) = 0 - OR (completed_at IS NOT NULL AND completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')) - ) - ORDER BY display_code ASC - `) - .all() as Array<{ display_code: number }>; - let expect = 1; - for (const r of rows) { - const dc = Number(r.display_code || 0); - if (dc < expect) continue; - if (dc === expect) { - expect++; - if (expect > MAX_DISPLAY_CODE) break; - continue; - } - // encontrado hueco - break; - } - if (expect > MAX_DISPLAY_CODE) { - throw new Error('No hay códigos disponibles (límite alcanzado)'); - } - return expect; - }; const insertTask = this.dbInstance.prepare(` INSERT INTO tasks (description, due_date, group_id, created_by, display_code) @@ -87,7 +55,7 @@ export class TaskService { } // Elegir display_code global reutilizable entre tareas activas - const displayCode = pickNextDisplayCode(); + const displayCode = pickNextDisplayCode(this.dbInstance); const runResult = insertTask.run( task.description, @@ -282,62 +250,7 @@ export class TaskService { // Fase 2: reacción ✅ al completar dentro del TTL y con gating try { - const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); - const enabled = ['true','1','yes','on'].includes(rxEnabled); - if (enabled) { - let origin: any = null; - try { - origin = this.dbInstance.prepare(` - SELECT chat_id, message_id, created_at, participant, from_me - FROM task_origins - WHERE task_id = ? - `).get(taskId) as { chat_id?: string; message_id?: string; created_at?: string; participant?: string | null; from_me?: number | boolean | null } | undefined; - } catch { - origin = this.dbInstance.prepare(` - SELECT chat_id, message_id, created_at - FROM task_origins - WHERE task_id = ? - `).get(taskId) as { id?: number; description?: string; due_date?: string | null; group_id?: string | null; completed?: number; completed_at?: string | null; display_code?: number | null } | undefined; - } - - if (origin && origin.chat_id && origin.message_id) { - const chatId = String(origin.chat_id); - const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase(); - if (scope === 'all' || isGroupId(chatId)) { - // TTL desde REACTIONS_TTL_DAYS (usar tal cual; default 14 si inválido) - const ttlDaysEnv = Number(process.env.REACTIONS_TTL_DAYS); - const ttlDays = Number.isFinite(ttlDaysEnv) && ttlDaysEnv > 0 ? ttlDaysEnv : 14; - const maxAgeMs = ttlDays * 24 * 60 * 60 * 1000; - - const createdRaw = String(origin.created_at || ''); - const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z'); - const createdMs = Date.parse(createdIso); - const withinTtl = Number.isFinite(createdMs) ? (Date.now() - createdMs <= maxAgeMs) : false; - - if (withinTtl) { - // Gating 'enforce' para grupos - let allowed = true; - if (isGroupId(chatId)) { - try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (mode === 'enforce') { - try { allowed = AllowedGroups.isAllowed(chatId); } catch { allowed = true; } - } - } - if (allowed) { - // Encolar reacción ✅ con idempotencia; no bloquear si falla - const participant = origin && origin.participant ? String(origin.participant) : undefined; - const fromMe = (origin && (origin.from_me === 1 || origin.from_me === true)) ? true : undefined; - const rxOpts: { participant?: string; fromMe?: boolean } = {}; - if (participant !== undefined) rxOpts.participant = participant; - if (typeof fromMe === 'boolean') rxOpts.fromMe = fromMe; - ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅', rxOpts) - .catch(() => {}); - } - } - } - } - } + enqueueCompletionReactionIfEligible(this.dbInstance, taskId); } catch {} return {