/** * ============================================================ * identity-resolver.js — Resolución de Miembros en 5 Capas * ============================================================ * 1. Discord ID (snowflake) — confianza 1.0 * 2. Mención Discord (<@ID>) — confianza 1.0 * 3. Username exacto en DB — confianza 0.95 * 4. Coincidencia exacta en Discord — confianza 0.9 * 5. Fuzzy matching (Levenshtein) — confianza variable */ import * as db from './db.js'; // ── Levenshtein distance ────────────────────────────────────────────────────── function levenshtein(a, b) { const dp = Array.from({length: a.length+1}, (_,i) => Array.from({length: b.length+1}, (_,j) => i===0?j:j===0?i:0)); for (let i=1;i<=a.length;i++) for (let j=1;j<=b.length;j++) dp[i][j] = a[i-1]===b[j-1] ? dp[i-1][j-1] : 1+Math.min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1]); return dp[a.length][b.length]; } function similarityScore(a, b) { if (!a||!b) return 0; const dist = levenshtein(a.toLowerCase(), b.toLowerCase()); return 1 - dist / Math.max(a.length, b.length); } // ── Identity Cache ──────────────────────────────────────────────────────────── export const identityCache = new Map(); // discordId → fullProfile let _guild = null; export async function buildIdentityCache(guild) { if (!guild) return; _guild = guild; try { const members = await guild.members.fetch(); for (const [id, member] of members) { const dbRecord = await db.getUserWithRoles(id).catch(() => null); identityCache.set(id, { discordId : id, discordUsername : member.user.username, displayName : member.displayName, roles : member.roles.cache.map(r => r.name).filter(n => n !== '@everyone'), minecraftUsername: dbRecord?.minecraft_username || null, dbRecord : dbRecord || null, cachedAt : Date.now(), }); } console.log('[IDENTITY] Cache construido: ' + identityCache.size + ' miembros'); } catch (e) { console.error('[IDENTITY] Error construyendo cache:', e.message); } } export function invalidateIdentityCache(userId) { identityCache.delete(userId); console.log('[IDENTITY] Cache invalidado para ' + userId); } // ── Resolver principal ──────────────────────────────────────────────────────── export async function resolvePlayer(identifier, guildMembers, dbRef = null) { const dbInst = dbRef ?? db; if (!identifier) return { member: null, dbRecord: null, confidence: 0, method: 'empty' }; // CAPA 1: Discord snowflake ID if (/^\d{17,19}$/.test(identifier)) { const member = guildMembers?.get?.(identifier) ?? _guild?.members.cache.get(identifier); if (member) { const dbRecord = await dbInst.getUserWithRoles(identifier).catch(() => null); return { member, dbRecord, confidence: 1.0, method: 'discord_id' }; } } // CAPA 2: Mención <@ID> o <@!ID> const mentionMatch = identifier.match(/^<@!?(\d{17,19})>$/); if (mentionMatch) return resolvePlayer(mentionMatch[1], guildMembers, dbRef); const q = identifier.toLowerCase(); // CAPA 3: Username exacto en DB try { const rows = await dbInst.db.execute({ sql : 'SELECT * FROM users WHERE LOWER(username)=? OR LOWER(nickname)=? LIMIT 1', args: [q, q], }); if (rows.rows.length > 0) { const rec = rows.rows[0]; const member = guildMembers?.find?.(m => m.id === rec.user_id) || _guild?.members.cache.get(rec.user_id); return { member: member || null, dbRecord: rec, confidence: 0.95, method: 'exact_db' }; } } catch {} // CAPA 4: Coincidencia exacta en Discord const members = guildMembers ?? _guild?.members.cache; if (members) { const exactMember = members.find?.(m => m.user.username.toLowerCase() === q || (m.nickname ?? '').toLowerCase() === q || m.displayName.toLowerCase() === q ); if (exactMember) { const dbRecord = await dbInst.getUserWithRoles(exactMember.id).catch(() => null); return { member: exactMember, dbRecord, confidence: 0.9, method: 'exact_discord' }; } } // CAPA 5: Fuzzy matching const candidates = []; if (members) { for (const [, m] of (members.entries?.() ?? [])) { const s = Math.max( similarityScore(identifier, m.user.username), similarityScore(identifier, m.displayName), m.nickname ? similarityScore(identifier, m.nickname) : 0 ); if (s > 0.6) candidates.push({ member: m, dbRecord: null, score: s, method: 'fuzzy_discord' }); } } // Fuzzy en DB try { const all = await dbInst.db.execute({ sql : 'SELECT * FROM users WHERE is_active=1 AND bot=0 LIMIT 200', args: [], }); for (const rec of all.rows) { const s = Math.max( rec.username ? similarityScore(identifier, rec.username) : 0, rec.nickname ? similarityScore(identifier, rec.nickname) : 0 ); if (s > 0.6) { const member = _guild?.members.cache.get(rec.user_id) || null; candidates.push({ member, dbRecord: rec, score: s, method: 'fuzzy_db' }); } } } catch {} if (!candidates.length) return { member: null, dbRecord: null, confidence: 0, method: 'not_found' }; candidates.sort((a, b) => b.score - a.score); const best = candidates[0]; if (best.member && !best.dbRecord) { best.dbRecord = await dbInst.getUserWithRoles(best.member.id).catch(() => null); } const ambiguous = candidates.filter(c => c.score > best.score - 0.1); if (ambiguous.length > 1 && best.score < 0.9) { return { member : best.member, dbRecord : best.dbRecord, confidence : best.score, method : 'ambiguous', alternatives: ambiguous.slice(1, 4), }; } return { member: best.member, dbRecord: best.dbRecord, confidence: best.score, method: best.method }; } // ── Ambiguity handler para Discord ──────────────────────────────────────────── export async function resolveOrAskDiscord(identifier, guildMembers, replyFn) { const resolved = await resolvePlayer(identifier, guildMembers); if (resolved.confidence >= 0.9) return resolved; if (resolved.confidence >= 0.7) { await replyFn('⚠️ Asumiendo que te refieres a **' + (resolved.member?.displayName ?? '?') + '** (' + (resolved.confidence*100).toFixed(0) + '% seguro). Si no, usa @mención directa.'); return resolved; } if (resolved.method === 'ambiguous' || resolved.confidence < 0.7) { const opts = [resolved, ...(resolved.alternatives ?? [])].slice(0, 3) .map((c, i) => (i+1) + '. **' + (c.member?.displayName ?? '?') + '** — ' + (c.score*100).toFixed(0) + '% similitud'); await replyFn('❓ No estoy segura a quién te refieres con "' + identifier + '".\n' + opts.join('\n') + '\n\n_Usa @usuario para evitar confusiones._'); return null; } await replyFn('❌ No encontré a nadie que coincida con "' + identifier + '".'); return null; }