diff --git a/src/services/command.ts b/src/services/command.ts index 2c798c7..1648561 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -1316,7 +1316,7 @@ Puedes interactuar escribiéndome por privado: } try { - const routed = await routeCommand(context); + const routed = await routeCommand(context, { db: this.dbInstance }); const responses = routed ?? (await this.processTareaCommand(context)); // Clasificación explícita del outcome (evita lógica en server) diff --git a/src/services/commands/handlers/configurar.ts b/src/services/commands/handlers/configurar.ts new file mode 100644 index 0000000..b494f31 --- /dev/null +++ b/src/services/commands/handlers/configurar.ts @@ -0,0 +1,87 @@ +import type { Database } from 'bun:sqlite'; +import { ensureUserExists } from '../../../db'; + +type Ctx = { + sender: string; + groupId: string; + message: string; +}; + +type Msg = { + recipient: string; + message: string; + mentions?: string[]; +}; + +export function handleConfigurar(context: Ctx, deps: { db: Database }): Msg[] { + const tokens = (context.message || '').trim().split(/\s+/); + + const optRaw = (tokens[2] || '').toLowerCase(); + const map: Record = { + 'daily': 'daily', + 'diario': 'daily', + 'diaria': 'daily', + 'l-v': 'weekdays', + 'lv': 'weekdays', + 'laborables': 'weekdays', + 'weekdays': 'weekdays', + 'semanal': 'weekly', + 'weekly': 'weekly', + 'off': 'off', + 'apagar': 'off', + 'ninguno': 'off' + }; + const freq = map[optRaw]; + + // Hora opcional HH:MM + const timeRaw = tokens[3] || ''; + let timeNorm: string | null = null; + if (timeRaw) { + const m = /^(\d{1,2}):([0-5]\d)$/.exec(timeRaw); + if (!m) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`' + }]; + } + const hh = Math.max(0, Math.min(23, parseInt(m[1], 10))); + timeNorm = `${String(hh).padStart(2, '0')}:${m[2]}`; + } + + if (!freq) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`' + }]; + } + + const ensured = ensureUserExists(context.sender, deps.db); + if (!ensured) { + throw new Error('No se pudo asegurar el usuario'); + } + + deps.db.prepare(` + INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) + VALUES (?, ?, COALESCE(?, COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30')), NULL, strftime('%Y-%m-%d %H:%M:%f', 'now')) + ON CONFLICT(user_id) DO UPDATE SET + reminder_freq = excluded.reminder_freq, + reminder_time = CASE WHEN ? IS NOT NULL THEN excluded.reminder_time ELSE reminder_time END, + updated_at = excluded.updated_at + `).run(ensured, freq, timeNorm, ensured, timeNorm); + + let label: string; + if (freq === 'daily') { + label = timeNorm ? `diario (${timeNorm})` : 'diario'; + } else if (freq === 'weekdays') { + label = timeNorm ? `laborables (lunes a viernes ${timeNorm})` : 'laborables (lunes a viernes)'; + } else if (freq === 'weekly') { + label = timeNorm ? `semanal (lunes ${timeNorm})` : 'semanal (lunes 08:30)'; + } else { + label = 'apagado'; + } + + return [{ + recipient: context.sender, + message: `✅ Recordatorios: ${label}` + }]; +} diff --git a/src/services/commands/handlers/web.ts b/src/services/commands/handlers/web.ts new file mode 100644 index 0000000..d636550 --- /dev/null +++ b/src/services/commands/handlers/web.ts @@ -0,0 +1,71 @@ +import type { Database } from 'bun:sqlite'; +import { ensureUserExists } from '../../../db'; +import { isGroupId } from '../../../utils/whatsapp'; +import { randomTokenBase64Url, sha256Hex } from '../../../utils/crypto'; +import { Metrics } from '../../metrics'; + +type Ctx = { + sender: string; + groupId: string; + message: string; +}; + +type Msg = { + recipient: string; + message: string; + mentions?: string[]; +}; + +export async function handleWeb(context: Ctx, deps: { db: Database }): Promise { + // Solo por DM + if (isGroupId(context.groupId)) { + return [{ + recipient: context.sender, + message: 'ℹ️ Este comando se usa por privado. Envíame `/t web` por DM.' + }]; + } + + const base = (process.env.WEB_BASE_URL || '').trim(); + if (!base) { + return [{ + recipient: context.sender, + message: '⚠️ La web no está configurada todavía. Contacta con el administrador (falta WEB_BASE_URL).' + }]; + } + + const ensured = ensureUserExists(context.sender, deps.db); + if (!ensured) { + throw new Error('No se pudo asegurar el usuario'); + } + + const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', ''); + const now = new Date(); + const nowIso = toIso(now); + const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos + + // Invalidar tokens vigentes (uso único) + deps.db.prepare(` + UPDATE web_tokens + SET used_at = ? + WHERE user_id = ? + AND used_at IS NULL + AND expires_at > ? + `).run(nowIso, ensured, nowIso); + + // Generar nuevo token y guardar solo el hash + const token = randomTokenBase64Url(32); + const tokenHash = await sha256Hex(token); + + deps.db.prepare(` + INSERT INTO web_tokens (user_id, token_hash, expires_at, metadata) + VALUES (?, ?, ?, NULL) + `).run(ensured, tokenHash, expiresIso); + + try { Metrics.inc('web_tokens_issued_total'); } catch { } + + const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString(); + return [{ + recipient: context.sender, + message: `Acceso web: ${url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar "/t web".` + }]; +} diff --git a/src/services/commands/index.ts b/src/services/commands/index.ts index d558979..c700e65 100644 --- a/src/services/commands/index.ts +++ b/src/services/commands/index.ts @@ -1,8 +1,13 @@ /** - * Router de comandos (Etapa 1) - * Por ahora no maneja nada y devuelve null para forzar fallback al CommandService actual. + * Router de comandos (Etapa 3) + * Maneja 'configurar' y 'web', y delega el resto al código actual (null → fallback). * Nota: No importar CommandService aquí para evitar ciclos de import. */ +import type { Database } from 'bun:sqlite'; +import { ACTION_ALIASES } from './shared'; +import { handleConfigurar } from './handlers/configurar'; +import { handleWeb } from './handlers/web'; +import { ResponseQueue } from '../response-queue'; export type RoutedMessage = { recipient: string; @@ -20,7 +25,26 @@ export type RouteContext = { fromMe?: boolean; }; -export async function route(_context: RouteContext): Promise { - // En esta etapa no se maneja nada; devolver null para usar el código actual. +export async function route(context: RouteContext, deps?: { db: Database }): Promise { + const trimmed = (context.message || '').trim(); + const tokens = trimmed.split(/\s+/); + const rawAction = (tokens[1] || '').toLowerCase(); + const action = ACTION_ALIASES[rawAction] || rawAction; + + // Requiere db inyectada para poder operar (CommandService la inyecta) + const database = deps?.db; + if (!database) return null; + + if (action === 'configurar') { + try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} + return handleConfigurar(context as any, { db: database }); + } + + if (action === 'web') { + try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} + return await handleWeb(context as any, { db: database }); + } + + // No manejado aquí → fallback al CommandService actual return null; }