feat: introduce Appwrite guild cache support and refactor guild config caching

This commit is contained in:
2025-10-07 10:52:47 -05:00
parent 8be8ea5925
commit a9087261ca
5 changed files with 492 additions and 359 deletions

View File

@@ -21,10 +21,14 @@ ENABLE_MEMORY_OPTIMIZER=false
REDIS_URL= REDIS_URL=
REDIS_PASS= REDIS_PASS=
# Appwrite (for reminders) # Appwrite (for reminders, AI conversations, and guild cache)
APPWRITE_ENDPOINT= APPWRITE_ENDPOINT=
APPWRITE_PROJECT_ID= APPWRITE_PROJECT_ID=
APPWRITE_API_KEY= APPWRITE_API_KEY=
APPWRITE_DATABASE_ID=
APPWRITE_COLLECTION_REMINDERS_ID=
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID=
APPWRITE_COLLECTION_GUILD_CACHE_ID=
# Reminders # Reminders
REMINDERS_POLL_INTERVAL_SECONDS=30 REMINDERS_POLL_INTERVAL_SECONDS=30

View File

@@ -5,6 +5,7 @@
"main": "src/main.ts", "main": "src/main.ts",
"scripts": { "scripts": {
"start": "npx tsx watch src/main.ts", "start": "npx tsx watch src/main.ts",
"script:guild": "node scripts/setupGuildCacheCollection.js",
"dev": "npx tsx watch src/main.ts", "dev": "npx tsx watch src/main.ts",
"dev:light": "CACHE_MESSAGES_LIMIT=25 CACHE_MEMBERS_LIMIT=50 SWEEP_MESSAGES_LIFETIME_SECONDS=600 SWEEP_MESSAGES_INTERVAL_SECONDS=240 npx tsx watch --clear-screen=false src/main.ts", "dev:light": "CACHE_MESSAGES_LIMIT=25 CACHE_MEMBERS_LIMIT=50 SWEEP_MESSAGES_LIFETIME_SECONDS=600 SWEEP_MESSAGES_INTERVAL_SECONDS=240 npx tsx watch --clear-screen=false src/main.ts",
"dev:mem": "MEMORY_LOG_INTERVAL_SECONDS=120 npx tsx watch src/main.ts", "dev:mem": "MEMORY_LOG_INTERVAL_SECONDS=120 npx tsx watch src/main.ts",

View File

@@ -1,14 +1,18 @@
// Simple Appwrite client wrapper // Simple Appwrite client wrapper
// @ts-ignore // @ts-ignore
import { Client, Databases } from 'node-appwrite'; import { Client, Databases } from "node-appwrite";
const endpoint = process.env.APPWRITE_ENDPOINT || ''; const endpoint = process.env.APPWRITE_ENDPOINT || "";
const projectId = process.env.APPWRITE_PROJECT_ID || ''; const projectId = process.env.APPWRITE_PROJECT_ID || "";
const apiKey = process.env.APPWRITE_API_KEY || ''; const apiKey = process.env.APPWRITE_API_KEY || "";
export const APPWRITE_DATABASE_ID = process.env.APPWRITE_DATABASE_ID || ''; export const APPWRITE_DATABASE_ID = process.env.APPWRITE_DATABASE_ID || "";
export const APPWRITE_COLLECTION_REMINDERS_ID = process.env.APPWRITE_COLLECTION_REMINDERS_ID || ''; export const APPWRITE_COLLECTION_REMINDERS_ID =
export const APPWRITE_COLLECTION_AI_CONVERSATIONS_ID = process.env.APPWRITE_COLLECTION_AI_CONVERSATIONS_ID || ''; process.env.APPWRITE_COLLECTION_REMINDERS_ID || "";
export const APPWRITE_COLLECTION_AI_CONVERSATIONS_ID =
process.env.APPWRITE_COLLECTION_AI_CONVERSATIONS_ID || "";
export const APPWRITE_COLLECTION_GUILD_CACHE_ID =
process.env.APPWRITE_COLLECTION_GUILD_CACHE_ID || "";
let client: Client | null = null; let client: Client | null = null;
let databases: Databases | null = null; let databases: Databases | null = null;
@@ -16,7 +20,10 @@ let databases: Databases | null = null;
function ensureClient() { function ensureClient() {
if (!endpoint || !projectId || !apiKey) return null; if (!endpoint || !projectId || !apiKey) return null;
if (client) return client; if (client) return client;
client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey); client = new Client()
.setEndpoint(endpoint)
.setProject(projectId)
.setKey(apiKey);
databases = new Databases(client); databases = new Databases(client);
return client; return client;
} }
@@ -26,9 +33,31 @@ export function getDatabases(): Databases | null {
} }
export function isAppwriteConfigured(): boolean { export function isAppwriteConfigured(): boolean {
return Boolean(endpoint && projectId && apiKey && APPWRITE_DATABASE_ID && APPWRITE_COLLECTION_REMINDERS_ID); return Boolean(
endpoint &&
projectId &&
apiKey &&
APPWRITE_DATABASE_ID &&
APPWRITE_COLLECTION_REMINDERS_ID
);
} }
export function isAIConversationsConfigured(): boolean { export function isAIConversationsConfigured(): boolean {
return Boolean(endpoint && projectId && apiKey && APPWRITE_DATABASE_ID && APPWRITE_COLLECTION_AI_CONVERSATIONS_ID); return Boolean(
endpoint &&
projectId &&
apiKey &&
APPWRITE_DATABASE_ID &&
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID
);
}
export function isGuildCacheConfigured(): boolean {
return Boolean(
endpoint &&
projectId &&
apiKey &&
APPWRITE_DATABASE_ID &&
APPWRITE_COLLECTION_GUILD_CACHE_ID
);
} }

View File

@@ -1,238 +1,280 @@
import {bot} from "../main"; import { bot } from "../main";
import {Events} from "discord.js"; import { Events } from "discord.js";
import {redis} from "../core/database/redis"; import { redis } from "../core/database/redis";
import {commands} from "../core/loaders/loader"; import { commands } from "../core/loaders/loader";
import {alliance} from "./extras/alliace"; import { alliance } from "./extras/alliace";
import logger from "../core/lib/logger"; import logger from "../core/lib/logger";
import { aiService } from "../core/services/AIService"; import { aiService } from "../core/services/AIService";
import { getGuildConfig } from "../core/database/guildCache";
// Función para manejar respuestas automáticas a la AI // Función para manejar respuestas automáticas a la AI
async function handleAIReply(message: any) { async function handleAIReply(message: any) {
// Verificar si es una respuesta a un mensaje del bot // Verificar si es una respuesta a un mensaje del bot
if (!message.reference?.messageId || message.author.bot) return; if (!message.reference?.messageId || message.author.bot) return;
try {
const referencedMessage = await message.channel.messages.fetch(
message.reference.messageId
);
// Verificar si el mensaje referenciado es del bot
if (referencedMessage.author.id !== message.client.user?.id) return;
// Verificar que el contenido no sea un comando (para evitar loops)
const guildConfig = await getGuildConfig(
message.guildId || message.guild!.id,
message.guild!.name,
bot.prisma
);
const PREFIX = guildConfig.prefix || "!";
if (message.content.startsWith(PREFIX)) return;
// Verificar que el mensaje tenga contenido válido
if (!message.content || message.content.trim().length === 0) return;
// Limitar longitud del mensaje
if (message.content.length > 4000) {
await message.reply(
"❌ **Error:** Tu mensaje es demasiado largo (máximo 4000 caracteres)."
);
return;
}
logger.info(
`Respuesta automática a AI detectada - Usuario: ${message.author.id}, Guild: ${message.guildId}`
);
// Indicador de que está escribiendo
const typingInterval = setInterval(() => {
message.channel.sendTyping().catch(() => {});
}, 5000);
try { try {
const referencedMessage = await message.channel.messages.fetch(message.reference.messageId); // Obtener emojis personalizados del servidor
const emojiResult = {
// Verificar si el mensaje referenciado es del bot names: [] as string[],
if (referencedMessage.author.id !== message.client.user?.id) return; map: {} as Record<string, string>,
};
// Verificar que el contenido no sea un comando (para evitar loops) try {
const server = await bot.prisma.guild.findUnique({ const guild = message.guild;
where: { id: message.guildId || undefined } if (guild) {
}); const emojis = await guild.emojis.fetch();
const PREFIX = server?.prefix || "!"; const list = Array.from(emojis.values());
for (const e of list) {
if (message.content.startsWith(PREFIX)) return; // @ts-ignore
const name = e.name;
// Verificar que el mensaje tenga contenido válido // @ts-ignore
if (!message.content || message.content.trim().length === 0) return; const id = e.id;
if (!name || !id) continue;
// Limitar longitud del mensaje // @ts-ignore
if (message.content.length > 4000) { const tag = e.animated ? `<a:${name}:${id}>` : `<:${name}:${id}>`;
await message.reply('❌ **Error:** Tu mensaje es demasiado largo (máximo 4000 caracteres).'); if (!(name in emojiResult.map)) {
return; emojiResult.map[name] = tag;
emojiResult.names.push(name);
}
}
emojiResult.names = emojiResult.names.slice(0, 25);
} }
} catch {
// Ignorar errores de emojis
}
logger.info(`Respuesta automática a AI detectada - Usuario: ${message.author.id}, Guild: ${message.guildId}`); // Construir metadatos del mensaje
const buildMessageMeta = (msg: any, emojiNames?: string[]): string => {
// Indicador de que está escribiendo
const typingInterval = setInterval(() => {
message.channel.sendTyping().catch(() => {});
}, 5000);
try { try {
// Obtener emojis personalizados del servidor const parts: string[] = [];
const emojiResult = { names: [] as string[], map: {} as Record<string, string> };
try {
const guild = message.guild;
if (guild) {
const emojis = await guild.emojis.fetch();
const list = Array.from(emojis.values());
for (const e of list) {
// @ts-ignore
const name = e.name;
// @ts-ignore
const id = e.id;
if (!name || !id) continue;
// @ts-ignore
const tag = e.animated ? `<a:${name}:${id}>` : `<:${name}:${id}>`;
if (!(name in emojiResult.map)) {
emojiResult.map[name] = tag;
emojiResult.names.push(name);
}
}
emojiResult.names = emojiResult.names.slice(0, 25);
}
} catch {
// Ignorar errores de emojis
}
// Construir metadatos del mensaje if (msg.channel?.name) {
const buildMessageMeta = (msg: any, emojiNames?: string[]): string => { parts.push(`Canal: #${msg.channel.name}`);
try { }
const parts: string[] = [];
if (msg.channel?.name) { const userMentions = msg.mentions?.users
parts.push(`Canal: #${msg.channel.name}`); ? Array.from(msg.mentions.users.values())
} : [];
const roleMentions = msg.mentions?.roles
? Array.from(msg.mentions.roles.values())
: [];
const userMentions = msg.mentions?.users ? Array.from(msg.mentions.users.values()) : []; if (userMentions.length) {
const roleMentions = msg.mentions?.roles ? Array.from(msg.mentions.roles.values()) : []; parts.push(
`Menciones usuario: ${userMentions
if (userMentions.length) { .slice(0, 5)
parts.push(`Menciones usuario: ${userMentions.slice(0, 5).map((u: any) => u.username ?? u.tag ?? u.id).join(', ')}`); .map((u: any) => u.username ?? u.tag ?? u.id)
} .join(", ")}`
if (roleMentions.length) {
parts.push(`Menciones rol: ${roleMentions.slice(0, 5).map((r: any) => r.name ?? r.id).join(', ')}`);
}
if (msg.reference?.messageId) {
parts.push('Es una respuesta a mensaje de AI');
}
if (emojiNames && emojiNames.length) {
parts.push(`Emojis personalizados disponibles (usa :nombre:): ${emojiNames.join(', ')}`);
}
const metaRaw = parts.join(' | ');
return metaRaw.length > 800 ? metaRaw.slice(0, 800) : metaRaw;
} catch {
return '';
}
};
const messageMeta = buildMessageMeta(message, emojiResult.names);
// Verificar si hay imágenes adjuntas
const attachments = Array.from(message.attachments.values());
const hasImages = attachments.length > 0 && aiService.hasImageAttachments(attachments);
// Procesar con el servicio de AI usando memoria persistente y soporte para imágenes
const aiResponse = await aiService.processAIRequestWithMemory(
message.author.id,
message.content,
message.guildId,
message.channel.id,
message.id,
message.reference.messageId,
message.client,
'normal',
{
meta: messageMeta + (hasImages ? ` | Tiene ${attachments.length} imagen(es) adjunta(s)` : ''),
attachments: hasImages ? attachments : undefined
}
); );
}
if (roleMentions.length) {
parts.push(
`Menciones rol: ${roleMentions
.slice(0, 5)
.map((r: any) => r.name ?? r.id)
.join(", ")}`
);
}
// Reemplazar emojis personalizados if (msg.reference?.messageId) {
let finalResponse = aiResponse; parts.push("Es una respuesta a mensaje de AI");
if (emojiResult.names.length > 0) { }
finalResponse = finalResponse.replace(/:([a-zA-Z0-9_]{2,32}):/g, (match, p1: string) => {
const found = emojiResult.map[p1]; if (emojiNames && emojiNames.length) {
return found ? found : match; parts.push(
}); `Emojis personalizados disponibles (usa :nombre:): ${emojiNames.join(
", "
)}`
);
}
const metaRaw = parts.join(" | ");
return metaRaw.length > 800 ? metaRaw.slice(0, 800) : metaRaw;
} catch {
return "";
}
};
const messageMeta = buildMessageMeta(message, emojiResult.names);
// Verificar si hay imágenes adjuntas
const attachments = Array.from(message.attachments.values());
const hasImages =
attachments.length > 0 && aiService.hasImageAttachments(attachments);
// Procesar con el servicio de AI usando memoria persistente y soporte para imágenes
const aiResponse = await aiService.processAIRequestWithMemory(
message.author.id,
message.content,
message.guildId,
message.channel.id,
message.id,
message.reference.messageId,
message.client,
"normal",
{
meta:
messageMeta +
(hasImages
? ` | Tiene ${attachments.length} imagen(es) adjunta(s)`
: ""),
attachments: hasImages ? attachments : undefined,
}
);
// Reemplazar emojis personalizados
let finalResponse = aiResponse;
if (emojiResult.names.length > 0) {
finalResponse = finalResponse.replace(
/:([a-zA-Z0-9_]{2,32}):/g,
(match, p1: string) => {
const found = emojiResult.map[p1];
return found ? found : match;
}
);
}
// Enviar respuesta (dividir si es muy larga)
const MAX_CONTENT = 2000;
if (finalResponse.length > MAX_CONTENT) {
const chunks: string[] = [];
let currentChunk = "";
const lines = finalResponse.split("\n");
for (const line of lines) {
if (currentChunk.length + line.length + 1 > MAX_CONTENT) {
if (currentChunk) {
chunks.push(currentChunk.trim());
currentChunk = "";
} }
}
// Enviar respuesta (dividir si es muy larga) currentChunk += (currentChunk ? "\n" : "") + line;
const MAX_CONTENT = 2000;
if (finalResponse.length > MAX_CONTENT) {
const chunks: string[] = [];
let currentChunk = '';
const lines = finalResponse.split('\n');
for (const line of lines) {
if (currentChunk.length + line.length + 1 > MAX_CONTENT) {
if (currentChunk) {
chunks.push(currentChunk.trim());
currentChunk = '';
}
}
currentChunk += (currentChunk ? '\n' : '') + line;
}
if (currentChunk) {
chunks.push(currentChunk.trim());
}
for (let i = 0; i < chunks.length && i < 3; i++) {
if (i === 0) {
await message.reply({ content: chunks[i] });
} else {
if ('send' in message.channel) {
await message.channel.send({ content: chunks[i] });
await new Promise(resolve => setTimeout(resolve, 500));
}
}
}
if (chunks.length > 3) {
if ('send' in message.channel) {
await message.channel.send({ content: "⚠️ Respuesta truncada por longitud." });
}
}
} else {
await message.reply({ content: finalResponse });
}
} catch (error: any) {
logger.error(`Error en respuesta automática AI:`, error);
await message.reply({
content: `❌ **Error:** ${error.message || 'No pude procesar tu respuesta. Intenta de nuevo.'}`
});
} finally {
clearInterval(typingInterval);
} }
} catch (error) { if (currentChunk) {
// Mensaje referenciado no encontrado o error, ignorar silenciosamente chunks.push(currentChunk.trim());
logger.debug(`Error obteniendo mensaje referenciado: ${error}`); }
for (let i = 0; i < chunks.length && i < 3; i++) {
if (i === 0) {
await message.reply({ content: chunks[i] });
} else {
if ("send" in message.channel) {
await message.channel.send({ content: chunks[i] });
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
if (chunks.length > 3) {
if ("send" in message.channel) {
await message.channel.send({
content: "⚠️ Respuesta truncada por longitud.",
});
}
}
} else {
await message.reply({ content: finalResponse });
}
} catch (error: any) {
logger.error(`Error en respuesta automática AI:`, error);
await message.reply({
content: `❌ **Error:** ${
error.message || "No pude procesar tu respuesta. Intenta de nuevo."
}`,
});
} finally {
clearInterval(typingInterval);
} }
} catch (error) {
// Mensaje referenciado no encontrado o error, ignorar silenciosamente
logger.debug(`Error obteniendo mensaje referenciado: ${error}`);
}
} }
bot.on(Events.MessageCreate, async (message) => { bot.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return; if (message.author.bot) return;
// Manejar respuestas automáticas a la AI // Manejar respuestas automáticas a la AI
await handleAIReply(message); await handleAIReply(message);
await alliance(message); await alliance(message);
const server = await bot.prisma.guild.upsert({
where: {
id: message.guildId || undefined
},
create: {
id: message!.guildId || message.guild!.id,
name: message.guild!.name
},
update: {}
})
const PREFIX = server.prefix || "!"
if (!message.content.startsWith(PREFIX)) return;
const [cmdName, ...args] = message.content.slice(PREFIX.length).trim().split(/\s+/); // Usar caché para obtener la configuración del guild
const command = commands.get(cmdName); const guildConfig = await getGuildConfig(
if (!command) return; message.guildId || message.guild!.id,
message.guild!.name,
bot.prisma
);
const cooldown = Math.floor(Number(command.cooldown) || 0); const PREFIX = guildConfig.prefix || "!";
if (!message.content.startsWith(PREFIX)) return;
if (cooldown > 0) { const [cmdName, ...args] = message.content
const key = `cooldown:${command.name}:${message.author.id}`; .slice(PREFIX.length)
const ttl = await redis.ttl(key); .trim()
logger.debug(`Key: ${key}, TTL: ${ttl}`); .split(/\s+/);
const command = commands.get(cmdName);
if (!command) return;
if (ttl > 0) { const cooldown = Math.floor(Number(command.cooldown) || 0);
return message.reply(`⏳ Espera ${ttl}s antes de volver a usar **${command.name}**.`);
}
// SET con expiración correcta para redis v4+ if (cooldown > 0) {
await redis.set(key, "1", { EX: cooldown }); const key = `cooldown:${command.name}:${message.author.id}`;
const ttl = await redis.ttl(key);
logger.debug(`Key: ${key}, TTL: ${ttl}`);
if (ttl > 0) {
return message.reply(
`⏳ Espera ${ttl}s antes de volver a usar **${command.name}**.`
);
} }
// SET con expiración correcta para redis v4+
await redis.set(key, "1", { EX: cooldown });
}
try { try {
await command.run(message, args, message.client); await command.run(message, args, message.client);
} catch (error) { } catch (error) {
logger.error({ err: error }, "Error ejecutando comando"); logger.error({ err: error }, "Error ejecutando comando");
await message.reply("❌ Hubo un error ejecutando el comando."); await message.reply("❌ Hubo un error ejecutando el comando.");
} }
}) });

View File

@@ -1,216 +1,273 @@
import Amayo from "./core/client"; import Amayo from "./core/client";
import { loadCommands } from "./core/loaders/loader"; import { loadCommands } from "./core/loaders/loader";
import { loadEvents } from "./core/loaders/loaderEvents"; import { loadEvents } from "./core/loaders/loaderEvents";
import { redis, redisConnect } from "./core/database/redis"; import { redis, redisConnect } from "./core/database/redis";
import { registeringCommands } from "./core/api/discordAPI"; import { registeringCommands } from "./core/api/discordAPI";
import {loadComponents} from "./core/lib/components"; import { loadComponents } from "./core/lib/components";
import { startMemoryMonitor } from "./core/memory/memoryMonitor"; import { startMemoryMonitor } from "./core/memory/memoryMonitor";
import {memoryOptimizer} from "./core/memory/memoryOptimizer"; import { memoryOptimizer } from "./core/memory/memoryOptimizer";
import { startReminderPoller } from "./core/api/reminders"; import { startReminderPoller } from "./core/api/reminders";
import { ensureRemindersSchema } from "./core/api/remindersSchema"; import { ensureRemindersSchema } from "./core/api/remindersSchema";
import { cleanExpiredGuildCache } from "./core/database/guildCache";
import logger from "./core/lib/logger"; import logger from "./core/lib/logger";
import { applyModalSubmitInteractionPatch } from "./core/patches/discordModalPatch"; import { applyModalSubmitInteractionPatch } from "./core/patches/discordModalPatch";
import { server } from "./server/server"; import { server } from "./server/server";
// Activar monitor de memoria si se define la variable // Activar monitor de memoria si se define la variable
const __memInt = parseInt(process.env.MEMORY_LOG_INTERVAL_SECONDS || '0', 10); const __memInt = parseInt(process.env.MEMORY_LOG_INTERVAL_SECONDS || "0", 10);
if (__memInt > 0) { if (__memInt > 0) {
startMemoryMonitor({ intervalSeconds: __memInt }); startMemoryMonitor({ intervalSeconds: __memInt });
} }
// Activar optimizador de memoria adicional // Activar optimizador de memoria adicional
if (process.env.ENABLE_MEMORY_OPTIMIZER === 'true') { if (process.env.ENABLE_MEMORY_OPTIMIZER === "true") {
memoryOptimizer.start(); memoryOptimizer.start();
} }
// Apply safety patch for ModalSubmitInteraction members resolution before anything else // Apply safety patch for ModalSubmitInteraction members resolution before anything else
try { try {
applyModalSubmitInteractionPatch(); applyModalSubmitInteractionPatch();
} catch (e) { } catch (e) {
logger.warn({ err: e }, 'No se pudo aplicar el patch de ModalSubmitInteraction'); logger.warn(
{ err: e },
"No se pudo aplicar el patch de ModalSubmitInteraction"
);
} }
export const bot = new Amayo(); export const bot = new Amayo();
// Listeners de robustez del cliente Discord // Listeners de robustez del cliente Discord
bot.on('error', (e) => logger.error({ err: e }, '🐞 Discord client error')); bot.on("error", (e) => logger.error({ err: e }, "🐞 Discord client error"));
bot.on('warn', (m) => logger.warn('⚠️ Discord warn: %s', m)); bot.on("warn", (m) => logger.warn("⚠️ Discord warn: %s", m));
// Evitar reintentos de re-login simultáneos // Evitar reintentos de re-login simultáneos
let relogging = false; let relogging = false;
// Cuando la sesión es invalidada, intentamos reconectar/login // Cuando la sesión es invalidada, intentamos reconectar/login
bot.on('invalidated', () => { bot.on("invalidated", () => {
if (relogging) return; if (relogging) return;
relogging = true; relogging = true;
logger.error('🔄 Sesión de Discord invalidada. Reintentando login...'); logger.error("🔄 Sesión de Discord invalidada. Reintentando login...");
withRetry('Re-login tras invalidated', () => bot.play(), { minDelayMs: 2000, maxDelayMs: 60_000 }) withRetry("Re-login tras invalidated", () => bot.play(), {
.catch(() => { minDelayMs: 2000,
logger.error('No se pudo reloguear tras invalidated, se seguirá intentando en el bucle general.'); maxDelayMs: 60_000,
}) })
.finally(() => { relogging = false; }); .catch(() => {
logger.error(
"No se pudo reloguear tras invalidated, se seguirá intentando en el bucle general."
);
})
.finally(() => {
relogging = false;
});
}); });
// Utilidad: reintentos con backoff exponencial + jitter // Utilidad: reintentos con backoff exponencial + jitter
async function withRetry<T>(name: string, fn: () => Promise<T>, opts?: { async function withRetry<T>(
name: string,
fn: () => Promise<T>,
opts?: {
retries?: number; retries?: number;
minDelayMs?: number; minDelayMs?: number;
maxDelayMs?: number; maxDelayMs?: number;
factor?: number; factor?: number;
jitter?: boolean; jitter?: boolean;
isRetryable?: (err: unknown, attempt: number) => boolean; isRetryable?: (err: unknown, attempt: number) => boolean;
}): Promise<T> { }
const { ): Promise<T> {
retries = Infinity, const {
minDelayMs = 1000, retries = Infinity,
maxDelayMs = 30_000, minDelayMs = 1000,
factor = 1.8, maxDelayMs = 30_000,
jitter = true, factor = 1.8,
isRetryable = () => true, jitter = true,
} = opts ?? {}; isRetryable = () => true,
} = opts ?? {};
let attempt = 0; let attempt = 0;
let delay = minDelayMs; let delay = minDelayMs;
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
try { try {
return await fn(); return await fn();
} catch (err) { } catch (err) {
attempt++; attempt++;
const errMsg = err instanceof Error ? `${err.name}: ${err.message}` : String(err); const errMsg =
logger.error(`${name} falló (intento ${attempt}) => %s`, errMsg); err instanceof Error ? `${err.name}: ${err.message}` : String(err);
logger.error(`${name} falló (intento ${attempt}) => %s`, errMsg);
if (!isRetryable(err, attempt)) { if (!isRetryable(err, attempt)) {
logger.error(`${name}: error no recuperable, deteniendo reintentos.`); logger.error(
throw err; `${name}: error no recuperable, deteniendo reintentos.`
} );
throw err;
}
if (attempt >= retries) throw err; if (attempt >= retries) throw err;
// calcular backoff // calcular backoff
let wait = delay; let wait = delay;
if (jitter) { if (jitter) {
const rand = Math.random() + 0.5; // 0.5x a 1.5x const rand = Math.random() + 0.5; // 0.5x a 1.5x
wait = Math.min(maxDelayMs, Math.floor(delay * rand)); wait = Math.min(maxDelayMs, Math.floor(delay * rand));
} else { } else {
wait = Math.min(maxDelayMs, delay); wait = Math.min(maxDelayMs, delay);
} }
logger.warn(`⏳ Reintentando ${name} en ${wait}ms...`); logger.warn(`⏳ Reintentando ${name} en ${wait}ms...`);
await new Promise((r) => setTimeout(r, wait)); await new Promise((r) => setTimeout(r, wait));
delay = Math.min(maxDelayMs, Math.floor(delay * factor)); delay = Math.min(maxDelayMs, Math.floor(delay * factor));
}
} }
}
} }
// Handlers globales para robustez // Handlers globales para robustez
process.on('unhandledRejection', (reason: any, p) => { process.on("unhandledRejection", (reason: any, p) => {
logger.error({ promise: p, reason }, '🚨 UnhandledRejection en Promise'); logger.error({ promise: p, reason }, "🚨 UnhandledRejection en Promise");
}); });
process.on('uncaughtException', (err) => { process.on("uncaughtException", (err) => {
logger.error({ err }, '🚨 UncaughtException'); logger.error({ err }, "🚨 UncaughtException");
// No salimos; dejamos que el bot continúe vivo // No salimos; dejamos que el bot continúe vivo
}); });
process.on('multipleResolves', (type, promise, reason: any) => { process.on("multipleResolves", (type, promise, reason: any) => {
// Ignorar resoluciones sin razón (ruido) // Ignorar resoluciones sin razón (ruido)
if (type === 'resolve' && (reason === undefined || reason === null)) { if (type === "resolve" && (reason === undefined || reason === null)) {
return; return;
} }
const msg = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason); const msg =
const stack = (reason && (reason as any).stack) ? String((reason as any).stack) : ''; reason instanceof Error
const isAbortErr = (reason && ((reason as any).code === 'ABORT_ERR' || /AbortError|operation was aborted/i.test(msg))); ? `${reason.name}: ${reason.message}`
const isDiscordWs = /@discordjs\/ws|WebSocketShard/.test(stack); : String(reason);
if (isAbortErr && isDiscordWs) { const stack =
// Ruido benigno de reconexiones del WS de Discord: ignorar reason && (reason as any).stack ? String((reason as any).stack) : "";
return; const isAbortErr =
} reason &&
logger.warn('⚠️ multipleResolves: %s %s', type, msg); ((reason as any).code === "ABORT_ERR" ||
/AbortError|operation was aborted/i.test(msg));
const isDiscordWs = /@discordjs\/ws|WebSocketShard/.test(stack);
if (isAbortErr && isDiscordWs) {
// Ruido benigno de reconexiones del WS de Discord: ignorar
return;
}
logger.warn("⚠️ multipleResolves: %s %s", type, msg);
}); });
let shuttingDown = false; let shuttingDown = false;
async function gracefulShutdown() { async function gracefulShutdown() {
if (shuttingDown) return; if (shuttingDown) return;
shuttingDown = true; shuttingDown = true;
logger.info('🛑 Apagado controlado iniciado...'); logger.info("🛑 Apagado controlado iniciado...");
try { try {
// Detener optimizador de memoria // Detener optimizador de memoria
memoryOptimizer.stop(); memoryOptimizer.stop();
// Cerrar Redis si procede // Cerrar Redis si procede
try { try {
if (redis?.isOpen) { if (redis?.isOpen) {
await redis.quit(); await redis.quit();
logger.info('🔌 Redis cerrado'); logger.info("🔌 Redis cerrado");
} }
} catch (e) { } catch (e) {
logger.warn({ err: e }, 'No se pudo cerrar Redis limpiamente'); logger.warn({ err: e }, "No se pudo cerrar Redis limpiamente");
}
// Cerrar Prisma y Discord
try {
await bot.prisma.$disconnect();
} catch {}
try {
await bot.destroy();
} catch {}
} finally {
logger.info('✅ Apagado controlado completo');
} }
// Cerrar Prisma y Discord
try {
await bot.prisma.$disconnect();
} catch {}
try {
await bot.destroy();
} catch {}
} finally {
logger.info("✅ Apagado controlado completo");
}
} }
process.on('SIGINT', gracefulShutdown); process.on("SIGINT", gracefulShutdown);
process.on('SIGTERM', gracefulShutdown); process.on("SIGTERM", gracefulShutdown);
async function bootstrap() { async function bootstrap() {
logger.info("🚀 Iniciando bot..."); logger.info("🚀 Iniciando bot...");
await server.listen(process.env.PORT || 3000, () => { await server.listen(process.env.PORT || 3000, () => {
logger.info(`📘 Amayo Docs disponible en http://localhost:${process.env.PORT || 3000}`); logger.info(
}); `📘 Amayo Docs disponible en http://localhost:${process.env.PORT || 3000}`
// Cargar recursos locales (no deberían tirar el proceso si fallan) );
try { loadCommands(); } catch (e) { logger.error({ err: e }, 'Error cargando comandos'); } });
try { loadComponents(); } catch (e) { logger.error({ err: e }, 'Error cargando componentes'); } // Cargar recursos locales (no deberían tirar el proceso si fallan)
try { loadEvents(); } catch (e) { logger.error({ err: e }, 'Error cargando eventos'); } try {
loadCommands();
} catch (e) {
logger.error({ err: e }, "Error cargando comandos");
}
try {
loadComponents();
} catch (e) {
logger.error({ err: e }, "Error cargando componentes");
}
try {
loadEvents();
} catch (e) {
logger.error({ err: e }, "Error cargando eventos");
}
// Registrar comandos en segundo plano con reintentos; no bloquea el arranque del bot // Registrar comandos en segundo plano con reintentos; no bloquea el arranque del bot
withRetry('Registrar slash commands', async () => { withRetry("Registrar slash commands", async () => {
await registeringCommands(); await registeringCommands();
}).catch((e) => logger.error({ err: e }, 'Registro de comandos agotó reintentos')); }).catch((e) =>
logger.error({ err: e }, "Registro de comandos agotó reintentos")
);
// Conectar Redis con reintentos // Conectar Redis con reintentos
await withRetry('Conectar a Redis', async () => { await withRetry("Conectar a Redis", async () => {
await redisConnect(); await redisConnect();
}); });
// Login Discord + DB con reintentos (gestionado en Amayo.play -> conecta Prisma + login) // Login Discord + DB con reintentos (gestionado en Amayo.play -> conecta Prisma + login)
await withRetry('Login de Discord', async () => { await withRetry(
await bot.play(); "Login de Discord",
}, { async () => {
isRetryable: (err) => { await bot.play();
const msg = err instanceof Error ? `${err.message}` : String(err); },
// Si falta el TOKEN o token inválido, no tiene sentido reintentar sin cambiar config {
return !/missing discord token|invalid token/i.test(msg); isRetryable: (err) => {
} const msg = err instanceof Error ? `${err.message}` : String(err);
}); // Si falta el TOKEN o token inválido, no tiene sentido reintentar sin cambiar config
return !/missing discord token|invalid token/i.test(msg);
},
}
);
// Asegurar esquema de Appwrite para recordatorios (colección + atributos + índice) // Asegurar esquema de Appwrite para recordatorios (colección + atributos + índice)
try { await ensureRemindersSchema(); } catch (e) { logger.warn({ err: e }, 'No se pudo asegurar el esquema de recordatorios'); } try {
await ensureRemindersSchema();
} catch (e) {
logger.warn({ err: e }, "No se pudo asegurar el esquema de recordatorios");
}
// Iniciar poller de recordatorios si Appwrite está configurado // Iniciar poller de recordatorios si Appwrite está configurado
startReminderPoller(bot); startReminderPoller(bot);
logger.info("✅ Bot conectado a Discord"); // Iniciar limpieza periódica de caché de guilds (cada 10 minutos)
setInterval(async () => {
try {
await cleanExpiredGuildCache();
} catch (error) {
logger.error({ error }, "❌ Error en limpieza periódica de caché");
}
}, 10 * 60 * 1000); // 10 minutos
logger.info("✅ Bot conectado a Discord");
} }
// Bucle de arranque resiliente: si bootstrap completo falla, reintenta sin matar el proceso // Bucle de arranque resiliente: si bootstrap completo falla, reintenta sin matar el proceso
(async function startLoop() { (async function startLoop() {
await withRetry('Arranque', bootstrap, { await withRetry("Arranque", bootstrap, {
minDelayMs: 1000, minDelayMs: 1000,
maxDelayMs: 60_000, maxDelayMs: 60_000,
isRetryable: (err) => { isRetryable: (err) => {
const msg = err instanceof Error ? `${err.message}` : String(err); const msg = err instanceof Error ? `${err.message}` : String(err);
// No reintentar en bucle si el problema es falta/invalid token // No reintentar en bucle si el problema es falta/invalid token
return !/missing discord token|invalid token/i.test(msg); return !/missing discord token|invalid token/i.test(msg);
} },
}); });
})(); })();