zelin-bot / src /db.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* ============================================
* πŸ—„οΈ db.js β€” Cliente Turso + Operaciones DB
* ============================================
*/
import { createClient } from '@libsql/client';
import { readConfig } from './utils.js';
const config = readConfig();
export const db = createClient({
url : config.turso.url,
authToken: config.turso.token,
});
// Desactivar foreign keys para evitar constraint errors en inserts rΓ‘pidos
db.execute('PRAGMA foreign_keys = OFF').catch(() => {});
// ── Migraciones automΓ‘ticas ───────────────────────────────────────────────────
// Se ejecutan al arrancar. Usan try/catch para ser idempotentes.
async function runMigrations() {
const migrations = [
// v1.5: columna username en conversation_context
`ALTER TABLE conversation_context ADD COLUMN username TEXT`,
// v1.5: tabla de mensajes eliminados para auditorΓ­a
`CREATE TABLE IF NOT EXISTS deleted_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
channel_name TEXT,
user_id TEXT NOT NULL,
username TEXT,
content TEXT,
had_attachments INTEGER DEFAULT 0,
deleted_at TEXT NOT NULL
)`,
];
for (const sql of migrations) {
try {
await db.execute({ sql, args: [] });
} catch {
// Ignorar: columna ya existe, tabla ya existe, etc.
}
}
}
// Ejecutar migraciones al importar el mΓ³dulo
runMigrations().catch(err => console.warn('[DB] Migration warning:', err.message));
// ── Guardar mensaje eliminado ─────────────────────────────────────────────────
export async function saveDeletedMessage({ messageId, channelId, channelName, userId, username, content, hadAttachments }) {
await run(`
INSERT OR IGNORE INTO deleted_messages
(message_id, channel_id, channel_name, user_id, username, content, had_attachments, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [messageId, channelId, channelName ?? null, userId, username ?? null, content ?? null, hadAttachments ? 1 : 0, new Date().toISOString()]);
}
// ── Obtener mensajes eliminados recientes ─────────────────────────────────────
export async function getDeletedMessages({ channelId, userId, limit = 20 } = {}) {
let sql = `SELECT * FROM deleted_messages`;
const args = [];
const where = [];
if (channelId) { where.push(`channel_id = ?`); args.push(channelId); }
if (userId) { where.push(`user_id = ?`); args.push(userId); }
if (where.length) sql += ` WHERE ${where.join(' AND ')}`;
sql += ` ORDER BY deleted_at DESC LIMIT ?`;
args.push(limit);
const r = await run(sql, args);
return r.rows;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function now() { return new Date().toISOString(); }
async function run(sql, args = []) {
try {
return await db.execute({ sql, args });
} catch (err) {
console.error('[DB] Error:', err.message, '|', sql.substring(0, 80));
throw err;
}
}
// ── USUARIOS ─────────────────────────────────────────────────────────────────
export async function upsertUser(member) {
const u = member.user ?? member;
await run(`
INSERT INTO users (user_id, username, global_name, nickname, avatar_url, bot,
joined_at, account_created, last_seen, is_active, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
ON CONFLICT(user_id) DO UPDATE SET
username = excluded.username,
global_name = excluded.global_name,
nickname = excluded.nickname,
avatar_url = excluded.avatar_url,
last_seen = excluded.last_seen,
is_active = 1,
updated_at = excluded.updated_at
`, [
u.id,
u.username,
u.globalName ?? null,
member.nickname ?? null,
u.displayAvatarURL?.({ size: 128 }) ?? null,
u.bot ? 1 : 0,
member.joinedAt?.toISOString() ?? null,
u.createdAt?.toISOString() ?? null,
now(),
now(),
]);
}
export async function getUser(userId) {
const r = await run(`SELECT * FROM users WHERE user_id = ?`, [userId]);
return r.rows[0] ?? null;
}
export async function getUserWithRoles(userId) {
const user = await getUser(userId);
if (!user) return null;
const rolesR = await run(`
SELECT r.name FROM user_roles ur
JOIN roles r ON ur.role_id = r.role_id
WHERE ur.user_id = ?
ORDER BY r.position DESC
`, [userId]);
return { ...user, roles: rolesR.rows.map(r => r.name) };
}
export async function markUserLeft(userId) {
await run(`UPDATE users SET is_active = 0, left_at = ?, updated_at = ? WHERE user_id = ?`,
[now(), now(), userId]);
}
export async function updateUserMessageCount(userId) {
await run(`UPDATE users SET message_count = message_count + 1, last_seen = ?, updated_at = ? WHERE user_id = ?`,
[now(), now(), userId]);
}
export async function updateUserNotes(userId, notes) {
await run(`UPDATE users SET notes = ?, updated_at = ? WHERE user_id = ?`,
[notes, now(), userId]);
}
// ── ROLES ────────────────────────────────────────────────────────────────────
export async function upsertRole(role) {
await run(`
INSERT INTO roles (role_id, name, color, position, hoist, managed, mentionable,
permissions, is_bot_role, is_boost_role, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(role_id) DO UPDATE SET
name = excluded.name, color = excluded.color,
position = excluded.position, updated_at = excluded.updated_at
`, [
role.id, role.name, role.hexColor, role.position,
role.hoist ? 1 : 0, role.managed ? 1 : 0, role.mentionable ? 1 : 0,
JSON.stringify(role.permissions?.toArray() ?? []),
role.tags?.botId ? 1 : 0,
role.tags?.premiumSubscriberRole ? 1 : 0,
now(),
]);
}
export async function syncUserRoles(userId, roleIds) {
await run(`DELETE FROM user_roles WHERE user_id = ?`, [userId]);
for (const roleId of roleIds) {
await run(`INSERT OR IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)`,
[userId, roleId]);
}
}
// ── CANALES ───────────────────────────────────────────────────────────────────
export async function upsertChannel(channel) {
const typeMap = {
0: 'GUILD_TEXT', 2: 'GUILD_VOICE', 4: 'GUILD_CATEGORY',
5: 'GUILD_ANNOUNCEMENT', 13: 'GUILD_STAGE', 15: 'GUILD_FORUM',
};
await run(`
INSERT INTO channels (channel_id, name, type, category_id, position, topic, nsfw, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(channel_id) DO UPDATE SET
name = excluded.name, position = excluded.position,
topic = excluded.topic, updated_at = excluded.updated_at
`, [
channel.id, channel.name,
typeMap[channel.type] ?? String(channel.type),
channel.parentId ?? null,
channel.rawPosition ?? 0,
channel.topic ?? null,
channel.nsfw ? 1 : 0,
now(),
]);
}
// ── MENSAJES ──────────────────────────────────────────────────────────────────
export async function saveMessage(message) {
// Asegurar que el usuario y canal existen antes de guardar el mensaje
try {
const u = message.author;
await run(`
INSERT OR IGNORE INTO users (user_id, username, global_name, avatar_url, bot, last_seen, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [u.id, u.username, u.globalName ?? null, u.displayAvatarURL?.({ size: 128 }) ?? null,
u.bot ? 1 : 0, now(), now(), now()]);
await run(`
INSERT OR IGNORE INTO channels (channel_id, name, type, updated_at)
VALUES (?, ?, ?, ?)
`, [message.channelId, message.channel?.name ?? 'unknown', 'GUILD_TEXT', now()]);
} catch {}
await run(`
INSERT OR IGNORE INTO messages
(message_id, channel_id, user_id, content, has_attachment, has_embed,
reply_to_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
message.id,
message.channelId,
message.author.id,
message.content ?? '',
message.attachments.size > 0 ? 1 : 0,
message.embeds.length > 0 ? 1 : 0,
message.reference?.messageId ?? null,
message.createdAt.toISOString(),
]);
await updateUserMessageCount(message.author.id);
}
export async function markMessageEdited(messageId, newContent) {
await run(`UPDATE messages SET is_edited = 1, edit_content = ? WHERE message_id = ?`,
[newContent, messageId]);
}
export async function markMessageDeleted(messageId) {
await run(`UPDATE messages SET is_deleted = 1, deleted_at = ? WHERE message_id = ?`,
[now(), messageId]);
}
export async function getRecentMessages(channelId, limit = 50) {
const r = await run(`
SELECT m.*, u.username, u.nickname FROM messages m
LEFT JOIN users u ON m.user_id = u.user_id
WHERE m.channel_id = ? AND m.is_deleted = 0
ORDER BY m.created_at DESC LIMIT ?
`, [channelId, limit]);
return r.rows.reverse();
}
export async function getUserMessages(userId, limit = 40) {
const r = await run(`
SELECT m.content FROM messages m
WHERE m.user_id = ? AND m.is_deleted = 0 AND length(m.content) > 5
ORDER BY m.created_at DESC LIMIT ?
`, [userId, limit]);
return r.rows.map(r => r.content);
}
export async function searchMessages(query, limit = 20) {
const r = await run(`
SELECT m.*, u.username FROM messages m
LEFT JOIN users u ON m.user_id = u.user_id
WHERE m.content LIKE ? AND m.is_deleted = 0
ORDER BY m.created_at DESC LIMIT ?
`, [`%${query}%`, limit]);
return r.rows;
}
// ── MODERACIΓ“N ────────────────────────────────────────────────────────────────
export async function logModAction({
action, targetUserId, moderatorId = 'zelin',
channelId = null, messageId = null,
reason, durationMs = null, evidence = null,
ruleViolated = null, auto = true,
}) {
await run(`
INSERT INTO moderation_logs
(action, target_user_id, moderator_id, channel_id, message_id,
reason, duration_ms, evidence, rule_violated, auto)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [action, targetUserId, moderatorId, channelId, messageId,
reason, durationMs, evidence, ruleViolated, auto ? 1 : 0]);
}
export async function getUserModHistory(userId, limit = 10) {
const r = await run(`
SELECT * FROM moderation_logs
WHERE target_user_id = ?
ORDER BY created_at DESC LIMIT ?
`, [userId, limit]);
return r.rows;
}
// ── EVENTOS DEL SERVIDOR ──────────────────────────────────────────────────────
export async function logServerEvent(type, userId = null, targetId = null, oldVal = null, newVal = null, meta = null) {
await run(`
INSERT INTO server_events (type, user_id, target_id, old_value, new_value, metadata)
VALUES (?, ?, ?, ?, ?, ?)
`, [type, userId, targetId, oldVal, newVal, meta ? JSON.stringify(meta) : null]);
}
// ── MEMORIA DE ZELIN ──────────────────────────────────────────────────────────
export async function memGet(key) {
const r = await run(`SELECT value FROM zelin_memory WHERE key = ?`, [key]);
if (!r.rows[0]) return null;
try { return JSON.parse(r.rows[0].value); } catch { return r.rows[0].value; }
}
export async function memSet(key, value, category = 'general', expiresAt = null) {
const val = typeof value === 'string' ? value : JSON.stringify(value);
await run(`
INSERT INTO zelin_memory (key, value, category, expires_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, [key, val, category, expiresAt, now()]);
}
export async function memGetCategory(category) {
const r = await run(`SELECT key, value FROM zelin_memory WHERE category = ?`, [category]);
const out = {};
for (const row of r.rows) {
try { out[row.key] = JSON.parse(row.value); } catch { out[row.key] = row.value; }
}
return out;
}
// ── CONTEXTO DE CONVERSACIΓ“N ───────────────────────────────────────────────────
export async function addContext(channelId, role, content, userId = null, username = null) {
// username es opcional β€” si la columna no existe el INSERT sigue funcionando
try {
await run(`
INSERT INTO conversation_context (channel_id, role, content, user_id, username)
VALUES (?, ?, ?, ?, ?)
`, [channelId, role, content.substring(0, 2000), userId, username]);
} catch {
// Fallback sin username si la columna no existe aΓΊn
await run(`
INSERT INTO conversation_context (channel_id, role, content, user_id)
VALUES (?, ?, ?, ?)
`, [channelId, role, content.substring(0, 2000), userId]);
}
// Mantener solo los ΓΊltimos N registros por canal
await run(`
DELETE FROM conversation_context
WHERE channel_id = ? AND id NOT IN (
SELECT id FROM conversation_context
WHERE channel_id = ?
ORDER BY created_at DESC LIMIT 100
)
`, [channelId, channelId]);
}
export async function getContext(channelId, limit = 20) {
try {
const r = await run(`
SELECT role, content, user_id, username, created_at FROM conversation_context
WHERE channel_id = ?
ORDER BY created_at DESC LIMIT ?
`, [channelId, limit]);
return r.rows.reverse();
} catch {
// Fallback sin username (migraciΓ³n aΓΊn no aplicada en esta DB)
const r = await run(`
SELECT role, content, user_id, created_at FROM conversation_context
WHERE channel_id = ?
ORDER BY created_at DESC LIMIT ?
`, [channelId, limit]);
return r.rows.reverse();
}
}
export async function getZelinUserHistory(userId, limit = 30) {
const r = await run(`
SELECT role, content, created_at FROM conversation_context
WHERE user_id = ?
ORDER BY created_at DESC LIMIT ?
`, [userId, limit]);
return r.rows.reverse();
}
// ── RESÚMENES ─────────────────────────────────────────────────────────────────
export async function saveSummary({ channelId, periodStart, periodEnd, messageCount, summary, topUsers, topTopics }) {
await run(`
INSERT INTO summaries (channel_id, period_start, period_end, message_count, summary, top_users, top_topics)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [channelId, periodStart, periodEnd, messageCount, summary,
JSON.stringify(topUsers ?? []), JSON.stringify(topTopics ?? [])]);
}
export async function getRecentSummaries(limit = 5) {
const r = await run(`SELECT * FROM summaries ORDER BY created_at DESC LIMIT ?`, [limit]);
return r.rows;
}
// ── LIMPIEZA ──────────────────────────────────────────────────────────────────
export async function getOldMessages(days) {
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
const r = await run(`
SELECT channel_id, COUNT(*) as count, MIN(created_at) as start, MAX(created_at) as end
FROM messages WHERE created_at < ? AND is_deleted = 0
GROUP BY channel_id
`, [cutoff]);
return r.rows;
}
export async function deleteOldMessages(channelId, beforeDate) {
await run(`DELETE FROM messages WHERE channel_id = ? AND created_at < ?`,
[channelId, beforeDate]);
}
// ── ESTADÍSTICAS ──────────────────────────────────────────────────────────────
export async function getServerStats() {
const [users, msgs, modActions, activeToday] = await Promise.all([
run(`SELECT COUNT(*) as c FROM users WHERE is_active = 1 AND bot = 0`),
run(`SELECT COUNT(*) as c FROM messages WHERE is_deleted = 0`),
run(`SELECT COUNT(*) as c FROM moderation_logs`),
run(`SELECT COUNT(DISTINCT user_id) as c FROM messages WHERE created_at > datetime('now', '-1 day')`),
]);
return {
activeUsers : users.rows[0].c,
totalMessages: msgs.rows[0].c,
modActions : modActions.rows[0].c,
activeToday : activeToday.rows[0].c,
};
}
export async function getTopUsers(days = 7, limit = 10) {
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
const r = await run(`
SELECT u.username, u.nickname, COUNT(m.message_id) as count
FROM messages m JOIN users u ON m.user_id = u.user_id
WHERE m.created_at > ? AND u.bot = 0 AND m.is_deleted = 0
GROUP BY m.user_id ORDER BY count DESC LIMIT ?
`, [cutoff, limit]);
return r.rows;
}
// ── Lista completa de canales para el prompt ──────────────────────────────────
export async function getAllChannels() {
const r = await run(`
SELECT channel_id, name, type, category_id, topic
FROM channels
ORDER BY position ASC, name ASC
`);
return r.rows;
}
// ── Limpieza de memoria expirada y datos old ─────────────────────────────────
export async function cleanExpiredMemory() {
const nowIso = new Date().toISOString();
// Borrar keys expiradas
await run(`DELETE FROM zelin_memory WHERE expires_at IS NOT NULL AND expires_at < ?`, [nowIso]).catch(() => {});
// Borrar feedback de learning con mΓ‘s de 60 dΓ­as
const cutoff60 = new Date(Date.now() - 60 * 86400000).toISOString();
await run(`DELETE FROM zelin_memory WHERE category = 'learning' AND updated_at < ?`, [cutoff60]).catch(() => {});
// Borrar audit logs con mΓ‘s de 30 dΓ­as
const cutoff30 = new Date(Date.now() - 30 * 86400000).toISOString();
await run(`DELETE FROM zelin_memory WHERE category = 'audit_log' AND updated_at < ?`, [cutoff30]).catch(() => {});
console.log('[DB] Limpieza de memoria expirada completada');
}
// ── Lista completa de usuarios activos para el prompt ─────────────────────────
export async function getAllActiveUsers() {
const r = await run(`
SELECT user_id, username, nickname, global_name, message_count
FROM users
WHERE is_active = 1 AND bot = 0
ORDER BY message_count DESC
LIMIT 80
`);
return r.rows;
}