diff --git a/.env b/.env index bfacda1..b7893f8 100644 --- a/.env +++ b/.env @@ -1,6 +1,14 @@ # Configuración de ejemplo para optimización de memoria # Copia este archivo como .env.test y ajusta los valores según tus necesidades +APPWRITE_PROJECT_ID="68d8c4b2001abc54d3cd" +APPWRITE_PROJECT_NAME="amayo" +APPWRITE_ENDPOINT="https://nyc.cloud.appwrite.io/v1" +APPWRITE_API_KEY="standard_b123c1dbaaf7d3f99aa81492509d6277da0cb89eaf86bb8c42210bae0bb39c3ce911ddf101b6be2d3af8972d44ae63beb32c269c24eedbbe032a6c4c13a16c7e542be00c06ab8e9c131986729d45b68e25ac35e770442ea5285b7367938105a7b2d0e380e944d3bd6582db2c3311b3c9d84be5227718f795f30e31de0b9e8439" +APPWRITE_DATABASE_ID="68d8cb9a00250607e236" +PPWRITE_COLLECTION_REMINDERS_ID="reminders_id" +REMINDERS_POLL_INTERVAL_SECONDS="30" + # =========================================== # CONFIGURACIÓN DE DISCORD # =========================================== diff --git a/package-lock.json b/package-lock.json index 44683e9..e6a30fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,11 @@ "@google/genai": "1.20.0", "@google/generative-ai": "0.24.1", "@prisma/client": "6.16.2", + "appwrite": "20.1.0", + "chrono-node": "2.9.0", "discord-api-types": "0.38.24", "discord.js": "14.22.1", + "node-appwrite": "19.1.0", "prisma": "6.16.2", "redis": "5.8.2" }, @@ -490,6 +493,12 @@ "node": ">= 14" } }, + "node_modules/appwrite": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/appwrite/-/appwrite-20.1.0.tgz", + "integrity": "sha512-dApZoqsb8Ug4Nbq5wlMd0WQxyBnxAC+utKgjiluRFimrwbbp8QVsntC4qbnYY8w25LzCRd4icKEFxCJrpQg3Qw==", + "license": "BSD-3-Clause" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -575,6 +584,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chrono-node": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz", + "integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -960,6 +978,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/node-appwrite": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-19.1.0.tgz", + "integrity": "sha512-fq8IGvukZX73Zf70p61sKoACEB3d+vwv47GklMVQAODXJJ1vZqszJF9ljCzCFVKuhX/WiImcDKSYXFF9kci+wg==", + "license": "BSD-3-Clause", + "dependencies": { + "node-fetch-native-with-agent": "1.7.2" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -986,6 +1013,12 @@ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, + "node_modules/node-fetch-native-with-agent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz", + "integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==", + "license": "MIT" + }, "node_modules/nypm": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", diff --git a/package.json b/package.json index 0e773c9..2caa310 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "start:prod-optimized": "NODE_ENV=production ENABLE_MEMORY_OPTIMIZER=true NODE_OPTIONS='--max-old-space-size=512 --expose-gc' npx tsx src/main.ts", "typecheck": "tsc --noEmit" }, - "keywords": [ ], + "keywords": [], "author": "", "license": "ISC", "type": "commonjs", @@ -22,8 +22,11 @@ "@google/genai": "1.20.0", "@google/generative-ai": "0.24.1", "@prisma/client": "6.16.2", + "appwrite": "20.1.0", + "chrono-node": "2.9.0", "discord-api-types": "0.38.24", "discord.js": "14.22.1", + "node-appwrite": "19.1.0", "prisma": "6.16.2", "redis": "5.8.2" }, @@ -32,4 +35,4 @@ "ts-node": "10.9.2", "typescript": "5.9.2" } -} \ No newline at end of file +} diff --git a/src/commands/messages/others/recordar.ts b/src/commands/messages/others/recordar.ts new file mode 100644 index 0000000..6976b04 --- /dev/null +++ b/src/commands/messages/others/recordar.ts @@ -0,0 +1,89 @@ +// Comando para crear recordatorios con Appwrite: !recordar {texto} {fecha} +// Ejemplos: +// !recordar hacer esto el miércoles a las 5pm +// !recordar pagar el hosting mañana a las 9:00 +// @ts-ignore +import { CommandMessage } from "../../../core/types/commands"; +import type { Message } from 'discord.js'; +import * as chrono from 'chrono-node'; +import { scheduleReminder } from '../../../core/api/reminders'; +import { isAppwriteConfigured } from '../../../core/api/appwrite'; + +function humanizeDate(d: Date) { + return d.toLocaleString('es-ES', { timeZone: 'UTC', hour12: false }); +} + +export const command: CommandMessage = { + name: 'recordar', + type: 'message', + aliases: ['reminder', 'rec'], + cooldown: 5, + description: 'Crea un recordatorio. Ej: !recordar hacer esto el miércoles a las 17:00', + category: 'Utilidad', + usage: 'recordar ', + run: async (message: Message, args: string[]) => { + if (!isAppwriteConfigured()) { + await message.reply('⚠️ Appwrite no está configurado en el bot. Define APPWRITE_* en variables de entorno.'); + return; + } + const text = (args || []).join(' ').trim(); + if (!text) { + await message.reply('Uso: !recordar Ej: !recordar enviar reporte el viernes a las 10:00'); + return; + } + + // Parsear en español, forzando fechas futuras cuando sea ambiguo + const ref = new Date(); + const results = chrono.es.parse(text, ref, { forwardDate: true }); + if (!results.length) { + await message.reply('❌ No pude entender cuándo. Intenta algo como: "mañana 9am", "el miércoles 17:00", "en 2 horas".'); + return; + } + + const r = results[0]; + const when = r.date(); + + if (!when || isNaN(when.getTime())) { + await message.reply('❌ La fecha/hora no es válida.'); + return; + } + + // Evitar fechas pasadas + if (when.getTime() <= Date.now()) { + await message.reply('❌ La fecha/hora ya pasó. Especifica una fecha futura.'); + return; + } + + // Extraer el texto del recordatorio eliminando el fragmento reconocido de fecha + const matched = r.text || ''; + let reminderText = text; + if (matched) { + const idx = text.toLowerCase().indexOf(matched.toLowerCase()); + if (idx >= 0) { + reminderText = (text.slice(0, idx) + text.slice(idx + matched.length)).trim(); + } + } + // Si quedó vacío, usar el texto completo + if (!reminderText) reminderText = text; + + // Guardar en Appwrite + const iso = new Date(when.getTime()).toISOString(); + try { + await scheduleReminder({ + userId: message.author.id, + guildId: message.guild?.id || null, + channelId: message.channel.id, + message: reminderText, + executeAt: iso + }); + } catch (e) { + console.error('Error programando recordatorio:', e); + await message.reply('❌ No pude guardar el recordatorio. Revisa la configuración de Appwrite.'); + return; + } + + const whenHuman = humanizeDate(when); + await message.reply(`✅ Recordatorio guardado para: ${whenHuman} UTC\nMensaje: ${reminderText}`); + } +}; + diff --git a/src/core/api/appwrite.ts b/src/core/api/appwrite.ts new file mode 100644 index 0000000..9b49eae --- /dev/null +++ b/src/core/api/appwrite.ts @@ -0,0 +1,29 @@ +// Simple Appwrite client wrapper +// @ts-ignore +import { Client, Databases } from 'node-appwrite'; + +const endpoint = process.env.APPWRITE_ENDPOINT || ''; +const projectId = process.env.APPWRITE_PROJECT_ID || ''; +const apiKey = process.env.APPWRITE_API_KEY || ''; + +export const APPWRITE_DATABASE_ID = process.env.APPWRITE_DATABASE_ID || ''; +export const APPWRITE_COLLECTION_REMINDERS_ID = process.env.APPWRITE_COLLECTION_REMINDERS_ID || ''; + +let client: Client | null = null; +let databases: Databases | null = null; + +function ensureClient() { + if (!endpoint || !projectId || !apiKey) return null; + if (client) return client; + client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey); + databases = new Databases(client); + return client; +} + +export function getDatabases(): Databases | null { + return ensureClient() ? (databases as Databases) : null; +} + +export function isAppwriteConfigured(): boolean { + return Boolean(endpoint && projectId && apiKey && APPWRITE_DATABASE_ID && APPWRITE_COLLECTION_REMINDERS_ID); +} diff --git a/src/core/api/reminders.ts b/src/core/api/reminders.ts new file mode 100644 index 0000000..6dbe289 --- /dev/null +++ b/src/core/api/reminders.ts @@ -0,0 +1,121 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const sdk: any = require('node-appwrite'); +import type Amayo from '../client'; +import { getDatabases, isAppwriteConfigured, APPWRITE_COLLECTION_REMINDERS_ID, APPWRITE_DATABASE_ID } from './appwrite'; + +export type ReminderDoc = { + $id?: string; + userId: string; + guildId?: string | null; + channelId?: string | null; + message: string; + executeAt: string; // ISO string + createdAt?: string; +}; + +// Row type returned by Appwrite for our reminders +export type ReminderRow = ReminderDoc & { + $id: string; + $createdAt?: string; + $updatedAt?: string; + $permissions?: string[]; + $collectionId?: string; + $databaseId?: string; +}; + +export async function scheduleReminder(doc: ReminderDoc): Promise { + const db = getDatabases(); + if (!db || !isAppwriteConfigured()) throw new Error('Appwrite no está configurado'); + const data = { + userId: doc.userId, + guildId: doc.guildId ?? null, + channelId: doc.channelId ?? null, + message: doc.message, + executeAt: doc.executeAt, + createdAt: doc.createdAt ?? new Date().toISOString() + } as Record; + const res = await db.createDocument(APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_REMINDERS_ID, sdk.ID.unique(), data) as unknown as { $id: string }; + return res.$id; +} + +async function fetchDueReminders(limit = 25): Promise { + const db = getDatabases(); + if (!db || !isAppwriteConfigured()) return []; + const nowIso = new Date().toISOString(); + try { + const list = await db.listDocuments(APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_REMINDERS_ID, [ + sdk.Query.lessThanEqual('executeAt', nowIso), + sdk.Query.limit(limit) + ]) as unknown as { documents?: ReminderRow[] }; + return (list.documents || []) as ReminderRow[]; + } catch (e) { + console.error('Error listando recordatorios vencidos:', e); + return []; + } +} + +async function deliverReminder(bot: Amayo, doc: ReminderRow) { + const userId: string = doc.userId; + const channelId: string | null = (doc.channelId as string | null) || null; + const message: string = doc.message || ''; + + let delivered = false; + // 1) Intentar en el canal original si existe y es de texto + if (channelId) { + try { + const ch: any = await bot.channels.fetch(channelId).catch(() => null); + if (ch && typeof ch.send === 'function') { + await ch.send({ content: `⏰ <@${userId}> Recordatorio: ${message}` }); + delivered = true; + } + } catch (e) { + console.warn('No se pudo enviar al canal original:', e); + } + } + // 2) Fallback: DM al usuario + if (!delivered) { + try { + const user = await bot.users.fetch(userId); + await user.send({ content: `⏰ Recordatorio: ${message}` }); + delivered = true; + } catch (e) { + console.warn('No se pudo enviar DM al usuario:', e); + } + } + + return delivered; +} + +export function startReminderPoller(bot: Amayo) { + if (!isAppwriteConfigured()) { + console.warn('Appwrite no configurado: el poller de recordatorios no se iniciará.'); + return null; + } + + const intervalSec = parseInt(process.env.REMINDERS_POLL_INTERVAL_SECONDS || '30', 10); + console.log(`⏱️ Iniciando poller de recordatorios cada ${intervalSec}s`); + + const timer = setInterval(async () => { + try { + const due = await fetchDueReminders(50); + if (!due.length) return; + for (const d of due) { + const ok = await deliverReminder(bot, d); + if (!ok) continue; // Dejar para reintento futuro + try { + const db = getDatabases(); + if (db) await db.deleteDocument(APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_REMINDERS_ID, d.$id); + } catch (e) { + console.warn('No se pudo eliminar recordatorio entregado:', e); + } + } + } catch (e) { + console.error('Error en ciclo de recordatorios:', e); + } + }, Math.max(10, intervalSec) * 1000); + + // Node no debería impedir salida por timers; por si acaso, unref + // @ts-ignore + if (typeof timer.unref === 'function') timer.unref(); + return timer; +} diff --git a/src/core/lib/vars.ts b/src/core/lib/vars.ts index f443080..09c7d20 100644 --- a/src/core/lib/vars.ts +++ b/src/core/lib/vars.ts @@ -35,8 +35,13 @@ const getInviteObject = (invite?: Invite) => invite?.guild ? { icon: invite.guild.icon ? `https://cdn.discordapp.com/icons/${invite.guild.id}/${invite.guild.icon}.webp?size=256` : '' } : null; -// Helper: calcula el rank dentro del servidor para un campo (weeklyPoints / monthlyPoints) -async function computeRankInGuild(guildId: string, userId: string, field: 'weeklyPoints' | 'monthlyPoints', knownPoints?: number): Promise { +// Helper: calcula el rank dentro del servidor para un campo (weeklyPoints / monthlyPoints / totalPoints) +async function computeRankInGuild( + guildId: string, + userId: string, + field: 'weeklyPoints' | 'monthlyPoints' | 'totalPoints', + knownPoints?: number +): Promise { try { let points = knownPoints; if (typeof points !== 'number') { @@ -85,6 +90,13 @@ export const VARIABLES: Record = { const rank = await computeRankInGuild(guildId, userId, 'monthlyPoints', stats?.monthlyPoints); return String(rank || 0); }, + 'user.rankTotal': async ({ user, guild, stats }) => { + const userId = getUserId(user); + const guildId = guild?.id; + if (!userId || !guildId) return '0'; + const rank = await computeRankInGuild(guildId, userId, 'totalPoints', stats?.totalPoints); + return String(rank || 0); + }, // GUILD INFO 'guild.name': ({ guild }) => guild?.name ?? '', diff --git a/src/main.ts b/src/main.ts index a023e6d..0b3e87b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { registeringCommands } from "./core/api/discordAPI"; import {loadComponents} from "./core/lib/components"; import { startMemoryMonitor } from "./core/memory/memoryMonitor"; import {memoryOptimizer} from "./core/memory/memoryOptimizer"; +import { startReminderPoller } from "./core/api/reminders"; // Activar monitor de memoria si se define la variable const __memInt = parseInt(process.env.MEMORY_LOG_INTERVAL_SECONDS || '0', 10); @@ -178,6 +179,9 @@ async function bootstrap() { } }); + // Iniciar poller de recordatorios si Appwrite está configurado + startReminderPoller(bot); + console.log("✅ Bot conectado a Discord"); }