From ada071d220ca215e61ec7caa02c03f2afbb8f050 Mon Sep 17 00:00:00 2001 From: brobert Date: Sat, 25 Oct 2025 23:52:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20handlers=20completar/toma?= =?UTF-8?q?r/soltar=20y=20enrutar=20comandos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/commands/handlers/completar.ts | 133 +++++++++++++++++ src/services/commands/handlers/soltar.ts | 105 ++++++++++++++ src/services/commands/handlers/tomar.ts | 149 ++++++++++++++++++++ src/services/commands/index.ts | 18 +++ 4 files changed, 405 insertions(+) create mode 100644 src/services/commands/handlers/completar.ts create mode 100644 src/services/commands/handlers/soltar.ts create mode 100644 src/services/commands/handlers/tomar.ts diff --git a/src/services/commands/handlers/completar.ts b/src/services/commands/handlers/completar.ts new file mode 100644 index 0000000..05bc947 --- /dev/null +++ b/src/services/commands/handlers/completar.ts @@ -0,0 +1,133 @@ +import { TaskService } from '../../../tasks/service'; +import { GroupSyncService } from '../../group-sync'; +import { ICONS } from '../../../utils/icons'; +import { codeId, formatDDMM } from '../../../utils/formatting'; +import { parseMultipleIds, resolveTaskIdFromInput, enforceMembership } from '../shared'; + +type Ctx = { + sender: string; + groupId: string; + message: string; +}; + +type Msg = { + recipient: string; + message: string; + mentions?: string[]; +}; + +export async function handleCompletar(context: Ctx): Promise { + const tokens = (context.message || '').trim().split(/\s+/); + + const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10); + + // Sin IDs: ayuda de uso + if (ids.length === 0) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `/t x 26` o múltiples: `/t x 14 19 24` o `/t x 14,19,24` (máx. 10)' + }]; + } + + // Caso de 1 ID: mantener comportamiento actual + if (ids.length === 1) { + const idInput = ids[0]; + const resolvedId = resolveTaskIdFromInput(idInput); + if (!resolvedId) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` + }]; + } + + const task = TaskService.getTaskById(resolvedId); + if (!task) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` + }]; + } + + if (!enforceMembership(context.sender, task)) { + return [{ + recipient: context.sender, + message: 'No puedes completar esta tarea porque no eres de este grupo.' + }]; + } + + const res = TaskService.completeTask(resolvedId, context.sender); + const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; + + if (res.status === 'not_found') { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` + }]; + } + if (res.status === 'already') { + return [{ + recipient: context.sender, + message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + + return [{ + recipient: context.sender, + message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + + // Modo múltiple + let cntUpdated = 0, cntAlready = 0, cntNotFound = 0, cntBlocked = 0; + const lines: string[] = []; + + if (truncated) { + lines.push('⚠️ Se procesarán solo los primeros 10 IDs.'); + } + + for (const idInput of ids) { + const resolvedId = resolveTaskIdFromInput(idInput); + if (!resolvedId) { + lines.push(`⚠️ ${codeId(idInput)} no encontrada.`); + cntNotFound++; + continue; + } + + const task = TaskService.getTaskById(resolvedId); + if (task && !enforceMembership(context.sender, task)) { + lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`); + cntBlocked++; + continue; + } + + const res = TaskService.completeTask(resolvedId, context.sender); + const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; + + if (res.status === 'already') { + lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`); + cntAlready++; + } else if (res.status === 'updated') { + lines.push(`${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} completada — ${res.task?.description || '(sin descripción)'}${due}`); + cntUpdated++; + } else if (res.status === 'not_found') { + lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`); + cntNotFound++; + } + } + + // Resumen final + const summary: string[] = []; + if (cntUpdated) summary.push(`completadas ${cntUpdated}`); + if (cntAlready) summary.push(`ya estaban ${cntAlready}`); + if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`); + if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`); + if (summary.length) { + lines.push(''); + lines.push(`Resumen: ${summary.join(', ')}.`); + } + + return [{ + recipient: context.sender, + message: lines.join('\n') + }]; +} diff --git a/src/services/commands/handlers/soltar.ts b/src/services/commands/handlers/soltar.ts new file mode 100644 index 0000000..d8082ce --- /dev/null +++ b/src/services/commands/handlers/soltar.ts @@ -0,0 +1,105 @@ +import { TaskService } from '../../../tasks/service'; +import { GroupSyncService } from '../../group-sync'; +import { ICONS } from '../../../utils/icons'; +import { codeId, formatDDMM, italic } from '../../../utils/formatting'; +import { resolveTaskIdFromInput, enforceMembership } from '../shared'; + +type Ctx = { + sender: string; + groupId: string; + message: string; +}; + +type Msg = { + recipient: string; + message: string; + mentions?: string[]; +}; + +export async function handleSoltar(context: Ctx): Promise { + const tokens = (context.message || '').trim().split(/\s+/); + + const idToken = tokens[2]; + const idInput = idToken ? parseInt(idToken, 10) : NaN; + if (!idInput || Number.isNaN(idInput)) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `/t soltar 26`' + }]; + } + + const resolvedId = resolveTaskIdFromInput(idInput); + if (!resolvedId) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` + }]; + } + + const task = TaskService.getTaskById(resolvedId); + if (!task) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` + }]; + } + + if (!enforceMembership(context.sender, task)) { + return [{ + recipient: context.sender, + message: '⚠️ No puedes soltar esta tarea porque no eres de este grupo.' + }]; + } + + const res = TaskService.unassignTask(resolvedId, context.sender); + const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; + + if (res.status === 'forbidden_personal') { + return [{ + recipient: context.sender, + message: '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla' + }]; + } + + if (res.status === 'not_found') { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` + }]; + } + if (res.status === 'completed') { + return [{ + recipient: context.sender, + message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + if (res.status === 'not_assigned') { + return [{ + recipient: context.sender, + message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + + if (res.now_unassigned) { + const lines = [ + `${ICONS.unassigned} ${codeId(resolvedId, res.task?.display_code)}`, + `${res.task?.description || '(sin descripción)'}`, + res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '', + italic('queda sin responsable.') + ].filter(Boolean); + return [{ + recipient: context.sender, + message: lines.join('\n') + }]; + } + + const lines = [ + `${ICONS.unassign} ${codeId(resolvedId, res.task?.display_code)}`, + `${res.task?.description || '(sin descripción)'}`, + res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' + ].filter(Boolean); + return [{ + recipient: context.sender, + message: lines.join('\n') + }]; +} diff --git a/src/services/commands/handlers/tomar.ts b/src/services/commands/handlers/tomar.ts new file mode 100644 index 0000000..1cffaec --- /dev/null +++ b/src/services/commands/handlers/tomar.ts @@ -0,0 +1,149 @@ +import { TaskService } from '../../../tasks/service'; +import { GroupSyncService } from '../../group-sync'; +import { ICONS } from '../../../utils/icons'; +import { codeId, formatDDMM, italic } from '../../../utils/formatting'; +import { parseMultipleIds, resolveTaskIdFromInput, enforceMembership } from '../shared'; + +type Ctx = { + sender: string; + groupId: string; + message: string; +}; + +type Msg = { + recipient: string; + message: string; + mentions?: string[]; +}; + +export async function handleTomar(context: Ctx): Promise { + const tokens = (context.message || '').trim().split(/\s+/); + + const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10); + + // Sin IDs: ayuda de uso + if (ids.length === 0) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `/t tomar 26` o múltiples: `/t tomar 12 19 50` o `/t tomar 12,19,50` (máx. 10)' + }]; + } + + // Caso de 1 ID: mantener comportamiento actual + if (ids.length === 1) { + const idInput = ids[0]; + const resolvedId = resolveTaskIdFromInput(idInput); + if (!resolvedId) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` + }]; + } + + const task = TaskService.getTaskById(resolvedId); + if (!task) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` + }]; + } + + if (!enforceMembership(context.sender, task)) { + return [{ + recipient: context.sender, + message: 'No puedes tomar esta tarea porque no eres de este grupo.' + }]; + } + + const res = TaskService.claimTask(resolvedId, context.sender); + const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; + + if (res.status === 'not_found') { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` + }]; + } + if (res.status === 'completed') { + return [{ + recipient: context.sender, + message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + if (res.status === 'already') { + return [{ + recipient: context.sender, + message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + + const lines = [ + italic(`${ICONS.take} Has tomado ${codeId(resolvedId, res.task?.display_code)}`), + `${res.task?.description || '(sin descripción)'}`, + res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' + ].filter(Boolean); + + return [{ + recipient: context.sender, + message: lines.join('\n') + }]; + } + + // Modo múltiple + let cntClaimed = 0, cntAlready = 0, cntCompleted = 0, cntNotFound = 0, cntBlocked = 0; + const lines: string[] = []; + + if (truncated) { + lines.push('⚠️ Se procesarán solo los primeros 10 IDs.'); + } + + for (const idInput of ids) { + const resolvedId = resolveTaskIdFromInput(idInput); + if (!resolvedId) { + lines.push(`⚠️ ${codeId(idInput)} no encontrada.`); + cntNotFound++; + continue; + } + + const task = TaskService.getTaskById(resolvedId); + if (task && !enforceMembership(context.sender, task)) { + lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`); + cntBlocked++; + continue; + } + + const res = TaskService.claimTask(resolvedId, context.sender); + const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; + + if (res.status === 'already') { + lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`); + cntAlready++; + } else if (res.status === 'claimed') { + lines.push(`${ICONS.take} ${codeId(resolvedId, res.task?.display_code)} tomada — ${res.task?.description || '(sin descripción)'}${due}`); + cntClaimed++; + } else if (res.status === 'completed') { + lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`); + cntCompleted++; + } else if (res.status === 'not_found') { + lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`); + cntNotFound++; + } + } + + // Resumen final + const summary: string[] = []; + if (cntClaimed) summary.push(`tomadas ${cntClaimed}`); + if (cntAlready) summary.push(`ya las tenías ${cntAlready}`); + if (cntCompleted) summary.push(`ya completadas ${cntCompleted}`); + if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`); + if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`); + if (summary.length) { + lines.push(''); + lines.push(`Resumen: ${summary.join(', ')}.`); + } + + return [{ + recipient: context.sender, + message: lines.join('\n') + }]; +} diff --git a/src/services/commands/index.ts b/src/services/commands/index.ts index 7afe6c7..9da8e0a 100644 --- a/src/services/commands/index.ts +++ b/src/services/commands/index.ts @@ -8,6 +8,9 @@ import { ACTION_ALIASES } from './shared'; import { handleConfigurar } from './handlers/configurar'; import { handleWeb } from './handlers/web'; import { handleVer } from './handlers/ver'; +import { handleCompletar } from './handlers/completar'; +import { handleTomar } from './handlers/tomar'; +import { handleSoltar } from './handlers/soltar'; import { ResponseQueue } from '../response-queue'; import { isGroupId } from '../../utils/whatsapp'; import { Metrics } from '../metrics'; @@ -62,6 +65,21 @@ export async function route(context: RouteContext, deps?: { db: Database }): Pro return await handleVer(context as any); } + if (action === 'completar') { + try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} + return await handleCompletar(context as any); + } + + if (action === 'tomar') { + try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} + return await handleTomar(context as any); + } + + if (action === 'soltar') { + try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} + return await handleSoltar(context as any); + } + if (action === 'configurar') { try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} return handleConfigurar(context as any, { db: database });