feat: añadir IdentityService para mapear alias a números

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
brobert 1 month ago
parent f4b0e4433e
commit 67caca8b26

@ -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);
`);
}
}
];

@ -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) {

@ -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([

@ -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;

@ -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<string, string>(); // 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') {

@ -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<string | null | undefined>): Map<string, string> {
const map = new Map<string, string>();
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;
}
}

@ -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

Loading…
Cancel
Save