From 67caca8b26c04daae31b9ca3fe23816df8ef73b5 Mon Sep 17 00:00:00 2001 From: brobert Date: Sat, 20 Sep 2025 19:33:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20IdentityService=20para=20?= =?UTF-8?q?mapear=20alias=20a=20n=C3=BAmeros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/db/migrations/index.ts | 21 +++++++++++ src/server.ts | 16 +++++++++ src/services/command.ts | 3 ++ src/services/contacts.ts | 10 ++++++ src/services/group-sync.ts | 21 +++++++++-- src/services/identity.ts | 65 ++++++++++++++++++++++++++++++++++ src/services/response-queue.ts | 12 ++++++- 7 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 src/services/identity.ts diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 61b2ede..658c47d 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -177,5 +177,26 @@ export const migrations: Migration[] = [ ON group_members (user_id, is_active); `); } + }, + { + version: 6, + name: 'user-aliases', + checksum: 'v6-user-aliases-2025-09-20', + up: (db: Database) => { + db.exec(` + CREATE TABLE IF NOT EXISTS user_aliases ( + alias TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + source TEXT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_user_aliases_user_id + ON user_aliases (user_id); + `); + } } ]; diff --git a/src/server.ts b/src/server.ts index ffc44aa..1ef944f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,7 @@ import { RateLimiter } from './services/rate-limit'; import { RemindersService } from './services/reminders'; import { Metrics } from './services/metrics'; import { MaintenanceService } from './services/maintenance'; +import { IdentityService } from './services/identity'; // Bun is available globally when running under Bun runtime declare global { @@ -238,6 +239,21 @@ export class WebhookServer { ? (data.key.participantAlt || participant) : remoteJid; + // Aprender mapping alias→número cuando vienen ambos y difieren (participant vs participantAlt) + if (isGroupId(remoteJid)) { + const pAlt = typeof data.key.participantAlt === 'string' ? data.key.participantAlt : null; + const p = typeof participant === 'string' ? participant : null; + if (pAlt && p) { + try { + const nAlt = normalizeWhatsAppId(pAlt); + const n = normalizeWhatsAppId(p); + if (nAlt && n && nAlt !== n) { + IdentityService.upsertAlias(p, pAlt, 'message.key'); + } + } catch {} + } + } + // Normalize sender ID for consistency and validation const normalizedSenderId = normalizeWhatsAppId(senderRaw); if (!normalizedSenderId) { diff --git a/src/services/command.ts b/src/services/command.ts index 458f411..843a941 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -6,6 +6,7 @@ import { GroupSyncService } from './group-sync'; import { ContactsService } from './contacts'; import { ICONS } from '../utils/icons'; import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting'; +import { IdentityService } from './identity'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -716,6 +717,7 @@ export class CommandService { const mentionsNormalizedFromContext = Array.from(new Set( (context.mentions || []) .map(j => normalizeWhatsAppId(j)) + .map(id => id ? IdentityService.resolveAliasOrNull(id) : null) .filter((id): id is string => !!id) )); // Detectar también tokens de texto que empiezan por '@' como posibles asignados @@ -725,6 +727,7 @@ export class CommandService { const normalizedFromAtTokens = Array.from(new Set( atTokenCandidates .map(v => normalizeWhatsAppId(v)) + .map(id => id ? IdentityService.resolveAliasOrNull(id) : null) .filter((id): id is string => !!id) )); const combinedAssigneeCandidates = Array.from(new Set([ diff --git a/src/services/contacts.ts b/src/services/contacts.ts index 51b169a..5225d7d 100644 --- a/src/services/contacts.ts +++ b/src/services/contacts.ts @@ -1,4 +1,5 @@ import { normalizeWhatsAppId, isUserJid } from '../utils/whatsapp'; +import { IdentityService } from './identity'; type CacheEntry = { name: string; @@ -60,6 +61,15 @@ export class ContactsService { const idCandidate = rec?.id ?? rec?.jid ?? rec?.user ?? rec?.remoteJid ?? rec?.wid ?? rec?.wid?._serialized; if (!idCandidate) continue; + // Aprender mapping alias→número si vienen ambos (id con @lid y jid de usuario) + try { + const rawId = typeof rec?.id === 'string' ? rec.id : null; + const rawJid = typeof rec?.jid === 'string' ? rec.jid : null; + if (rawId && rawJid && rawId.includes('@lid') && rawJid.endsWith('@s.whatsapp.net')) { + IdentityService.upsertAlias(rawId, rawJid, 'contacts.update'); + } + } catch {} + // Evitar grupos if (typeof idCandidate === 'string' && idCandidate.endsWith('@g.us')) continue; diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 0991b3f..ece52ed 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -2,6 +2,7 @@ import type { Database } from 'bun:sqlite'; import { db, ensureUserExists } from '../db'; import { normalizeWhatsAppId } from '../utils/whatsapp'; import { Metrics } from './metrics'; +import { IdentityService } from './identity'; // In-memory cache for active groups // const activeGroupsCache = new Map(); // groupId -> groupName @@ -392,7 +393,15 @@ export class GroupSyncService { if (typeof p === 'string') { jid = p; } else if (p && typeof p === 'object') { - jid = p.id || p.jid || p?.user?.id || p.user || null; + const rawId = p.id || p?.user?.id || p.user || null; + const rawJid = p.jid || null; // preferir .jid cuando exista + jid = rawJid || rawId || null; + + // Aprender mapping alias→número si vienen ambos + if (rawId && rawJid) { + IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); + } + if (typeof p.isAdmin === 'boolean') { isAdmin = p.isAdmin; } else if (typeof p.admin === 'string') { @@ -465,7 +474,15 @@ export class GroupSyncService { if (typeof p === 'string') { jid = p; } else if (p && typeof p === 'object') { - jid = p.id || p.jid || p?.user?.id || p.user || null; + const rawId = p.id || p?.user?.id || p.user || null; + const rawJid = p.jid || null; // preferir .jid cuando exista + jid = rawJid || rawId || null; + + // Aprender mapping alias→número si vienen ambos + if (rawId && rawJid) { + IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); + } + if (typeof p.isAdmin === 'boolean') { isAdmin = p.isAdmin; } else if (typeof p.admin === 'string') { diff --git a/src/services/identity.ts b/src/services/identity.ts new file mode 100644 index 0000000..f80b855 --- /dev/null +++ b/src/services/identity.ts @@ -0,0 +1,65 @@ +import type { Database } from 'bun:sqlite'; +import { db } from '../db'; +import { normalizeWhatsAppId } from '../utils/whatsapp'; +import { Metrics } from './metrics'; + +export class IdentityService { + static dbInstance: Database = db; + + /** + * Registra o actualiza un alias (LID u otro identificador) apuntando al user_id real (número). + * Devuelve true si se insertó/actualizó, false si no se pudo o no era necesario. + */ + static upsertAlias(alias: string | null | undefined, userId: string | null | undefined, source?: string | null): boolean { + const a = normalizeWhatsAppId(alias || ''); + const u = normalizeWhatsAppId(userId || ''); + if (!a || !u || a === u) return false; + try { + this.dbInstance.prepare(` + INSERT INTO user_aliases (alias, user_id, source, created_at, updated_at) + VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'), strftime('%Y-%m-%d %H:%M:%f', 'now')) + ON CONFLICT(alias) DO UPDATE SET + user_id = excluded.user_id, + source = COALESCE(excluded.source, source), + updated_at = excluded.updated_at + `).run(a, u, source ?? null); + try { Metrics.inc('identity_alias_upserts_total'); } catch {} + return true; + } catch { + return false; + } + } + + /** + * Resuelve un alias a su número real. Devuelve null si no hay mapeo. + */ + static resolveAliasOrNull(id: string | null | undefined): string | null { + const n = normalizeWhatsAppId(id || ''); + if (!n) return null; + try { + const row = this.dbInstance.prepare(`SELECT user_id FROM user_aliases WHERE alias = ?`).get(n) as any; + if (row?.user_id) { + try { Metrics.inc('identity_alias_resolved_total'); } catch {} + return String(row.user_id); + } + try { Metrics.inc('identity_alias_unresolved_total'); } catch {} + return null; + } catch { + return null; + } + } + + /** + * Resuelve en lote varios ids (devuelve solo los que tengan mapeo). + */ + static resolveMany(ids: Array): Map { + const map = new Map(); + for (const id of ids) { + const n = normalizeWhatsAppId(id || ''); + if (!n) continue; + const r = this.resolveAliasOrNull(n); + if (r) map.set(n, r); + } + return map; + } +} diff --git a/src/services/response-queue.ts b/src/services/response-queue.ts index 461f25d..3de7c02 100644 --- a/src/services/response-queue.ts +++ b/src/services/response-queue.ts @@ -1,5 +1,7 @@ import type { Database } from 'bun:sqlite'; import { db } from '../db'; +import { IdentityService } from './identity'; +import { normalizeWhatsAppId } from '../utils/whatsapp'; type QueuedResponse = { recipient: string; @@ -125,7 +127,15 @@ export const ResponseQueue = { try { const parsed = JSON.parse(item.metadata); if (parsed && Array.isArray(parsed.mentioned) && parsed.mentioned.length > 0) { - payload.mentioned = parsed.mentioned; + const resolved: string[] = []; + for (const m of parsed.mentioned) { + const n = normalizeWhatsAppId(String(m)); + if (!n) continue; + const r = IdentityService.resolveAliasOrNull(n) || n; + resolved.push(`${r}@s.whatsapp.net`); + } + // Eliminar duplicados + payload.mentioned = Array.from(new Set(resolved)); } } catch { // ignore bad metadata