refactor: extraer display_code y complete-reaction; ajustar TaskService

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
brobert 1 month ago
parent a46b5dac68
commit e3ec82037b

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

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

@ -2,8 +2,8 @@ import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db'; import { db, ensureUserExists } from '../db';
import { AllowedGroups } from '../services/allowed-groups'; import { AllowedGroups } from '../services/allowed-groups';
import { isGroupId } from '../utils/whatsapp'; import { isGroupId } from '../utils/whatsapp';
import { ResponseQueue } from '../services/response-queue'; import { pickNextDisplayCode } from './display-code';
import { Metrics } from '../services/metrics'; import { enqueueCompletionReactionIfEligible } from './complete-reaction';
type CreateTaskInput = { type CreateTaskInput = {
description: string; description: string;
@ -21,39 +21,7 @@ export class TaskService {
static dbInstance: Database = db; static dbInstance: Database = db;
static createTask(task: CreateTaskInput, assignments: CreateAssignmentInput[] = []): number { static createTask(task: CreateTaskInput, assignments: CreateAssignmentInput[] = []): number {
const MAX_DISPLAY_CODE = 9999;
const runTx = this.dbInstance.transaction(() => { 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(` const insertTask = this.dbInstance.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, display_code) 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 // Elegir display_code global reutilizable entre tareas activas
const displayCode = pickNextDisplayCode(); const displayCode = pickNextDisplayCode(this.dbInstance);
const runResult = insertTask.run( const runResult = insertTask.run(
task.description, task.description,
@ -282,62 +250,7 @@ export class TaskService {
// Fase 2: reacción ✅ al completar dentro del TTL y con gating // Fase 2: reacción ✅ al completar dentro del TTL y con gating
try { try {
const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); enqueueCompletionReactionIfEligible(this.dbInstance, taskId);
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(() => {});
}
}
}
}
}
} catch {} } catch {}
return { return {

Loading…
Cancel
Save