From f31ae7acc2636c07623dc7d1186f10cf860e0173 Mon Sep 17 00:00:00 2001 From: cut0x Date: Sat, 18 Oct 2025 08:14:08 +0200 Subject: [PATCH] Push structure V1 --- .env.exemple | 60 +++++ .gitignore | 2 + README.md | 98 +++++++- package.json | 29 +++ schema.sql | 172 +++++++++++++ schema_clean.sql | 131 ++++++++++ src/commands/info.js | 107 ++++++++ src/commands/ranking.js | 284 +++++++++++++++++++++ src/commands/sync.js | 238 ++++++++++++++++++ src/events/interactionCreate.js | 68 +++++ src/events/ready.js | 52 ++++ src/handlers/commandHandler.js | 55 +++++ src/handlers/componentHandler.js | 31 +++ src/handlers/eventHandler.js | 29 +++ src/services/clanRankingService.js | 311 +++++++++++++++++++++++ src/services/clashRoyaleService.js | 315 ++++++++++++++++++++++++ src/services/rankingService.js | 260 ++++++++++++++++++++ src/services/trophyService.js | 268 ++++++++++++++++++++ src/utils/database.js | 382 +++++++++++++++++++++++++++++ start.js | 181 ++++++++++++++ 20 files changed, 3071 insertions(+), 2 deletions(-) create mode 100644 .env.exemple create mode 100644 .gitignore create mode 100644 package.json create mode 100644 schema.sql create mode 100644 schema_clean.sql create mode 100644 src/commands/info.js create mode 100644 src/commands/ranking.js create mode 100644 src/commands/sync.js create mode 100644 src/events/interactionCreate.js create mode 100644 src/events/ready.js create mode 100644 src/handlers/commandHandler.js create mode 100644 src/handlers/componentHandler.js create mode 100644 src/handlers/eventHandler.js create mode 100644 src/services/clanRankingService.js create mode 100644 src/services/clashRoyaleService.js create mode 100644 src/services/rankingService.js create mode 100644 src/services/trophyService.js create mode 100644 src/utils/database.js create mode 100644 start.js diff --git a/.env.exemple b/.env.exemple new file mode 100644 index 0000000..9b8f47e --- /dev/null +++ b/.env.exemple @@ -0,0 +1,60 @@ +# Configuration du Bot Discord - Clash Royale +# Renommer ce fichier en .env après avoir rempli les valeurs + +# =========================================== +# Configuration Discord +# =========================================== +DISCORD_TOKEN=DISCORD_BOT_TOKEN_HERE +DISCORD_CLIENT_ID=DISCORD_CLIENT_ID_HERE +GUILD_ID=GUILD_ID_HERE + +# =========================================== +# Configuration Clash Royale API +# =========================================== +CLASH_ROYALE_TOKEN=TOKEN_CLASH_ROYALE_HERE + +# Tag du clan principal pour le classement +CLAN_TAG=CLAN_TAG_HERE # Sans le # ! + +# =========================================== +# Configuration des Salons Discord +# =========================================== +# Salon pour afficher le classement top 10 +RANKING_CHANNEL_ID=RANKING_CHANNEL_ID_HERE + +# Salon pour les félicitations de trophées +CONGRATULATIONS_CHANNEL_ID=CONGRATULATIONS_CHANNEL_ID_HERE + +# =========================================== +# Configuration des Rôles Discord +# =========================================== +# Rôle requis pour utiliser les commandes Clash Royale +CLASH_ROYALE_ROLE_ID=CLASH_ROYALE_ROLE_ID_HERE + +# Rôles de la hiérarchie du clan +CHEF_ROLE_ID=CHEF_ROLE_ID_HERE +AINE_ROLE_ID=AINE_ROLE_ID_HERE +MEMBRE_ROLE_ID=MEMBRE_ROLE_ID_HERE + +# =========================================== +# Configuration Base de Données +# =========================================== +DATABASE_PATH=./database/bot.db + +# =========================================== +# Configuration Bot +# =========================================== +# Intervalle de vérification des trophées (en minutes) +TROPHY_CHECK_INTERVAL=30 + +# Intervalle de mise à jour du classement (en minutes) +RANKING_UPDATE_INTERVAL=60 + +# Seuil de trophées pour félicitations +TROPHY_MILESTONE=1000 + +# Mode de développement (true/false) +DEVELOPMENT_MODE=false + +# Niveau de logs (error, warn, info, debug) +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dcef2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env \ No newline at end of file diff --git a/README.md b/README.md index f4f4dff..3848daa 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,97 @@ -# clashroyale-bot +# 🏰 Clash Royale Discord Bot -Code source de notre bot Discord Clash Royale \ No newline at end of file +Un bot Discord avancé intégrant l'API officielle Clash Royale pour gérer automatiquement les classements, félicitations et synchronisations de profils. + +## ✨ Fonctionnalités Principales + +### 🎯 Synchronisation de Profils +- **Commande `/sync`** : Associe automatiquement un profil Clash Royale à un utilisateur Discord +- Vérification des rôles requis avant synchronisation +- Validation en temps réel via l'API officielle Clash Royale +- Protection contre les doublons de profils + +### 🏆 Système de Classement +- **Classement Top 10** automatiquement mis à jour dans le salon dédié +- Mise à jour programmée toutes les heures +- Interface visuelle avec médailles et icônes de trophées +- Statistiques du serveur (moyenne, total des trophées) +- **Commande `/ranking`** avec sous-commandes : + - `top` : Affiche le top 10 + - `me` : Votre position personnelle + - `update` : Mise à jour forcée (admin) + +### 🎉 Félicitations Automatiques +- Détection automatique des paliers de 1000 trophées +- Messages personnalisés selon le niveau atteint +- Réactions automatiques sur les messages +- Envoi dans le salon de félicitations configuré +- Gestion des doublons (pas de spam) + +## 🚀 Installation Rapide + +### 1. Prérequis +```bash +Node.js 16+ (recommandé : 18+) +npm ou yarn +``` + +### 2. Installation +```bash +git clone +cd clashroyale-bot +npm install +``` + +### 3. Configuration +```bash +# Copier le fichier de configuration +cp .env.exemple .env + +# Éditer le fichier .env avec vos tokens et IDs +nano .env +``` + +### 4. Lancement +```bash +npm start +``` + +## ⚙️ Configuration + +Copiez `.env.exemple` vers `.env` et remplissez toutes les valeurs selon votre serveur Discord et votre token API Clash Royale. + +### Variables principales : +- `DISCORD_TOKEN` : Token de votre bot Discord +- `CLASH_ROYALE_TOKEN` : Token API Clash Royale (Supercell) +- `GUILD_ID` : ID de votre serveur Discord +- `RANKING_CHANNEL_ID` : Salon pour le classement automatique +- `CONGRATULATIONS_CHANNEL_ID` : Salon pour les félicitations +- `CLASH_ROYALE_ROLE_ID` : Rôle requis pour utiliser les commandes + +## 🗂️ Structure + +``` +src/ +├── handlers/ # Chargement automatique des modules +├── commands/ # Commandes slash Discord +├── events/ # Événements Discord +├── services/ # Logique métier (API, classements, etc.) +└── utils/ # Base de données et utilitaires +``` + +## 📋 Commandes + +- `/sync id:TAG` - Synchroniser son profil Clash Royale +- `/ranking top` - Top 10 du serveur +- `/ranking me` - Votre position +- `/ranking update` - Mise à jour forcée (admin) + +## 🔄 Automatisations + +- **Classement** : Mis à jour automatiquement toutes les heures +- **Félicitations** : Détection des paliers de 1000 trophées toutes les 30 minutes +- **Logs colorés** : Monitoring en temps réel avec système de couleurs + +--- + +**Développé par l'équipe Neptunia** • Bot opérationnel 24/7 \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..66431f0 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "clashroyale-discord-bot", + "version": "1.0.0", + "description": "Bot Discord avec intégration Clash Royale pour classements et félicitations", + "main": "start.js", + "scripts": { + "start": "node start.js", + "dev": "nodemon start.js" + }, + "dependencies": { + "discord.js": "^14.14.1", + "axios": "^1.6.2", + "sqlite3": "^5.1.6", + "dotenv": "^16.3.1", + "chalk": "^4.1.2", + "node-cron": "^3.0.3" + }, + "devDependencies": { + "nodemon": "^3.0.2" + }, + "keywords": [ + "discord", + "bot", + "clash-royale", + "gaming" + ], + "author": "Neptunia Team", + "license": "MIT" +} \ No newline at end of file diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..0805324 --- /dev/null +++ b/schema.sql @@ -0,0 +1,172 @@ +-- ===================================================== +-- SCHEMA BASE DE DONNÉES - CLASH ROYALE DISCORD BOT +-- ===================================================== +-- Créé pour gérer les associations utilisateurs Discord <-> Clash Royale +-- et le suivi des trophées pour les félicitations automatiques + +-- Suppression des tables existantes (si elles existent) +DROP TABLE IF EXISTS trophy_history; +DROP TABLE IF EXISTS user_profiles; +DROP TABLE IF EXISTS bot_settings; + +-- ===================================================== +-- TABLE: user_profiles +-- ===================================================== +-- Stocke les associations entre utilisateurs Discord et profils Clash Royale +CREATE TABLE user_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT UNIQUE NOT NULL, -- ID Discord de l'utilisateur + clash_tag TEXT UNIQUE NOT NULL, -- Tag Clash Royale (ex: #ABC123DEF) + clash_name TEXT NOT NULL, -- Nom du joueur Clash Royale + current_trophies INTEGER DEFAULT 0, -- Trophées actuels + highest_trophies INTEGER DEFAULT 0, -- Record de trophées + level INTEGER DEFAULT 1, -- Niveau du joueur + clan_name TEXT, -- Nom du clan (si dans un clan) + clan_tag TEXT, -- Tag du clan (si dans un clan) + clan_role TEXT, -- Rôle dans le clan + verified BOOLEAN DEFAULT FALSE, -- Profil vérifié + sync_date DATETIME DEFAULT CURRENT_TIMESTAMP, -- Date de synchronisation + last_update DATETIME DEFAULT CURRENT_TIMESTAMP, -- Dernière mise à jour + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ===================================================== +-- TABLE: trophy_history +-- ===================================================== +-- Historique des trophées pour détecter les gains et féliciter +CREATE TABLE trophy_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_profile_id INTEGER NOT NULL, -- Référence vers user_profiles + old_trophies INTEGER NOT NULL, -- Ancien nombre de trophées + new_trophies INTEGER NOT NULL, -- Nouveau nombre de trophées + trophy_gain INTEGER NOT NULL, -- Gain de trophées + milestone_reached INTEGER, -- Palier atteint (1000, 2000, etc.) + congratulated BOOLEAN DEFAULT FALSE, -- Si félicitations envoyées + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE +); + +-- ===================================================== +-- TABLE: bot_settings +-- ===================================================== +-- Paramètres et configuration du bot +CREATE TABLE bot_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + setting_key TEXT UNIQUE NOT NULL, -- Clé du paramètre + setting_value TEXT NOT NULL, -- Valeur du paramètre + description TEXT, -- Description du paramètre + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ===================================================== +-- INDEX pour optimiser les performances +-- ===================================================== +CREATE INDEX idx_user_profiles_discord_id ON user_profiles(discord_id); +CREATE INDEX idx_user_profiles_clash_tag ON user_profiles(clash_tag); +CREATE INDEX idx_trophy_history_user_profile ON trophy_history(user_profile_id); +CREATE INDEX idx_trophy_history_recorded_at ON trophy_history(recorded_at); +CREATE INDEX idx_trophy_history_milestone ON trophy_history(milestone_reached); +CREATE INDEX idx_bot_settings_key ON bot_settings(setting_key); + +-- ===================================================== +-- DONNÉES INITIALES +-- ===================================================== +-- Paramètres par défaut du bot +INSERT INTO bot_settings (setting_key, setting_value, description) VALUES +('last_ranking_update', '0', 'Timestamp de la derniere mise a jour du classement'), +('total_users_synced', '0', 'Nombre total utilisateurs synchronises'), +('trophy_check_enabled', 'true', 'Active/desactive la verification des trophees'), +('ranking_update_enabled', 'true', 'Active/desactive les mises a jour du classement'), +('congratulations_enabled', 'true', 'Active/desactive les felicitations automatiques'), +('bot_version', '1.0.0', 'Version actuelle du bot'), +('maintenance_mode', 'false', 'Mode maintenance du bot'); + +-- ===================================================== +-- VUES UTILES +-- ===================================================== +-- Vue pour le classement des utilisateurs +CREATE VIEW ranking_view AS +SELECT + up.discord_id, + up.clash_name, + up.current_trophies, + up.highest_trophies, + up.level, + up.clan_name, + up.clan_role, + ROW_NUMBER() OVER (ORDER BY up.current_trophies DESC) as rank_position +FROM user_profiles up +WHERE up.verified = TRUE +ORDER BY up.current_trophies DESC; + +-- Vue pour les gains de trophées récents (24h) +CREATE VIEW recent_trophy_gains AS +SELECT + up.discord_id, + up.clash_name, + th.old_trophies, + th.new_trophies, + th.trophy_gain, + th.milestone_reached, + th.congratulated, + th.recorded_at +FROM trophy_history th +JOIN user_profiles up ON th.user_profile_id = up.id +WHERE th.recorded_at >= datetime('now', '-1 day') +AND th.trophy_gain > 0 +ORDER BY th.recorded_at DESC; + +-- ===================================================== +-- TRIGGERS +-- ===================================================== +-- Trigger pour mettre à jour last_update automatiquement +CREATE TRIGGER update_user_profiles_timestamp + AFTER UPDATE ON user_profiles + BEGIN + UPDATE user_profiles + SET last_update = CURRENT_TIMESTAMP + WHERE id = NEW.id; + END; + +-- Trigger pour mettre à jour updated_at dans bot_settings +CREATE TRIGGER update_bot_settings_timestamp + AFTER UPDATE ON bot_settings + BEGIN + UPDATE bot_settings + SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id; + END; + +-- ===================================================== +-- FONCTIONS UTILES (commentées pour référence) +-- ===================================================== +/* +-- Exemple de requête pour obtenir le top 10 +SELECT * FROM ranking_view LIMIT 10; + +-- Exemple pour obtenir les utilisateurs ayant gagné plus de 1000 trophées +SELECT * FROM recent_trophy_gains WHERE trophy_gain >= 1000; + +-- Exemple pour obtenir les statistiques d'un utilisateur +SELECT + up.*, + COUNT(th.id) as total_records, + SUM(CASE WHEN th.trophy_gain > 0 THEN th.trophy_gain ELSE 0 END) as total_gains, + SUM(CASE WHEN th.trophy_gain < 0 THEN ABS(th.trophy_gain) ELSE 0 END) as total_losses +FROM user_profiles up +LEFT JOIN trophy_history th ON up.id = th.user_profile_id +WHERE up.discord_id = 'DISCORD_USER_ID' +GROUP BY up.id; +*/ + +-- ===================================================== +-- VALIDATION DU SCHÉMA +-- ===================================================== +-- Vérification que toutes les tables sont créées correctement +SELECT + name as table_name, + type +FROM sqlite_master +WHERE type IN ('table', 'view', 'index', 'trigger') +ORDER BY type, name; \ No newline at end of file diff --git a/schema_clean.sql b/schema_clean.sql new file mode 100644 index 0000000..f839825 --- /dev/null +++ b/schema_clean.sql @@ -0,0 +1,131 @@ +-- ===================================================== +-- CLASH ROYALE DISCORD BOT - DATABASE SCHEMA +-- ===================================================== +-- Clean schema without special characters +-- ===================================================== + +-- Drop existing tables if they exist +DROP TABLE IF EXISTS trophy_history; +DROP TABLE IF EXISTS user_profiles; +DROP TABLE IF EXISTS bot_settings; + +-- ===================================================== +-- TABLE: user_profiles +-- ===================================================== +CREATE TABLE user_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT UNIQUE NOT NULL, + clash_tag TEXT UNIQUE NOT NULL, + clash_name TEXT NOT NULL, + current_trophies INTEGER DEFAULT 0, + highest_trophies INTEGER DEFAULT 0, + level INTEGER DEFAULT 1, + clan_name TEXT, + clan_tag TEXT, + clan_role TEXT, + verified BOOLEAN DEFAULT FALSE, + sync_date DATETIME DEFAULT CURRENT_TIMESTAMP, + last_update DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ===================================================== +-- TABLE: trophy_history +-- ===================================================== +CREATE TABLE trophy_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_profile_id INTEGER NOT NULL, + old_trophies INTEGER NOT NULL, + new_trophies INTEGER NOT NULL, + trophy_gain INTEGER NOT NULL, + milestone_reached INTEGER, + congratulated BOOLEAN DEFAULT FALSE, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE +); + +-- ===================================================== +-- TABLE: bot_settings +-- ===================================================== +CREATE TABLE bot_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + setting_key TEXT UNIQUE NOT NULL, + setting_value TEXT NOT NULL, + description TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ===================================================== +-- INDEXES FOR PERFORMANCE +-- ===================================================== +CREATE INDEX idx_user_profiles_discord_id ON user_profiles(discord_id); +CREATE INDEX idx_user_profiles_clash_tag ON user_profiles(clash_tag); +CREATE INDEX idx_trophy_history_user_profile ON trophy_history(user_profile_id); +CREATE INDEX idx_trophy_history_recorded_at ON trophy_history(recorded_at); +CREATE INDEX idx_trophy_history_milestone ON trophy_history(milestone_reached); +CREATE INDEX idx_bot_settings_key ON bot_settings(setting_key); + +-- ===================================================== +-- DEFAULT BOT SETTINGS +-- ===================================================== +INSERT INTO bot_settings (setting_key, setting_value, description) VALUES +('last_ranking_update', '0', 'Last ranking update timestamp'), +('total_users_synced', '0', 'Total number of synced users'), +('trophy_check_enabled', 'true', 'Enable trophy milestone checking'), +('ranking_update_enabled', 'true', 'Enable automatic ranking updates'), +('congratulations_enabled', 'true', 'Enable automatic congratulations'), +('bot_version', '1.0.0', 'Current bot version'), +('maintenance_mode', 'false', 'Bot maintenance mode'); + +-- ===================================================== +-- USEFUL VIEWS +-- ===================================================== +CREATE VIEW ranking_view AS +SELECT + up.discord_id, + up.clash_name, + up.current_trophies, + up.highest_trophies, + up.level, + up.clan_name, + up.clan_role, + ROW_NUMBER() OVER (ORDER BY up.current_trophies DESC) as rank_position +FROM user_profiles up +WHERE up.verified = 1 +ORDER BY up.current_trophies DESC; + +CREATE VIEW recent_trophy_gains AS +SELECT + up.discord_id, + up.clash_name, + th.old_trophies, + th.new_trophies, + th.trophy_gain, + th.milestone_reached, + th.congratulated, + th.recorded_at +FROM trophy_history th +JOIN user_profiles up ON th.user_profile_id = up.id +WHERE th.recorded_at >= datetime('now', '-1 day') +AND th.trophy_gain > 0 +ORDER BY th.recorded_at DESC; + +-- ===================================================== +-- TRIGGERS +-- ===================================================== +CREATE TRIGGER update_user_profiles_timestamp + AFTER UPDATE ON user_profiles + BEGIN + UPDATE user_profiles + SET last_update = CURRENT_TIMESTAMP + WHERE id = NEW.id; + END; + +CREATE TRIGGER update_bot_settings_timestamp + AFTER UPDATE ON bot_settings + BEGIN + UPDATE bot_settings + SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id; + END; \ No newline at end of file diff --git a/src/commands/info.js b/src/commands/info.js new file mode 100644 index 0000000..559d3c1 --- /dev/null +++ b/src/commands/info.js @@ -0,0 +1,107 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('info') + .setDescription('Affiche les informations du bot'), + + async execute(interaction, client) { + await interaction.deferReply(); + + try { + const guild = interaction.guild; + const uptime = process.uptime(); + const uptimeFormatted = this.formatUptime(uptime); + + const embed = new EmbedBuilder() + .setColor('#FFD700') + .setTitle('🏰 Clash Royale Discord Bot') + .setDescription('Bot Discord intégrant l\'API Clash Royale pour classements automatiques et félicitations') + .setThumbnail(client.user.displayAvatarURL({ dynamic: true, size: 256 })) + .addFields([ + { + name: '⚙️ Informations Techniques', + value: `📡 **Ping:** ${Math.round(client.ws.ping)}ms\n⏱️ **Uptime:** ${uptimeFormatted}\n🟢 **Statut:** Opérationnel\n📦 **Version:** 1.0.0`, + inline: true + }, + { + name: '🏰 Clan Configuré', + value: `🏷️ **Tag:** \`${"#" + process.env.CLAN_TAG || 'Non configuré'}\`\n🔄 **Mise à jour:** Toutes les 5 minutes\n📊 **Classement:** Automatique`, + inline: true + }, + { + name: '📈 Statistiques', + value: `🎮 **Serveurs:** ${client.guilds.cache.size}\n👥 **Utilisateurs:** ${client.users.cache.size}\n💬 **Commandes:** ${client.commands.size}`, + inline: true + } + ]) + .addFields([ + { + name: '🎯 Fonctionnalités', + value: '• **`/sync`** - Synchroniser profil Clash Royale\n• **`/ranking`** - Classement du clan\n• **Félicitations automatiques** - Paliers de trophées\n• **Mise à jour live** - Toutes les 5 minutes', + inline: false + } + ]) + .addFields([ + { + name: '🔗 Code Source', + value: '[📂 Repository Git](https://git.valloic.dev/Neptunia/clashroyale-bot)\n*Code open source disponible*', + inline: true + }, + { + name: '👨‍💻 Développé par', + value: '**cut0x**\n[valloic.dev](https://valloic.dev)', + inline: true + }, + { + name: '📞 Support', + value: 'Contactez les administrateurs\npour toute assistance', + inline: true + } + ]) + .setFooter({ + text: `Demandé par ${interaction.user.tag} • Bot opérationnel`, + iconURL: interaction.user.displayAvatarURL({ dynamic: true }) + }) + .setTimestamp(); + + // Ajouter des informations spécifiques au serveur si disponible + if (guild) { + embed.addFields([{ + name: '🏢 Serveur Actuel', + value: `**${guild.name}**\n👥 ${guild.memberCount} membres\n📅 Créé le ${guild.createdAt.toLocaleDateString('fr-FR')}`, + inline: true + }]); + } + + await interaction.editReply({ embeds: [embed] }); + + } catch (error) { + client.logger.error(`Erreur lors de l'affichage des informations: ${error.message}`); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Erreur') + .setDescription('Impossible d\'afficher les informations du bot.') + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } + }, + + // Fonction pour formater l'uptime + formatUptime(seconds) { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + let result = ''; + if (days > 0) result += `${days}j `; + if (hours > 0) result += `${hours}h `; + if (minutes > 0) result += `${minutes}m `; + if (secs > 0 || result === '') result += `${secs}s`; + + return result.trim(); + } +}; \ No newline at end of file diff --git a/src/commands/ranking.js b/src/commands/ranking.js new file mode 100644 index 0000000..f43a01b --- /dev/null +++ b/src/commands/ranking.js @@ -0,0 +1,284 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { clanRankingService } = require('../services/clanRankingService'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('ranking') + .setDescription('Affiche le classement du clan') + .addSubcommand(subcommand => + subcommand + .setName('top') + .setDescription('Affiche le top 10 du clan') + ) + .addSubcommand(subcommand => + subcommand + .setName('me') + .setDescription('Affiche votre position dans le clan') + ) + .addSubcommand(subcommand => + subcommand + .setName('update') + .setDescription('Force la mise à jour du classement (Admin uniquement)') + ), + + async execute(interaction, client) { + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'top': + await this.handleTopRanking(interaction, client); + break; + case 'me': + await this.handleMyRanking(interaction, client); + break; + case 'update': + await this.handleUpdateRanking(interaction, client); + break; + } + }, + + async handleTopRanking(interaction, client) { + await interaction.deferReply(); + + try { + const clanRanking = await clanRankingService.getFullClanRanking(); + + if (!clanRanking.success) { + const embed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Erreur') + .setDescription(`Impossible de récupérer le classement du clan: ${clanRanking.error}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + return; + } + + const topPlayers = clanRanking.data.slice(0, 10); + + if (topPlayers.length === 0) { + const embed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('🏰 Classement du Clan') + .setDescription(`Aucun membre trouvé dans le clan \`${"#" + process.env.CLAN_TAG}\``) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + return; + } + + const embed = new EmbedBuilder() + .setColor('#FFD700') + .setTitle('🏰 Top 10 du Clan'); + + let description = ''; + const medals = ['🥇', '🥈', '🥉']; + + for (let i = 0; i < topPlayers.length; i++) { + const player = topPlayers[i]; + const medal = i < 3 ? medals[i] : `**${i + 1}.**`; + const trophyIcon = clanRankingService.getTrophyIcon(player.trophies); + const roleEmoji = clanRankingService.getRoleEmoji(player.role); + + description += `${medal} ${trophyIcon} **${player.name}** ${roleEmoji} • ${player.trophies.toLocaleString()}\n`; + } + + embed.setDescription(`Clan: \`${"#" + process.env.CLAN_TAG}\`\n\n${description}`); + embed.setFooter({ + text: `${clanRanking.data.length} membres dans le clan • Mise à jour toutes les 5min`, + iconURL: client.user.displayAvatarURL() + }); + embed.setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + + } catch (error) { + client.logger.error(`Erreur lors de l'affichage du classement: ${error.message}`); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Erreur') + .setDescription('Impossible d\'afficher le classement.') + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } + }, + + async handleMyRanking(interaction, client) { + await interaction.deferReply({ ephemeral: true }); + + try { + // D'abord vérifier si l'utilisateur a un profil synchronisé + const { UserProfileService } = require('../utils/database'); + const userProfile = await UserProfileService.getByDiscordId(interaction.user.id); + + if (!userProfile) { + const embed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('❌ Profil non synchronisé') + .setDescription('Vous devez d\'abord synchroniser votre profil Clash Royale.') + .addFields([{ + name: '💡 Comment faire ?', + value: 'Utilisez la commande `/sync id:VOTRE_TAG` pour associer votre profil.', + inline: false + }]) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + return; + } + + // Obtenir la position dans le clan + const memberRank = await clanRankingService.getMemberRank(userProfile.clash_tag); + + if (!memberRank) { + const embed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('❌ Non trouvé dans le clan') + .setDescription(`Vous n'êtes pas membre du clan configuré \`${"#" + process.env.CLAN_TAG}\``) + .addFields([{ + name: '💡 Solution', + value: 'Rejoignez le clan ou contactez un administrateur si c\'est une erreur.', + inline: false + }]) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + return; + } + + const { rank, total, member, above, below } = memberRank; + const trophyIcon = clanRankingService.getTrophyIcon(member.trophies); + const roleEmoji = clanRankingService.getRoleEmoji(member.role); + + const embed = new EmbedBuilder() + .setColor('#00BFFF') + .setTitle(`${trophyIcon} Votre Position dans le Clan`) + .setDescription(`Vous êtes **#${rank}** sur **${total}** membres`) + .setThumbnail(interaction.user.displayAvatarURL({ dynamic: true })) + .addFields([ + { + name: '👤 Votre Profil', + value: `**${member.name}**\n${roleEmoji} **${member.role}**\n🏆 ${member.trophies.toLocaleString()} trophées\n💎 Niveau ${member.expLevel}`, + inline: true + } + ]); + + if (above) { + embed.addFields([{ + name: '⬆️ Membre au-dessus', + value: `**${above.name}**\n${clanRankingService.getRoleEmoji(above.role)} ${above.role}\n🏆 ${above.trophies.toLocaleString()} trophées\n📈 +${(above.trophies - member.trophies).toLocaleString()} pour le rattraper`, + inline: true + }]); + } + + if (below) { + embed.addFields([{ + name: '⬇️ Membre en-dessous', + value: `**${below.name}**\n${clanRankingService.getRoleEmoji(below.role)} ${below.role}\n🏆 ${below.trophies.toLocaleString()} trophées\n📉 Avance: +${(member.trophies - below.trophies).toLocaleString()}`, + inline: true + }]); + } + + // Ajouter des encouragements selon la position + let encouragement = ''; + if (rank === 1) { + encouragement = '👑 **LEADER DU CLAN !** Performance exceptionnelle !'; + } else if (rank <= 3) { + encouragement = `${rank === 2 ? '🥈' : '🥉'} **Podium du clan !** Excellente performance !`; + } else if (rank <= 10) { + encouragement = '🏆 **Top 10 du clan !** Vous faites partie de l\'élite !'; + } else { + const percentile = Math.round(((total - rank + 1) / total) * 100); + encouragement = `📊 Top ${percentile}% du clan ! Continuez à progresser !`; + } + + embed.addFields([{ + name: '💪 Motivation', + value: encouragement, + inline: false + }]); + + embed.setFooter({ + text: `Classement clan • Mise à jour toutes les 5min`, + iconURL: client.user.displayAvatarURL() + }); + embed.setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + + } catch (error) { + client.logger.error(`Erreur lors de l'affichage du rang personnel: ${error.message}`); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Erreur') + .setDescription('Impossible d\'afficher votre position.') + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } + }, + + async handleUpdateRanking(interaction, client) { + // Vérifier les permissions d'administrateur + if (!interaction.member.permissions.has('Administrator') && + !interaction.member.roles.cache.has(process.env.CHEF_ROLE_ID) && + !interaction.member.roles.cache.has(process.env.AINE_ROLE_ID)) { + + const embed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Permission refusée') + .setDescription('Seuls les administrateurs et les responsables peuvent forcer la mise à jour du classement.') + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; + } + + await interaction.deferReply(); + + try { + const embed = new EmbedBuilder() + .setColor('#FFD700') + .setTitle('🔄 Mise à jour en cours...') + .setDescription('Mise à jour du classement du clan en cours, veuillez patienter...') + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + + // Forcer la mise à jour + await clanRankingService.updateClanRanking(client); + + const successEmbed = new EmbedBuilder() + .setColor('#00FF00') + .setTitle('✅ Mise à jour terminée') + .setDescription('Le classement du clan a été mis à jour avec succès !') + .addFields([{ + name: '📍 Où voir le classement ?', + value: `Consultez <#${process.env.RANKING_CHANNEL_ID}> pour voir le classement actualisé.`, + inline: false + }]) + .setTimestamp(); + + await interaction.editReply({ embeds: [successEmbed] }); + + } catch (error) { + client.logger.error(`Erreur lors de la mise à jour forcée: ${error.message}`); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Erreur') + .setDescription('Impossible de mettre à jour le classement.') + .addFields([{ + name: 'Détails', + value: error.message, + inline: false + }]) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } + } +}; \ No newline at end of file diff --git a/src/commands/sync.js b/src/commands/sync.js new file mode 100644 index 0000000..0a3e5cc --- /dev/null +++ b/src/commands/sync.js @@ -0,0 +1,238 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); +const { clashRoyaleAPI } = require('../services/clashRoyaleService'); +const { UserProfileService } = require('../utils/database'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('sync') + .setDescription('Associer votre profil Clash Royale à votre compte Discord') + .addStringOption(option => + option + .setName('id') + .setDescription('Votre tag Clash Royale (ex: #ABC123DEF)') + .setRequired(true) + ), + + async execute(interaction, client) { + await interaction.deferReply({ ephemeral: true }); + + try { + // Vérification du rôle requis + const requiredRoleId = process.env.CLASH_ROYALE_ROLE_ID; + if (!interaction.member.roles.cache.has(requiredRoleId)) { + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Accès refusé') + .setDescription('Vous devez avoir le rôle requis pour utiliser cette commande.') + .addFields([ + { + name: 'Rôle requis', + value: `<@&${requiredRoleId}>`, + inline: true + } + ]) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + return; + } + + const clashTag = interaction.options.getString('id'); + const discordId = interaction.user.id; + + client.logger.clash(`Tentative de synchronisation: ${interaction.user.tag} -> ${clashTag}`); + + // Vérifier si l'utilisateur a déjà un profil synchronisé + const existingProfile = await UserProfileService.getByDiscordId(discordId); + + // Vérifier si le tag Clash Royale est déjà utilisé par quelqu'un d'autre + const existingClashProfile = await UserProfileService.getByClashTag(clashTag); + if (existingClashProfile && existingClashProfile.discord_id !== discordId) { + const conflictEmbed = new EmbedBuilder() + .setColor('#FF6B00') + .setTitle('⚠️ Tag déjà utilisé') + .setDescription('Ce tag Clash Royale est déjà associé à un autre utilisateur Discord.') + .addFields([ + { + name: 'Tag Clash Royale', + value: `\`${clashTag}\``, + inline: true + }, + { + name: 'Solution', + value: 'Vérifiez que vous avez entré le bon tag ou contactez un administrateur.', + inline: false + } + ]) + .setTimestamp(); + + await interaction.editReply({ embeds: [conflictEmbed] }); + return; + } + + // Valider le profil Clash Royale via l'API + const validationResult = await clashRoyaleAPI.validatePlayer(clashTag); + + if (!validationResult.valid) { + let errorMessage = 'Tag Clash Royale invalide ou joueur non trouvé.'; + let errorTitle = '❌ Profil non trouvé'; + + switch (validationResult.error.type) { + case 'NOT_FOUND': + errorMessage = 'Aucun joueur trouvé avec ce tag. Vérifiez que vous avez entré le bon tag.'; + break; + case 'BAD_REQUEST': + errorMessage = 'Format de tag invalide. Utilisez le format #ABC123DEF.'; + break; + case 'RATE_LIMITED': + errorMessage = 'Trop de requêtes à l\'API. Réessayez dans quelques minutes.'; + errorTitle = '⏳ Limite atteinte'; + break; + case 'SERVICE_UNAVAILABLE': + errorMessage = 'API Clash Royale temporairement indisponible. Réessayez plus tard.'; + errorTitle = '🛠️ Maintenance'; + break; + } + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle(errorTitle) + .setDescription(errorMessage) + .addFields([ + { + name: 'Tag fourni', + value: `\`${clashTag}\``, + inline: true + }, + { + name: 'Aide', + value: 'Le tag se trouve dans votre profil Clash Royale, sous votre nom.', + inline: false + } + ]) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + return; + } + + const playerData = validationResult.data; + + // Créer ou mettre à jour le profil dans la base de données + const profile = await UserProfileService.createOrUpdate(discordId, playerData); + + client.logger.success(`Profil synchronisé: ${playerData.name} (${playerData.tag})`); + + // Déterminer si c'est une nouvelle synchronisation ou une mise à jour + const isNewSync = !existingProfile; + const actionText = isNewSync ? 'synchronisé' : 'mis à jour'; + const actionEmoji = isNewSync ? '🎉' : '🔄'; + + // Créer l'embed de confirmation + const successEmbed = new EmbedBuilder() + .setColor('#00FF00') + .setTitle(`${actionEmoji} Profil ${actionText} avec succès !`) + .setDescription(`Votre compte Discord est maintenant associé à votre profil Clash Royale.`) + .setThumbnail(interaction.user.displayAvatarURL({ dynamic: true })) + .addFields([ + { + name: '👤 Nom du joueur', + value: playerData.name, + inline: true + }, + { + name: '🏷️ Tag Clash Royale', + value: `\`${playerData.tag}\``, + inline: true + }, + { + name: '🏆 Trophées actuels', + value: `${playerData.trophies.toLocaleString()}`, + inline: true + }, + { + name: '🥇 Meilleur score', + value: `${playerData.bestTrophies.toLocaleString()}`, + inline: true + }, + { + name: '⭐ Niveau', + value: `${playerData.expLevel}`, + inline: true + }, + { + name: '🏰 Clan', + value: playerData.clan ? + `${playerData.clan.name}\n\`${playerData.clan.tag}\`\n*${playerData.clan.role}*` : + 'Aucun clan', + inline: true + } + ]) + .addFields([ + { + name: '✨ Fonctionnalités disponibles', + value: '• Classement automatique du serveur\n• Félicitations pour les gains de trophées\n• Statistiques personnalisées\n• Comparaisons avec les autres membres', + inline: false + } + ]) + .setFooter({ + text: `${isNewSync ? 'Première synchronisation' : 'Profile mis à jour'} • Clash Royale Bot`, + iconURL: client.user.displayAvatarURL() + }) + .setTimestamp(); + + await interaction.editReply({ embeds: [successEmbed] }); + + // Log dans le canal de logs si configuré + try { + const logChannel = client.channels.cache.get(process.env.LOGS_CHANNEL_ID); + if (logChannel) { + const logEmbed = new EmbedBuilder() + .setColor('#00BFFF') + .setTitle('📊 Nouvelle synchronisation') + .setDescription(`Un utilisateur a ${actionText} son profil Clash Royale`) + .addFields([ + { + name: 'Utilisateur Discord', + value: `${interaction.user.tag} (<@${interaction.user.id}>)`, + inline: true + }, + { + name: 'Joueur Clash Royale', + value: `${playerData.name} (\`${playerData.tag}\`)`, + inline: true + }, + { + name: 'Trophées', + value: `${playerData.trophies.toLocaleString()}`, + inline: true + } + ]) + .setTimestamp(); + + await logChannel.send({ embeds: [logEmbed] }); + } + } catch (error) { + client.logger.warning('Impossible d\'envoyer le log de synchronisation'); + } + + } catch (error) { + client.logger.error(`Erreur lors de la synchronisation: ${error.message}`); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Erreur interne') + .setDescription('Une erreur s\'est produite lors de la synchronisation. Veuillez réessayer.') + .addFields([ + { + name: 'Code d\'erreur', + value: `\`${error.message}\``, + inline: false + } + ]) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } + } +}; \ No newline at end of file diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js new file mode 100644 index 0000000..902e25f --- /dev/null +++ b/src/events/interactionCreate.js @@ -0,0 +1,68 @@ +const { EmbedBuilder } = require('discord.js'); + +module.exports = { + name: 'interactionCreate', + async execute(interaction, client) { + // Gestion des commandes slash + if (interaction.isChatInputCommand()) { + const command = client.commands.get(interaction.commandName); + + if (!command) { + client.logger.warning(`Commande inexistante: ${interaction.commandName}`); + return; + } + + try { + client.logger.command(`Commande exécutée: /${interaction.commandName} par ${interaction.user.tag}`); + await command.execute(interaction, client); + } catch (error) { + client.logger.error(`Erreur lors de l'exécution de /${interaction.commandName}: ${error.message}`); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Erreur') + .setDescription('Une erreur s\'est produite lors de l\'exécution de cette commande.') + .setTimestamp(); + + const replyOptions = { embeds: [errorEmbed], ephemeral: true }; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp(replyOptions); + } else { + await interaction.reply(replyOptions); + } + } + } + + // Gestion des composants (boutons, menus) + else if (interaction.isButton() || interaction.isStringSelectMenu()) { + const component = client.components.get(interaction.customId); + + if (!component) { + client.logger.warning(`Composant inexistant: ${interaction.customId}`); + return; + } + + try { + client.logger.system(`Composant utilisé: ${interaction.customId} par ${interaction.user.tag}`); + await component.execute(interaction, client); + } catch (error) { + client.logger.error(`Erreur lors de l'exécution du composant ${interaction.customId}: ${error.message}`); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Erreur') + .setDescription('Une erreur s\'est produite lors du traitement de votre interaction.') + .setTimestamp(); + + const replyOptions = { embeds: [errorEmbed], ephemeral: true }; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp(replyOptions); + } else { + await interaction.reply(replyOptions); + } + } + } + } +}; \ No newline at end of file diff --git a/src/events/ready.js b/src/events/ready.js new file mode 100644 index 0000000..48579d8 --- /dev/null +++ b/src/events/ready.js @@ -0,0 +1,52 @@ +module.exports = { + name: 'ready', + once: true, + async execute(client) { + client.logger.discord(`Bot connecté en tant que ${client.user.tag}`); + + const guild = client.guilds.cache.get(process.env.GUILD_ID); + if (guild) { + client.logger.discord(`Serveur principal: ${guild.name} (${guild.memberCount} membres)`); + } + + // Configuration du statut du bot + client.user.setActivity('⚔️ Clash Royale Rankings', { type: 'Watching' }); + + // Démarrer le système de classement automatique du clan + try { + const { startAutoUpdate } = require('../services/clanRankingService'); + startAutoUpdate(client); + client.logger.success('Système de classement clan démarré (toutes les 5min)'); + } catch (error) { + client.logger.error(`Erreur lors du démarrage du classement clan: ${error.message}`); + } + + // Afficher les informations du clan configuré + if (process.env.CLAN_TAG) { + try { + const { clashRoyaleAPI } = require('../services/clashRoyaleService'); + const clanResult = await clashRoyaleAPI.getClan("#" + process.env.CLAN_TAG); + + if (clanResult.success) { + const clan = clanResult.data; + client.logger.clash(`Clan configuré: ${clan.name} (${clan.tag}) - ${clan.members}/50 membres`); + } else { + client.logger.warning(`Impossible de récupérer les infos du clan ${"#" + process.env.CLAN_TAG}: ${clanResult.error.message}`); + } + } catch (error) { + client.logger.error(`Erreur lors de la vérification du clan: ${error.message}`); + } + } else { + client.logger.warning('CLAN_TAG non configuré - le classement clan ne fonctionnera pas'); + } + + client.logger.success('🎉 Bot entièrement opérationnel !'); + console.log(); + client.logger.info('Fonctionnalités actives:'); + client.logger.info(' • Classement automatique du clan toutes les 5min'); + client.logger.info(' • Commandes slash Clash Royale'); + client.logger.info(' • Félicitations pour les trophées'); + client.logger.info(' • Synchronisation des profils'); + console.log(); + } +}; \ No newline at end of file diff --git a/src/handlers/commandHandler.js b/src/handlers/commandHandler.js new file mode 100644 index 0000000..c3f0bc0 --- /dev/null +++ b/src/handlers/commandHandler.js @@ -0,0 +1,55 @@ +const { REST, Routes } = require('discord.js'); +const { readdirSync } = require('fs'); +const { join } = require('path'); + +module.exports = async (client) => { + const commandFiles = readdirSync(join(__dirname, '..', 'commands')).filter(file => file.endsWith('.js')); + const commands = []; + + client.logger.command('Chargement des commandes slash...'); + + for (const file of commandFiles) { + try { + const command = require(join(__dirname, '..', 'commands', file)); + + if (!command.data || !command.execute) { + client.logger.warning(`Commande ${file} invalide - propriétés data/execute manquantes`); + continue; + } + + client.commands.set(command.data.name, command); + commands.push(command.data.toJSON()); + + client.logger.success(`Commande chargée: /${command.data.name}`); + } catch (error) { + client.logger.error(`Erreur lors du chargement de ${file}: ${error.message}`); + } + } + + // Enregistrement des commandes slash auprès de Discord + if (commands.length > 0) { + try { + const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN); + + client.logger.system('Enregistrement des commandes slash auprès de Discord...'); + + if (process.env.DEVELOPMENT_MODE === 'true') { + // Mode développement - commandes pour le serveur spécifique uniquement + await rest.put( + Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.GUILD_ID), + { body: commands } + ); + client.logger.success(`${commands.length} commandes enregistrées en mode développement`); + } else { + // Mode production - commandes globales + await rest.put( + Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), + { body: commands } + ); + client.logger.success(`${commands.length} commandes enregistrées globalement`); + } + } catch (error) { + client.logger.error(`Erreur lors de l'enregistrement des commandes: ${error.message}`); + } + } +}; \ No newline at end of file diff --git a/src/handlers/componentHandler.js b/src/handlers/componentHandler.js new file mode 100644 index 0000000..2c59820 --- /dev/null +++ b/src/handlers/componentHandler.js @@ -0,0 +1,31 @@ +const { readdirSync } = require('fs'); +const { join } = require('path'); + +module.exports = async (client) => { + const componentDirs = readdirSync(join(__dirname, '..', 'components'), { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + + client.logger.system('Chargement des composants...'); + + for (const dir of componentDirs) { + const componentFiles = readdirSync(join(__dirname, '..', 'components', dir)) + .filter(file => file.endsWith('.js')); + + for (const file of componentFiles) { + try { + const component = require(join(__dirname, '..', 'components', dir, file)); + + if (!component.customId || !component.execute) { + client.logger.warning(`Composant ${file} invalide - propriétés customId/execute manquantes`); + continue; + } + + client.components.set(component.customId, component); + client.logger.success(`Composant ${dir} chargé: ${component.customId}`); + } catch (error) { + client.logger.error(`Erreur lors du chargement du composant ${file}: ${error.message}`); + } + } + } +}; \ No newline at end of file diff --git a/src/handlers/eventHandler.js b/src/handlers/eventHandler.js new file mode 100644 index 0000000..bac56ad --- /dev/null +++ b/src/handlers/eventHandler.js @@ -0,0 +1,29 @@ +const { readdirSync } = require('fs'); +const { join } = require('path'); + +module.exports = async (client) => { + const eventFiles = readdirSync(join(__dirname, '..', 'events')).filter(file => file.endsWith('.js')); + + client.logger.event('Chargement des événements...'); + + for (const file of eventFiles) { + try { + const event = require(join(__dirname, '..', 'events', file)); + + if (!event.name || !event.execute) { + client.logger.warning(`Événement ${file} invalide - propriétés name/execute manquantes`); + continue; + } + + if (event.once) { + client.once(event.name, (...args) => event.execute(...args, client)); + } else { + client.on(event.name, (...args) => event.execute(...args, client)); + } + + client.logger.success(`Événement chargé: ${event.name}`); + } catch (error) { + client.logger.error(`Erreur lors du chargement de l'événement ${file}: ${error.message}`); + } + } +}; \ No newline at end of file diff --git a/src/services/clanRankingService.js b/src/services/clanRankingService.js new file mode 100644 index 0000000..d7c75fd --- /dev/null +++ b/src/services/clanRankingService.js @@ -0,0 +1,311 @@ +const { EmbedBuilder } = require('discord.js'); +const { clashRoyaleAPI } = require('./clashRoyaleService'); +const { SettingsService } = require('../utils/database'); + +class ClanRankingService { + constructor() { + this.isUpdating = false; + this.lastUpdate = null; + this.clanTag = '#' + process.env.CLAN_TAG; + this.updateInterval = null; + } + + // Démarrer les mises à jour automatiques toutes les 5 minutes + startAutoUpdate(client) { + if (this.updateInterval) { + clearInterval(this.updateInterval); + } + + // Première mise à jour immédiate + this.updateClanRanking(client); + + // Puis toutes les 5 minutes + this.updateInterval = setInterval(async () => { + await this.updateClanRanking(client); + }, 5 * 60 * 1000); // 5 minutes + + client.logger.system('Mise à jour automatique du classement clan démarrée (5min)'); + } + + // Arrêter les mises à jour automatiques + stopAutoUpdate() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } + + // Mettre à jour le classement du clan dans le salon dédié + async updateClanRanking(client) { + if (this.isUpdating) { + client.logger.warning('Mise à jour du classement clan déjà en cours'); + return; + } + + if (!this.clanTag) { + client.logger.error('CLAN_TAG non configuré dans .env'); + return; + } + + this.isUpdating = true; + + try { + client.logger.clash(`Mise à jour du classement clan ${this.clanTag}...`); + + const rankingChannelId = process.env.RANKING_CHANNEL_ID; + if (!rankingChannelId) { + throw new Error('RANKING_CHANNEL_ID non configuré'); + } + + const channel = await client.channels.fetch(rankingChannelId); + if (!channel) { + throw new Error('Canal de classement non trouvé'); + } + + // Récupérer les membres du clan via l'API + const clanResult = await clashRoyaleAPI.getClanMembers(this.clanTag); + + if (!clanResult.success) { + client.logger.error(`Erreur API clan: ${clanResult.error.message}`); + return; + } + + const clanMembers = clanResult.data.items || []; + + if (clanMembers.length === 0) { + const noMembersEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('🏰 Classement du Clan') + .setDescription(`Aucun membre trouvé dans le clan \`${this.clanTag}\``) + .setTimestamp(); + + await this.sendOrUpdateRanking(channel, noMembersEmbed, client); + return; + } + + // Récupérer les infos du clan + const clanInfoResult = await clashRoyaleAPI.getClan(this.clanTag); + const clanInfo = clanInfoResult.success ? clanInfoResult.data : null; + + client.logger.success(`${clanMembers.length} membres trouvés dans le clan`); + + // Trier les membres par trophées (décroissant) + clanMembers.sort((a, b) => b.trophies - a.trophies); + + // Prendre les 10 premiers + const topMembers = clanMembers.slice(0, 10); + + // Créer l'embed du classement + const rankingEmbed = await this.createClanRankingEmbed(topMembers, clanInfo, clanMembers.length, client); + + // Envoyer ou mettre à jour le message + await this.sendOrUpdateRanking(channel, rankingEmbed, client); + + // Mettre à jour les statistiques + await SettingsService.set('last_clan_ranking_update', Date.now().toString()); + + this.lastUpdate = new Date(); + client.logger.success('Classement clan mis à jour avec succès'); + + } catch (error) { + client.logger.error(`Erreur lors de la mise à jour du classement clan: ${error.message}`); + } finally { + this.isUpdating = false; + } + } + + // Créer l'embed du classement du clan + async createClanRankingEmbed(topMembers, clanInfo, totalMembers, client) { + const embed = new EmbedBuilder() + .setColor('#FFD700') + .setTitle('🏰 Classement du Clan') + .setThumbnail(clanInfo?.badgeUrls?.large || null); + + // Description avec infos du clan + let description = ''; + if (clanInfo) { + description = `**${clanInfo.name}** \`${clanInfo.tag}\`\n`; + description += `👥 **${totalMembers}/50** membres • `; + description += `🏆 **${clanInfo.clanScore.toLocaleString()}** score clan\n`; + description += `🎯 **${clanInfo.requiredTrophies.toLocaleString()}** trophées requis\n\n`; + } + + embed.setDescription(description + '**🏆 Top 10 du Clan**'); + + if (topMembers.length === 0) { + embed.addFields([{ + name: 'Aucun membre', + value: 'Le clan semble vide.', + inline: false + }]); + return embed; + } + + // Ajouter les 3 premiers avec des médailles spéciales + const medals = ['🥇', '🥈', '🥉']; + + for (let i = 0; i < Math.min(3, topMembers.length); i++) { + const member = topMembers[i]; + const roleEmoji = this.getRoleEmoji(member.role); + + embed.addFields([{ + name: `${medals[i]} ${i + 1}. ${member.name}`, + value: `${roleEmoji} **${member.role}**\n🏆 **${member.trophies.toLocaleString()}** trophées\n💎 Niveau ${member.expLevel}`, + inline: i < 2 ? true : false + }]); + } + + // Ajouter le reste du top 10 dans un seul champ + if (topMembers.length > 3) { + let rankingText = ''; + + for (let i = 3; i < topMembers.length; i++) { + const member = topMembers[i]; + const roleEmoji = this.getRoleEmoji(member.role); + const trophyIcon = this.getTrophyIcon(member.trophies); + + rankingText += `**${i + 1}.** ${trophyIcon} ${member.name} ${roleEmoji} • **${member.trophies.toLocaleString()}**\n`; + } + + embed.addFields([{ + name: '🏅 Reste du classement', + value: rankingText || 'Aucun autre membre', + inline: false + }]); + } + + // Statistiques du clan + const totalTrophies = topMembers.reduce((sum, m) => sum + m.trophies, 0); + const averageTrophies = Math.round(totalTrophies / topMembers.length); + const highestTrophies = Math.max(...topMembers.map(m => m.trophies)); + + embed.addFields([{ + name: '📊 Statistiques Top 10', + value: `🏆 **${totalTrophies.toLocaleString()}** trophées total\n📈 **${averageTrophies.toLocaleString()}** moyenne\n🎯 **${highestTrophies.toLocaleString()}** meilleur score`, + inline: false + }]); + + embed.setFooter({ + text: `Mise à jour automatique toutes les 5min • ${new Date().toLocaleTimeString('fr-FR')}`, + iconURL: client.user.displayAvatarURL() + }); + + embed.setTimestamp(); + + return embed; + } + + // Obtenir l'emoji correspondant au rôle dans le clan + getRoleEmoji(role) { + switch (role.toLowerCase()) { + case 'leader': return '👑'; + case 'coleader': return '🔱'; + case 'elder': return '⭐'; + case 'member': return '🛡️'; + default: return '👤'; + } + } + + // Obtenir l'icône correspondant au niveau de trophées + getTrophyIcon(trophies) { + if (trophies >= 8000) return '👑'; + if (trophies >= 7000) return '💎'; + if (trophies >= 6000) return '🏆'; + if (trophies >= 5000) return '🥇'; + if (trophies >= 4000) return '🥈'; + if (trophies >= 3000) return '🥉'; + if (trophies >= 2000) return '🏅'; + if (trophies >= 1000) return '⭐'; + return '🔰'; + } + + // Envoyer ou mettre à jour le message de classement + async sendOrUpdateRanking(channel, embed, client) { + try { + // Chercher un message existant du bot dans les 50 derniers messages + const messages = await channel.messages.fetch({ limit: 50 }); + const botMessage = messages.find(msg => + msg.author.id === client.user.id && + msg.embeds.length > 0 && + (msg.embeds[0].title?.includes('Classement') || msg.embeds[0].title?.includes('Clan')) + ); + + if (botMessage) { + // Mettre à jour le message existant + await botMessage.edit({ embeds: [embed] }); + client.logger.system('Message de classement mis à jour'); + } else { + // Envoyer un nouveau message + await channel.send({ embeds: [embed] }); + client.logger.system('Nouveau message de classement envoyé'); + } + } catch (error) { + client.logger.error(`Erreur lors de l'envoi du classement: ${error.message}`); + // Si la mise à jour échoue, essayer d'envoyer un nouveau message + try { + await channel.send({ embeds: [embed] }); + } catch (retryError) { + client.logger.error(`Erreur lors du retry: ${retryError.message}`); + } + } + } + + // Obtenir le classement complet du clan + async getFullClanRanking() { + if (!this.clanTag) { + return { success: false, error: 'Clan tag not configured' }; + } + + const result = await clashRoyaleAPI.getClanMembers(this.clanTag); + + if (!result.success) { + return result; + } + + const members = result.data.items || []; + members.sort((a, b) => b.trophies - a.trophies); + + return { + success: true, + data: members.map((member, index) => ({ + ...member, + rank: index + 1 + })) + }; + } + + // Obtenir la position d'un membre spécifique + async getMemberRank(playerTag) { + const fullRanking = await this.getFullClanRanking(); + + if (!fullRanking.success) { + return null; + } + + const memberIndex = fullRanking.data.findIndex(member => + member.tag.toUpperCase() === playerTag.toUpperCase() + ); + + if (memberIndex === -1) { + return null; + } + + return { + rank: memberIndex + 1, + total: fullRanking.data.length, + member: fullRanking.data[memberIndex], + above: fullRanking.data[memberIndex - 1] || null, + below: fullRanking.data[memberIndex + 1] || null + }; + } +} + +// Instance singleton +const clanRankingService = new ClanRankingService(); + +module.exports = { + ClanRankingService, + clanRankingService, + updateClanRanking: (client) => clanRankingService.updateClanRanking(client), + startAutoUpdate: (client) => clanRankingService.startAutoUpdate(client) +}; \ No newline at end of file diff --git a/src/services/clashRoyaleService.js b/src/services/clashRoyaleService.js new file mode 100644 index 0000000..1288d6c --- /dev/null +++ b/src/services/clashRoyaleService.js @@ -0,0 +1,315 @@ +const axios = require('axios'); +const chalk = require('chalk'); + +class ClashRoyaleAPI { + constructor() { + this.baseURL = 'https://api.clashroyale.com/v1'; + this.token = process.env.CLASH_ROYALE_TOKEN; + + if (!this.token) { + throw new Error('CLASH_ROYALE_TOKEN manquant dans les variables d\'environnement'); + } + + this.client = axios.create({ + baseURL: this.baseURL, + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Accept': 'application/json' + }, + timeout: 10000 + }); + + // Intercepteur pour logger les requêtes + this.client.interceptors.request.use( + (config) => { + console.log(chalk.hex('#FFD700')('🌐 API'), `Requête vers: ${config.url}`); + return config; + }, + (error) => { + console.error(chalk.red('❌'), 'Erreur requête API:', error.message); + return Promise.reject(error); + } + ); + + this.client.interceptors.response.use( + (response) => { + console.log(chalk.hex('#FFD700')('✅ API'), `Réponse reçue: ${response.status}`); + return response; + }, + (error) => { + const status = error.response?.status; + const message = error.response?.data?.message || error.message; + console.error(chalk.red('❌ API'), `Erreur ${status}: ${message}`); + return Promise.reject(error); + } + ); + } + + // Normaliser un tag Clash Royale (ajouter # si nécessaire) + normalizeTag(tag) { + if (!tag) return null; + tag = tag.trim().toUpperCase(); + return tag.startsWith('#') ? tag : `#${tag}`; + } + + // Encoder un tag pour l'URL + encodeTag(tag) { + return encodeURIComponent(tag); + } + + // Obtenir les informations d'un joueur + async getPlayer(playerTag) { + try { + const normalizedTag = this.normalizeTag(playerTag); + const encodedTag = this.encodeTag(normalizedTag); + + const response = await this.client.get(`/players/${encodedTag}`); + return { + success: true, + data: response.data + }; + } catch (error) { + return { + success: false, + error: this.handleError(error), + data: null + }; + } + } + + // Obtenir les informations d'un clan + async getClan(clanTag) { + try { + const normalizedTag = this.normalizeTag(clanTag); + const encodedTag = this.encodeTag(normalizedTag); + + const response = await this.client.get(`/clans/${encodedTag}`); + return { + success: true, + data: response.data + }; + } catch (error) { + return { + success: false, + error: this.handleError(error), + data: null + }; + } + } + + // Obtenir les membres d'un clan + async getClanMembers(clanTag) { + try { + const normalizedTag = this.normalizeTag(clanTag); + const encodedTag = this.encodeTag(normalizedTag); + + const response = await this.client.get(`/clans/${encodedTag}/members`); + return { + success: true, + data: response.data + }; + } catch (error) { + return { + success: false, + error: this.handleError(error), + data: null + }; + } + } + + // Rechercher des clans par nom + async searchClans(name, limit = 10) { + try { + const response = await this.client.get('/clans', { + params: { + name: name, + limit: limit + } + }); + return { + success: true, + data: response.data + }; + } catch (error) { + return { + success: false, + error: this.handleError(error), + data: null + }; + } + } + + // Obtenir le classement global des joueurs + async getGlobalPlayerRanking(limit = 200) { + try { + const response = await this.client.get('/locations/global/rankings/players', { + params: { limit } + }); + return { + success: true, + data: response.data + }; + } catch (error) { + return { + success: false, + error: this.handleError(error), + data: null + }; + } + } + + // Obtenir le classement global des clans + async getGlobalClanRanking(limit = 200) { + try { + const response = await this.client.get('/locations/global/rankings/clans', { + params: { limit } + }); + return { + success: true, + data: response.data + }; + } catch (error) { + return { + success: false, + error: this.handleError(error), + data: null + }; + } + } + + // Vérifier si un joueur existe et obtenir ses informations de base + async validatePlayer(playerTag) { + const result = await this.getPlayer(playerTag); + + if (!result.success) { + return { + valid: false, + error: result.error, + data: null + }; + } + + const player = result.data; + return { + valid: true, + error: null, + data: { + tag: player.tag, + name: player.name, + trophies: player.trophies, + bestTrophies: player.bestTrophies, + expLevel: player.expLevel, + clan: player.clan ? { + tag: player.clan.tag, + name: player.clan.name, + role: player.role + } : null, + arena: player.arena, + wins: player.wins, + losses: player.losses, + battleCount: player.battleCount, + threeCrownWins: player.threeCrownWins + } + }; + } + + // Obtenir les statistiques détaillées d'un joueur + async getPlayerStats(playerTag) { + const result = await this.getPlayer(playerTag); + + if (!result.success) { + return result; + } + + const player = result.data; + return { + success: true, + data: { + basic: { + tag: player.tag, + name: player.name, + trophies: player.trophies, + bestTrophies: player.bestTrophies, + level: player.expLevel + }, + clan: player.clan ? { + tag: player.clan.tag, + name: player.clan.name, + role: player.role, + donations: player.donations, + donationsReceived: player.donationsReceived + } : null, + stats: { + wins: player.wins, + losses: player.losses, + battleCount: player.battleCount, + threeCrownWins: player.threeCrownWins, + winRate: player.battleCount > 0 ? ((player.wins / player.battleCount) * 100).toFixed(2) : 0 + }, + arena: player.arena, + leagueStatistics: player.leagueStatistics || null + } + }; + } + + // Gérer les erreurs de l'API + handleError(error) { + if (!error.response) { + return { + type: 'NETWORK_ERROR', + message: 'Erreur de connexion à l\'API Clash Royale', + details: error.message + }; + } + + const status = error.response.status; + const data = error.response.data; + + switch (status) { + case 400: + return { + type: 'BAD_REQUEST', + message: 'Paramètres de requête invalides', + details: data?.message || 'Vérifiez le format du tag' + }; + case 403: + return { + type: 'FORBIDDEN', + message: 'Token API invalide ou accès refusé', + details: data?.message || 'Vérifiez votre token API' + }; + case 404: + return { + type: 'NOT_FOUND', + message: 'Joueur ou clan non trouvé', + details: 'Vérifiez que le tag est correct' + }; + case 429: + return { + type: 'RATE_LIMITED', + message: 'Limite de requêtes atteinte', + details: 'Réessayez dans quelques minutes' + }; + case 503: + return { + type: 'SERVICE_UNAVAILABLE', + message: 'API Clash Royale temporairement indisponible', + details: 'Maintenance en cours' + }; + default: + return { + type: 'UNKNOWN_ERROR', + message: `Erreur API inconnue (${status})`, + details: data?.message || error.message + }; + } + } +} + +// Instance singleton +const clashRoyaleAPI = new ClashRoyaleAPI(); + +module.exports = { + ClashRoyaleAPI, + clashRoyaleAPI +}; \ No newline at end of file diff --git a/src/services/rankingService.js b/src/services/rankingService.js new file mode 100644 index 0000000..4da159d --- /dev/null +++ b/src/services/rankingService.js @@ -0,0 +1,260 @@ +const { EmbedBuilder, AttachmentBuilder } = require('discord.js'); +const { UserProfileService, SettingsService } = require('../utils/database'); +const { clashRoyaleAPI } = require('./clashRoyaleService'); + +class RankingService { + constructor() { + this.isUpdating = false; + this.lastUpdate = null; + } + + // Mettre à jour le classement dans le salon dédié + async updateRanking(client) { + if (this.isUpdating) { + client.logger.warning('Mise à jour du classement déjà en cours'); + return; + } + + this.isUpdating = true; + + try { + client.logger.system('Début de la mise à jour du classement...'); + + const rankingChannelId = process.env.RANKING_CHANNEL_ID; + if (!rankingChannelId) { + throw new Error('RANKING_CHANNEL_ID non configuré'); + } + + const channel = await client.channels.fetch(rankingChannelId); + if (!channel) { + throw new Error('Canal de classement non trouvé'); + } + + // Récupérer tous les profils synchronisés + const profiles = await UserProfileService.getAllVerified(); + + if (profiles.length === 0) { + const noPlayersEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('🏆 Classement du Serveur') + .setDescription('Aucun joueur synchronisé pour le moment.\n\nUtilisez `/sync` pour associer votre profil Clash Royale !') + .setTimestamp(); + + await this.sendOrUpdateRanking(channel, noPlayersEmbed); + return; + } + + client.logger.info(`Mise à jour de ${profiles.length} profils...`); + + // Mettre à jour les trophées de tous les joueurs + const updatedProfiles = []; + let updateCount = 0; + + for (const profile of profiles) { + try { + const playerResult = await clashRoyaleAPI.getPlayer(profile.clash_tag); + + if (playerResult.success) { + const currentTrophies = playerResult.data.trophies; + const oldTrophies = profile.current_trophies; + + // Mettre à jour si les trophées ont changé + if (currentTrophies !== oldTrophies) { + await UserProfileService.updateTrophies(profile.discord_id, currentTrophies); + updateCount++; + client.logger.info(`Trophées mis à jour: ${profile.clash_name} ${oldTrophies} -> ${currentTrophies}`); + } + + updatedProfiles.push({ + ...profile, + current_trophies: currentTrophies, + clash_name: playerResult.data.name, + level: playerResult.data.expLevel, + clan_name: playerResult.data.clan?.name || null, + clan_role: playerResult.data.clan?.role || null + }); + } else { + // Si l'API échoue, garder les données existantes + updatedProfiles.push(profile); + client.logger.warning(`Impossible de mettre à jour ${profile.clash_name}: ${playerResult.error.message}`); + } + + // Petit délai pour éviter les rate limits + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + updatedProfiles.push(profile); + client.logger.error(`Erreur lors de la mise à jour de ${profile.clash_name}: ${error.message}`); + } + } + + client.logger.success(`${updateCount} profils mis à jour`); + + // Trier par trophées décroissant + updatedProfiles.sort((a, b) => b.current_trophies - a.current_trophies); + + // Créer l'embed du classement + const rankingEmbed = await this.createRankingEmbed(updatedProfiles, client); + + // Envoyer ou mettre à jour le message + await this.sendOrUpdateRanking(channel, rankingEmbed); + + // Mettre à jour les statistiques + await SettingsService.set('last_ranking_update', Date.now().toString()); + await SettingsService.set('total_users_synced', updatedProfiles.length.toString()); + + this.lastUpdate = new Date(); + client.logger.success('Classement mis à jour avec succès'); + + } catch (error) { + client.logger.error(`Erreur lors de la mise à jour du classement: ${error.message}`); + throw error; + } finally { + this.isUpdating = false; + } + } + + // Créer l'embed du classement + async createRankingEmbed(profiles, client) { + const topProfiles = profiles.slice(0, 10); + const guildId = process.env.GUILD_ID; + const guild = await client.guilds.fetch(guildId); + + const embed = new EmbedBuilder() + .setColor('#FFD700') + .setTitle('🏆 Classement Top 10 du Serveur') + .setDescription(`**${guild.name}** • Dernière mise à jour`) + .setThumbnail(guild.iconURL({ dynamic: true })); + + if (topProfiles.length === 0) { + embed.setDescription('Aucun joueur dans le classement'); + return embed; + } + + // Ajouter les 3 premiers avec des médailles spéciales + const podiumFields = []; + const medals = ['🥇', '🥈', '🥉']; + + for (let i = 0; i < Math.min(3, topProfiles.length); i++) { + const profile = topProfiles[i]; + const member = guild.members.cache.get(profile.discord_id); + const displayName = member?.displayName || 'Utilisateur introuvable'; + + podiumFields.push({ + name: `${medals[i]} ${i + 1}. ${profile.clash_name}`, + value: `👤 ${displayName}\n🏆 **${profile.current_trophies.toLocaleString()}** trophées\n${profile.clan_name ? `🏰 ${profile.clan_name}` : ''}`, + inline: i === 2 ? false : true + }); + } + + embed.addFields(podiumFields); + + // Ajouter le reste du top 10 dans un seul champ + if (topProfiles.length > 3) { + let rankingText = ''; + + for (let i = 3; i < topProfiles.length; i++) { + const profile = topProfiles[i]; + const member = guild.members.cache.get(profile.discord_id); + const displayName = member?.displayName || 'Utilisateur introuvable'; + + const trophyIcon = this.getTrophyIcon(profile.current_trophies); + rankingText += `**${i + 1}.** ${trophyIcon} ${profile.clash_name} • **${profile.current_trophies.toLocaleString()}**\n`; + } + + embed.addFields([{ + name: '🏅 Reste du classement', + value: rankingText || 'Aucun autre joueur', + inline: false + }]); + } + + // Statistiques globales + const totalTrophies = profiles.reduce((sum, p) => sum + p.current_trophies, 0); + const averageTrophies = Math.round(totalTrophies / profiles.length); + + embed.addFields([{ + name: '📊 Statistiques du serveur', + value: `👥 **${profiles.length}** joueurs synchronisés\n🏆 **${totalTrophies.toLocaleString()}** trophées au total\n📈 **${averageTrophies.toLocaleString()}** trophées en moyenne`, + inline: false + }]); + + embed.setFooter({ + text: `Mise à jour automatique • Prochain update dans 1h • /sync pour rejoindre`, + iconURL: client.user.displayAvatarURL() + }); + + embed.setTimestamp(); + + return embed; + } + + // Obtenir l'icône correspondant au niveau de trophées + getTrophyIcon(trophies) { + if (trophies >= 8000) return '👑'; + if (trophies >= 7000) return '💎'; + if (trophies >= 6000) return '🏆'; + if (trophies >= 5000) return '🥇'; + if (trophies >= 4000) return '🥈'; + if (trophies >= 3000) return '🥉'; + if (trophies >= 2000) return '🏅'; + if (trophies >= 1000) return '⭐'; + return '🔰'; + } + + // Envoyer ou mettre à jour le message de classement + async sendOrUpdateRanking(channel, embed) { + try { + // Chercher un message existant du bot dans les 50 derniers messages + const messages = await channel.messages.fetch({ limit: 50 }); + const botMessage = messages.find(msg => + msg.author.id === channel.client.user.id && + msg.embeds.length > 0 && + msg.embeds[0].title?.includes('Classement') + ); + + if (botMessage) { + // Mettre à jour le message existant + await botMessage.edit({ embeds: [embed] }); + } else { + // Envoyer un nouveau message + await channel.send({ embeds: [embed] }); + } + } catch (error) { + // Si la mise à jour échoue, envoyer un nouveau message + await channel.send({ embeds: [embed] }); + } + } + + // Obtenir le classement d'un utilisateur spécifique + async getUserRank(discordId) { + const profiles = await UserProfileService.getAllVerified(); + profiles.sort((a, b) => b.current_trophies - a.current_trophies); + + const userIndex = profiles.findIndex(p => p.discord_id === discordId); + if (userIndex === -1) return null; + + return { + rank: userIndex + 1, + total: profiles.length, + profile: profiles[userIndex], + above: profiles[userIndex - 1] || null, + below: profiles[userIndex + 1] || null + }; + } + + // Obtenir le top N + async getTopPlayers(limit = 10) { + const profiles = await UserProfileService.getAllVerified(); + profiles.sort((a, b) => b.current_trophies - a.current_trophies); + return profiles.slice(0, limit); + } +} + +// Instance singleton +const rankingService = new RankingService(); + +module.exports = { + RankingService, + rankingService, + updateRanking: (client) => rankingService.updateRanking(client) +}; \ No newline at end of file diff --git a/src/services/trophyService.js b/src/services/trophyService.js new file mode 100644 index 0000000..b1bb7c6 --- /dev/null +++ b/src/services/trophyService.js @@ -0,0 +1,268 @@ +const { EmbedBuilder } = require('discord.js'); +const { TrophyHistoryService, UserProfileService } = require('../utils/database'); + +class TrophyService { + constructor() { + this.isChecking = false; + } + + // Vérifier les paliers de trophées et envoyer des félicitations + async checkTrophyMilestones(client) { + if (this.isChecking) { + client.logger.warning('Vérification des trophées déjà en cours'); + return; + } + + this.isChecking = true; + + try { + client.logger.trophy('Vérification des paliers de trophées...'); + + // Récupérer les paliers non félicités + const milestones = await TrophyHistoryService.getUncongratedMilestones(); + + if (milestones.length === 0) { + client.logger.trophy('Aucun nouveau palier à féliciter'); + return; + } + + const congratsChannelId = process.env.CONGRATULATIONS_CHANNEL_ID; + if (!congratsChannelId) { + client.logger.warning('Canal de félicitations non configuré'); + return; + } + + const channel = await client.channels.fetch(congratsChannelId); + if (!channel) { + client.logger.error('Canal de félicitations non trouvé'); + return; + } + + client.logger.trophy(`${milestones.length} paliers à féliciter`); + + for (const milestone of milestones) { + try { + await this.sendCongratulations(channel, milestone, client); + await TrophyHistoryService.markAsCongratulated(milestone.id); + client.logger.success(`Félicitations envoyées pour ${milestone.clash_name} (${milestone.milestone_reached} trophées)`); + + // Petit délai entre les messages + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (error) { + client.logger.error(`Erreur lors de l'envoi des félicitations pour ${milestone.clash_name}: ${error.message}`); + } + } + + } catch (error) { + client.logger.error(`Erreur lors de la vérification des trophées: ${error.message}`); + throw error; + } finally { + this.isChecking = false; + } + } + + // Envoyer un message de félicitations + async sendCongratulations(channel, milestone, client) { + const { discord_id, clash_name, milestone_reached, trophy_gain, new_trophies } = milestone; + + const guild = channel.guild; + const member = await guild.members.fetch(discord_id).catch(() => null); + + if (!member) { + client.logger.warning(`Membre Discord non trouvé pour ${clash_name}`); + return; + } + + // Déterminer le type de félicitations selon le palier + const congratsData = this.getCongratulationsData(milestone_reached, new_trophies); + + const embed = new EmbedBuilder() + .setColor(congratsData.color) + .setTitle(congratsData.title) + .setDescription(congratsData.description) + .setThumbnail(member.displayAvatarURL({ dynamic: true, size: 128 })) + .addFields([ + { + name: '👤 Joueur', + value: `**${clash_name}**\n<@${discord_id}>`, + inline: true + }, + { + name: '🏆 Nouveau palier', + value: `**${milestone_reached.toLocaleString()}** trophées`, + inline: true + }, + { + name: '📈 Gain récent', + value: `+${trophy_gain.toLocaleString()} trophées`, + inline: true + } + ]) + .addFields([ + { + name: `${congratsData.emoji} Message`, + value: congratsData.message, + inline: false + } + ]) + .setFooter({ + text: `Continuez comme ça ! • Clash Royale Bot`, + iconURL: client.user.displayAvatarURL() + }) + .setTimestamp(); + + // Ajouter des réactions selon le niveau + const message = await channel.send({ + content: `🎉 <@${discord_id}>`, + embeds: [embed] + }); + + // Ajouter des réactions automatiques + const reactions = this.getReactionsForMilestone(milestone_reached); + for (const reaction of reactions) { + try { + await message.react(reaction); + await new Promise(resolve => setTimeout(resolve, 300)); + } catch (error) { + // Ignorer les erreurs de réactions + } + } + } + + // Obtenir les données de félicitations selon le palier + getCongratulationsData(milestone, currentTrophies) { + const data = { + 1000: { + title: '🎉 Premier Palier Atteint !', + description: '**Félicitations pour vos 1000 trophées !**', + message: 'Excellente progression ! Vous entrez dans la cour des grands. Continuez sur cette lancée !', + color: '#00FF00', + emoji: '🔰' + }, + 2000: { + title: '⭐ 2000 Trophées !', + description: '**Bravo, vous montez en grade !**', + message: 'Votre détermination paie ! Vous progressez rapidement vers les ligues supérieures.', + color: '#FFD700', + emoji: '⭐' + }, + 3000: { + title: '🥉 3000 Trophées !', + description: '**Niveau bronze atteint !**', + message: 'Impressionnant ! Vous développez vraiment votre jeu et ça se voit !', + color: '#CD7F32', + emoji: '🥉' + }, + 4000: { + title: '🥈 4000 Trophées !', + description: '**Niveau argent débloqué !**', + message: 'Fantastique ! Vous faites partie des joueurs sérieux maintenant. Respect ! 💪', + color: '#C0C0C0', + emoji: '🥈' + }, + 5000: { + title: '🥇 5000 Trophées !', + description: '**Niveau or atteint !**', + message: 'INCROYABLE ! 🔥 Vous êtes maintenant dans l\'élite du serveur ! Quel talent !', + color: '#FFD700', + emoji: '🥇' + }, + 6000: { + title: '🏆 6000 Trophées !', + description: '**Maître confirmé !**', + message: 'EXTRAORDINAIRE ! 🚀 Peu de joueurs atteignent ce niveau. Vous êtes une légende !', + color: '#FF6B35', + emoji: '🏆' + }, + 7000: { + title: '💎 7000 Trophées !', + description: '**Rang de diamant !**', + message: 'PHÉNOMÉNAL ! 💎✨ Vous brillez comme un diamant ! Performance absolument époustouflante !', + color: '#00CED1', + emoji: '💎' + }, + 8000: { + title: '👑 8000 Trophées !', + description: '**CHAMPION SUPRÊME !**', + message: '👑 LÉGENDAIRE ! 👑 Vous régner sur l\'arène ! Une performance digne des plus grands champions ! 🔥🎯', + color: '#9932CC', + emoji: '👑' + } + }; + + // Si le palier n'est pas défini, créer un message générique + if (!data[milestone]) { + const isHighLevel = milestone >= 9000; + return { + title: `${isHighLevel ? '🌟' : '🎯'} ${milestone.toLocaleString()} Trophées !`, + description: `**${isHighLevel ? 'Niveau cosmique atteint !' : 'Nouveau palier débloqué !'}**`, + message: isHighLevel + ? `🌟 COSMIQUE ! 🌟 Vous transcendez le jeu lui-même ! Performance légendaire de ${milestone.toLocaleString()} trophées ! 🚀✨` + : `Bravo pour ce nouveau palier de ${milestone.toLocaleString()} trophées ! Votre progression est remarquable ! 🎯`, + color: isHighLevel ? '#FF1493' : '#32CD32', + emoji: isHighLevel ? '🌟' : '🎯' + }; + } + + return data[milestone]; + } + + // Obtenir les réactions à ajouter selon le palier + getReactionsForMilestone(milestone) { + if (milestone >= 8000) return ['👑', '🔥', '⚡', '🌟', '💎']; + if (milestone >= 7000) return ['💎', '✨', '🔥', '⚡']; + if (milestone >= 6000) return ['🏆', '🔥', '💪', '⚡']; + if (milestone >= 5000) return ['🥇', '🔥', '💪']; + if (milestone >= 4000) return ['🥈', '💪', '👏']; + if (milestone >= 3000) return ['🥉', '👏', '🎯']; + if (milestone >= 2000) return ['⭐', '👏']; + return ['🎉', '👏']; + } + + // Analyser les gains de trophées récents + async getRecentGainsAnalysis(hours = 24) { + const gains = await TrophyHistoryService.getRecentGains(hours); + + const analysis = { + totalPlayers: gains.length, + totalGains: gains.reduce((sum, g) => sum + (g.trophy_gain > 0 ? g.trophy_gain : 0), 0), + totalLosses: Math.abs(gains.reduce((sum, g) => sum + (g.trophy_gain < 0 ? g.trophy_gain : 0), 0)), + biggestGain: gains.length > 0 ? Math.max(...gains.map(g => g.trophy_gain)) : 0, + biggestLoss: gains.length > 0 ? Math.min(...gains.map(g => g.trophy_gain)) : 0, + milestones: gains.filter(g => g.milestone_reached !== null).length + }; + + return analysis; + } + + // Obtenir les stats d'un joueur spécifique + async getPlayerTrophyStats(discordId) { + const profile = await UserProfileService.getByDiscordId(discordId); + if (!profile) return null; + + // Obtenir l'historique récent + const recentGains = await TrophyHistoryService.getRecentGains(168); // 7 jours + + const playerGains = recentGains.filter(g => g.discord_id === discordId); + + const stats = { + currentTrophies: profile.current_trophies, + bestTrophies: profile.highest_trophies, + recentChanges: playerGains.length, + weeklyGain: playerGains.reduce((sum, g) => sum + g.trophy_gain, 0), + biggestDailyGain: playerGains.length > 0 ? Math.max(...playerGains.map(g => g.trophy_gain)) : 0, + milestonesThisWeek: playerGains.filter(g => g.milestone_reached !== null).length + }; + + return stats; + } +} + +// Instance singleton +const trophyService = new TrophyService(); + +module.exports = { + TrophyService, + trophyService, + checkTrophyMilestones: (client) => trophyService.checkTrophyMilestones(client) +}; \ No newline at end of file diff --git a/src/utils/database.js b/src/utils/database.js new file mode 100644 index 0000000..e587e69 --- /dev/null +++ b/src/utils/database.js @@ -0,0 +1,382 @@ +const sqlite3 = require('sqlite3').verbose(); +const { readFileSync } = require('fs'); +const { join } = require('path'); +const chalk = require('chalk'); + +class Database { + constructor() { + this.db = null; + this.isInitialized = false; + } + + async init() { + return new Promise((resolve, reject) => { + const dbPath = process.env.DATABASE_PATH || './database/bot.db'; + + this.db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error(chalk.red('❌ Erreur connexion base de données:'), err.message); + reject(err); + return; + } + + console.log(chalk.green('✅ Base de données connectée:'), chalk.cyan(dbPath)); + this.isInitialized = true; + resolve(); + }); + + // Activer les clés étrangères + this.db.run('PRAGMA foreign_keys = ON'); + }); + } + + async executeSchema() { + if (!this.isInitialized) { + throw new Error('Base de données non initialisée'); + } + + return new Promise((resolve, reject) => { + try { + // Inline schema to avoid file reading issues - with safe creation + const schema = ` + -- Drop views first (they depend on tables) + DROP VIEW IF EXISTS recent_trophy_gains; + DROP VIEW IF EXISTS ranking_view; + + -- Drop tables + DROP TABLE IF EXISTS trophy_history; + DROP TABLE IF EXISTS user_profiles; + DROP TABLE IF EXISTS bot_settings; + + -- Create tables + CREATE TABLE IF NOT EXISTS user_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT UNIQUE NOT NULL, + clash_tag TEXT UNIQUE NOT NULL, + clash_name TEXT NOT NULL, + current_trophies INTEGER DEFAULT 0, + highest_trophies INTEGER DEFAULT 0, + level INTEGER DEFAULT 1, + clan_name TEXT, + clan_tag TEXT, + clan_role TEXT, + verified BOOLEAN DEFAULT 0, + sync_date DATETIME DEFAULT CURRENT_TIMESTAMP, + last_update DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS trophy_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_profile_id INTEGER NOT NULL, + old_trophies INTEGER NOT NULL, + new_trophies INTEGER NOT NULL, + trophy_gain INTEGER NOT NULL, + milestone_reached INTEGER, + congratulated BOOLEAN DEFAULT 0, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS bot_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + setting_key TEXT UNIQUE NOT NULL, + setting_value TEXT NOT NULL, + description TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Create indexes if they don't exist + CREATE INDEX IF NOT EXISTS idx_user_profiles_discord_id ON user_profiles(discord_id); + CREATE INDEX IF NOT EXISTS idx_user_profiles_clash_tag ON user_profiles(clash_tag); + CREATE INDEX IF NOT EXISTS idx_trophy_history_user_profile ON trophy_history(user_profile_id); + CREATE INDEX IF NOT EXISTS idx_trophy_history_recorded_at ON trophy_history(recorded_at); + CREATE INDEX IF NOT EXISTS idx_trophy_history_milestone ON trophy_history(milestone_reached); + CREATE INDEX IF NOT EXISTS idx_bot_settings_key ON bot_settings(setting_key); + + -- Insert default settings (ignore if they already exist) + INSERT OR IGNORE INTO bot_settings (setting_key, setting_value, description) VALUES + ('last_ranking_update', '0', 'Last ranking update timestamp'), + ('total_users_synced', '0', 'Total number of synced users'), + ('trophy_check_enabled', 'true', 'Enable trophy milestone checking'), + ('ranking_update_enabled', 'true', 'Enable automatic ranking updates'), + ('congratulations_enabled', 'true', 'Enable automatic congratulations'), + ('bot_version', '1.0.0', 'Current bot version'), + ('maintenance_mode', 'false', 'Bot maintenance mode'); + + -- Create views (will be recreated if dropped above) + CREATE VIEW IF NOT EXISTS ranking_view AS + SELECT + up.discord_id, + up.clash_name, + up.current_trophies, + up.highest_trophies, + up.level, + up.clan_name, + up.clan_role, + ROW_NUMBER() OVER (ORDER BY up.current_trophies DESC) as rank_position + FROM user_profiles up + WHERE up.verified = 1 + ORDER BY up.current_trophies DESC; + + CREATE VIEW IF NOT EXISTS recent_trophy_gains AS + SELECT + up.discord_id, + up.clash_name, + th.old_trophies, + th.new_trophies, + th.trophy_gain, + th.milestone_reached, + th.congratulated, + th.recorded_at + FROM trophy_history th + JOIN user_profiles up ON th.user_profile_id = up.id + WHERE th.recorded_at >= datetime('now', '-1 day') + AND th.trophy_gain > 0 + ORDER BY th.recorded_at DESC; + `; + + this.db.exec(schema, (err) => { + if (err) { + console.error(chalk.red('❌ Erreur exécution schéma:'), err.message); + reject(err); + return; + } + + console.log(chalk.green('✅ Schéma de base de données appliqué')); + resolve(); + }); + } catch (error) { + reject(error); + } + }); + } + + // Méthodes utilitaires pour les requêtes + run(sql, params = []) { + return new Promise((resolve, reject) => { + this.db.run(sql, params, function (err) { + if (err) { + reject(err); + return; + } + resolve({ id: this.lastID, changes: this.changes }); + }); + }); + } + + get(sql, params = []) { + return new Promise((resolve, reject) => { + this.db.get(sql, params, (err, row) => { + if (err) { + reject(err); + return; + } + resolve(row); + }); + }); + } + + all(sql, params = []) { + return new Promise((resolve, reject) => { + this.db.all(sql, params, (err, rows) => { + if (err) { + reject(err); + return; + } + resolve(rows); + }); + }); + } + + close() { + return new Promise((resolve, reject) => { + if (!this.db) { + resolve(); + return; + } + + this.db.close((err) => { + if (err) { + reject(err); + return; + } + console.log(chalk.yellow('⚠️ Connexion base de données fermée')); + resolve(); + }); + }); + } +} + +// Instance singleton +const database = new Database(); + +// Fonction d'initialisation +async function initDB() { + try { + await database.init(); + await database.executeSchema(); + console.log(chalk.green('🗄️ Base de données entièrement configurée')); + return database; + } catch (error) { + console.error(chalk.red('❌ Erreur initialisation base de données:'), error.message); + throw error; + } +} + +// Méthodes spécifiques au bot +const UserProfileService = { + async createOrUpdate(discordId, clashData) { + const existingUser = await database.get( + 'SELECT * FROM user_profiles WHERE discord_id = ?', + [discordId] + ); + + if (existingUser) { + // Mettre à jour l'utilisateur existant + await database.run(` + UPDATE user_profiles SET + clash_tag = ?, clash_name = ?, current_trophies = ?, + highest_trophies = ?, level = ?, clan_name = ?, + clan_tag = ?, clan_role = ?, verified = 1, + last_update = CURRENT_TIMESTAMP + WHERE discord_id = ? + `, [ + clashData.tag, clashData.name, clashData.trophies, + clashData.bestTrophies, clashData.expLevel, + clashData.clan?.name || null, clashData.clan?.tag || null, + clashData.clan?.role || null, discordId + ]); + + return { ...existingUser, ...clashData }; + } else { + // Créer un nouveau profil + const result = await database.run(` + INSERT INTO user_profiles + (discord_id, clash_tag, clash_name, current_trophies, highest_trophies, + level, clan_name, clan_tag, clan_role, verified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + `, [ + discordId, clashData.tag, clashData.name, clashData.trophies, + clashData.bestTrophies, clashData.expLevel, + clashData.clan?.name || null, clashData.clan?.tag || null, + clashData.clan?.role || null + ]); + + return { id: result.id, discord_id: discordId, ...clashData }; + } + }, + + async getByDiscordId(discordId) { + return await database.get( + 'SELECT * FROM user_profiles WHERE discord_id = ?', + [discordId] + ); + }, + + async getByClashTag(clashTag) { + return await database.get( + 'SELECT * FROM user_profiles WHERE clash_tag = ?', + [clashTag] + ); + }, + + async getAllVerified() { + return await database.all( + 'SELECT * FROM user_profiles WHERE verified = 1 ORDER BY current_trophies DESC' + ); + }, + + async getTopPlayers(limit = 10) { + return await database.all( + 'SELECT * FROM ranking_view LIMIT ?', + [limit] + ); + }, + + async updateTrophies(discordId, newTrophies) { + const user = await this.getByDiscordId(discordId); + if (!user) return null; + + const oldTrophies = user.current_trophies; + const trophyGain = newTrophies - oldTrophies; + + // Mettre à jour les trophées + await database.run( + 'UPDATE user_profiles SET current_trophies = ?, last_update = CURRENT_TIMESTAMP WHERE discord_id = ?', + [newTrophies, discordId] + ); + + // Enregistrer l'historique si il y a un changement + if (trophyGain !== 0) { + const milestone = Math.floor(newTrophies / 1000) * 1000; + const oldMilestone = Math.floor(oldTrophies / 1000) * 1000; + const milestoneReached = milestone > oldMilestone ? milestone : null; + + await database.run(` + INSERT INTO trophy_history + (user_profile_id, old_trophies, new_trophies, trophy_gain, milestone_reached) + VALUES (?, ?, ?, ?, ?) + `, [user.id, oldTrophies, newTrophies, trophyGain, milestoneReached]); + } + + return { oldTrophies, newTrophies, trophyGain }; + } +}; + +const TrophyHistoryService = { + async getUncongratedMilestones() { + return await database.all(` + SELECT th.*, up.discord_id, up.clash_name + FROM trophy_history th + JOIN user_profiles up ON th.user_profile_id = up.id + WHERE th.milestone_reached IS NOT NULL + AND th.congratulated = FALSE + AND th.trophy_gain > 0 + `); + }, + + async markAsCongratulated(historyId) { + await database.run( + 'UPDATE trophy_history SET congratulated = TRUE WHERE id = ?', + [historyId] + ); + }, + + async getRecentGains(hours = 24) { + return await database.all( + 'SELECT * FROM recent_trophy_gains WHERE recorded_at >= datetime("now", "-" || ? || " hours")', + [hours] + ); + } +}; + +const SettingsService = { + async get(key) { + const result = await database.get( + 'SELECT setting_value FROM bot_settings WHERE setting_key = ?', + [key] + ); + return result ? result.setting_value : null; + }, + + async set(key, value) { + await database.run(` + INSERT OR REPLACE INTO bot_settings (setting_key, setting_value) + VALUES (?, ?) + `, [key, value]); + }, + + async increment(key, amount = 1) { + const currentValue = parseInt(await this.get(key)) || 0; + await this.set(key, currentValue + amount); + } +}; + +module.exports = { + database, + initDB, + UserProfileService, + TrophyHistoryService, + SettingsService +}; \ No newline at end of file diff --git a/start.js b/start.js new file mode 100644 index 0000000..cc24361 --- /dev/null +++ b/start.js @@ -0,0 +1,181 @@ +const { Client, GatewayIntentBits, Collection, ActivityType } = require('discord.js'); +const { readdirSync } = require('fs'); +const { join } = require('path'); +const chalk = require('chalk'); +const cron = require('node-cron'); +require('dotenv').config(); + +// Configuration des couleurs pour les logs +const colors = { + success: chalk.green.bold, + error: chalk.red.bold, + warning: chalk.yellow.bold, + info: chalk.blue.bold, + debug: chalk.magenta.bold, + system: chalk.cyan.bold, + command: chalk.white.bold, + event: chalk.greenBright.bold, + database: chalk.blueBright.bold, + api: chalk.yellowBright.bold, + discord: chalk.hex('#5865F2').bold, + clash: chalk.hex('#FFD700').bold, + trophy: chalk.hex('#FF6B35').bold +}; + +// Fonction de logging avec couleurs +const logger = { + success: (msg) => console.log(`${colors.success('✅ SUCCESS')} ${chalk.white(msg)}`), + error: (msg) => console.log(`${colors.error('❌ ERROR')} ${chalk.white(msg)}`), + warning: (msg) => console.log(`${colors.warning('⚠️ WARNING')} ${chalk.white(msg)}`), + info: (msg) => console.log(`${colors.info('ℹ️ INFO')} ${chalk.white(msg)}`), + debug: (msg) => console.log(`${colors.debug('🔍 DEBUG')} ${chalk.white(msg)}`), + system: (msg) => console.log(`${colors.system('⚙️ SYSTEM')} ${chalk.white(msg)}`), + command: (msg) => console.log(`${colors.command('🔧 COMMAND')} ${chalk.white(msg)}`), + event: (msg) => console.log(`${colors.event('📡 EVENT')} ${chalk.white(msg)}`), + database: (msg) => console.log(`${colors.database('🗄️ DATABASE')} ${chalk.white(msg)}`), + api: (msg) => console.log(`${colors.api('🌐 API')} ${chalk.white(msg)}`), + discord: (msg) => console.log(`${colors.discord('🎮 DISCORD')} ${chalk.white(msg)}`), + clash: (msg) => console.log(`${colors.clash('⚔️ CLASH')} ${chalk.white(msg)}`), + trophy: (msg) => console.log(`${colors.trophy('🏆 TROPHY')} ${chalk.white(msg)}`) +}; + +// Banner d'initialisation +function displayBanner() { + console.clear(); + console.log(colors.clash('╔══════════════════════════════════════════════════════════════════╗')); + console.log(colors.clash('║ 🏰 CLASH ROYALE DISCORD BOT 🏰 ║')); + console.log(colors.clash('║ By Neptunia Team ║')); + console.log(colors.clash('╠══════════════════════════════════════════════════════════════════╣')); + console.log(colors.clash('║ 🎮 Discord Integration | ⚔️ Clash Royale API | 🏆 Rankings ║')); + console.log(colors.clash('╚══════════════════════════════════════════════════════════════════╝')); + console.log(); +} + +// Initialisation du client Discord +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMessageReactions + ] +}); + +// Collections pour stocker les handlers +client.commands = new Collection(); +client.events = new Collection(); +client.components = new Collection(); +client.logger = logger; + +// Fonction pour charger les handlers +async function loadHandlers() { + const handlerFiles = readdirSync(join(__dirname, 'src', 'handlers')).filter(file => file.endsWith('.js')); + + logger.system('Chargement des handlers...'); + + for (const file of handlerFiles) { + try { + const handler = require(join(__dirname, 'src', 'handlers', file)); + await handler(client); + logger.success(`Handler chargé: ${colors.command(file)}`); + } catch (error) { + logger.error(`Erreur lors du chargement du handler ${file}: ${error.message}`); + } + } +} + +// Fonction pour initialiser la base de données +async function initializeDatabase() { + try { + const { initDB } = require('./src/utils/database'); + await initDB(); + logger.database('Base de données initialisée avec succès'); + } catch (error) { + logger.error(`Erreur lors de l'initialisation de la base de données: ${error.message}`); + process.exit(1); + } +} + +// Fonction pour programmer les tâches automatiques +function scheduleTasks() { + // Vérification des trophées toutes les 30 minutes + cron.schedule('*/30 * * * *', async () => { + try { + const { checkTrophyMilestones } = require('./src/services/trophyService'); + await checkTrophyMilestones(client); + logger.system('Vérification des trophées effectuée'); + } catch (error) { + logger.error(`Erreur lors de la vérification des trophées: ${error.message}`); + } + }); + + logger.system('Tâches automatiques programmées (trophées uniquement)'); +} + +// Fonction principale d'initialisation +async function initialize() { + displayBanner(); + + logger.system('🚀 Initialisation du bot en cours...'); + + try { + // Vérification des variables d'environnement + if (!process.env.DISCORD_TOKEN) { + throw new Error('DISCORD_TOKEN manquant dans le fichier .env'); + } + + if (!process.env.CLASH_ROYALE_TOKEN) { + throw new Error('CLASH_ROYALE_TOKEN manquant dans le fichier .env'); + } + + logger.success('Variables d\'environnement validées'); + + // Initialisation de la base de données + await initializeDatabase(); + + // Chargement des handlers + await loadHandlers(); + + // Connexion à Discord + logger.discord('Connexion à Discord en cours...'); + await client.login(process.env.DISCORD_TOKEN); + + } catch (error) { + logger.error(`Erreur fatale lors de l'initialisation: ${error.message}`); + process.exit(1); + } +} + +// L'événement ready est géré par src/events/ready.js +// Programmation des tâches automatiques (trophées uniquement) +client.once('ready', () => { + scheduleTasks(); +}); + +// Gestion des erreurs globales +process.on('unhandledRejection', (reason, promise) => { + logger.error(`Promesse rejetée non gérée: ${reason}`); + console.error(promise); +}); + +process.on('uncaughtException', (error) => { + logger.error(`Exception non capturée: ${error.message}`); + console.error(error); +}); + +// Gestion de l'arrêt propre +process.on('SIGINT', () => { + logger.warning('Signal SIGINT reçu, arrêt du bot...'); + client.destroy(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + logger.warning('Signal SIGTERM reçu, arrêt du bot...'); + client.destroy(); + process.exit(0); +}); + +// Démarrage du bot +initialize(); \ No newline at end of file