zelin-bot / src /identity-resolver.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* ============================================================
* 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;
}