From 182c97669779eaf749d15f9338562700d02c14f1 Mon Sep 17 00:00:00 2001 From: brobert Date: Fri, 17 Oct 2025 12:29:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20resolver=20alias=20y=20d=C3=ADgitos=20e?= =?UTF-8?q?n=20ResponseQueue;=20reconciliaci=C3=B3n?= 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/maintenance.ts | 58 ++++++++++++++++++++++++++++++++++ src/services/response-queue.ts | 49 +++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/services/maintenance.ts b/src/services/maintenance.ts index b68e01e..965e8c4 100644 --- a/src/services/maintenance.ts +++ b/src/services/maintenance.ts @@ -23,6 +23,9 @@ export class MaintenanceService { this.cleanupInactiveMembersOnce().catch(err => { console.error('❌ Error en cleanup de miembros inactivos:', err); }); + this.reconcileAliasUsersOnce().catch(err => { + console.error('❌ Error en reconcile de alias de usuarios:', err); + }); }, intervalMs); } @@ -44,4 +47,59 @@ export class MaintenanceService { const deleted = Number(res?.changes || 0); return deleted; } + + /** + * Reconciliación de usuarios: fusiona IDs alias (LID u opacos) hacia el número real + * en todas las tablas relevantes, basándose en user_aliases. + * Devuelve el número de alias procesados. + */ + static async reconcileAliasUsersOnce(instance: Database = db): Promise { + try { + const rows = instance.prepare(`SELECT alias, user_id FROM user_aliases WHERE alias != user_id`).all() as any[]; + let merged = 0; + + for (const r of rows) { + const alias = String(r.alias); + const real = String(r.user_id); + + instance.transaction(() => { + const nowIso = toIsoSql(new Date()); + // Asegurar existencia del usuario real + try { + instance.prepare(`INSERT OR IGNORE INTO users (id, created_at, updated_at) VALUES (?, ?, ?)`) + .run(real, nowIso, nowIso); + } catch {} + + const updates = [ + `UPDATE tasks SET created_by = ? WHERE created_by = ?`, + `UPDATE task_assignments SET user_id = ? WHERE user_id = ?`, + `UPDATE task_assignments SET assigned_by = ? WHERE assigned_by = ?`, + `UPDATE user_preferences SET user_id = ? WHERE user_id = ?`, + `UPDATE web_tokens SET user_id = ? WHERE user_id = ?`, + `UPDATE group_members SET user_id = ? WHERE user_id = ?` + ]; + + for (const sql of updates) { + try { + instance.prepare(sql).run(real, alias); + } catch { + // Ignorar si la tabla no existe en este despliegue + } + } + + // Intentar eliminar el usuario alias si ya no tiene referencias + try { + instance.prepare(`DELETE FROM users WHERE id = ?`).run(alias); + } catch {} + })(); + + merged++; + } + + return merged; + } catch { + // Si no existe la tabla user_aliases o hay error de DB, no hacemos nada + return 0; + } + } } diff --git a/src/services/response-queue.ts b/src/services/response-queue.ts index feb90f6..18d09b7 100644 --- a/src/services/response-queue.ts +++ b/src/services/response-queue.ts @@ -2,6 +2,15 @@ import type { Database } from 'bun:sqlite'; import { db } from '../db'; import { IdentityService } from './identity'; import { normalizeWhatsAppId } from '../utils/whatsapp'; +import { Metrics } from './metrics'; + +const MAX_FALLBACK_DIGITS = (() => { + const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim(); + const n = parseInt(raw || '15', 10); + return Number.isFinite(n) && n > 0 ? n : 15; +})(); + +const isDigits = (s: string) => /^\d+$/.test(s); type QueuedResponse = { recipient: string; @@ -117,9 +126,47 @@ export const ResponseQueue = { const url = `${baseUrl}/message/sendText/${instance}`; try { + // Resolver destinatario efectivo (alias → número) y validar antes de construir el payload + const rawRecipient = String(item.recipient || ''); + let numberOrJid = rawRecipient; + + if (rawRecipient.includes('@')) { + if (rawRecipient.endsWith('@g.us')) { + // Envío a grupo: usar el JID completo tal cual + numberOrJid = rawRecipient; + } else if (rawRecipient.endsWith('@s.whatsapp.net')) { + // JID de usuario: normalizar a dígitos + const n = normalizeWhatsAppId(rawRecipient); + if (!n || !isDigits(n)) { + try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'non_numeric' }); } catch {} + return { ok: false, status: 422, error: 'unresolvable_recipient_non_numeric' }; + } + if (n.length >= MAX_FALLBACK_DIGITS) { + try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'too_long' }); } catch {} + return { ok: false, status: 422, error: 'unresolvable_recipient_too_long' }; + } + numberOrJid = n; + } else { + try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'bad_domain' }); } catch {} + return { ok: false, status: 422, error: 'unresolvable_recipient_bad_domain' }; + } + } else { + // Sin dominio: resolver alias si existe y validar + const resolved = IdentityService.resolveAliasOrNull(rawRecipient) || rawRecipient; + if (!isDigits(resolved)) { + try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'non_numeric' }); } catch {} + return { ok: false, status: 422, error: 'unresolvable_recipient_non_numeric' }; + } + if (resolved.length >= MAX_FALLBACK_DIGITS) { + try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'too_long' }); } catch {} + return { ok: false, status: 422, error: 'unresolvable_recipient_too_long' }; + } + numberOrJid = resolved; + } + // Build payload, adding mentioned JIDs if present in metadata const payload: any = { - number: item.recipient, + number: numberOrJid, text: item.message, };