feat: implement reminder command with Appwrite integration and polling mechanism
This commit is contained in:
8
.env
8
.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
|
||||
# ===========================================
|
||||
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
89
src/commands/messages/others/recordar.ts
Normal file
89
src/commands/messages/others/recordar.ts
Normal 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
29
src/core/api/appwrite.ts
Normal 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
121
src/core/api/reminders.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user