From fcffa00ec8f4b5e2c7976d0ccba078476c39f05d Mon Sep 17 00:00:00 2001 From: Arthur Puechberty Date: Sun, 18 Jan 2026 00:53:23 +0100 Subject: [PATCH] add vocal stats system --- app/bot.js | 271 ++++++++++++++++++++++++++ app/db.js | 10 + app/public/dashboard.css | 4 +- app/public/dashboard.html | 5 +- app/public/guild.css | 37 ++++ app/public/guild.html | 79 ++++++++ app/public/guild/statsChannelsForm.js | 197 +++++++++++++++++++ app/public/index.html | 7 +- app/routes/api.js | 75 +++++++ 9 files changed, 680 insertions(+), 5 deletions(-) create mode 100644 app/public/guild/statsChannelsForm.js diff --git a/app/bot.js b/app/bot.js index 3cbd573..def4d6d 100644 --- a/app/bot.js +++ b/app/bot.js @@ -140,6 +140,277 @@ setInterval(() => { }, 60 * 1000); // Toutes les minutes +// ===== STATS CHANNELS UPDATE ===== +// Met à jour les noms des salons de statistiques toutes les 5 minutes +async function updateStatsChannels() { + try { + const statsChannels = await db.allAsync(`SELECT * FROM stats_channels`); + + for (const config of statsChannels) { + const guild = client.guilds.cache.get(config.guild_id); + if (!guild) continue; + + const channel = guild.channels.cache.get(config.channel_id); + if (!channel) continue; + + let statValue; + + switch (config.stat_type) { + case "members": + // Total des membres + statValue = guild.memberCount; + break; + + case "humans": + // Membres sans les bots + await guild.members.fetch(); + statValue = guild.members.cache.filter(m => !m.user.bot).size; + break; + + case "bots": + // Nombre de bots + await guild.members.fetch(); + statValue = guild.members.cache.filter(m => m.user.bot).size; + break; + + case "online": + // Membres en ligne (online, idle, dnd) + await guild.members.fetch({ withPresences: true }); + statValue = guild.members.cache.filter(m => + m.presence && ["online", "idle", "dnd"].includes(m.presence.status) + ).size; + break; + + case "voice": + // Membres en vocal + statValue = guild.members.cache.filter(m => m.voice.channelId).size; + break; + + case "roles": + // Nombre de rôles + statValue = guild.roles.cache.size; + break; + + case "channels": + // Nombre de salons + statValue = guild.channels.cache.size; + break; + + case "boosts": + // Nombre de boosts + statValue = guild.premiumSubscriptionCount || 0; + break; + + case "boost_level": + // Niveau de boost + statValue = guild.premiumTier; + break; + + case "role_members": + // Membres ayant un rôle spécifique + if (config.role_id) { + await guild.members.fetch(); + const role = guild.roles.cache.get(config.role_id); + statValue = role ? role.members.size : 0; + } else { + statValue = 0; + } + break; + + default: + statValue = "?"; + } + + // Construire le nouveau nom + const newName = config.format.replace("{stat}", statValue); + + // Ne mettre à jour que si le nom a changé + if (channel.name !== newName) { + try { + await channel.setName(newName); + } catch (err) { + console.error(`Erreur lors de la mise à jour du salon ${config.channel_id}:`, err.message); + } + } + } + } catch (err) { + console.error("Erreur updateStatsChannels:", err); + } +} + +// Met à jour uniquement les stats d'un type spécifique pour un serveur +async function updateGuildStats(guildId, statTypes) { + try { + const statsChannels = await db.allAsync( + `SELECT * FROM stats_channels WHERE guild_id = ? AND stat_type IN (${statTypes.map(() => '?').join(',')})`, + [guildId, ...statTypes] + ); + + const guild = client.guilds.cache.get(guildId); + if (!guild) return; + + for (const config of statsChannels) { + const channel = guild.channels.cache.get(config.channel_id); + if (!channel) continue; + + let statValue; + + switch (config.stat_type) { + case "members": + statValue = guild.memberCount; + break; + case "humans": + statValue = guild.members.cache.filter(m => !m.user.bot).size; + break; + case "bots": + statValue = guild.members.cache.filter(m => m.user.bot).size; + break; + case "online": + statValue = guild.members.cache.filter(m => + m.presence && ["online", "idle", "dnd"].includes(m.presence.status) + ).size; + break; + case "voice": + statValue = guild.members.cache.filter(m => m.voice.channelId).size; + break; + case "roles": + statValue = guild.roles.cache.size; + break; + case "channels": + statValue = guild.channels.cache.size; + break; + case "boosts": + statValue = guild.premiumSubscriptionCount || 0; + break; + case "boost_level": + statValue = guild.premiumTier; + break; + case "role_members": + if (config.role_id) { + const role = guild.roles.cache.get(config.role_id); + statValue = role ? role.members.size : 0; + } else { + statValue = 0; + } + break; + default: + statValue = "?"; + } + + const newName = config.format.replace("{stat}", statValue); + + if (channel.name !== newName) { + try { + await channel.setName(newName); + } catch (err) { + console.error(`Erreur mise à jour salon ${config.channel_id}:`, err.message); + } + } + } + } catch (err) { + console.error("Erreur updateGuildStats:", err); + } +} + +// Debounce pour éviter le rate limiting (Discord limite le renommage de salon à 2 fois par 10 minutes) +const statsDebounceTimers = new Map(); +function debounceStatsUpdate(guildId, statTypes, delay = 10000) { + const key = `${guildId}-${statTypes.sort().join(",")}`; + + if (statsDebounceTimers.has(key)) { + clearTimeout(statsDebounceTimers.get(key)); + } + + statsDebounceTimers.set(key, setTimeout(() => { + updateGuildStats(guildId, statTypes); + statsDebounceTimers.delete(key); + }, delay)); +} + +// ===== ÉVÉNEMENTS POUR LES STATS ===== + +// Membre rejoint/quitte -> members, humans, bots +client.on("guildMemberAdd", (member) => { + const types = ["members", "humans"]; + if (member.user.bot) types.push("bots"); + debounceStatsUpdate(member.guild.id, types); +}); + +client.on("guildMemberRemove", (member) => { + const types = ["members", "humans"]; + if (member.user.bot) types.push("bots"); + debounceStatsUpdate(member.guild.id, types); +}); + +// Changement de présence -> online +client.on("presenceUpdate", (oldPresence, newPresence) => { + if (!newPresence || !newPresence.guild) return; + const wasOnline = oldPresence && ["online", "idle", "dnd"].includes(oldPresence.status); + const isOnline = ["online", "idle", "dnd"].includes(newPresence.status); + if (wasOnline !== isOnline) { + debounceStatsUpdate(newPresence.guild.id, ["online"]); + } +}); + +// Changement vocal -> voice (géré dans voiceStateUpdate.js mais on ajoute ici pour les stats) +client.on("voiceStateUpdate", (oldState, newState) => { + const guildId = newState.guild?.id || oldState.guild?.id; + if (!guildId) return; + // Si rejoint ou quitte un vocal + if (oldState.channelId !== newState.channelId) { + debounceStatsUpdate(guildId, ["voice"]); + } +}); + +// Rôle créé/supprimé -> roles +client.on("roleCreate", (role) => { + debounceStatsUpdate(role.guild.id, ["roles"]); +}); + +client.on("roleDelete", (role) => { + debounceStatsUpdate(role.guild.id, ["roles"]); +}); + +// Salon créé/supprimé -> channels +client.on("channelCreate", (channel) => { + if (channel.guild) debounceStatsUpdate(channel.guild.id, ["channels"]); +}); + +client.on("channelDelete", (channel) => { + if (channel.guild) debounceStatsUpdate(channel.guild.id, ["channels"]); +}); + +// Mise à jour du serveur -> boosts, boost_level +client.on("guildUpdate", (oldGuild, newGuild) => { + const types = []; + if (oldGuild.premiumSubscriptionCount !== newGuild.premiumSubscriptionCount) { + types.push("boosts"); + } + if (oldGuild.premiumTier !== newGuild.premiumTier) { + types.push("boost_level"); + } + if (types.length > 0) { + debounceStatsUpdate(newGuild.id, types); + } +}); + +// Mise à jour membre (rôle ajouté/retiré) -> role_members +client.on("guildMemberUpdate", (oldMember, newMember) => { + const oldRoles = oldMember.roles.cache; + const newRoles = newMember.roles.cache; + if (oldRoles.size !== newRoles.size || !oldRoles.every((r, id) => newRoles.has(id))) { + debounceStatsUpdate(newMember.guild.id, ["role_members"]); + } +}); + +// Au démarrage du bot -> toutes les stats +client.once("clientReady", async () => { + console.log("📊 Mise à jour initiale des salons de statistiques..."); + await updateStatsChannels(); +}); + + client.login(process.env.BOT_TOKEN); module.exports = client; +module.exports.updateGuildStats = updateGuildStats; diff --git a/app/db.js b/app/db.js index 88a8466..3adcdb9 100644 --- a/app/db.js +++ b/app/db.js @@ -184,6 +184,16 @@ db.exec(` current_count INTEGER NOT NULL DEFAULT 0, last_user_id TEXT ); + + CREATE TABLE IF NOT EXISTS stats_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + stat_type TEXT NOT NULL, + role_id TEXT, + format TEXT NOT NULL DEFAULT '{stat}', + UNIQUE(guild_id, channel_id) + ); `); module.exports = db; diff --git a/app/public/dashboard.css b/app/public/dashboard.css index f72fff5..f7823eb 100644 --- a/app/public/dashboard.css +++ b/app/public/dashboard.css @@ -146,7 +146,7 @@ body { background-color: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--border-radius-lg); - overflow: hidden; + overflow: visible; transition: transform var(--transition-normal), border-color var(--transition-normal), box-shadow var(--transition-normal); cursor: pointer; } @@ -161,7 +161,7 @@ body { height: 80px; background: linear-gradient(135deg, var(--primary), #7289da); position: relative; - overflow: visible; + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; } .guild-card-avatar { diff --git a/app/public/dashboard.html b/app/public/dashboard.html index 1a6c656..9ae82eb 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -117,8 +117,9 @@ : `https://cdn.discordapp.com/embed/avatars/0.png`; card.innerHTML = ` -
- ${g.name} +
+ ${g.name} +

${g.name}

Cliquez pour configurer

diff --git a/app/public/guild.css b/app/public/guild.css index 4262820..6b927ac 100644 --- a/app/public/guild.css +++ b/app/public/guild.css @@ -552,6 +552,43 @@ body { z-index: 200; } +/* ===== Stats Channels List ===== */ +.stats-channel-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md); + background: var(--bg-secondary); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-sm); + gap: var(--spacing-md); +} + +.stats-channel-info { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + flex: 1; +} + +.stats-channel-info strong { + color: var(--text-primary); +} + +.stats-channel-type { + font-size: 0.85rem; + color: var(--text-muted); +} + +.stats-channel-format { + background: var(--bg-card); + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.8rem; + color: var(--text-secondary); + width: fit-content; +} + /* ===== Responsive ===== */ @media (max-width: 900px) { .sidebar { diff --git a/app/public/guild.html b/app/public/guild.html index f2576be..a00e743 100644 --- a/app/public/guild.html +++ b/app/public/guild.html @@ -58,6 +58,10 @@ 🔢 Comptage + + 📊 + Salons de stats +
@@ -698,6 +702,80 @@ + +
+
+
+
+ 📊 +

Salons de statistiques

+
+
+
+
+ 💡 Comment ça marche ?
+ Créez des salons vocaux dont le nom affiche des statistiques du serveur en temps réel. + Les noms sont mis à jour automatiquement toutes les 5 minutes. +
+ + +
+

➕ Ajouter un salon de stats

+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+
Variables disponibles
+
+ {stat} → valeur de la statistique +
+
+ + +
+ + +
+

📋 Salons configurés

+
+

Aucun salon configuré.

+
+
+
+
+
+ @@ -714,5 +792,6 @@ + diff --git a/app/public/guild/statsChannelsForm.js b/app/public/guild/statsChannelsForm.js new file mode 100644 index 0000000..285dc2a --- /dev/null +++ b/app/public/guild/statsChannelsForm.js @@ -0,0 +1,197 @@ +// ===== STATS CHANNELS FORM ===== +(async function () { + const channelSelect = document.getElementById("stats-channel-select"); + const typeSelect = document.getElementById("stats-type-select"); + const roleGroup = document.getElementById("stats-role-group"); + const roleSelect = document.getElementById("stats-role-select"); + const formatInput = document.getElementById("stats-format-input"); + const addBtn = document.getElementById("add-stats-channel"); + const listContainer = document.getElementById("stats-channels-list"); + + const statTypeNames = { + members: "👥 Membres (total)", + humans: "👤 Membres (sans bots)", + bots: "🤖 Bots", + online: "🟢 En ligne", + voice: "🎤 En vocal", + roles: "🎭 Rôles", + channels: "📺 Salons", + boosts: "🚀 Boosts", + boost_level: "💎 Niveau boost", + role_members: "🏷️ Membres avec rôle" + }; + + // Afficher/masquer le sélecteur de rôle + typeSelect.addEventListener("change", () => { + if (typeSelect.value === "role_members") { + roleGroup.style.display = "block"; + } else { + roleGroup.style.display = "none"; + } + + // Mettre à jour le format par défaut + const formats = { + members: "👥 Membres: {stat}", + humans: "👤 Humains: {stat}", + bots: "🤖 Bots: {stat}", + online: "🟢 En ligne: {stat}", + voice: "🎤 En vocal: {stat}", + roles: "🎭 Rôles: {stat}", + channels: "📺 Salons: {stat}", + boosts: "🚀 Boosts: {stat}", + boost_level: "💎 Niveau: {stat}", + role_members: "🏷️ Rôle: {stat}" + }; + formatInput.value = formats[typeSelect.value] || "📊 {stat}"; + }); + + // Charger les salons vocaux + async function loadVoiceChannels() { + try { + const res = await fetch(`/api/bot/get-voice-channels/${guildId}`); + const channels = await res.json(); + channelSelect.innerHTML = ''; + channels.forEach(ch => { + const opt = document.createElement("option"); + opt.value = ch.id; + opt.textContent = "🔊 " + ch.name; + channelSelect.appendChild(opt); + }); + } catch (err) { + console.error("Erreur chargement salons vocaux:", err); + } + } + + // Charger les rôles + async function loadRoles() { + try { + const res = await fetch(`/api/bot/get-roles/${guildId}`); + const roles = await res.json(); + roleSelect.innerHTML = ''; + roles.forEach(role => { + const opt = document.createElement("option"); + opt.value = role.id; + opt.textContent = role.name; + roleSelect.appendChild(opt); + }); + } catch (err) { + console.error("Erreur chargement rôles:", err); + } + } + + // Charger la liste des salons configurés + async function loadStatsChannels() { + try { + const res = await fetch(`/api/bot/get-stats-channels/${guildId}`); + const channels = await res.json(); + + if (channels.length === 0) { + listContainer.innerHTML = '

Aucun salon configuré.

'; + return; + } + + // Récupérer les infos des salons vocaux pour afficher les noms + const voiceRes = await fetch(`/api/bot/get-voice-channels/${guildId}`); + const voiceChannels = await voiceRes.json(); + const voiceMap = {}; + voiceChannels.forEach(ch => voiceMap[ch.id] = ch.name); + + // Récupérer les rôles + const rolesRes = await fetch(`/api/bot/get-roles/${guildId}`); + const roles = await rolesRes.json(); + const rolesMap = {}; + roles.forEach(r => rolesMap[r.id] = r.name); + + listContainer.innerHTML = channels.map(ch => { + const channelName = voiceMap[ch.channel_id] || "Salon inconnu"; + const typeName = statTypeNames[ch.stat_type] || ch.stat_type; + const roleInfo = ch.stat_type === "role_members" && ch.role_id + ? ` (${rolesMap[ch.role_id] || "Rôle inconnu"})` + : ""; + + return ` +
+
+ 🔊 ${channelName} + ${typeName}${roleInfo} + ${ch.format} +
+ +
+ `; + }).join(""); + + // Ajouter les événements de suppression + document.querySelectorAll(".delete-stats-channel").forEach(btn => { + btn.addEventListener("click", async () => { + const id = btn.dataset.id; + if (!confirm("Supprimer ce salon de statistiques ?")) return; + + try { + const res = await fetch(`/api/bot/delete-stats-channel/${id}`, { + method: "DELETE" + }); + const result = await res.json(); + if (result.success) { + loadStatsChannels(); + } + } catch (err) { + console.error("Erreur suppression:", err); + } + }); + }); + } catch (err) { + console.error("Erreur chargement stats channels:", err); + } + } + + // Ajouter un salon + addBtn.addEventListener("click", async () => { + const channelId = channelSelect.value; + const statType = typeSelect.value; + const roleId = typeSelect.value === "role_members" ? roleSelect.value : null; + const format = formatInput.value || "📊 {stat}"; + + if (!channelId) { + alert("Veuillez sélectionner un salon."); + return; + } + + if (statType === "role_members" && !roleId) { + alert("Veuillez sélectionner un rôle."); + return; + } + + addBtn.disabled = true; + addBtn.textContent = "Ajout..."; + + try { + const res = await fetch("/api/bot/add-stats-channel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ guildId, channelId, statType, roleId, format }) + }); + const result = await res.json(); + + if (result.success) { + channelSelect.value = ""; + loadStatsChannels(); + } else { + alert("Erreur lors de l'ajout."); + } + } catch (err) { + console.error("Erreur ajout:", err); + alert("Erreur réseau."); + } + + addBtn.disabled = false; + addBtn.textContent = "➕ Ajouter le salon"; + }); + + // Init + await loadVoiceChannels(); + await loadRoles(); + await loadStatsChannels(); +})(); diff --git a/app/public/index.html b/app/public/index.html index 529cdee..678565e 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -82,6 +82,11 @@

Jeu de Comptage

Un mini-jeu collaboratif où les membres comptent à l'infini. Ils doivent alterner et ne pas se tromper sinon le compteur repart à 0 !

+
+
📊
+

Salons de Statistiques

+

Affichez les stats de votre serveur en temps réel dans des salons vocaux : membres, bots, en ligne, boosts, rôles et plus encore.

+
⚙️

Dashboard Intuitif

@@ -110,7 +115,7 @@ Utilisateurs
- 20+ + 30+ Commandes
diff --git a/app/routes/api.js b/app/routes/api.js index 620516d..81c6b91 100644 --- a/app/routes/api.js +++ b/app/routes/api.js @@ -890,5 +890,80 @@ module.exports = (app, db, client) => { ); }); + // ===== STATS CHANNELS ===== + router.get("/bot/get-stats-channels/:guildId", (req, res) => { + const { guildId } = req.params; + + db.all( + "SELECT id, channel_id, stat_type, role_id, format FROM stats_channels WHERE guild_id = ?", + [guildId], + (err, rows) => { + if (err) { + console.error(err); + return res.json([]); + } + res.json(rows || []); + } + ); + }); + + router.post("/bot/add-stats-channel", express.json(), (req, res) => { + const { guildId, channelId, statType, roleId, format } = req.body; + + if (!req.session.guilds) { + return res.status(401).json({ success: false }); + } + + const isAdmin = req.session.guilds.find( + g => g.id === guildId && (BigInt(g.permissions) & 0x8n) === 0x8n + ); + + if (!isAdmin) { + return res.status(403).json({ success: false }); + } + + db.run( + `INSERT INTO stats_channels (guild_id, channel_id, stat_type, role_id, format) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(guild_id, channel_id) DO UPDATE SET + stat_type = ?, role_id = ?, format = ?`, + [guildId, channelId, statType, roleId || null, format || '{stat}', statType, roleId || null, format || '{stat}'], + async function(err) { + if (err) { + console.error(err); + return res.status(500).json({ success: false }); + } + + // Mettre à jour le salon immédiatement + try { + const client = require("../bot"); + if (client.updateGuildStats) { + await client.updateGuildStats(guildId, [statType]); + } + } catch (e) { + console.error("Erreur mise à jour stats:", e); + } + + res.json({ success: true, id: this.lastID }); + } + ); + }); + + router.delete("/bot/delete-stats-channel/:id", (req, res) => { + const { id } = req.params; + + if (!req.session.guilds) { + return res.status(401).json({ success: false }); + } + + db.run("DELETE FROM stats_channels WHERE id = ?", [id], (err) => { + if (err) { + console.error(err); + return res.status(500).json({ success: false }); + } + res.json({ success: true }); + }); + }); + app.use("/api", router); };