diff --git a/app/commands/stats.js b/app/commands/stats.js new file mode 100644 index 0000000..94bbf36 --- /dev/null +++ b/app/commands/stats.js @@ -0,0 +1,383 @@ +const { EmbedBuilder, AttachmentBuilder } = require("discord.js"); +const addCommand = require("../fonctions/addCommand"); +const db = require("../db"); +const { ChartJSNodeCanvas } = require('chartjs-node-canvas'); + +// Créer le renderer de graphique +const chartRenderer = new ChartJSNodeCanvas({ + width: 800, + height: 400, + backgroundColour: '#2f3136' +}); + +// Fonction pour générer le buffer du graphique +async function generateChartBuffer(labels, messagesData, voiceData, title) { + const configuration = { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'Messages', + data: messagesData, + backgroundColor: '#5865F2', + borderColor: '#5865F2', + yAxisID: 'y' + }, + { + label: 'Heures vocal', + data: voiceData, + backgroundColor: '#57F287', + borderColor: '#57F287', + yAxisID: 'y1' + } + ] + }, + options: { + responsive: false, + plugins: { + title: { + display: true, + text: title, + color: '#ffffff', + font: { size: 18 } + }, + legend: { + labels: { color: '#ffffff', font: { size: 12 } } + } + }, + scales: { + x: { + ticks: { color: '#ffffff' }, + grid: { color: 'rgba(255,255,255,0.1)' } + }, + y: { + position: 'left', + title: { display: true, text: 'Messages', color: '#5865F2' }, + ticks: { color: '#5865F2' }, + grid: { color: 'rgba(255,255,255,0.1)' }, + beginAtZero: true + }, + y1: { + position: 'right', + title: { display: true, text: 'Heures', color: '#57F287' }, + ticks: { color: '#57F287' }, + grid: { drawOnChartArea: false }, + beginAtZero: true + } + } + } + }; + + try { + const buffer = await chartRenderer.renderToBuffer(configuration); + return buffer; + } catch (err) { + console.error('Erreur génération graphique:', err); + return null; + } +} + +// Fonction pour obtenir les dates selon la période +function getDateRange(period) { + const now = new Date(); + let startDate; + let labels = []; + let groupBy = 'day'; + + switch (period) { + case 'week': + startDate = new Date(now); + startDate.setDate(now.getDate() - 6); + // Labels pour les 7 derniers jours + for (let i = 6; i >= 0; i--) { + const d = new Date(now); + d.setDate(now.getDate() - i); + labels.push(d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric' })); + } + break; + case 'month': + startDate = new Date(now); + startDate.setDate(now.getDate() - 29); + groupBy = 'week'; + labels = ['Sem. 4', 'Sem. 3', 'Sem. 2', 'Sem. 1']; + break; + case 'year': + startDate = new Date(now); + startDate.setFullYear(now.getFullYear() - 1); + groupBy = 'month'; + for (let i = 11; i >= 0; i--) { + const d = new Date(now); + d.setMonth(now.getMonth() - i); + labels.push(d.toLocaleDateString('fr-FR', { month: 'short' })); + } + break; + case 'all': + default: + startDate = new Date(2020, 0, 1); // Depuis le début + groupBy = 'month'; + // On génère les labels dynamiquement pour "all" + labels = []; + break; + } + + return { + startDate: startDate.toISOString().split('T')[0], + labels, + groupBy, + period + }; +} + +// Fonction pour formater le temps en heures et minutes +function formatVoiceTime(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +async function executeStats(context, isSlash) { + const guild = context.guild; + const targetUser = isSlash + ? (context.options.getUser('utilisateur') || context.user) + : (context.mentions.users.first() || context.author); + const period = isSlash + ? (context.options.getString('periode') || 'week') + : 'week'; + + const { startDate, labels, groupBy, period: selectedPeriod } = getDateRange(period); + + // Récupérer les stats de l'utilisateur + const stats = await db.allAsync( + `SELECT stat_type, value, date FROM user_activity_stats + WHERE guild_id = ? AND user_id = ? AND date >= ? + ORDER BY date ASC`, + [guild.id, targetUser.id, startDate] + ); + + // Calculer les totaux + let totalMessages = 0; + let totalVoiceSeconds = 0; + const messagesByDate = {}; + const voiceByDate = {}; + + stats.forEach(stat => { + if (stat.stat_type === 'messages') { + totalMessages += stat.value; + messagesByDate[stat.date] = (messagesByDate[stat.date] || 0) + stat.value; + } else if (stat.stat_type === 'voice_time') { + totalVoiceSeconds += stat.value; + voiceByDate[stat.date] = (voiceByDate[stat.date] || 0) + stat.value; + } + }); + + // Préparer les données pour le graphique + let chartLabels = labels; + let messagesData = []; + let voiceData = []; + + if (selectedPeriod === 'week') { + // Données journalières pour la semaine + const now = new Date(); + for (let i = 6; i >= 0; i--) { + const d = new Date(now); + d.setDate(now.getDate() - i); + const dateStr = d.toISOString().split('T')[0]; + messagesData.push(messagesByDate[dateStr] || 0); + voiceData.push(Math.round(((voiceByDate[dateStr] || 0) / 3600) * 10) / 10); // En heures + } + } else if (selectedPeriod === 'month') { + // Données par semaine pour le mois + const now = new Date(); + for (let week = 3; week >= 0; week--) { + let weekMessages = 0; + let weekVoice = 0; + for (let day = 0; day < 7; day++) { + const d = new Date(now); + d.setDate(now.getDate() - (week * 7 + day)); + const dateStr = d.toISOString().split('T')[0]; + weekMessages += messagesByDate[dateStr] || 0; + weekVoice += voiceByDate[dateStr] || 0; + } + messagesData.push(weekMessages); + voiceData.push(Math.round((weekVoice / 3600) * 10) / 10); + } + } else if (selectedPeriod === 'year') { + // Données par mois pour l'année + const now = new Date(); + for (let i = 11; i >= 0; i--) { + const targetMonth = new Date(now); + targetMonth.setMonth(now.getMonth() - i); + const year = targetMonth.getFullYear(); + const month = targetMonth.getMonth(); + + let monthMessages = 0; + let monthVoice = 0; + + Object.entries(messagesByDate).forEach(([date, value]) => { + const d = new Date(date); + if (d.getFullYear() === year && d.getMonth() === month) { + monthMessages += value; + } + }); + + Object.entries(voiceByDate).forEach(([date, value]) => { + const d = new Date(date); + if (d.getFullYear() === year && d.getMonth() === month) { + monthVoice += value; + } + }); + + messagesData.push(monthMessages); + voiceData.push(Math.round((monthVoice / 3600) * 10) / 10); + } + } else { + // "all" - Tout depuis le début, groupé par mois + const allDates = [...new Set([...Object.keys(messagesByDate), ...Object.keys(voiceByDate)])].sort(); + if (allDates.length > 0) { + const firstDate = new Date(allDates[0]); + const lastDate = new Date(); + + chartLabels = []; + const current = new Date(firstDate.getFullYear(), firstDate.getMonth(), 1); + + while (current <= lastDate) { + const year = current.getFullYear(); + const month = current.getMonth(); + chartLabels.push(current.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' })); + + let monthMessages = 0; + let monthVoice = 0; + + Object.entries(messagesByDate).forEach(([date, value]) => { + const d = new Date(date); + if (d.getFullYear() === year && d.getMonth() === month) { + monthMessages += value; + } + }); + + Object.entries(voiceByDate).forEach(([date, value]) => { + const d = new Date(date); + if (d.getFullYear() === year && d.getMonth() === month) { + monthVoice += value; + } + }); + + messagesData.push(monthMessages); + voiceData.push(Math.round((monthVoice / 3600) * 10) / 10); + + current.setMonth(current.getMonth() + 1); + } + } else { + chartLabels = ['Aucune donnée']; + messagesData = [0]; + voiceData = [0]; + } + } + + // Limiter à 12 labels max pour le graphique + if (chartLabels.length > 12) { + const step = Math.ceil(chartLabels.length / 12); + chartLabels = chartLabels.filter((_, i) => i % step === 0); + messagesData = messagesData.filter((_, i) => i % step === 0); + voiceData = voiceData.filter((_, i) => i % step === 0); + } + + const periodNames = { + 'week': 'cette semaine', + 'month': 'ce mois', + 'year': 'cette année', + 'all': 'depuis le début' + }; + + const chartTitle = `Statistiques de ${targetUser.username} - ${periodNames[selectedPeriod]}`; + const chartBuffer = await generateChartBuffer(chartLabels, messagesData, voiceData, chartTitle); + + // Calculer le rang sur le serveur + const allUsersMessages = await db.allAsync( + `SELECT user_id, SUM(value) as total FROM user_activity_stats + WHERE guild_id = ? AND stat_type = 'messages' AND date >= ? + GROUP BY user_id ORDER BY total DESC`, + [guild.id, startDate] + ); + + const allUsersVoice = await db.allAsync( + `SELECT user_id, SUM(value) as total FROM user_activity_stats + WHERE guild_id = ? AND stat_type = 'voice_time' AND date >= ? + GROUP BY user_id ORDER BY total DESC`, + [guild.id, startDate] + ); + + const messageRank = allUsersMessages.findIndex(u => u.user_id === targetUser.id) + 1 || '-'; + const voiceRank = allUsersVoice.findIndex(u => u.user_id === targetUser.id) + 1 || '-'; + + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setAuthor({ + name: `📊 Statistiques de ${targetUser.username}`, + iconURL: targetUser.displayAvatarURL({ dynamic: true }) + }) + .setDescription(`Statistiques d'activité **${periodNames[selectedPeriod]}**`) + .addFields( + { + name: '💬 Messages', + value: `**${totalMessages.toLocaleString('fr-FR')}** messages\n🏆 Rang: #${messageRank}`, + inline: true + }, + { + name: '🎤 Temps vocal', + value: `**${formatVoiceTime(totalVoiceSeconds)}**\n🏆 Rang: #${voiceRank}`, + inline: true + } + ) + .setFooter({ text: `Serveur: ${guild.name}` }) + .setTimestamp(); + + // Créer l'attachment et ajouter l'image à l'embed + const files = []; + if (chartBuffer) { + const attachment = new AttachmentBuilder(chartBuffer, { name: 'stats.png' }); + files.push(attachment); + embed.setImage('attachment://stats.png'); + } + + if (isSlash) { + await context.reply({ embeds: [embed], files }); + } else { + await context.channel.send({ embeds: [embed], files }); + } +} + +module.exports = addCommand({ + name: "stats", + description: "Affiche les statistiques d'activité d'un utilisateur", + slashOptions: [ + { + name: "utilisateur", + description: "L'utilisateur dont vous voulez voir les stats", + type: "USER", + required: false + }, + { + name: "periode", + description: "La période à afficher", + type: "STRING", + required: false, + choices: [ + { name: "📅 Cette semaine", value: "week" }, + { name: "📆 Ce mois", value: "month" }, + { name: "🗓️ Cette année", value: "year" }, + { name: "♾️ Depuis le début", value: "all" } + ] + } + ], + executeSlash: async (client, interaction) => { + await executeStats(interaction, true); + }, + executePrefix: async (client, message, args) => { + await executeStats(message, false); + } +}); diff --git a/app/db.js b/app/db.js index 3adcdb9..ce3b3a1 100644 --- a/app/db.js +++ b/app/db.js @@ -194,6 +194,25 @@ db.exec(` format TEXT NOT NULL DEFAULT '{stat}', UNIQUE(guild_id, channel_id) ); + + CREATE TABLE IF NOT EXISTS user_activity_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + stat_type TEXT NOT NULL, + value INTEGER NOT NULL DEFAULT 0, + date TEXT NOT NULL, + UNIQUE(guild_id, user_id, stat_type, date) + ); + + CREATE TABLE IF NOT EXISTS voice_sessions ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + join_timestamp INTEGER NOT NULL, + PRIMARY KEY(guild_id, user_id) + ); + + CREATE INDEX IF NOT EXISTS idx_user_activity_stats_date ON user_activity_stats(guild_id, user_id, date); `); module.exports = db; diff --git a/app/events/messageCreate.js b/app/events/messageCreate.js index 2f97bb4..8abef03 100644 --- a/app/events/messageCreate.js +++ b/app/events/messageCreate.js @@ -7,6 +7,16 @@ module.exports = { if (message.author.bot) return; const guildId = message.guild.id; + const userId = message.author.id; + + // ===== TRACK MESSAGE STATS ===== + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + db.run( + `INSERT INTO user_activity_stats (guild_id, user_id, stat_type, value, date) + VALUES (?, ?, 'messages', 1, ?) + ON CONFLICT(guild_id, user_id, stat_type, date) DO UPDATE SET value = value + 1`, + [guildId, userId, today] + ); // ===== XP SYSTEM ===== db.get( diff --git a/app/events/voiceStateUpdate.js b/app/events/voiceStateUpdate.js index 19bdddb..63cf83c 100644 --- a/app/events/voiceStateUpdate.js +++ b/app/events/voiceStateUpdate.js @@ -10,13 +10,16 @@ module.exports = { async execute(client, oldState, newState) { // ===== PRIVATE ROOM (TEMP VOICE CHANNELS) ===== await handlePrivateRoom(client, oldState, newState); - + if (newState.member.user.bot) return; const guildId = newState.guild.id; const oderId = newState.member.id; const key = `${guildId}_${oderId}`; + // ===== TRACK VOICE TIME STATS ===== + await trackVoiceTime(guildId, oderId, oldState, newState); + // ===== AUTOROLE VOCAL ===== db.get( "SELECT enabled, role_id, exclude_channel_ids FROM autorole_vocal_config WHERE guild_id = ?", @@ -222,3 +225,50 @@ async function checkAndDeleteEmptyTempChannel(oldState) { } } } + +// ===== VOICE TIME TRACKING ===== +async function trackVoiceTime(guildId, userId, oldState, newState) { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + + // User joined voice channel + if (newState.channelId && !oldState.channelId) { + // Save join timestamp + db.run( + `INSERT OR REPLACE INTO voice_sessions (guild_id, user_id, join_timestamp) + VALUES (?, ?, ?)`, + [guildId, userId, Date.now()] + ); + } + // User left voice channel + else if (!newState.channelId && oldState.channelId) { + // Get join timestamp and calculate duration + const session = await db.getAsync( + "SELECT join_timestamp FROM voice_sessions WHERE guild_id = ? AND user_id = ?", + [guildId, userId] + ); + + if (session) { + const durationMs = Date.now() - session.join_timestamp; + const durationSeconds = Math.floor(durationMs / 1000); + + // Add voice time to stats (in seconds) + db.run( + `INSERT INTO user_activity_stats (guild_id, user_id, stat_type, value, date) + VALUES (?, ?, 'voice_time', ?, ?) + ON CONFLICT(guild_id, user_id, stat_type, date) DO UPDATE SET value = value + ?`, + [guildId, userId, durationSeconds, today, durationSeconds] + ); + + // Delete session + db.run( + "DELETE FROM voice_sessions WHERE guild_id = ? AND user_id = ?", + [guildId, userId] + ); + } + } + // User switched channels (still in voice) + else if (newState.channelId && oldState.channelId && newState.channelId !== oldState.channelId) { + // Update session timestamp (continue tracking) + // No action needed, session continues + } +} diff --git a/app/package.json b/app/package.json index f745aff..371d96e 100644 --- a/app/package.json +++ b/app/package.json @@ -29,6 +29,7 @@ "dotenv": "^17.2.3", "express": "^5.2.1", "express-session": "^1.18.2", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "chartjs-node-canvas": "^4.1.6" } } diff --git a/app/public/index.html b/app/public/index.html index 678565e..ac178a8 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -87,6 +87,11 @@
Affichez les stats de votre serveur en temps réel dans des salons vocaux : membres, bots, en ligne, boosts, rôles et plus encore.
+Suivez l'activité de vos membres avec des graphiques détaillés : messages envoyés, temps en vocal, par semaine, mois ou année.
+