From 82833887d7d99e02b4183a67e0a6dc35872e9da0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:50:53 +0000 Subject: [PATCH 1/6] Initial plan From 8c208c1ddf968d816dd5c2fe6ca773480964e4ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:56:57 +0000 Subject: [PATCH 2/6] Add TypeScript config and migrate config, utils, and services to TypeScript Co-authored-by: siramong <51140436+siramong@users.noreply.github.com> --- .gitignore | 4 + config/config.ts | 80 ++++++++++ config/enums.ts | 30 ++++ config/strings.ts | 88 +++++++++++ package.json | 14 +- services/n8n.ts | 43 ++++++ services/openrouter.ts | 92 ++++++++++++ services/supabase.ts | 287 ++++++++++++++++++++++++++++++++++++ tsconfig.json | 28 ++++ utils/consoleLogger.ts | 173 ++++++++++++++++++++++ utils/formatting.ts | 66 +++++++++ utils/logger.ts | 204 +++++++++++++++++++++++++ utils/pendingAttachments.ts | 23 +++ utils/permissions.ts | 62 ++++++++ utils/userManager.ts | 39 +++++ utils/validation.ts | 57 +++++++ 16 files changed, 1286 insertions(+), 4 deletions(-) create mode 100644 config/config.ts create mode 100644 config/enums.ts create mode 100644 config/strings.ts create mode 100644 services/n8n.ts create mode 100644 services/openrouter.ts create mode 100644 services/supabase.ts create mode 100644 tsconfig.json create mode 100644 utils/consoleLogger.ts create mode 100644 utils/formatting.ts create mode 100644 utils/logger.ts create mode 100644 utils/pendingAttachments.ts create mode 100644 utils/permissions.ts create mode 100644 utils/userManager.ts create mode 100644 utils/validation.ts diff --git a/.gitignore b/.gitignore index e7fb28f..297a9e1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ Thumbs.db # Temporary files tmp/ temp/ + +# TypeScript +dist/ +*.tsbuildinfo diff --git a/config/config.ts b/config/config.ts new file mode 100644 index 0000000..ebd26be --- /dev/null +++ b/config/config.ts @@ -0,0 +1,80 @@ +import dotenv from 'dotenv'; +import { Nivel } from './enums'; + +dotenv.config(); + +interface ForumChannels { + 'Primero': string | undefined; + 'Segundo': string | undefined; + 'Tercero': string | undefined; +} + +interface AnnouncementChannels { + 'Primero': string | undefined; + 'Segundo': string | undefined; + 'Tercero': string | undefined; +} + +interface CursoToNivel { + '1E1': 'Primero'; + '1E2': 'Primero'; + '2E1': 'Segundo'; + '2E2': 'Segundo'; + '3E1': 'Tercero'; + '3E2': 'Tercero'; +} + +interface Config { + DISCORD_TOKEN: string | undefined; + SUPABASE_URL: string | undefined; + SUPABASE_KEY: string | undefined; + OPENROUTER_API_KEY: string | undefined; + N8N_WEBHOOK_URL: string | undefined; + TEACHER_ROLE_ID: string | undefined; + TEACHER_CHANNEL_ID: string | undefined; + DEVELOPER_USER_ID: string | undefined; + FORUM_CHANNELS: ForumChannels; + ANNOUNCEMENT_CHANNELS: AnnouncementChannels; + CURSO_TO_NIVEL: CursoToNivel; + BID_INCREMENT: number; + COIN_REQUEST_COOLDOWN: number; +} + +const config: Config = { + DISCORD_TOKEN: process.env.DISCORD_TOKEN, + SUPABASE_URL: process.env.SUPABASE_URL, + SUPABASE_KEY: process.env.SUPABASE_KEY, + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + N8N_WEBHOOK_URL: process.env.N8N_WEBHOOK_URL, + TEACHER_ROLE_ID: process.env.TEACHER_ROLE_ID, + TEACHER_CHANNEL_ID: process.env.TEACHER_CHANNEL_ID, + DEVELOPER_USER_ID: process.env.DEVELOPER_USER_ID, + + // Discord channels organized by NIVEL (not curso) + FORUM_CHANNELS: { + 'Primero': process.env.FORUM_PRIMERO_ID, + 'Segundo': process.env.FORUM_SEGUNDO_ID, + 'Tercero': process.env.FORUM_TERCERO_ID, + }, + + ANNOUNCEMENT_CHANNELS: { + 'Primero': process.env.ANNOUNCEMENT_PRIMERO_ID, + 'Segundo': process.env.ANNOUNCEMENT_SEGUNDO_ID, + 'Tercero': process.env.ANNOUNCEMENT_TERCERO_ID, + }, + + // Map curso to nivel for channel lookups + CURSO_TO_NIVEL: { + '1E1': 'Primero', + '1E2': 'Primero', + '2E1': 'Segundo', + '2E2': 'Segundo', + '3E1': 'Tercero', + '3E2': 'Tercero', + }, + + BID_INCREMENT: 10, // Minimum bid increase + COIN_REQUEST_COOLDOWN: 3600000, // 1 hour in ms +}; + +export default config; diff --git a/config/enums.ts b/config/enums.ts new file mode 100644 index 0000000..d23e239 --- /dev/null +++ b/config/enums.ts @@ -0,0 +1,30 @@ +export type Curso = '1E1' | '1E2' | '2E1' | '2E2' | '3E1' | '3E2'; +export type Nivel = 'Primero' | 'Segundo' | 'Tercero'; + +export const CURSOS: Curso[] = ['1E1', '1E2', '2E1', '2E2', '3E1', '3E2']; + +export const NIVELES: Nivel[] = ['Primero', 'Segundo', 'Tercero']; + +export const CURSO_TO_NIVEL: Record = { + '1E1': 'Primero', + '1E2': 'Primero', + '2E1': 'Segundo', + '2E2': 'Segundo', + '3E1': 'Tercero', + '3E2': 'Tercero', +}; + +export const NIVEL_NAMES: Record = { + 'Primero': 'Primero de Bachillerato', + 'Segundo': 'Segundo de Bachillerato', + 'Tercero': 'Tercero de Bachillerato', +}; + +export const CURSO_NAMES: Record = { + '1E1': 'Primero de Bachillerato - Paralelo 1', + '1E2': 'Primero de Bachillerato - Paralelo 2', + '2E1': 'Segundo de Bachillerato - Paralelo 1', + '2E2': 'Segundo de Bachillerato - Paralelo 2', + '3E1': 'Tercero de Bachillerato - Paralelo 1', + '3E2': 'Tercero de Bachillerato - Paralelo 2', +}; diff --git a/config/strings.ts b/config/strings.ts new file mode 100644 index 0000000..61f37a9 --- /dev/null +++ b/config/strings.ts @@ -0,0 +1,88 @@ +export const ERRORS = { + NO_PERMISSION: '`❌` No tienes permisos para usar este comando.', + INSUFFICIENT_COINS: '`❌` No tienes suficientes monedas.', + USER_NOT_FOUND: '`❌` Usuario no encontrado.', + INVALID_AMOUNT: '`❌` Cantidad inválida.', + INVALID_USER: '`❌` Usuario inválido.', + DATABASE_ERROR: '`❌` Error de base de datos. Intenta de nuevo más tarde.', + EXPORT_FAILED: '`❌` Error en la exportación después de 3 intentos.', + INSUFFICIENT_USER_COINS: '`❌` {user} solo tiene **{current} monedas**. No puedes remover {amount}.', + BID_TOO_LOW: '`❌` La puja debe ser al menos **{minimum} monedas**.', + AUCTION_ENDED: '`❌` Esta subasta ya ha finalizado.', + NO_NIVEL: '`❌` Debes configurar tu nivel primero.', + INVALID_NIVEL: '`❌` Nivel inválido.', + RATE_LIMIT: '`❌` Debes esperar {time} antes de usar este comando nuevamente.', + OPENROUTER_ERROR: '`❌` Error al generar resumen con IA.', + INVALID_DATE: '`❌` Formato de fecha inválido. Usa AAAA-MM-DD.', +}; + +export const SUCCESS = { + COINS_ADDED: '`✅` Se han añadido **{amount} monedas** a {user}', + COINS_REMOVED: '`✅` Se han removido **{amount} monedas** de {user}', + COINS_RESET_USER: '`✅` Las monedas de {user} han sido reseteadas a 0', + COINS_RESET_ALL: '`✅` Todas las monedas del servidor han sido reseteadas', + REQUEST_SENT: '`✅` Tu solicitud ha sido enviada a los docentes.', + EXPORT_SUCCESS: '`✅` Datos exportados exitosamente el {timestamp}', + ACTIVITY_CREATED: '`✅` Actividad **{title}** creada exitosamente en el foro de **{nivel}**', +}; + +export const INFO = { + PROCESSING: '`⏳` Procesando...', + GENERATING_AI: '`⏳` Generando resumen con IA...', +}; + +export const DM = { + COINS_RECEIVED: '`🎉` Has recibido **{amount} monedas**.\nRazón: {reason}', + COINS_DEDUCTED: '`⚠️` Se han deducido **{amount} monedas** de tu cuenta.\nRazón: {reason}', + AUCTION_OUTBID: '`⚠️` Has sido superado en la subasta de {item}', + AUCTION_WON: '`🏆` ¡Felicidades! Has ganado **{item}** por {amount} monedas', +}; + +export const EMBEDS = { + REQUEST_TITLE: '`📝` Nueva Solicitud de Monedas', + BALANCE_TITLE: '`💰` Tu Balance de Monedas', + TOP_TITLE: '`🏆` Ranking de Monedas', + AUCTION_TITLE: '`🔨` Subasta: {item}', + AUCTION_ENDED_TITLE: '`🎉` ¡Subasta finalizada!', + ACTIVITY_TITLE: '`📚` {title}', +}; + +export const MODALS = { + REQUEST_TITLE: 'Solicitar Monedas', + BID_TITLE: 'Realizar Puja', + ACTIVITY_TITLE: 'Crear Nueva Actividad', + RESET_CONFIRM_TITLE: 'Confirmar Reset Total', +}; + +export const FIELDS = { + USER: 'Usuario', + AMOUNT: 'Cantidad', + REASON: 'Razón', + DESCRIPTION: 'Descripción', + PROOF: 'Prueba', + NIVEL: 'Nivel', + CURRENT_BALANCE: 'Saldo actual', + RANK: 'Posición en ranking', + CURRENT_BID: '`💰` Puja actual', + TOP_BIDDER: '`👤` Mejor postor', + TIME_REMAINING: '`⏰` Tiempo restante', + DOCUMENTATION: '`📖` Documentación', + AI_SUMMARY: '`🤖` Resumen IA', + DEADLINE: '`📅` Fecha límite', + REWARD: '`💰` Recompensa', + IMAGE: '`🖼️` Imagen de referencia', +}; + +export const BUTTONS = { + APPROVE: '✅ Aceptar', + DENY: '❌ Rechazar', + BID: '💵 Pujar', + END_AUCTION: '🛑 Finalizar Subasta', + CONFIRM: '✅ Confirmar', + CANCEL: '❌ Cancelar', +}; + +export const PLACEHOLDERS = { + NONE: 'Ninguno', + NOT_SPECIFIED: 'No especificada', +}; diff --git a/package.json b/package.json index 27c62a4..6cf2033 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,11 @@ "name": "reactify-bot", "version": "1.0.0", "description": "Discord bot for educational gamification with virtual currency system", - "main": "src/index.js", + "main": "dist/index.js", "scripts": { - "start": "node src/index.js", - "dev": "node src/index.js" + "build": "tsc", + "start": "npm run build && node dist/index.js", + "dev": "ts-node index.ts" }, "keywords": [ "discord", @@ -16,9 +17,14 @@ "author": "", "license": "ISC", "dependencies": { - "discord.js": "^14.14.1", "@supabase/supabase-js": "^2.39.0", "axios": "^1.6.0", + "discord.js": "^14.14.1", "dotenv": "^16.3.1" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" } } diff --git a/services/n8n.ts b/services/n8n.ts new file mode 100644 index 0000000..981dad4 --- /dev/null +++ b/services/n8n.ts @@ -0,0 +1,43 @@ +import axios from 'axios'; +import config from '../config/config'; + +class N8nService { + private webhookUrl: string | undefined; + + constructor() { + this.webhookUrl = config.N8N_WEBHOOK_URL; + } + + // Export data to n8n webhook with retry logic + async exportToN8n(data: any, retries: number = 3): Promise { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + console.log(`Export attempt ${attempt}/${retries}`); + + const response = await axios.post(this.webhookUrl!, data, { + headers: { + 'Content-Type': 'application/json' + }, + timeout: 10000 // 10 second timeout + }); + + console.log('Export successful:', response.status); + return response.data; + } catch (error) { + console.error(`Export attempt ${attempt} failed:`, (error as Error).message); + lastError = error as Error; + + // Wait 2 seconds before retry (except on last attempt) + if (attempt < retries) { + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + } + + throw lastError; + } +} + +export default new N8nService(); diff --git a/services/openrouter.ts b/services/openrouter.ts new file mode 100644 index 0000000..aca93de --- /dev/null +++ b/services/openrouter.ts @@ -0,0 +1,92 @@ +import axios from 'axios'; +import config from '../config/config'; +import consoleLogger from '../utils/consoleLogger'; + +class OpenRouterService { + private apiKey: string | undefined; + private baseUrl: string; + private freeModel: string; + + constructor() { + this.apiKey = config.OPENROUTER_API_KEY; + this.baseUrl = 'https://openrouter.ai/api/v1/chat/completions'; + // FREE MODEL - No cost at all! + this.freeModel = 'google/gemma-3n-e4b-it:free'; + consoleLogger.info('OPENROUTER', `Usando modelo: ${this.freeModel}`); + } + + // Summarize a single documentation resource + async summarizeResource(resource: string): Promise { + try { + consoleLogger.api('OpenRouter', `Generando resumen (Modelo: ${this.freeModel})`); + const response = await axios.post( + this.baseUrl, + { + // USING FREE MODEL - google/gemini-flash-1.5 is completely FREE on OpenRouter + model: this.freeModel, + messages: [ + { + role: 'system', + content: 'Eres un asistente educativo.' + }, + { + role: 'user', + content: `Resume el siguiente recurso en 2-3 oraciones para estudiantes de bachillerato: ${resource}` + } + ] + }, + { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://github.com/siramong/reactify-bot', + 'X-Title': 'Reactify Bot' + } + } + ); + + // Check if response has the expected structure + if (!response.data || !response.data.choices || !response.data.choices[0] || !response.data.choices[0].message) { + consoleLogger.error('OPENROUTER', 'Respuesta inesperada de OpenRouter', response.data); + throw new Error('Respuesta inesperada de OpenRouter'); + } + + consoleLogger.api('OpenRouter', 'Resumen generado exitosamente', 'success'); + return response.data.choices[0].message.content; + } catch (error) { + consoleLogger.error('OPENROUTER', 'Error al generar resumen', error as Error); + throw error; + } + } + + // Summarize multiple documentation resources + async summarizeDocumentation(docs: string): Promise { + try { + // Split by comma and trim + const docList = docs.split(',').map(doc => doc.trim()).filter(doc => doc.length > 0); + + if (docList.length === 0) { + return 'No se proporcionó documentación.'; + } + + // Summarize each document + const summaries: string[] = []; + for (const doc of docList) { + try { + const summary = await this.summarizeResource(doc); + summaries.push(`• ${doc}\n ${summary}`); + } catch (error) { + consoleLogger.error('OPENROUTER', `Error resumiendo: ${doc}`, error as Error); + summaries.push(`• ${doc}\n (No se pudo generar resumen)`); + } + } + + return summaries.join('\n\n'); + } catch (error) { + consoleLogger.error('OPENROUTER', 'Error al resumir documentación', error as Error); + throw error; + } + } +} + +export default new OpenRouterService(); diff --git a/services/supabase.ts b/services/supabase.ts new file mode 100644 index 0000000..1816020 --- /dev/null +++ b/services/supabase.ts @@ -0,0 +1,287 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import config from '../config/config'; +import consoleLogger from '../utils/consoleLogger'; +import { Curso } from '../config/enums'; + +interface User { + userId: string; + username: string; + amount: number; + curso?: Curso | null; +} + +interface Activity { + uuid: string; + name: string; + teacher: string; + curso: Curso; + threadId: string; +} + +class SupabaseService { + private client: SupabaseClient; + + constructor() { + this.client = createClient(config.SUPABASE_URL!, config.SUPABASE_KEY!); + consoleLogger.success('SUPABASE', 'Cliente inicializado correctamente'); + } + + // Get user from database + async getUser(userId: string): Promise { + try { + const { data, error } = await this.client + .from('coins') + .select('*') + .eq('userId', userId) + .single(); + + if (error && error.code !== 'PGRST116') { + throw error; + } + + return data; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error obteniendo usuario', error as Error); + throw error; + } + } + + // Create new user with defaults + async createUser(userId: string, username: string, curso: Curso | null = null): Promise { + try { + const { data, error } = await this.client + .from('coins') + .insert([ + { + userId: userId, + username: username, + amount: 0, + curso: curso + } + ]) + .select() + .single(); + + if (error) throw error; + consoleLogger.database('CREAR USUARIO', `${username} (${userId})`); + return data; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error creando usuario', error as Error); + throw error; + } + } + + // Ensure user exists (get or create) + async ensureUser(userId: string, username: string): Promise { + try { + let user = await this.getUser(userId); + + if (!user) { + user = await this.createUser(userId, username); + } + + return user; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error asegurando existencia de usuario', error as Error); + throw error; + } + } + + // Update user coins (add or subtract) + async updateCoins(userId: string, amount: number): Promise { + try { + const { data, error} = await this.client + .from('coins') + .update({ amount }) + .eq('userId', userId) + .select() + .single(); + + if (error) throw error; + return data; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error actualizando monedas', error as Error); + throw error; + } + } + + // Add coins to user + async addCoins(userId: string, amount: number): Promise { + try { + const user = await this.getUser(userId); + const newAmount = (user?.amount || 0) + amount; + return await this.updateCoins(userId, newAmount); + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error añadiendo monedas', error as Error); + throw error; + } + } + + // Remove coins from user + async removeCoins(userId: string, amount: number): Promise { + try { + const user = await this.getUser(userId); + const currentAmount = user?.amount || 0; + + if (currentAmount < amount) { + throw new Error('INSUFFICIENT_COINS'); + } + + const newAmount = currentAmount - amount; + return await this.updateCoins(userId, newAmount); + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error removiendo monedas', error as Error); + throw error; + } + } + + // Get top users by coins + async getTopUsers(limit: number = 10): Promise { + try { + const { data, error } = await this.client + .from('coins') + .select('*') + .order('amount', { ascending: false }) + .limit(limit); + + if (error) throw error; + return data || []; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error obteniendo top usuarios', error as Error); + throw error; + } + } + + // Get user rank + async getUserRank(userId: string): Promise { + try { + const user = await this.getUser(userId); + if (!user) return null; + + const { count, error } = await this.client + .from('coins') + .select('*', { count: 'exact', head: true }) + .gt('amount', user.amount); + + if (error) throw error; + return (count || 0) + 1; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error obteniendo rank de usuario', error as Error); + throw error; + } + } + + // Get all users + async getAllUsers(): Promise { + try { + const { data, error } = await this.client + .from('coins') + .select('*') + .order('amount', { ascending: false }); + + if (error) throw error; + return data || []; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error obteniendo todos los usuarios', error as Error); + throw error; + } + } + + // Reset user coins to 0 + async resetUserCoins(userId: string): Promise { + try { + const { data, error } = await this.client + .from('coins') + .update({ amount: 0 }) + .eq('userId', userId) + .select() + .single(); + + if (error) throw error; + consoleLogger.database('RESET MONEDAS', `Usuario: ${userId}`); + return data; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error reseteando monedas de usuario', error as Error); + throw error; + } + } + + // Reset all coins to 0 + async resetAllCoins(): Promise { + try { + const { data, error } = await this.client + .from('coins') + .update({ amount: 0 }) + .neq('userId', ''); + + if (error) throw error; + consoleLogger.database('RESET MONEDAS', 'Todos los usuarios'); + return data; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error reseteando todas las monedas', error as Error); + throw error; + } + } + + // Update user curso + async updateUserCurso(userId: string, curso: Curso): Promise { + try { + const { data, error } = await this.client + .from('coins') + .update({ curso }) + .eq('userId', userId) + .select() + .single(); + + if (error) throw error; + consoleLogger.database('ACTUALIZAR CURSO', `${userId} -> ${curso}`); + return data; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error actualizando curso de usuario', error as Error); + throw error; + } + } + + // Create activity + async createActivity(name: string, teacher: string, curso: Curso, threadId: string): Promise { + try { + const { data, error } = await this.client + .from('activities') + .insert([ + { + name, + teacher, + curso, + threadId + } + ]) + .select() + .single(); + + if (error) throw error; + consoleLogger.database('CREAR ACTIVIDAD', `${name} - Curso: ${curso}`); + return data; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error creando actividad', error as Error); + throw error; + } + } + + // Get activities by curso + async getActivitiesByCurso(curso: Curso): Promise { + try { + const { data, error } = await this.client + .from('activities') + .select('*') + .eq('curso', curso); + + if (error) throw error; + return data || []; + } catch (error) { + consoleLogger.error('BASE DE DATOS', 'Error obteniendo actividades', error as Error); + throw error; + } + } +} + +export default new SupabaseService(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9dc6120 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.js" + ] +} diff --git a/utils/consoleLogger.ts b/utils/consoleLogger.ts new file mode 100644 index 0000000..7ae7b67 --- /dev/null +++ b/utils/consoleLogger.ts @@ -0,0 +1,173 @@ +/** + * Beautiful console logger with colors and emojis + * Makes console output modern, readable and understandable + */ + +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + + // Foreground colors + black: '\x1b[30m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + + // Background colors + bgBlack: '\x1b[40m', + bgRed: '\x1b[41m', + bgGreen: '\x1b[42m', + bgYellow: '\x1b[43m', + bgBlue: '\x1b[44m', + bgMagenta: '\x1b[45m', + bgCyan: '\x1b[46m', + bgWhite: '\x1b[47m', +}; + +/** + * Get current timestamp formatted + */ +function getTimestamp(): string { + const now = new Date(); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${hours}:${minutes}:${seconds}`; +} + +/** + * Format a log message with timestamp and color + */ +function formatMessage(emoji: string, category: string, message: string, color: string): string { + const timestamp = `${colors.dim}[${getTimestamp()}]${colors.reset}`; + const categoryStr = `${color}${colors.bright}[${category}]${colors.reset}`; + return `${timestamp} ${emoji} ${categoryStr} ${message}`; +} + +const consoleLogger = { + /** + * Log success message + */ + success(category: string, message: string): void { + console.log(formatMessage('✅', category, message, colors.green)); + }, + + /** + * Log error message + */ + error(category: string, message: string, error: Error | null = null): void { + console.log(formatMessage('❌', category, message, colors.red)); + if (error && error.stack) { + console.log(`${colors.dim}${error.stack}${colors.reset}`); + } + }, + + /** + * Log warning message + */ + warn(category: string, message: string): void { + console.log(formatMessage('⚠️', category, message, colors.yellow)); + }, + + /** + * Log info message + */ + info(category: string, message: string): void { + console.log(formatMessage('ℹ️', category, message, colors.cyan)); + }, + + /** + * Log command execution + */ + command(commandName: string, user: string, guild: string): void { + const message = `${colors.bright}/${commandName}${colors.reset} ejecutado por ${colors.cyan}${user}${colors.reset} en ${colors.magenta}${guild}${colors.reset}`; + console.log(formatMessage('⚡', 'COMANDO', message, colors.blue)); + }, + + /** + * Log database operation + */ + database(operation: string, details: string): void { + const message = `${colors.bright}${operation}${colors.reset} - ${details}`; + console.log(formatMessage('🗄️', 'BASE DE DATOS', message, colors.magenta)); + }, + + /** + * Log API call + */ + api(service: string, action: string, status: string = 'success'): void { + const emoji = status === 'success' ? '✓' : '✗'; + const message = `${colors.bright}${service}${colors.reset} - ${action} ${emoji}`; + console.log(formatMessage('🌐', 'API', message, colors.blue)); + }, + + /** + * Log bot startup + */ + startup(message: string): void { + console.log(formatMessage('🚀', 'INICIO', message, colors.green)); + }, + + /** + * Log Discord event + */ + event(eventName: string, details: string): void { + const message = `${colors.bright}${eventName}${colors.reset} - ${details}`; + console.log(formatMessage('📡', 'EVENTO', message, colors.cyan)); + }, + + /** + * Log interaction + */ + interaction(type: string, user: string, details: string): void { + const message = `${colors.bright}${type}${colors.reset} por ${colors.cyan}${user}${colors.reset} - ${details}`; + console.log(formatMessage('💬', 'INTERACCIÓN', message, colors.yellow)); + }, + + /** + * Log transaction + */ + transaction(type: string, amount: number, user: string): void { + const message = `${colors.bright}${type}${colors.reset} - ${colors.yellow}${amount} monedas${colors.reset} - ${colors.cyan}${user}${colors.reset}`; + console.log(formatMessage('💰', 'TRANSACCIÓN', message, colors.green)); + }, + + /** + * Print a beautiful banner + */ + banner(text: string): void { + const border = '═'.repeat(text.length + 4); + console.log(`\n${colors.bright}${colors.cyan}╔${border}╗${colors.reset}`); + console.log(`${colors.bright}${colors.cyan}║ ${text} ║${colors.reset}`); + console.log(`${colors.bright}${colors.cyan}╚${border}╝${colors.reset}\n`); + }, + + /** + * Print system info + */ + systemInfo(info: Record): void { + console.log(`\n${colors.bright}${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`); + Object.entries(info).forEach(([key, value]) => { + console.log(` ${colors.bright}${key}:${colors.reset} ${colors.green}${value}${colors.reset}`); + }); + console.log(`${colors.bright}${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}\n`); + }, + + /** + * Log debug message (only in development) + */ + debug(category: string, message: string): void { + if (process.env.NODE_ENV === 'development') { + const timestamp = `${colors.dim}[${getTimestamp()}]${colors.reset}`; + const categoryStr = `${colors.dim}[${category}]${colors.reset}`; + console.log(`${timestamp} 🔍 ${categoryStr} ${colors.dim}${message}${colors.reset}`); + } + }, +}; + +export default consoleLogger; diff --git a/utils/formatting.ts b/utils/formatting.ts new file mode 100644 index 0000000..b2551b4 --- /dev/null +++ b/utils/formatting.ts @@ -0,0 +1,66 @@ +interface User { + amount: number; + username: string; + curso?: string; +} + +// Format number with thousands separator +export function formatNumber(num: number): string { + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +// Format coins display +export function formatCoins(amount: number): string { + return `**${formatNumber(amount)} monedas**`; +} + +// Format user mention +export function formatUserMention(userId: string): string { + return `<@${userId}>`; +} + +// Format rank emoji +export function getRankEmoji(rank: number): string { + const emojis: Record = { + 1: '1️⃣', + 2: '2️⃣', + 3: '3️⃣', + 4: '4️⃣', + 5: '5️⃣', + 6: '6️⃣', + 7: '7️⃣', + 8: '8️⃣', + 9: '9️⃣', + 10: '🔟' + }; + return emojis[rank] || `**${rank}.**`; +} + +// Format top users list +export function formatTopUsersList(users: User[]): string { + return users.map((user, index) => { + const rank = index + 1; + const emoji = getRankEmoji(rank); + const curso = user.curso ? ` (Curso: ${user.curso})` : ''; + return `${emoji} **${user.username}** - ${formatNumber(user.amount)} monedas${curso}`; + }).join('\n'); +} + +// Format time duration +export function formatDuration(minutes: number): string { + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} + +// Replace placeholders in string +export function replacePlaceholders(template: string, values: Record): string { + let result = template; + for (const [key, value] of Object.entries(values)) { + result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value)); + } + return result; +} diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..97e9f23 --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,204 @@ +import { Client, EmbedBuilder, TextChannel } from 'discord.js'; +import config from '../config/config'; + +interface TransactionData { + type: string; + userId: string; + username?: string; + amount?: number; + reason?: string; + performedBy: string; + nivel?: string; + itemName?: string; + startingBid?: number; + duration?: number; + winnerId?: string; + finalPrice?: number; + title?: string; + reward?: number; +} + +/** + * Dedicated logging module for transaction logging to teacher channel + */ +class Logger { + private client: Client; + private teacherChannelId: string | undefined; + + constructor(client: Client) { + this.client = client; + this.teacherChannelId = config.TEACHER_CHANNEL_ID; + } + + /** + * Get the teacher channel + */ + async getTeacherChannel(): Promise { + if (!this.teacherChannelId) { + console.error('TEACHER_CHANNEL_ID not configured'); + return null; + } + + try { + const channel = await this.client.channels.fetch(this.teacherChannelId); + return channel as TextChannel; + } catch (error) { + console.error('Error fetching teacher channel:', error); + return null; + } + } + + /** + * Log a transaction to the teacher channel + */ + async logTransaction(data: TransactionData): Promise { + const channel = await this.getTeacherChannel(); + if (!channel) return; + + try { + const embed = new EmbedBuilder() + .setTimestamp() + .setFooter({ text: `ID: ${data.userId}` }); + + switch (data.type) { + case 'COINS_ADDED': + embed + .setTitle('`💰` Monedas Añadidas') + .setColor(0x2ecc71) // Green + .addFields( + { name: 'Usuario', value: `<@${data.userId}>`, inline: true }, + { name: 'Cantidad', value: `**+${data.amount}** monedas`, inline: true }, + { name: 'Nivel', value: data.nivel || '*No configurado*', inline: true }, + { name: 'Razón', value: data.reason || 'No especificada', inline: false }, + { name: 'Realizado por', value: `<@${data.performedBy}>`, inline: false } + ); + break; + + case 'COINS_REMOVED': + embed + .setTitle('`⚠️` Monedas Removidas') + .setColor(0xe74c3c) // Red + .addFields( + { name: 'Usuario', value: `<@${data.userId}>`, inline: true }, + { name: 'Cantidad', value: `**-${data.amount}** monedas`, inline: true }, + { name: 'Nivel', value: data.nivel || '*No configurado*', inline: true }, + { name: 'Razón', value: data.reason || 'No especificada', inline: false }, + { name: 'Realizado por', value: `<@${data.performedBy}>`, inline: false } + ); + break; + + case 'COINS_RESET': + embed + .setTitle('`🔄` Monedas Reseteadas') + .setColor(0xf39c12) // Orange + .addFields( + { name: 'Usuario', value: data.userId === 'ALL' ? '**Todos los usuarios**' : `<@${data.userId}>`, inline: true }, + { name: 'Realizado por', value: `<@${data.performedBy}>`, inline: true } + ); + break; + + case 'AUCTION_CREATED': + embed + .setTitle('`🔨` Subasta Creada') + .setColor(0x9b59b6) // Purple + .addFields( + { name: 'Artículo', value: data.itemName!, inline: true }, + { name: 'Puja inicial', value: `${data.startingBid} monedas`, inline: true }, + { name: 'Nivel', value: data.nivel!, inline: true }, + { name: 'Duración', value: `${data.duration} minutos`, inline: true }, + { name: 'Creado por', value: `<@${data.performedBy}>`, inline: true } + ); + break; + + case 'AUCTION_ENDED': + embed + .setTitle('`🎉` Subasta Finalizada') + .setColor(0x3498db) // Blue + .addFields( + { name: 'Artículo', value: data.itemName!, inline: true }, + { name: 'Ganador', value: data.winnerId ? `<@${data.winnerId}>` : 'Sin postores', inline: true }, + { name: 'Precio final', value: `${data.finalPrice} monedas`, inline: true } + ); + break; + + case 'ACTIVITY_CREATED': + embed + .setTitle('`📚` Actividad Creada') + .setColor(0x1abc9c) // Turquoise + .addFields( + { name: 'Título', value: data.title!, inline: false }, + { name: 'Nivel', value: data.nivel!, inline: true }, + { name: 'Recompensa', value: data.reward && data.reward > 0 ? `${data.reward} monedas` : 'Sin recompensa', inline: true }, + { name: 'Creado por', value: `<@${data.performedBy}>`, inline: true } + ); + break; + + case 'REQUEST_APPROVED': + embed + .setTitle('`✅` Solicitud Aprobada') + .setColor(0x2ecc71) // Green + .addFields( + { name: 'Solicitante', value: `<@${data.userId}>`, inline: true }, + { name: 'Cantidad', value: `**+${data.amount}** monedas`, inline: true }, + { name: 'Aprobado por', value: `<@${data.performedBy}>`, inline: true }, + { name: 'Razón', value: data.reason!, inline: false } + ); + break; + + case 'REQUEST_DENIED': + embed + .setTitle('`❌` Solicitud Rechazada') + .setColor(0x95a5a6) // Gray + .addFields( + { name: 'Solicitante', value: `<@${data.userId}>`, inline: true }, + { name: 'Cantidad solicitada', value: `${data.amount} monedas`, inline: true }, + { name: 'Rechazado por', value: `<@${data.performedBy}>`, inline: true } + ); + break; + + default: + console.warn('Unknown transaction type:', data.type); + return; + } + + await channel.send({ embeds: [embed] }); + } catch (error) { + console.error('Error logging transaction:', error); + } + } + + /** + * Log a general message to the teacher channel + */ + async logMessage(message: string): Promise { + const channel = await this.getTeacherChannel(); + if (!channel) return; + + try { + await channel.send(message); + } catch (error) { + console.error('Error logging message:', error); + } + } +} + +// Singleton instance +let loggerInstance: Logger | null = null; + +/** + * Initialize the logger with the Discord client + */ +export function initLogger(client: Client): Logger { + loggerInstance = new Logger(client); + return loggerInstance; +} + +/** + * Get the logger instance + */ +export function getLogger(): Logger { + if (!loggerInstance) { + throw new Error('Logger not initialized. Call initLogger first.'); + } + return loggerInstance; +} diff --git a/utils/pendingAttachments.ts b/utils/pendingAttachments.ts new file mode 100644 index 0000000..34ab9f1 --- /dev/null +++ b/utils/pendingAttachments.ts @@ -0,0 +1,23 @@ +import { Attachment } from 'discord.js'; + +// Simple in-memory store for attachments provided via command options +// Keyed by userId. Not persistent across restarts — acceptable for passing +// data from the command invocation to the modal submit handler. + +const map = new Map(); + +export function set(userId: string, attachment: Attachment): void { + map.set(String(userId), attachment); +} + +export function getAndDelete(userId: string): Attachment | null { + const key = String(userId); + const val = map.get(key) || null; + map.delete(key); + return val; +} + +// Exposed for tests/debugging +export function _peek(userId: string): Attachment | undefined { + return map.get(String(userId)); +} diff --git a/utils/permissions.ts b/utils/permissions.ts new file mode 100644 index 0000000..b5fb416 --- /dev/null +++ b/utils/permissions.ts @@ -0,0 +1,62 @@ +import { GuildMember } from 'discord.js'; +import config from '../config/config'; + +interface RateLimitResult { + allowed: boolean; + timeRemaining?: number; +} + +// Check if user has teacher role +export function checkTeacherRole(member: GuildMember | null | undefined): boolean { + if (!member) return false; + + // Allow developer override by user ID (useful for testing) + try { + const memberId = member.user ? member.user.id : null; + if (config.DEVELOPER_USER_ID && memberId && memberId === config.DEVELOPER_USER_ID) { + return true; + } + } catch (err) { + // ignore and continue to role check + } + + if (!member.roles || !config.TEACHER_ROLE_ID) return false; + return member.roles.cache.has(config.TEACHER_ROLE_ID); +} + +// Rate limiting map: userId -> { action -> timestamp } +const rateLimitMap = new Map>(); + +// Check rate limit for a user action +export function checkRateLimit(userId: string, action: string, cooldown: number = config.COIN_REQUEST_COOLDOWN): RateLimitResult { + const now = Date.now(); + + if (!rateLimitMap.has(userId)) { + rateLimitMap.set(userId, {}); + } + + const userLimits = rateLimitMap.get(userId)!; + + if (userLimits[action]) { + const timeSinceLastUse = now - userLimits[action]; + if (timeSinceLastUse < cooldown) { + const timeRemaining = cooldown - timeSinceLastUse; + return { + allowed: false, + timeRemaining: Math.ceil(timeRemaining / 1000) // seconds + }; + } + } + + userLimits[action] = now; + return { allowed: true }; +} + +// Format time remaining +export function formatTimeRemaining(seconds: number): string { + if (seconds < 60) { + return `${seconds} segundos`; + } + const minutes = Math.ceil(seconds / 60); + return `${minutes} minutos`; +} diff --git a/utils/userManager.ts b/utils/userManager.ts new file mode 100644 index 0000000..8041507 --- /dev/null +++ b/utils/userManager.ts @@ -0,0 +1,39 @@ +import supabaseService from '../services/supabase'; +import { Curso } from '../config/enums'; + +interface User { + userId: string; + username: string; + amount: number; + curso?: Curso | null; +} + +// Ensure user exists in database +export async function ensureUserExists(userId: string, username: string): Promise { + try { + return await supabaseService.ensureUser(userId, username); + } catch (error) { + console.error('Error in ensureUserExists:', error); + throw error; + } +} + +// Get user with error handling +export async function getUser(userId: string): Promise { + try { + return await supabaseService.getUser(userId); + } catch (error) { + console.error('Error in getUser:', error); + throw error; + } +} + +// Update user curso +export async function updateUserCurso(userId: string, curso: Curso): Promise { + try { + return await supabaseService.updateUserCurso(userId, curso); + } catch (error) { + console.error('Error in updateUserCurso:', error); + throw error; + } +} diff --git a/utils/validation.ts b/utils/validation.ts new file mode 100644 index 0000000..4610758 --- /dev/null +++ b/utils/validation.ts @@ -0,0 +1,57 @@ +import { CURSOS, Curso } from '../config/enums'; + +interface ValidationResult { + valid: boolean; + error?: string; +} + +// Validate coin amount +export function validateAmount(amount: any): ValidationResult { + if (typeof amount !== 'number' || isNaN(amount)) { + return { valid: false, error: 'INVALID_AMOUNT' }; + } + if (amount <= 0) { + return { valid: false, error: 'INVALID_AMOUNT' }; + } + return { valid: true }; +} + +// Validate curso +export function validateCurso(curso: string): ValidationResult { + if (!CURSOS.includes(curso as Curso)) { + return { valid: false, error: 'INVALID_CURSO' }; + } + return { valid: true }; +} + +// Validate date format (YYYY-MM-DD) +export function validateDate(dateString: string | null | undefined): ValidationResult { + if (!dateString) return { valid: true }; // Optional field + + const regex = /^\d{4}-\d{2}-\d{2}$/; + if (!regex.test(dateString)) { + return { valid: false, error: 'INVALID_DATE' }; + } + + const date = new Date(dateString); + if (isNaN(date.getTime())) { + return { valid: false, error: 'INVALID_DATE' }; + } + + return { valid: true }; +} + +// Sanitize text input +export function sanitizeText(text: string | null | undefined): string { + if (!text) return ''; + return text.trim().slice(0, 2000); // Discord message limit +} + +// Validate text length +export function validateTextLength(text: string | null | undefined, maxLength: number): ValidationResult { + if (!text) return { valid: false, error: 'Text is required' }; + if (text.length > maxLength) { + return { valid: false, error: `Text exceeds maximum length of ${maxLength}` }; + } + return { valid: true }; +} From df2517fa40b8b9712e2405826a448b5e324983f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:02:19 +0000 Subject: [PATCH 3/6] Migrate events and all command files to TypeScript Co-authored-by: siramong <51140436+siramong@users.noreply.github.com> --- commands/activity/create.ts | 84 +++++++++ commands/coins/add.ts | 94 ++++++++++ commands/coins/bid.ts | 347 ++++++++++++++++++++++++++++++++++++ commands/coins/export.ts | 48 +++++ commands/coins/get.ts | 55 ++++++ commands/coins/remove.ts | 104 +++++++++++ commands/coins/request.ts | 75 ++++++++ commands/coins/reset.ts | 72 ++++++++ commands/coins/top.ts | 53 ++++++ events/interactionCreate.ts | 145 +++++++++++++++ events/ready.ts | 29 +++ index.ts | 179 +++++++++++++++++++ 12 files changed, 1285 insertions(+) create mode 100644 commands/activity/create.ts create mode 100644 commands/coins/add.ts create mode 100644 commands/coins/bid.ts create mode 100644 commands/coins/export.ts create mode 100644 commands/coins/get.ts create mode 100644 commands/coins/remove.ts create mode 100644 commands/coins/request.ts create mode 100644 commands/coins/reset.ts create mode 100644 commands/coins/top.ts create mode 100644 events/interactionCreate.ts create mode 100644 events/ready.ts create mode 100644 index.ts diff --git a/commands/activity/create.ts b/commands/activity/create.ts new file mode 100644 index 0000000..b6433e2 --- /dev/null +++ b/commands/activity/create.ts @@ -0,0 +1,84 @@ +import { SlashCommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, CommandInteraction } from 'discord.js'; +import { checkTeacherRole } from '../../utils/permissions'; +import * as pendingAttachments from '../../utils/pendingAttachments'; +import { MODALS, ERRORS } from '../../config/strings'; + +export default { + data: new SlashCommandBuilder() + .setName('activity') + .setDescription('Comandos de actividades (solo docentes)') + .addAttachmentOption(option => + option + .setName('attachment') + .setDescription('Adjunto de referencia (archivo) - Opcional') + .setRequired(false) + ), + + async execute(interaction: CommandInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: 64 // Ephemeral + }); + return; + } + + // If the user provided an attachment option with the command, store it + // so the modal submit handler can access it later. + const attachmentOption = interaction.options.get('attachment'); + if (attachmentOption && attachmentOption.attachment) { + pendingAttachments.set(interaction.user.id, attachmentOption.attachment as any); + } + + // Show modal to create activity + const modal = new ModalBuilder() + .setCustomId('activitycreate') + .setTitle(MODALS.ACTIVITY_TITLE); + + const titleInput = new TextInputBuilder() + .setCustomId('title') + .setLabel('Título') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(100); + + const descriptionInput = new TextInputBuilder() + .setCustomId('description') + .setLabel('Descripción') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(1000); + + const docsInput = new TextInputBuilder() + .setCustomId('documentation') + .setLabel('Documentación (URLs separadas por comas)') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(500); + + const rewardInput = new TextInputBuilder() + .setCustomId('reward') + .setLabel('Recompensa en monedas - Opcional') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setMaxLength(5); + + const row1 = new ActionRowBuilder().addComponents(titleInput); + const row2 = new ActionRowBuilder().addComponents(descriptionInput); + const row3 = new ActionRowBuilder().addComponents(docsInput); + const row4 = new ActionRowBuilder().addComponents(rewardInput); + + modal.addComponents(row1, row2, row3, row4); + + await interaction.showModal(modal); + } catch (error) { + console.error('Error in activity create command:', error); + await interaction.reply({ + content: ERRORS.DATABASE_ERROR, + flags: 64 // Ephemeral + }); + } + }, +}; diff --git a/commands/coins/add.ts b/commands/coins/add.ts new file mode 100644 index 0000000..b26483f --- /dev/null +++ b/commands/coins/add.ts @@ -0,0 +1,94 @@ +import { SlashCommandSubcommandBuilder, MessageFlags, CommandInteraction } from 'discord.js'; +import { ensureUserExists } from '../../utils/userManager'; +import { checkTeacherRole } from '../../utils/permissions'; +import supabaseService from '../../services/supabase'; +import { replacePlaceholders } from '../../utils/formatting'; +import { SUCCESS, ERRORS, DM, PLACEHOLDERS } from '../../config/strings'; +import { getLogger } from '../../utils/logger'; +import consoleLogger from '../../utils/consoleLogger'; + +export default { + data: new SlashCommandSubcommandBuilder() + .setName('add') + .setDescription('Añadir monedas a un usuario (solo docentes)') + .addUserOption(option => + option.setName('user') + .setDescription('Usuario al que añadir monedas') + .setRequired(true)) + .addIntegerOption(option => + option.setName('amount') + .setDescription('Cantidad de monedas a añadir') + .setRequired(true) + .setMinValue(1)) + .addStringOption(option => + option.setName('reason') + .setDescription('Razón de la adición') + .setRequired(false) + .setMaxLength(200)), + + async execute(interaction: CommandInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.deferReply(); + + const targetUser = interaction.options.getUser('user', true); + const amount = interaction.options.get('amount', true).value as number; + const reason = interaction.options.get('reason')?.value as string || PLACEHOLDERS.NOT_SPECIFIED; + + // Ensure target user exists + await ensureUserExists(targetUser.id, targetUser.username); + + // Add coins + await supabaseService.addCoins(targetUser.id, amount); + + // Get user data for curso (displayed as nivel) + const userData = await supabaseService.getUser(targetUser.id); + + // Log transaction + const logger = getLogger(); + await logger.logTransaction({ + type: 'COINS_ADDED', + userId: targetUser.id, + username: targetUser.username, + amount: amount, + reason: reason, + performedBy: interaction.user.id, + nivel: userData?.curso || 'No configurado' + }); + + consoleLogger.transaction('AÑADIR', amount, targetUser.tag); + + // Send response + const response = replacePlaceholders(SUCCESS.COINS_ADDED, { + amount: amount, + user: targetUser.toString() + }); + + await interaction.editReply(response); + + // Try to DM the recipient + try { + const dmMessage = replacePlaceholders(DM.COINS_RECEIVED, { + amount: amount, + reason: reason + }); + await targetUser.send(dmMessage); + } catch (error) { + consoleLogger.warn('DM', 'No se pudo enviar DM al usuario'); + } + } catch (error) { + consoleLogger.error('COMANDO', 'Error en coins add', error as Error); + await interaction.editReply({ + content: ERRORS.DATABASE_ERROR + }); + } + }, +}; diff --git a/commands/coins/bid.ts b/commands/coins/bid.ts new file mode 100644 index 0000000..6991fdc --- /dev/null +++ b/commands/coins/bid.ts @@ -0,0 +1,347 @@ +import { SlashCommandSubcommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, MessageFlags, CommandInteraction, Message, StringSelectMenuInteraction } from 'discord.js'; +import { checkTeacherRole } from '../../utils/permissions'; +import { formatDuration, replacePlaceholders } from '../../utils/formatting'; +import { NIVELES, Nivel } from '../../config/enums'; +import config from '../../config/config'; +import { EMBEDS, BUTTONS, FIELDS, ERRORS } from '../../config/strings'; +import { getLogger } from '../../utils/logger'; +import consoleLogger from '../../utils/consoleLogger'; +import supabaseService from '../../services/supabase'; + +interface AuctionData { + itemName: string; + currentBid: number; + topBidder: string | null; + topBidderName: string | null; + endTime: number; + duration: number; + nivel: Nivel; + createdBy: string; + messageId: string; + channelId: string; + timer: NodeJS.Timeout | null; +} + +// Store active auctions +const activeAuctions = new Map(); + +export default { + data: new SlashCommandSubcommandBuilder() + .setName('bid') + .setDescription('Crear una subasta (solo docentes)') + .addStringOption(option => + option.setName('item_name') + .setDescription('Nombre del artículo') + .setRequired(true)) + .addIntegerOption(option => + option.setName('starting_bid') + .setDescription('Puja inicial mínima') + .setRequired(true) + .setMinValue(10)) + .addIntegerOption(option => + option.setName('duration') + .setDescription('Duración en minutos') + .setRequired(true) + .setMinValue(1) + .setMaxValue(1440)), + + async execute(interaction: CommandInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: MessageFlags.Ephemeral + }); + return; + } + + const itemName = interaction.options.get('item_name', true).value as string; + const startingBid = interaction.options.get('starting_bid', true).value as number; + const duration = interaction.options.get('duration', true).value as number; + + // Show nivel selection menu + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`auctionnivel_${interaction.user.id}_${Date.now()}`) + .setPlaceholder('Selecciona el nivel para la subasta') + .addOptions( + NIVELES.map(nivel => ({ + label: nivel, + value: nivel + })) + ); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + const response = await interaction.reply({ + content: '`ℹ️` Selecciona el nivel para la subasta:', + components: [row], + flags: MessageFlags.Ephemeral, + fetchReply: true + }); + + // Store auction info temporarily + const tempKey = `temp_${interaction.user.id}_${Date.now()}`; + (activeAuctions as any)[tempKey] = { + itemName, + startingBid, + duration, + createdBy: interaction.user.id + }; + + // Wait for nivel selection + const collector = (response as Message).createMessageComponentCollector({ + filter: (i: any) => i.user.id === interaction.user.id && i.customId.startsWith('auctionnivel'), + time: 60000, + max: 1 + }); + + collector.on('collect', async (i: StringSelectMenuInteraction) => { + const selectedNivel = i.values[0] as Nivel; + const tempData = (activeAuctions as any)[tempKey]; + delete (activeAuctions as any)[tempKey]; + + await i.update({ + content: '`⏳` Creando subasta...', + components: [] + }); + + try { + await createAuction( + i, + tempData.itemName, + tempData.startingBid, + tempData.duration, + selectedNivel, + tempData.createdBy + ); + } catch (error) { + consoleLogger.error('SUBASTA', 'Error creando subasta', error as Error); + await i.editReply({ + content: ERRORS.DATABASE_ERROR + }); + } + }); + + collector.on('end', (collected) => { + if (collected.size === 0) { + interaction.editReply({ + content: '`❌` Selección de nivel cancelada por timeout.', + components: [] + }); + delete (activeAuctions as any)[tempKey]; + } + }); + } catch (error) { + consoleLogger.error('COMANDO', 'Error en coins bid', error as Error); + await interaction.reply({ + content: ERRORS.DATABASE_ERROR, + flags: MessageFlags.Ephemeral + }); + } + }, + + // Expose active auctions for button handlers + activeAuctions, + formatAuctionEmbed, + endAuction +}; + +async function createAuction( + interaction: StringSelectMenuInteraction, + itemName: string, + startingBid: number, + duration: number, + nivel: Nivel, + createdBy: string +): Promise { + const channelId = config.ANNOUNCEMENT_CHANNELS[nivel]; + + if (!channelId) { + await interaction.editReply({ + content: `\`❌\` Canal de anuncios para ${nivel} no configurado.` + }); + return; + } + + const channel = await interaction.client.channels.fetch(channelId); + if (!channel || !channel.isTextBased()) { + await interaction.editReply({ + content: `\`❌\` No se pudo acceder al canal de anuncios.` + }); + return; + } + + const endTime = Date.now() + duration * 60 * 1000; + const auctionId = `auction_${Date.now()}`; + + // Create auction embed + const embed = formatAuctionEmbed(itemName, startingBid, null, null, endTime, false); + + // Create buttons + const bidButton = new ButtonBuilder() + .setCustomId(`auction_${auctionId}`) + .setLabel(BUTTONS.BID) + .setStyle(ButtonStyle.Primary); + + const endButton = new ButtonBuilder() + .setCustomId(`endauction_${auctionId}`) + .setLabel(BUTTONS.END_AUCTION) + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(bidButton, endButton); + + const auctionMessage = await (channel as any).send({ + embeds: [embed], + components: [row] + }); + + // Store auction data + activeAuctions.set(auctionId, { + itemName, + currentBid: startingBid, + topBidder: null, + topBidderName: null, + endTime, + duration, + nivel, + createdBy, + messageId: auctionMessage.id, + channelId: channel.id, + timer: null + }); + + // Set timer to end auction + const timer = setTimeout(async () => { + await endAuction(auctionId, interaction.client); + }, duration * 60 * 1000); + + activeAuctions.get(auctionId)!.timer = timer; + + // Log auction creation + const logger = getLogger(); + await logger.logTransaction({ + type: 'AUCTION_CREATED', + userId: createdBy, + performedBy: createdBy, + itemName, + startingBid, + duration, + nivel + }); + + consoleLogger.success('SUBASTA', `Creada: ${itemName} - ${nivel}`); + + await interaction.editReply({ + content: `\`✅\` Subasta creada en el canal de **${nivel}**` + }); +} + +function formatAuctionEmbed( + itemName: string, + currentBid: number, + topBidder: string | null, + topBidderName: string | null, + endTime: number, + ended: boolean +): EmbedBuilder { + const title = ended ? replacePlaceholders(EMBEDS.AUCTION_ENDED_TITLE, { item: itemName }) : replacePlaceholders(EMBEDS.AUCTION_TITLE, { item: itemName }); + + const embed = new EmbedBuilder() + .setTitle(title) + .setColor(ended ? 0x95a5a6 : 0x9b59b6) // Gray if ended, Purple if active + .addFields( + { + name: FIELDS.CURRENT_BID, + value: `${currentBid} monedas`, + inline: true + }, + { + name: FIELDS.TOP_BIDDER, + value: topBidder ? `<@${topBidder}>` : 'Ninguno', + inline: true + } + ) + .setTimestamp(); + + if (!ended) { + const timeRemaining = Math.max(0, Math.ceil((endTime - Date.now()) / 60000)); + embed.addFields({ + name: FIELDS.TIME_REMAINING, + value: formatDuration(timeRemaining), + inline: true + }); + } + + return embed; +} + +async function endAuction(auctionId: string, client: any): Promise { + const auction = activeAuctions.get(auctionId); + if (!auction) return; + + // Clear timer + if (auction.timer) { + clearTimeout(auction.timer); + } + + // Get channel and message + try { + const channel = await client.channels.fetch(auction.channelId); + if (!channel) return; + + const message = await (channel as any).messages.fetch(auction.messageId); + if (!message) return; + + // Update embed to show auction ended + const endedEmbed = formatAuctionEmbed( + auction.itemName, + auction.currentBid, + auction.topBidder, + auction.topBidderName, + auction.endTime, + true + ); + + await message.edit({ + embeds: [endedEmbed], + components: [] // Remove buttons + }); + + // If there was a winner, notify them and deduct coins + if (auction.topBidder) { + try { + await supabaseService.removeCoins(auction.topBidder, auction.currentBid); + + const winner = await client.users.fetch(auction.topBidder); + await winner.send( + replacePlaceholders(FIELDS.CURRENT_BID, { + item: auction.itemName, + amount: auction.currentBid + }) + ); + + consoleLogger.transaction('SUBASTA GANADA', auction.currentBid, auction.topBidderName || 'Unknown'); + } catch (error) { + consoleLogger.error('SUBASTA', 'Error procesando ganador', error as Error); + } + } + + // Log auction end + const logger = getLogger(); + await logger.logTransaction({ + type: 'AUCTION_ENDED', + userId: auction.createdBy, + performedBy: auction.createdBy, + itemName: auction.itemName, + winnerId: auction.topBidder || undefined, + finalPrice: auction.currentBid + }); + + consoleLogger.success('SUBASTA', `Finalizada: ${auction.itemName}`); + } catch (error) { + consoleLogger.error('SUBASTA', 'Error finalizando subasta', error as Error); + } finally { + activeAuctions.delete(auctionId); + } +} diff --git a/commands/coins/export.ts b/commands/coins/export.ts new file mode 100644 index 0000000..b10412a --- /dev/null +++ b/commands/coins/export.ts @@ -0,0 +1,48 @@ +import { SlashCommandSubcommandBuilder, CommandInteraction } from 'discord.js'; +import { checkTeacherRole } from '../../utils/permissions'; +import supabaseService from '../../services/supabase'; +import n8nService from '../../services/n8n'; +import { ERRORS } from '../../config/strings'; + +export default { + data: new SlashCommandSubcommandBuilder() + .setName('export') + .setDescription('Exportar datos a n8n (solo docentes)'), + + async execute(interaction: CommandInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: 64 // Ephemeral + }); + return; + } + + await interaction.deferReply({ flags: 64 }); // Ephemeral + + // Get all user data + const allUsers = await supabaseService.getAllUsers(); + + // Trigger n8n webhook and get the returned URL + const result = await n8nService.exportToN8n({ + timestamp: new Date().toISOString(), + totalUsers: allUsers.length, + users: allUsers + }); + + // Extract URL from n8n response + const exportUrl = result?.url || result?.fileUrl || 'URL no disponible'; + + await interaction.editReply({ + content: `✅ Datos exportados exitosamente\n📎 **URL:** ${exportUrl}` + }); + } catch (error) { + console.error('Error in coins export command:', error); + await interaction.editReply({ + content: ERRORS.EXPORT_FAILED + }); + } + }, +}; diff --git a/commands/coins/get.ts b/commands/coins/get.ts new file mode 100644 index 0000000..e27b016 --- /dev/null +++ b/commands/coins/get.ts @@ -0,0 +1,55 @@ +import { SlashCommandSubcommandBuilder, EmbedBuilder, MessageFlags, CommandInteraction } from 'discord.js'; +import { ensureUserExists } from '../../utils/userManager'; +import supabaseService from '../../services/supabase'; +import { formatCoins } from '../../utils/formatting'; +import { EMBEDS, FIELDS, ERRORS } from '../../config/strings'; +import consoleLogger from '../../utils/consoleLogger'; + +export default { + data: new SlashCommandSubcommandBuilder() + .setName('get') + .setDescription('Ver tu balance de monedas'), + + async execute(interaction: CommandInteraction) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + // Ensure user exists + const user = await ensureUserExists(interaction.user.id, interaction.user.username); + + // Get user rank + const rank = await supabaseService.getUserRank(interaction.user.id); + + // Create embed + const embed = new EmbedBuilder() + .setTitle(EMBEDS.BALANCE_TITLE) + .setColor(0xFFD700) // Gold + .addFields( + { + name: FIELDS.CURRENT_BALANCE, + value: formatCoins(user.amount), + inline: true + }, + { + name: FIELDS.RANK, + value: `**#${rank}**`, + inline: true + }, + { + name: FIELDS.NIVEL, + value: user.curso ? `**${user.curso}**` : '*No configurado*', + inline: true + } + ) + .setTimestamp() + .setFooter({ text: `Usuario: ${interaction.user.username}` }); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + consoleLogger.error('COMANDO', 'Error en coins get', error as Error); + await interaction.editReply({ + content: ERRORS.DATABASE_ERROR + }); + } + }, +}; diff --git a/commands/coins/remove.ts b/commands/coins/remove.ts new file mode 100644 index 0000000..a1bf2f0 --- /dev/null +++ b/commands/coins/remove.ts @@ -0,0 +1,104 @@ +import { SlashCommandSubcommandBuilder, MessageFlags, CommandInteraction } from 'discord.js'; +import { ensureUserExists } from '../../utils/userManager'; +import { checkTeacherRole } from '../../utils/permissions'; +import supabaseService from '../../services/supabase'; +import { replacePlaceholders } from '../../utils/formatting'; +import { SUCCESS, ERRORS, DM, PLACEHOLDERS } from '../../config/strings'; +import { getLogger } from '../../utils/logger'; +import consoleLogger from '../../utils/consoleLogger'; + +export default { + data: new SlashCommandSubcommandBuilder() + .setName('remove') + .setDescription('Remover monedas de un usuario (solo docentes)') + .addUserOption(option => + option.setName('user') + .setDescription('Usuario del que remover monedas') + .setRequired(true)) + .addIntegerOption(option => + option.setName('amount') + .setDescription('Cantidad de monedas a remover') + .setRequired(true) + .setMinValue(1)) + .addStringOption(option => + option.setName('reason') + .setDescription('Razón de la deducción') + .setRequired(false) + .setMaxLength(200)), + + async execute(interaction: CommandInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.deferReply(); + + const targetUser = interaction.options.getUser('user', true); + const amount = interaction.options.get('amount', true).value as number; + const reason = interaction.options.get('reason')?.value as string || PLACEHOLDERS.NOT_SPECIFIED; + + // Ensure target user exists + await ensureUserExists(targetUser.id, targetUser.username); + + // Get current balance + const user = await supabaseService.getUser(targetUser.id); + + if (user && user.amount < amount) { + const errorMsg = replacePlaceholders(ERRORS.INSUFFICIENT_USER_COINS, { + user: targetUser.username, + current: user.amount, + amount: amount + }); + await interaction.editReply(errorMsg); + return; + } + + // Remove coins + await supabaseService.removeCoins(targetUser.id, amount); + + // Log transaction + const logger = getLogger(); + await logger.logTransaction({ + type: 'COINS_REMOVED', + userId: targetUser.id, + username: targetUser.username, + amount: amount, + reason: reason, + performedBy: interaction.user.id, + nivel: user?.curso || 'No configurado' + }); + + consoleLogger.transaction('REMOVER', amount, targetUser.tag); + + // Send response + const response = replacePlaceholders(SUCCESS.COINS_REMOVED, { + amount: amount, + user: targetUser.toString() + }); + + await interaction.editReply(response); + + // Try to DM the user + try { + const dmMessage = replacePlaceholders(DM.COINS_DEDUCTED, { + amount: amount, + reason: reason + }); + await targetUser.send(dmMessage); + } catch (error) { + consoleLogger.warn('DM', 'No se pudo enviar DM al usuario'); + } + } catch (error) { + consoleLogger.error('COMANDO', 'Error en coins remove', error as Error); + await interaction.editReply({ + content: ERRORS.DATABASE_ERROR + }); + } + }, +}; diff --git a/commands/coins/request.ts b/commands/coins/request.ts new file mode 100644 index 0000000..e5775ea --- /dev/null +++ b/commands/coins/request.ts @@ -0,0 +1,75 @@ +import { SlashCommandSubcommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, CommandInteraction } from 'discord.js'; +import { ensureUserExists } from '../../utils/userManager'; +import * as pendingAttachments from '../../utils/pendingAttachments'; +import { MODALS, ERRORS } from '../../config/strings'; + +export default { + data: new SlashCommandSubcommandBuilder() + .setName('request') + .setDescription('Solicitar monedas a los docentes') + .addAttachmentOption(option => + option + .setName('attachment') + .setDescription('Adjunto de referencia (archivo) - Opcional') + .setRequired(false) + ), + + async execute(interaction: CommandInteraction) { + try { + // Ensure user exists + await ensureUserExists(interaction.user.id, interaction.user.username); + + // If the user provided an attachment option with the command, store it + // so the modal submit handler can access it. + const attachmentOption = interaction.options.get('attachment'); + if (attachmentOption && attachmentOption.attachment) { + pendingAttachments.set(interaction.user.id, attachmentOption.attachment as any); + } + + // Create modal + const modal = new ModalBuilder() + .setCustomId(`coinrequest_${interaction.user.id}`) + .setTitle(MODALS.REQUEST_TITLE); + + // Reason field + const reasonInput = new TextInputBuilder() + .setCustomId('reason') + .setLabel('Razón') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(100); + + // Amount field + const amountInput = new TextInputBuilder() + .setCustomId('amount') + .setLabel('Cantidad') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder('Ejemplo: 100') + .setMinLength(1) + .setMaxLength(4); + + // Description field + const descriptionInput = new TextInputBuilder() + .setCustomId('description') + .setLabel('Descripción adicional') + .setStyle(TextInputStyle.Paragraph) + .setRequired(false) + .setMaxLength(500); + + const firstRow = new ActionRowBuilder().addComponents(reasonInput); + const secondRow = new ActionRowBuilder().addComponents(amountInput); + const thirdRow = new ActionRowBuilder().addComponents(descriptionInput); + + modal.addComponents(firstRow, secondRow, thirdRow); + + await interaction.showModal(modal); + } catch (error) { + console.error('Error in coins request command:', error); + await interaction.reply({ + content: ERRORS.DATABASE_ERROR, + flags: 64 // Ephemeral + }); + } + }, +}; diff --git a/commands/coins/reset.ts b/commands/coins/reset.ts new file mode 100644 index 0000000..804bb19 --- /dev/null +++ b/commands/coins/reset.ts @@ -0,0 +1,72 @@ +import { SlashCommandSubcommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction } from 'discord.js'; +import { checkTeacherRole } from '../../utils/permissions'; +import { BUTTONS, MODALS, ERRORS } from '../../config/strings'; + +export default { + data: new SlashCommandSubcommandBuilder() + .setName('reset') + .setDescription('Resetear monedas (solo docentes)') + .addUserOption(option => + option.setName('user') + .setDescription('Usuario específico (opcional)') + .setRequired(false)), + + async execute(interaction: CommandInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: 64 // Ephemeral + }); + return; + } + + const targetUser = interaction.options.getUser('user'); + + // If specific user + if (targetUser) { + const confirmButton = new ButtonBuilder() + .setCustomId(`confirmreset_user_${targetUser.id}`) + .setLabel(BUTTONS.CONFIRM) + .setStyle(ButtonStyle.Danger); + + const cancelButton = new ButtonBuilder() + .setCustomId(`confirmreset_cancel`) + .setLabel(BUTTONS.CANCEL) + .setStyle(ButtonStyle.Secondary); + + const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); + + await interaction.reply({ + content: `⚠️ ¿Estás seguro de que quieres resetear las monedas de ${targetUser.username} a 0?`, + components: [row], + flags: 64 // Ephemeral + }); + } else { + // Full reset - show modal + const modal = new ModalBuilder() + .setCustomId(`resetconfirm_all`) + .setTitle(MODALS.RESET_CONFIRM_TITLE); + + const confirmInput = new TextInputBuilder() + .setCustomId('confirmation') + .setLabel('Escribe "CONFIRMAR RESET" para continuar') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(20); + + const row = new ActionRowBuilder().addComponents(confirmInput); + modal.addComponents(row); + + await interaction.showModal(modal); + } + } catch (error) { + console.error('Error in coins reset command:', error); + await interaction.reply({ + content: ERRORS.DATABASE_ERROR, + flags: 64 // Ephemeral + }); + } + }, +}; diff --git a/commands/coins/top.ts b/commands/coins/top.ts new file mode 100644 index 0000000..6e01b82 --- /dev/null +++ b/commands/coins/top.ts @@ -0,0 +1,53 @@ +import { SlashCommandSubcommandBuilder, EmbedBuilder, CommandInteraction } from 'discord.js'; +import supabaseService from '../../services/supabase'; +import { formatTopUsersList, formatNumber } from '../../utils/formatting'; +import { EMBEDS, ERRORS } from '../../config/strings'; + +export default { + data: new SlashCommandSubcommandBuilder() + .setName('top') + .setDescription('Ver el ranking de monedas'), + + async execute(interaction: CommandInteraction) { + try { + await interaction.deferReply(); + + // Get top 10 users + const topUsers = await supabaseService.getTopUsers(10); + + if (topUsers.length === 0) { + await interaction.editReply('No hay usuarios en el ranking todavía.'); + return; + } + + // Get current user's rank + const userRank = await supabaseService.getUserRank(interaction.user.id); + const user = await supabaseService.getUser(interaction.user.id); + + // Format top users list + const description = formatTopUsersList(topUsers); + + // Create embed + const embed = new EmbedBuilder() + .setTitle(EMBEDS.TOP_TITLE) + .setDescription(description) + .setColor(0x3498db); // Blue + + // Add footer if user is outside top 10 + if (userRank && userRank > 10) { + embed.setFooter({ + text: `Tu posición: #${userRank} con ${formatNumber(user?.amount || 0)} monedas` + }); + } + + embed.setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Error in coins top command:', error); + await interaction.editReply({ + content: ERRORS.DATABASE_ERROR + }); + } + }, +}; diff --git a/events/interactionCreate.ts b/events/interactionCreate.ts new file mode 100644 index 0000000..c829423 --- /dev/null +++ b/events/interactionCreate.ts @@ -0,0 +1,145 @@ +import { Events, MessageFlags, Interaction, CommandInteraction, ButtonInteraction, ModalSubmitInteraction } from 'discord.js'; +import fs from 'fs'; +import path from 'path'; +import consoleLogger from '../utils/consoleLogger'; + +interface CommandHandler { + execute: (interaction: CommandInteraction) => Promise; +} + +interface ButtonHandler { + customId: string; + execute: (interaction: ButtonInteraction) => Promise; +} + +interface ModalHandler { + customId: string; + execute: (interaction: ModalSubmitInteraction) => Promise; +} + +// Load button handlers +const buttonHandlers = new Map(); +const buttonPath = path.join(__dirname, '../interactions/buttons'); +if (fs.existsSync(buttonPath)) { + const buttonFiles = fs.readdirSync(buttonPath).filter(file => file.endsWith('.js') || file.endsWith('.ts')); + for (const file of buttonFiles) { + const handler = require(path.join(buttonPath, file)); + const handlerData = handler.default || handler; + if (handlerData.customId && handlerData.execute) { + buttonHandlers.set(handlerData.customId, handlerData); + } + } +} + +// Load modal handlers +const modalHandlers = new Map(); +const modalPath = path.join(__dirname, '../interactions/modals'); +if (fs.existsSync(modalPath)) { + const modalFiles = fs.readdirSync(modalPath).filter(file => file.endsWith('.js') || file.endsWith('.ts')); + for (const file of modalFiles) { + const handler = require(path.join(modalPath, file)); + const handlerData = handler.default || handler; + if (handlerData.customId && handlerData.execute) { + modalHandlers.set(handlerData.customId, handlerData); + } + } +} + +export default { + name: Events.InteractionCreate, + async execute(interaction: Interaction) { + // Handle slash commands + if (interaction.isChatInputCommand()) { + const commandName = interaction.commandName; + const subcommand = interaction.options.getSubcommand(false); + + let command: CommandHandler | undefined; + if (subcommand) { + command = (interaction.client as any).commands.get(`${commandName}_${subcommand}`); + } else { + command = (interaction.client as any).commands.get(`${commandName}_create`); + if (!command) { + command = (interaction.client as any).commands.get(commandName); + } + } + + if (!command) { + consoleLogger.error('COMANDO', `No se encontró el comando: ${commandName}`); + return; + } + + const fullCommand = subcommand ? `${commandName} ${subcommand}` : commandName; + consoleLogger.command(fullCommand, interaction.user.tag, interaction.guild?.name || 'DM'); + + try { + await command.execute(interaction); + } catch (error) { + consoleLogger.error('COMANDO', `Error ejecutando /${fullCommand}`, error as Error); + + const errorMessage = '`❌` Hubo un error al ejecutar este comando.'; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: errorMessage, flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ content: errorMessage, flags: MessageFlags.Ephemeral }); + } + } + } + // Handle button interactions + else if (interaction.isButton()) { + const customId = interaction.customId; + const baseId = customId.split('_')[0]; + + const handler = buttonHandlers.get(baseId); + + if (!handler) { + consoleLogger.error('BOTÓN', `No se encontró el manejador para: ${customId}`); + return; + } + + consoleLogger.interaction('BOTÓN', interaction.user.tag, customId); + + try { + await handler.execute(interaction); + } catch (error) { + consoleLogger.error('BOTÓN', `Error procesando botón: ${customId}`, error as Error); + + const errorMessage = '`❌` Hubo un error al procesar esta acción.'; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: errorMessage, flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ content: errorMessage, flags: MessageFlags.Ephemeral }); + } + } + } + // Handle modal submissions + else if (interaction.isModalSubmit()) { + const customId = interaction.customId; + const baseId = customId.split('_')[0]; + + const handler = modalHandlers.get(baseId); + + if (!handler) { + consoleLogger.error('MODAL', `No se encontró el manejador para: ${customId}`); + return; + } + + consoleLogger.interaction('MODAL', interaction.user.tag, customId); + + try { + await handler.execute(interaction); + } catch (error) { + consoleLogger.error('MODAL', `Error procesando formulario: ${customId}`, error as Error); + + const errorMessage = '`❌` Hubo un error al procesar este formulario.'; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: errorMessage, flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ content: errorMessage, flags: MessageFlags.Ephemeral }); + } + } + } + }, +}; diff --git a/events/ready.ts b/events/ready.ts new file mode 100644 index 0000000..11da7ef --- /dev/null +++ b/events/ready.ts @@ -0,0 +1,29 @@ +import { Events, Client } from 'discord.js'; +import { initLogger } from '../utils/logger'; +import consoleLogger from '../utils/consoleLogger'; +import { version as discordVersion } from 'discord.js'; + +export default { + name: Events.ClientReady, + once: true, + execute(client: Client) { + // Initialize logger + initLogger(client); + + // Display system info + consoleLogger.systemInfo({ + 'Bot': client.user!.tag, + 'ID': client.user!.id, + 'Servidores': client.guilds.cache.size, + 'Usuarios': client.users.cache.size, + 'Node.js': process.version, + 'Discord.js': discordVersion + }); + + consoleLogger.success('SISTEMA', 'Logger de transacciones inicializado'); + consoleLogger.success('BOT', '¡Bot completamente operativo y listo para usar!'); + + // Set bot status + client.user!.setActivity('¡Usa /coins get!', { type: 3 }); // 3 = WATCHING + }, +}; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..5a145a6 --- /dev/null +++ b/index.ts @@ -0,0 +1,179 @@ +import { Client, GatewayIntentBits, Collection, REST, Routes, SlashCommandBuilder } from 'discord.js'; +import fs from 'fs'; +import path from 'path'; +import config from './config/config'; +import { initLogger } from './utils/logger'; +import consoleLogger from './utils/consoleLogger'; + +// Extend Client type to include commands +declare module 'discord.js' { + export interface Client { + commands: Collection; + } +} + +// Print startup banner +consoleLogger.banner('REACTIFY BOT'); + +// Create Discord client +consoleLogger.startup('Inicializando cliente de Discord...'); +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMembers, + ] +}); + +// Initialize command collections +client.commands = new Collection(); + +// Load command files +function loadCommands(dir: string[], commandPath: string[] = []): void { + const fullPath = path.join(__dirname, 'commands', ...dir); + + if (!fs.existsSync(fullPath)) { + return; + } + + const files = fs.readdirSync(fullPath); + + for (const file of files) { + const filePath = path.join(fullPath, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + loadCommands([...dir, file], [...commandPath, file]); + } else if (file.endsWith('.js') || file.endsWith('.ts')) { + const command = require(filePath); + const commandData = command.default || command; + if (commandData.data && commandData.execute) { + const commandName = [...commandPath, path.parse(file).name].join('_'); + client.commands.set(commandName, commandData); + consoleLogger.success('COMANDO', `Cargado: /${commandName.replace('_', ' ')}`); + } + } + } +} + +// Load all commands recursively from the `commands` folder +consoleLogger.info('SISTEMA', 'Cargando comandos...'); +loadCommands([]); + +// Build commands payload for registration (mirrors register-commands.js) +function buildCommandsPayload(): any[] { + const commands: any[] = []; + const commandsDir = path.join(__dirname, 'commands'); + + if (!fs.existsSync(commandsDir)) return commands; + + const entries = fs.readdirSync(commandsDir, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(commandsDir, entry.name); + + if (entry.isDirectory()) { + const files = fs.readdirSync(entryPath).filter(f => f.endsWith('.js') || f.endsWith('.ts')); + const subcommandOptions: any[] = []; + let pushedTopLevel = false; + + for (const file of files) { + const mod = require(path.join(entryPath, file)); + const modData = mod.default || mod; + if (!modData || !modData.data) continue; + + const ctor = modData.data.constructor && modData.data.constructor.name; + + if (ctor === 'SlashCommandBuilder') { + commands.push(modData.data.toJSON()); + pushedTopLevel = true; + } else if (ctor === 'SlashCommandSubcommandBuilder') { + subcommandOptions.push(modData.data.toJSON()); + } else { + try { + const json = modData.data.toJSON(); + if (json.type === 1) subcommandOptions.push(json); + else commands.push(json); + } catch (err) { + // ignore unknown exports + } + } + } + + if (subcommandOptions.length > 0 && !pushedTopLevel) { + const parent = new SlashCommandBuilder() + .setName(entry.name) + .setDescription(`Comandos de ${entry.name}`); + + const parentJson: any = parent.toJSON(); + parentJson.options = subcommandOptions; + commands.push(parentJson); + } + } else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.ts'))) { + const mod = require(entryPath); + const modData = mod.default || mod; + if (modData && modData.data) { + try { + commands.push(modData.data.toJSON()); + } catch (err) { + // ignore + } + } + } + } + + return commands; +} + +// Load event handlers +const eventsPath = path.join(__dirname, 'events'); +const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js') || file.endsWith('.ts')); + +consoleLogger.info('SISTEMA', 'Cargando manejadores de eventos...'); +for (const file of eventFiles) { + const filePath = path.join(eventsPath, file); + const event = require(filePath); + const eventData = event.default || event; + + if (eventData.once) { + client.once(eventData.name, (...args: any[]) => eventData.execute(...args)); + } else { + client.on(eventData.name, (...args: any[]) => eventData.execute(...args)); + } + consoleLogger.success('EVENTO', `Registrado: ${eventData.name}`); +} + +// Login to Discord (and register commands automatically) +consoleLogger.startup('Conectando con Discord...'); + +// Register application commands automatically unless SKIP_REGISTRATION=1 +(async () => { + try { + const skip = process.env.SKIP_REGISTRATION === '1'; + const applicationId = process.env.APPLICATION_ID; + + if (skip) { + consoleLogger.info('SISTEMA', 'SKIP_REGISTRATION=1 -> construcción de comandos (no registro)'); + const built = buildCommandsPayload(); + consoleLogger.info('SISTEMA', JSON.stringify(built, null, 2)); + } else { + if (!applicationId) { + consoleLogger.error('SISTEMA', '❌ APPLICATION_ID no está configurado en .env; omitiendo registro de comandos'); + } else { + consoleLogger.startup('SISTEMA', 'Registrando comandos slash...'); + const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN || process.env.DISCORD_TOKEN!); + const commandsPayload = buildCommandsPayload(); + await rest.put( + Routes.applicationCommands(applicationId), + { body: commandsPayload } + ); + consoleLogger.success('SISTEMA', '✅ Comandos registrados exitosamente'); + } + } + } catch (error) { + consoleLogger.error('SISTEMA', `❌ Error al registrar comandos: ${(error as Error).message || error}`); + } finally { + // Start the client regardless of registration outcome + client.login(config.DISCORD_TOKEN); + } +})(); From cefccc033dc380bc602f16073bf82325ee2b1a66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:05:46 +0000 Subject: [PATCH 4/6] Migrate all interaction handlers (buttons and modals) to TypeScript Co-authored-by: siramong <51140436+siramong@users.noreply.github.com> --- interactions/buttons/auction.ts | 56 +++++++ interactions/buttons/coinRequest.ts | 100 ++++++++++++ interactions/buttons/confirmReset.ts | 64 ++++++++ interactions/buttons/denyRequest.ts | 83 ++++++++++ interactions/buttons/endAuction.ts | 38 +++++ interactions/modals/activityCreate.ts | 225 ++++++++++++++++++++++++++ interactions/modals/bidAmount.ts | 113 +++++++++++++ interactions/modals/coinRequest.ts | 129 +++++++++++++++ interactions/modals/resetConfirm.ts | 33 ++++ 9 files changed, 841 insertions(+) create mode 100644 interactions/buttons/auction.ts create mode 100644 interactions/buttons/coinRequest.ts create mode 100644 interactions/buttons/confirmReset.ts create mode 100644 interactions/buttons/denyRequest.ts create mode 100644 interactions/buttons/endAuction.ts create mode 100644 interactions/modals/activityCreate.ts create mode 100644 interactions/modals/bidAmount.ts create mode 100644 interactions/modals/coinRequest.ts create mode 100644 interactions/modals/resetConfirm.ts diff --git a/interactions/buttons/auction.ts b/interactions/buttons/auction.ts new file mode 100644 index 0000000..824df94 --- /dev/null +++ b/interactions/buttons/auction.ts @@ -0,0 +1,56 @@ +import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonInteraction } from 'discord.js'; +import { ensureUserExists } from '../../utils/userManager'; +import { checkTeacherRole } from '../../utils/permissions'; +import bidCommand from '../../commands/coins/bid'; +import { MODALS, ERRORS } from '../../config/strings'; + +export default { + customId: 'auction', + async execute(interaction: ButtonInteraction) { + try { + const parts = interaction.customId.split('_'); + const action = parts[0]; + const auctionId = parts.slice(1).join('_'); + + if (action === 'auction') { + // Ensure user exists + await ensureUserExists(interaction.user.id, interaction.user.username); + + // Get auction data + const auction = bidCommand.activeAuctions.get(auctionId); + + if (!auction) { + await interaction.reply({ + content: '❌ Esta subasta no existe o ha finalizado.', + flags: 64 // Ephemeral + }); + return; + } + + // Show modal for bid amount + const modal = new ModalBuilder() + .setCustomId(`bidamount_${auctionId}`) + .setTitle(MODALS.BID_TITLE); + + const amountInput = new TextInputBuilder() + .setCustomId('amount') + .setLabel(`Cantidad (mínimo: ${auction.currentBid + 10} monedas)`) + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder(`${auction.currentBid + 10}`) + .setMaxLength(6); + + const row = new ActionRowBuilder().addComponents(amountInput); + modal.addComponents(row); + + await interaction.showModal(modal); + } + } catch (error) { + console.error('Error in auction button handler:', error); + await interaction.reply({ + content: ERRORS.DATABASE_ERROR, + flags: 64 // Ephemeral + }); + } + }, +}; diff --git a/interactions/buttons/coinRequest.ts b/interactions/buttons/coinRequest.ts new file mode 100644 index 0000000..2802b99 --- /dev/null +++ b/interactions/buttons/coinRequest.ts @@ -0,0 +1,100 @@ +import { EmbedBuilder, MessageFlags, ButtonInteraction } from 'discord.js'; +import supabaseService from '../../services/supabase'; +import { checkTeacherRole } from '../../utils/permissions'; +import { replacePlaceholders } from '../../utils/formatting'; +import { FIELDS, PLACEHOLDERS, ERRORS, DM } from '../../config/strings'; +import { getLogger } from '../../utils/logger'; +import consoleLogger from '../../utils/consoleLogger'; + +export default { + customId: 'approve', + async execute(interaction: ButtonInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.deferUpdate(); + + // Get embed data + const embed = interaction.message.embeds[0]; + const fields = embed.fields; + + // Extract user ID and amount from embed + const userField = fields.find(f => f.name === FIELDS.USER); + const amountField = fields.find(f => f.name === FIELDS.AMOUNT); + const nivelField = fields.find(f => f.name === FIELDS.NIVEL); + + const userMention = userField?.value || ''; + const userId = userMention.match(/<@(\d+)>/)?.[1]; + const amount = parseInt(amountField?.value || '0'); + const nivel = nivelField?.value || 'No configurado'; + + if (!userId || !amount) { + await interaction.followUp({ + content: '`❌` Error al procesar la solicitud.', + flags: MessageFlags.Ephemeral + }); + return; + } + + // Add coins to user + await supabaseService.addCoins(userId, amount); + + // Get reason for logging + const reasonField = fields.find(f => f.name === FIELDS.REASON); + const reason = reasonField?.value || PLACEHOLDERS.NOT_SPECIFIED; + + // Log transaction + const logger = getLogger(); + await logger.logTransaction({ + type: 'REQUEST_APPROVED', + userId: userId, + amount: amount, + reason: reason, + performedBy: interaction.user.id + }); + + consoleLogger.transaction('SOLICITUD APROBADA', amount, userId); + + // Update embed to show approved + const updatedEmbed = EmbedBuilder.from(embed) + .setColor(0x2ecc71) // Green + .setFooter({ text: `Aprobado por ${interaction.user.username}` }); + + await interaction.message.edit({ + embeds: [updatedEmbed], + components: [] // Remove buttons + }); + + // Try to DM the user + try { + const user = await interaction.client.users.fetch(userId); + + const dmMessage = replacePlaceholders(DM.COINS_RECEIVED, { + amount: amount, + reason: reason + }); + await user.send(dmMessage); + } catch (error) { + consoleLogger.warn('DM', 'No se pudo enviar DM al usuario'); + } + + await interaction.followUp({ + content: '`✅` Solicitud aprobada', + flags: MessageFlags.Ephemeral + }); + } catch (error) { + consoleLogger.error('BOTÓN', 'Error en botón de aprobar', error as Error); + await interaction.followUp({ + content: ERRORS.DATABASE_ERROR, + flags: MessageFlags.Ephemeral + }); + } + }, +}; diff --git a/interactions/buttons/confirmReset.ts b/interactions/buttons/confirmReset.ts new file mode 100644 index 0000000..98ea4f5 --- /dev/null +++ b/interactions/buttons/confirmReset.ts @@ -0,0 +1,64 @@ +import { ButtonInteraction } from 'discord.js'; +import { checkTeacherRole } from '../../utils/permissions'; +import supabaseService from '../../services/supabase'; +import { replacePlaceholders } from '../../utils/formatting'; +import { SUCCESS, ERRORS } from '../../config/strings'; + +export default { + customId: 'confirmreset', + async execute(interaction: ButtonInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: 64 // Ephemeral + }); + return; + } + + const parts = interaction.customId.split('_'); + const action = parts[1]; // 'user' or 'cancel' + + if (action === 'cancel') { + await interaction.update({ + content: '❌ Reset cancelado', + components: [] + }); + return; + } + + if (action === 'user') { + await interaction.deferUpdate(); + + const userId = parts[2]; + + // Reset user coins + await supabaseService.resetUserCoins(userId); + + try { + const user = await interaction.client.users.fetch(userId); + const response = replacePlaceholders(SUCCESS.COINS_RESET_USER, { + user: user.username + }); + + await interaction.editReply({ + content: response, + components: [] + }); + } catch (error) { + await interaction.editReply({ + content: '✅ Las monedas del usuario han sido reseteadas a 0', + components: [] + }); + } + } + } catch (error) { + console.error('Error in confirm reset button handler:', error); + await interaction.followUp({ + content: ERRORS.DATABASE_ERROR, + flags: 64 // Ephemeral + }); + } + }, +}; diff --git a/interactions/buttons/denyRequest.ts b/interactions/buttons/denyRequest.ts new file mode 100644 index 0000000..c5f5c0a --- /dev/null +++ b/interactions/buttons/denyRequest.ts @@ -0,0 +1,83 @@ +import { EmbedBuilder, MessageFlags, ButtonInteraction } from 'discord.js'; +import { checkTeacherRole } from '../../utils/permissions'; +import { FIELDS, ERRORS } from '../../config/strings'; +import { getLogger } from '../../utils/logger'; +import consoleLogger from '../../utils/consoleLogger'; + +export default { + customId: 'deny', + async execute(interaction: ButtonInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.deferUpdate(); + + // Get embed data + const embed = interaction.message.embeds[0]; + const fields = embed.fields; + + // Extract user ID and amount from embed + const userField = fields.find(f => f.name === FIELDS.USER); + const amountField = fields.find(f => f.name === FIELDS.AMOUNT); + + const userMention = userField?.value || ''; + const userId = userMention.match(/<@(\d+)>/)?.[1]; + const amount = parseInt(amountField?.value || '0'); + + if (!userId) { + await interaction.followUp({ + content: '`❌` Error al procesar la solicitud.', + flags: MessageFlags.Ephemeral + }); + return; + } + + // Log transaction + const logger = getLogger(); + await logger.logTransaction({ + type: 'REQUEST_DENIED', + userId: userId, + amount: amount, + performedBy: interaction.user.id + }); + + consoleLogger.info('SOLICITUD', `Rechazada: ${amount} monedas - Usuario: ${userId}`); + + // Update embed to show denied + const updatedEmbed = EmbedBuilder.from(embed) + .setColor(0xe74c3c) // Red + .setFooter({ text: `Rechazado por ${interaction.user.username}` }); + + await interaction.message.edit({ + embeds: [updatedEmbed], + components: [] // Remove buttons + }); + + // Try to DM the user + try { + const user = await interaction.client.users.fetch(userId); + await user.send(`\`❌\` Tu solicitud de ${amount} monedas ha sido rechazada.`); + } catch (error) { + consoleLogger.warn('DM', 'No se pudo enviar DM al usuario'); + } + + await interaction.followUp({ + content: '`✅` Solicitud rechazada', + flags: MessageFlags.Ephemeral + }); + } catch (error) { + consoleLogger.error('BOTÓN', 'Error en botón de rechazar', error as Error); + await interaction.followUp({ + content: ERRORS.DATABASE_ERROR, + flags: MessageFlags.Ephemeral + }); + } + }, +}; diff --git a/interactions/buttons/endAuction.ts b/interactions/buttons/endAuction.ts new file mode 100644 index 0000000..188e14e --- /dev/null +++ b/interactions/buttons/endAuction.ts @@ -0,0 +1,38 @@ +import { ButtonInteraction } from 'discord.js'; +import { checkTeacherRole } from '../../utils/permissions'; +import bidCommand from '../../commands/coins/bid'; +import { ERRORS } from '../../config/strings'; + +export default { + customId: 'endauction', + async execute(interaction: ButtonInteraction) { + try { + // Check teacher permission + if (!checkTeacherRole(interaction.member as any)) { + await interaction.reply({ + content: ERRORS.NO_PERMISSION, + flags: 64 // Ephemeral + }); + return; + } + + await interaction.deferUpdate(); + + const auctionId = interaction.customId.split('_').slice(1).join('_'); + + // End auction + await bidCommand.endAuction(auctionId, interaction.client); + + await interaction.followUp({ + content: '✅ Subasta finalizada', + flags: 64 // Ephemeral + }); + } catch (error) { + console.error('Error in end auction button handler:', error); + await interaction.followUp({ + content: ERRORS.DATABASE_ERROR, + flags: 64 // Ephemeral + }); + } + }, +}; diff --git a/interactions/modals/activityCreate.ts b/interactions/modals/activityCreate.ts new file mode 100644 index 0000000..0efa85d --- /dev/null +++ b/interactions/modals/activityCreate.ts @@ -0,0 +1,225 @@ +import { EmbedBuilder, StringSelectMenuBuilder, ActionRowBuilder, MessageFlags, ModalSubmitInteraction, ForumChannel, Message } from 'discord.js'; +import supabaseService from '../../services/supabase'; +import openrouterService from '../../services/openrouter'; +import config from '../../config/config'; +import { CURSOS, Curso } from '../../config/enums'; +import { replacePlaceholders } from '../../utils/formatting'; +import { EMBEDS, FIELDS, SUCCESS, ERRORS, INFO } from '../../config/strings'; +import { getLogger } from '../../utils/logger'; +import consoleLogger from '../../utils/consoleLogger'; +import * as pendingAttachments from '../../utils/pendingAttachments'; + +interface PendingActivity { + title: string; + description: string; + documentation: string; + attachment: string | null; + reward: number; + teacherId: string; +} + +// Store pending activities that need curso selection +const pendingActivities = new Map(); + +export default { + customId: 'activitycreate', + async execute(interaction: ModalSubmitInteraction) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + // Get form values + const title = interaction.fields.getTextInputValue('title'); + const description = interaction.fields.getTextInputValue('description'); + const documentation = interaction.fields.getTextInputValue('documentation'); + let fieldsAttachment: string | null = null; + try { + fieldsAttachment = interaction.fields.getTextInputValue('attachment'); + } catch (e) { + fieldsAttachment = null; + } + + const storedAttachment = pendingAttachments.getAndDelete(interaction.user.id); + const attachment = fieldsAttachment || (storedAttachment ? String(storedAttachment) : null); + const rewardStr = interaction.fields.getTextInputValue('reward') || '0'; + + // Parse reward + const reward = parseInt(rewardStr) || 0; + + // Show curso selection menu + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`activitycurso_${interaction.user.id}_${Date.now()}`) + .setPlaceholder('Selecciona el curso exacto') + .addOptions( + CURSOS.map(curso => ({ + label: curso, + value: curso + })) + ); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + // Store activity data + const activityId = `${interaction.user.id}_${Date.now()}`; + pendingActivities.set(activityId, { + title, + description, + documentation, + attachment, + reward, + teacherId: interaction.user.id + }); + + await interaction.editReply({ + content: '📚 Selecciona el curso para esta actividad:', + components: [row] + }); + + // Wait for selection + const filter = (i: any) => i.customId.startsWith('activitycurso_') && i.user.id === interaction.user.id; + + try { + const selection = await interaction.channel!.awaitMessageComponent({ + filter, + time: 60000 + }); + + await selection.deferUpdate(); + + const curso = (selection as any).values[0] as Curso; + + // Get nivel from curso + const nivel = config.CURSO_TO_NIVEL[curso]; + if (!nivel) { + await interaction.editReply({ + content: `❌ No se pudo determinar el nivel para el curso ${curso}`, + components: [] + }); + return; + } + + // Update initial message + await interaction.editReply({ + content: INFO.GENERATING_AI, + components: [] + }); + + // Generate AI summary (using FREE model) + let aiSummary = ''; + try { + aiSummary = await openrouterService.summarizeDocumentation(documentation); + } catch (error) { + consoleLogger.error('OPENROUTER', 'Error generando resumen IA', error as Error); + aiSummary = 'No se pudo generar resumen automático.'; + } + + // Get forum channel for nivel + const forumChannelId = config.FORUM_CHANNELS[nivel]; + if (!forumChannelId) { + await interaction.editReply({ + content: `❌ No se encontró el canal del foro para ${nivel}`, + components: [] + }); + return; + } + + const forumChannel = await interaction.client.channels.fetch(forumChannelId) as ForumChannel; + + // Create thread in forum with format "Curso | Activity Title" + const threadName = `${curso} | ${title}`; + const thread = await forumChannel.threads.create({ + name: threadName, + message: { + content: `Nueva actividad creada por <@${interaction.user.id}>` + } + }); + + // Create activity embed + const embed = new EmbedBuilder() + .setTitle(replacePlaceholders(EMBEDS.ACTIVITY_TITLE, { title })) + .setDescription(description) + .setColor(0x9b59b6) // Purple + .addFields( + { + name: FIELDS.DOCUMENTATION, + value: documentation, + inline: false + }, + { + name: FIELDS.AI_SUMMARY, + value: aiSummary, + inline: false + } + ); + + if (attachment) { + embed.addFields({ + name: FIELDS.IMAGE, + value: attachment, + inline: false + }); + } + + if (reward > 0) { + embed.addFields({ + name: FIELDS.REWARD, + value: `${reward} monedas`, + inline: true + }); + } + + embed.setTimestamp(); + + // Send embed to thread + const threadMessage = await thread.send({ embeds: [embed] }) as Message; + + // Add reactions + await threadMessage.react('👀'); + await threadMessage.react('✅'); + + // Save to database with curso (exact course) + await supabaseService.createActivity(title, interaction.user.id, curso, thread.id); + + // Log activity creation + const logger = getLogger(); + await logger.logTransaction({ + type: 'ACTIVITY_CREATED', + userId: interaction.user.id, + title: title, + nivel: curso, + reward: reward, + performedBy: interaction.user.id + }); + + consoleLogger.info('ACTIVIDAD', `Creada: ${title} - Curso: ${curso} - Nivel: ${nivel}`); + + // Respond to teacher + const response = replacePlaceholders(SUCCESS.ACTIVITY_CREATED, { + title: title, + nivel: curso + }); + + await interaction.editReply({ + content: response + }); + + // Clean up + pendingActivities.delete(activityId); + } catch (error: any) { + if (error.message === 'Collector received no interactions before ending with reason: time') { + await interaction.editReply({ + content: '❌ Tiempo de espera agotado. Por favor, intenta de nuevo.', + components: [] + }); + } else { + throw error; + } + } + } catch (error) { + consoleLogger.error('MODAL', 'Error en creación de actividad', error as Error); + await interaction.editReply({ + content: ERRORS.DATABASE_ERROR, + components: [] + }); + } + }, +}; diff --git a/interactions/modals/bidAmount.ts b/interactions/modals/bidAmount.ts new file mode 100644 index 0000000..4904d58 --- /dev/null +++ b/interactions/modals/bidAmount.ts @@ -0,0 +1,113 @@ +import { EmbedBuilder, ModalSubmitInteraction, TextChannel, Message } from 'discord.js'; +import supabaseService from '../../services/supabase'; +import config from '../../config/config'; +import bidCommand from '../../commands/coins/bid'; +import { replacePlaceholders } from '../../utils/formatting'; +import { FIELDS, ERRORS, DM } from '../../config/strings'; + +export default { + customId: 'bidamount', + async execute(interaction: ModalSubmitInteraction) { + try { + await interaction.deferReply({ flags: 64 }); // Ephemeral + + const auctionId = interaction.customId.split('_').slice(1).join('_'); + const amountStr = interaction.fields.getTextInputValue('amount'); + + // Parse bid amount + const bidAmount = parseInt(amountStr); + + if (isNaN(bidAmount) || bidAmount <= 0) { + await interaction.editReply({ + content: ERRORS.INVALID_AMOUNT + }); + return; + } + + // Get auction data + const auction = bidCommand.activeAuctions.get(auctionId); + + if (!auction) { + await interaction.editReply({ + content: '❌ Esta subasta no existe o ha finalizado.' + }); + return; + } + + // Validate minimum bid + const minBid = auction.currentBid + config.BID_INCREMENT; + if (bidAmount < minBid) { + const errorMsg = replacePlaceholders(ERRORS.BID_TOO_LOW, { + minimum: minBid + }); + await interaction.editReply({ + content: errorMsg + }); + return; + } + + // Check user has sufficient coins + const user = await supabaseService.getUser(interaction.user.id); + if (!user || user.amount < bidAmount) { + await interaction.editReply({ + content: ERRORS.INSUFFICIENT_COINS + }); + return; + } + + // Store previous bidder + const previousBidder = auction.topBidder; + + // Update auction + auction.currentBid = bidAmount; + auction.topBidder = interaction.user.id; + auction.topBidderName = interaction.user.username; + + // Update auction message + try { + const channel = await interaction.client.channels.fetch(auction.channelId) as TextChannel; + const message = await channel.messages.fetch(auction.messageId) as Message; + + const updatedEmbed = EmbedBuilder.from(message.embeds[0]) + .spliceFields(0, 2, + { + name: FIELDS.CURRENT_BID, + value: `**${bidAmount} monedas**`, + inline: true + }, + { + name: FIELDS.TOP_BIDDER, + value: interaction.user.toString(), + inline: true + } + ); + + await message.edit({ embeds: [updatedEmbed] }); + } catch (error) { + console.error('Error updating auction message:', error); + } + + // Notify previous bidder + if (previousBidder && previousBidder !== interaction.user.id) { + try { + const prevUser = await interaction.client.users.fetch(previousBidder); + const dmMessage = replacePlaceholders(DM.AUCTION_OUTBID, { + item: auction.itemName + }); + await prevUser.send(dmMessage); + } catch (error) { + console.log('Could not send DM to previous bidder:', error); + } + } + + await interaction.editReply({ + content: `✅ ¡Puja realizada! Has pujado **${bidAmount} monedas** por **${auction.itemName}**` + }); + } catch (error) { + console.error('Error in bid amount modal handler:', error); + await interaction.editReply({ + content: ERRORS.DATABASE_ERROR + }); + } + }, +}; diff --git a/interactions/modals/coinRequest.ts b/interactions/modals/coinRequest.ts new file mode 100644 index 0000000..2e0c69a --- /dev/null +++ b/interactions/modals/coinRequest.ts @@ -0,0 +1,129 @@ +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, ModalSubmitInteraction, TextChannel } from 'discord.js'; +import supabaseService from '../../services/supabase'; +import config from '../../config/config'; +import { EMBEDS, FIELDS, BUTTONS, PLACEHOLDERS, SUCCESS, ERRORS } from '../../config/strings'; +import consoleLogger from '../../utils/consoleLogger'; +import * as pendingAttachments from '../../utils/pendingAttachments'; + +export default { + customId: 'coinrequest', + async execute(interaction: ModalSubmitInteraction) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + // Get form values + const reason = interaction.fields.getTextInputValue('reason'); + const amountStr = interaction.fields.getTextInputValue('amount'); + const description = interaction.fields.getTextInputValue('description') || PLACEHOLDERS.NONE; + let fieldsAttachment: string | null = null; + try { + fieldsAttachment = interaction.fields.getTextInputValue('attachment'); + } catch (e) { + fieldsAttachment = null; + } + + // If the modal no longer contains an attachment field, try the + // temporary in-memory store where the command may have saved it. + const storedAttachment = pendingAttachments.getAndDelete(interaction.user.id); + const attachment = fieldsAttachment || (storedAttachment ? String(storedAttachment) : null) || PLACEHOLDERS.NONE; + + // Parse amount + const amount = parseInt(amountStr); + + if (isNaN(amount) || amount < 1 || amount > 1000) { + await interaction.editReply({ + content: '`❌` La cantidad debe ser un número entre 1 y 1000.' + }); + return; + } + + // Get user data + const user = await supabaseService.getUser(interaction.user.id); + + // Create embed for teacher channel + const fields: any[] = [ + { + name: FIELDS.USER, + value: interaction.user.toString(), + inline: true + }, + { + name: FIELDS.AMOUNT, + value: `${amount}`, + inline: true + }, + { + name: FIELDS.NIVEL, + value: user?.curso || '*No configurado*', + inline: true + }, + { + name: FIELDS.REASON, + value: reason, + inline: false + }, + { + name: FIELDS.DESCRIPTION, + value: description, + inline: false + } + ]; + + // Only include proof field if there's a real attachment + const hasAttachment = attachment && attachment !== PLACEHOLDERS.NONE; + if (hasAttachment) { + fields.push({ + name: FIELDS.PROOF, + value: attachment, + inline: false + }); + } + + const embed = new EmbedBuilder() + .setTitle(EMBEDS.REQUEST_TITLE) + .setColor(0x3498db) // Blue + .addFields(fields) + .setTimestamp() + .setFooter({ text: `ID: ${interaction.user.id}` }); + + // Only set image if we have a real attachment URL + if (hasAttachment) { + embed.setImage(attachment!); + } + + // Create buttons + const requestId = `${Date.now()}_${interaction.user.id}`; + + const approveButton = new ButtonBuilder() + .setCustomId(`approve_${requestId}`) + .setLabel(BUTTONS.APPROVE) + .setStyle(ButtonStyle.Success); + + const denyButton = new ButtonBuilder() + .setCustomId(`deny_${requestId}`) + .setLabel(BUTTONS.DENY) + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(approveButton, denyButton); + + // Send to teacher channel + const teacherChannel = await interaction.client.channels.fetch(config.TEACHER_CHANNEL_ID!) as TextChannel; + await teacherChannel.send({ + embeds: [embed], + components: [row] + }); + + consoleLogger.info('SOLICITUD', `Monedas solicitadas: ${amount} por ${interaction.user.tag}`); + + // Respond to user + await interaction.editReply({ + content: SUCCESS.REQUEST_SENT + }); + } catch (error) { + consoleLogger.error('MODAL', 'Error en solicitud de monedas', error as Error); + await interaction.editReply({ + content: ERRORS.DATABASE_ERROR + }); + } + }, +}; diff --git a/interactions/modals/resetConfirm.ts b/interactions/modals/resetConfirm.ts new file mode 100644 index 0000000..e2a0943 --- /dev/null +++ b/interactions/modals/resetConfirm.ts @@ -0,0 +1,33 @@ +import { ModalSubmitInteraction } from 'discord.js'; +import supabaseService from '../../services/supabase'; +import { SUCCESS, ERRORS } from '../../config/strings'; + +export default { + customId: 'resetconfirm', + async execute(interaction: ModalSubmitInteraction) { + try { + await interaction.deferReply({ flags: 64 }); // Ephemeral + + const confirmation = interaction.fields.getTextInputValue('confirmation'); + + if (confirmation !== 'CONFIRMAR RESET') { + await interaction.editReply({ + content: '❌ Confirmación incorrecta. El reset ha sido cancelado.' + }); + return; + } + + // Reset all coins + await supabaseService.resetAllCoins(); + + await interaction.editReply({ + content: SUCCESS.COINS_RESET_ALL + }); + } catch (error) { + console.error('Error in reset confirm modal handler:', error); + await interaction.editReply({ + content: ERRORS.DATABASE_ERROR + }); + } + }, +}; From 5dfdfcd6e4e5e343ea50f13bd021023e1c59c151 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:07:59 +0000 Subject: [PATCH 5/6] Fix TypeScript compilation errors and successfully build the project Co-authored-by: siramong <51140436+siramong@users.noreply.github.com> --- commands/activity/create.ts | 4 ++-- commands/coins/add.ts | 4 ++-- commands/coins/bid.ts | 4 ++-- commands/coins/export.ts | 4 ++-- commands/coins/get.ts | 4 ++-- commands/coins/remove.ts | 4 ++-- commands/coins/request.ts | 4 ++-- commands/coins/reset.ts | 4 ++-- commands/coins/top.ts | 4 ++-- index.ts | 2 +- utils/formatting.ts | 6 +++--- 11 files changed, 22 insertions(+), 22 deletions(-) diff --git a/commands/activity/create.ts b/commands/activity/create.ts index b6433e2..7f467e6 100644 --- a/commands/activity/create.ts +++ b/commands/activity/create.ts @@ -1,4 +1,4 @@ -import { SlashCommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, CommandInteraction } from 'discord.js'; +import { SlashCommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ChatInputCommandInteraction } from 'discord.js'; import { checkTeacherRole } from '../../utils/permissions'; import * as pendingAttachments from '../../utils/pendingAttachments'; import { MODALS, ERRORS } from '../../config/strings'; @@ -14,7 +14,7 @@ export default { .setRequired(false) ), - async execute(interaction: CommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { try { // Check teacher permission if (!checkTeacherRole(interaction.member as any)) { diff --git a/commands/coins/add.ts b/commands/coins/add.ts index b26483f..702c2a7 100644 --- a/commands/coins/add.ts +++ b/commands/coins/add.ts @@ -1,4 +1,4 @@ -import { SlashCommandSubcommandBuilder, MessageFlags, CommandInteraction } from 'discord.js'; +import { SlashCommandSubcommandBuilder, MessageFlags, ChatInputCommandInteraction } from 'discord.js'; import { ensureUserExists } from '../../utils/userManager'; import { checkTeacherRole } from '../../utils/permissions'; import supabaseService from '../../services/supabase'; @@ -26,7 +26,7 @@ export default { .setRequired(false) .setMaxLength(200)), - async execute(interaction: CommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { try { // Check teacher permission if (!checkTeacherRole(interaction.member as any)) { diff --git a/commands/coins/bid.ts b/commands/coins/bid.ts index 6991fdc..412deb1 100644 --- a/commands/coins/bid.ts +++ b/commands/coins/bid.ts @@ -1,4 +1,4 @@ -import { SlashCommandSubcommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, MessageFlags, CommandInteraction, Message, StringSelectMenuInteraction } from 'discord.js'; +import { SlashCommandSubcommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, MessageFlags, ChatInputCommandInteraction, Message, StringSelectMenuInteraction } from 'discord.js'; import { checkTeacherRole } from '../../utils/permissions'; import { formatDuration, replacePlaceholders } from '../../utils/formatting'; import { NIVELES, Nivel } from '../../config/enums'; @@ -45,7 +45,7 @@ export default { .setMinValue(1) .setMaxValue(1440)), - async execute(interaction: CommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { try { // Check teacher permission if (!checkTeacherRole(interaction.member as any)) { diff --git a/commands/coins/export.ts b/commands/coins/export.ts index b10412a..a1adc31 100644 --- a/commands/coins/export.ts +++ b/commands/coins/export.ts @@ -1,4 +1,4 @@ -import { SlashCommandSubcommandBuilder, CommandInteraction } from 'discord.js'; +import { SlashCommandSubcommandBuilder, ChatInputCommandInteraction } from 'discord.js'; import { checkTeacherRole } from '../../utils/permissions'; import supabaseService from '../../services/supabase'; import n8nService from '../../services/n8n'; @@ -9,7 +9,7 @@ export default { .setName('export') .setDescription('Exportar datos a n8n (solo docentes)'), - async execute(interaction: CommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { try { // Check teacher permission if (!checkTeacherRole(interaction.member as any)) { diff --git a/commands/coins/get.ts b/commands/coins/get.ts index e27b016..93f73ec 100644 --- a/commands/coins/get.ts +++ b/commands/coins/get.ts @@ -1,4 +1,4 @@ -import { SlashCommandSubcommandBuilder, EmbedBuilder, MessageFlags, CommandInteraction } from 'discord.js'; +import { SlashCommandSubcommandBuilder, EmbedBuilder, MessageFlags, ChatInputCommandInteraction } from 'discord.js'; import { ensureUserExists } from '../../utils/userManager'; import supabaseService from '../../services/supabase'; import { formatCoins } from '../../utils/formatting'; @@ -10,7 +10,7 @@ export default { .setName('get') .setDescription('Ver tu balance de monedas'), - async execute(interaction: CommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { try { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); diff --git a/commands/coins/remove.ts b/commands/coins/remove.ts index a1bf2f0..7e19125 100644 --- a/commands/coins/remove.ts +++ b/commands/coins/remove.ts @@ -1,4 +1,4 @@ -import { SlashCommandSubcommandBuilder, MessageFlags, CommandInteraction } from 'discord.js'; +import { SlashCommandSubcommandBuilder, MessageFlags, ChatInputCommandInteraction } from 'discord.js'; import { ensureUserExists } from '../../utils/userManager'; import { checkTeacherRole } from '../../utils/permissions'; import supabaseService from '../../services/supabase'; @@ -26,7 +26,7 @@ export default { .setRequired(false) .setMaxLength(200)), - async execute(interaction: CommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { try { // Check teacher permission if (!checkTeacherRole(interaction.member as any)) { diff --git a/commands/coins/request.ts b/commands/coins/request.ts index e5775ea..d2ab621 100644 --- a/commands/coins/request.ts +++ b/commands/coins/request.ts @@ -1,4 +1,4 @@ -import { SlashCommandSubcommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, CommandInteraction } from 'discord.js'; +import { SlashCommandSubcommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ChatInputCommandInteraction } from 'discord.js'; import { ensureUserExists } from '../../utils/userManager'; import * as pendingAttachments from '../../utils/pendingAttachments'; import { MODALS, ERRORS } from '../../config/strings'; @@ -14,7 +14,7 @@ export default { .setRequired(false) ), - async execute(interaction: CommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { try { // Ensure user exists await ensureUserExists(interaction.user.id, interaction.user.username); diff --git a/commands/coins/reset.ts b/commands/coins/reset.ts index 804bb19..27f2e88 100644 --- a/commands/coins/reset.ts +++ b/commands/coins/reset.ts @@ -1,4 +1,4 @@ -import { SlashCommandSubcommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction } from 'discord.js'; +import { SlashCommandSubcommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction } from 'discord.js'; import { checkTeacherRole } from '../../utils/permissions'; import { BUTTONS, MODALS, ERRORS } from '../../config/strings'; @@ -11,7 +11,7 @@ export default { .setDescription('Usuario específico (opcional)') .setRequired(false)), - async execute(interaction: CommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { try { // Check teacher permission if (!checkTeacherRole(interaction.member as any)) { diff --git a/commands/coins/top.ts b/commands/coins/top.ts index 6e01b82..8a007d5 100644 --- a/commands/coins/top.ts +++ b/commands/coins/top.ts @@ -1,4 +1,4 @@ -import { SlashCommandSubcommandBuilder, EmbedBuilder, CommandInteraction } from 'discord.js'; +import { SlashCommandSubcommandBuilder, EmbedBuilder, ChatInputCommandInteraction } from 'discord.js'; import supabaseService from '../../services/supabase'; import { formatTopUsersList, formatNumber } from '../../utils/formatting'; import { EMBEDS, ERRORS } from '../../config/strings'; @@ -8,7 +8,7 @@ export default { .setName('top') .setDescription('Ver el ranking de monedas'), - async execute(interaction: CommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { try { await interaction.deferReply(); diff --git a/index.ts b/index.ts index 5a145a6..b1c2f77 100644 --- a/index.ts +++ b/index.ts @@ -160,7 +160,7 @@ consoleLogger.startup('Conectando con Discord...'); if (!applicationId) { consoleLogger.error('SISTEMA', '❌ APPLICATION_ID no está configurado en .env; omitiendo registro de comandos'); } else { - consoleLogger.startup('SISTEMA', 'Registrando comandos slash...'); + consoleLogger.info('SISTEMA', 'Registrando comandos slash...'); const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN || process.env.DISCORD_TOKEN!); const commandsPayload = buildCommandsPayload(); await rest.put( diff --git a/utils/formatting.ts b/utils/formatting.ts index b2551b4..3fefb45 100644 --- a/utils/formatting.ts +++ b/utils/formatting.ts @@ -1,7 +1,7 @@ -interface User { +interface TopUser { amount: number; username: string; - curso?: string; + curso?: string | null; } // Format number with thousands separator @@ -37,7 +37,7 @@ export function getRankEmoji(rank: number): string { } // Format top users list -export function formatTopUsersList(users: User[]): string { +export function formatTopUsersList(users: TopUser[]): string { return users.map((user, index) => { const rank = index + 1; const emoji = getRankEmoji(rank); From 8affbe7a3beb28305ac90913273eef9944ed2720 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:09:02 +0000 Subject: [PATCH 6/6] Update README with TypeScript migration documentation and build instructions Co-authored-by: siramong <51140436+siramong@users.noreply.github.com> --- README.md | 60 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index dbd2a0c..208ae55 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Bot de Discord para gamificación educativa con sistema de monedas virtuales. +**Nota**: Este proyecto está completamente escrito en **TypeScript** para mejor seguridad de tipos y mantenibilidad. + ## 📋 Características - **Sistema de Monedas**: Los estudiantes pueden ganar y gastar monedas virtuales @@ -16,6 +18,8 @@ Bot de Discord para gamificación educativa con sistema de monedas virtuales. ### Requisitos Previos - Node.js 16.x o superior +- npm o yarn +- TypeScript (se instala automáticamente con las dependencias) - Cuenta de Discord con permisos de desarrollador - Cuenta de Supabase - API Key de OpenRouter @@ -206,12 +210,28 @@ Ejecuta el script: node register-commands.js ``` -### Paso 6: Iniciar el Bot +### Paso 6: Construir el Proyecto + +El bot está escrito en TypeScript y debe ser compilado antes de ejecutarse: + +```bash +npm run build +``` + +Esto compilará todos los archivos TypeScript a JavaScript en el directorio `dist/`. + +### Paso 7: Iniciar el Bot ```bash npm start ``` +Para desarrollo con recarga automática: + +```bash +npm run dev +``` + ## 📚 Comandos Disponibles ### Comandos para Estudiantes @@ -233,21 +253,22 @@ npm start ``` reactify-bot/ -├── src/ -│ ├── index.js # Punto de entrada principal -│ ├── commands/ # Comandos slash -│ │ ├── coins/ # Comandos de monedas -│ │ └── activity/ # Comandos de actividades -│ ├── events/ # Manejadores de eventos -│ ├── interactions/ # Manejadores de interacciones -│ │ ├── buttons/ # Botones -│ │ └── modals/ # Modales -│ ├── services/ # Servicios externos -│ │ ├── supabase.js # Base de datos -│ │ ├── openrouter.js # IA -│ │ └── n8n.js # Webhooks -│ ├── utils/ # Utilidades -│ └── config/ # Configuración +├── commands/ # Comandos slash (TypeScript) +│ ├── coins/ # Comandos de monedas +│ └── activity/ # Comandos de actividades +├── events/ # Manejadores de eventos (TypeScript) +├── interactions/ # Manejadores de interacciones (TypeScript) +│ ├── buttons/ # Botones +│ └── modals/ # Modales +├── services/ # Servicios externos (TypeScript) +│ ├── supabase.ts # Base de datos +│ ├── openrouter.ts # IA +│ └── n8n.ts # Webhooks +├── utils/ # Utilidades (TypeScript) +├── config/ # Configuración (TypeScript) +├── dist/ # JavaScript compilado (generado) +├── index.ts # Punto de entrada principal +├── tsconfig.json # Configuración de TypeScript ├── package.json ├── .env.example └── README.md @@ -263,12 +284,19 @@ reactify-bot/ ## 🛠️ Tecnologías Utilizadas +- **TypeScript** - Lenguaje de programación con tipos estáticos - **Discord.js v14** - Librería para interactuar con Discord - **Supabase** - Base de datos PostgreSQL - **OpenRouter** - API de IA para resúmenes automáticos - **Axios** - Cliente HTTP para llamadas API - **dotenv** - Gestión de variables de entorno +## 🔧 Scripts Disponibles + +- `npm run build` - Compila TypeScript a JavaScript +- `npm start` - Construye y ejecuta el bot +- `npm run dev` - Ejecuta el bot en modo desarrollo con ts-node + ## 📝 Notas - Todas las respuestas del bot están en español