feat: integrar router etapa 3 con handlers configurar/web y db

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
brobert 4 days ago
parent d591697402
commit 6fcfd2719f

@ -1316,7 +1316,7 @@ Puedes interactuar escribiéndome por privado:
} }
try { try {
const routed = await routeCommand(context); const routed = await routeCommand(context, { db: this.dbInstance });
const responses = routed ?? (await this.processTareaCommand(context)); const responses = routed ?? (await this.processTareaCommand(context));
// Clasificación explícita del outcome (evita lógica en server) // Clasificación explícita del outcome (evita lógica en server)

@ -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<string, 'daily' | 'weekly' | 'off' | 'weekdays'> = {
'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}`
}];
}

@ -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<Msg[]> {
// 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".`
}];
}

@ -1,8 +1,13 @@
/** /**
* Router de comandos (Etapa 1) * Router de comandos (Etapa 3)
* Por ahora no maneja nada y devuelve null para forzar fallback al CommandService actual. * Maneja 'configurar' y 'web', y delega el resto al código actual (null fallback).
* Nota: No importar CommandService aquí para evitar ciclos de import. * 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 = { export type RoutedMessage = {
recipient: string; recipient: string;
@ -20,7 +25,26 @@ export type RouteContext = {
fromMe?: boolean; fromMe?: boolean;
}; };
export async function route(_context: RouteContext): Promise<RoutedMessage[] | null> { export async function route(context: RouteContext, deps?: { db: Database }): Promise<RoutedMessage[] | null> {
// En esta etapa no se maneja nada; devolver null para usar el código actual. 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; return null;
} }

Loading…
Cancel
Save