feat: implement reminder command with Appwrite integration and polling mechanism

This commit is contained in:
2025-09-28 01:00:43 -05:00
parent 57d4d28cb9
commit 916b85acb4
8 changed files with 303 additions and 4 deletions

View File

@@ -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 <texto> <cuando>',
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 <texto> <fecha/hora> 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}`);
}
};

29
src/core/api/appwrite.ts Normal file
View File

@@ -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);
}

121
src/core/api/reminders.ts Normal file
View File

@@ -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<string> {
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<string, any>;
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<ReminderRow[]> {
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;
}

View File

@@ -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<number> {
// 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<number> {
try {
let points = knownPoints;
if (typeof points !== 'number') {
@@ -85,6 +90,13 @@ export const VARIABLES: Record<string, VarResolver> = {
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 ?? '',

View File

@@ -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");
}