From 5ee641fdd4e3a897f50be9463b36afb988ac0e3e Mon Sep 17 00:00:00 2001 From: - Date: Tue, 21 Oct 2025 18:26:30 -0700 Subject: [PATCH 1/3] Implement slash commands, 3 hour cooldown when multiple matches in a row is the same (to prevent api spam) --- index.js | 233 ++++++++++++++++++++++++++----------- package.json | 2 +- services/matchTracker.js | 10 +- slashCommands/link.js | 175 ++++++++++++++++++++++++++++ slashCommands/setup.js | 129 +++++++++++++++++++++ slashCommands/stats.js | 93 +++++++++++++++ slashCommands/tracker.js | 155 +++++++++++++++++++++++++ utils/commandDeployer.js | 114 ++++++++++++++++++ utils/guildTracker.js | 241 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 1082 insertions(+), 70 deletions(-) create mode 100644 slashCommands/link.js create mode 100644 slashCommands/setup.js create mode 100644 slashCommands/stats.js create mode 100644 slashCommands/tracker.js create mode 100644 utils/commandDeployer.js create mode 100644 utils/guildTracker.js diff --git a/index.js b/index.js index 2f23334..931b546 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,20 @@ require('dotenv').config(); -const { Client, GatewayIntentBits, Collection, EmbedBuilder } = require('discord.js'); +const { Client, GatewayIntentBits, Collection, EmbedBuilder, MessageFlags } = require('discord.js'); +const fs = require('fs'); +const path = require('path'); const config = require('./config'); -const linkCommand = require('./commands/link'); -const trackerCommand = require('./commands/tracker'); -const statsCommand = require('./commands/stats'); -const setupCommand = require('./commands/setup'); const matchTracker = require('./services/matchTracker'); const { deleteGuildConfig } = require('./utils/guildConfigManager'); const { removeGuildFromAllUsers } = require('./utils/userLinksManager'); +const { deployCommandsToGuild, removeCommandsFromGuild } = require('./utils/commandDeployer'); +const { + trackGuild, + markCommandsDeployed, + markOnboardingComplete, + untrackGuild, + detectNewGuilds, + getGuildsNeedingOnboarding, +} = require('./utils/guildTracker'); const client = new Client({ intents: [ @@ -17,55 +24,29 @@ const client = new Client({ ], }); -// Initialize commands collection -client.commands = new Collection(); -client.commands.set('link', linkCommand); -client.commands.set('tracker', trackerCommand); -client.commands.set('stats', statsCommand); -client.commands.set('setup', setupCommand); +// Initialize slash commands collection +client.slashCommands = new Collection(); -client.once('clientReady', () => { - console.log(`Logged in as ${client.user.tag}`); - console.log('Bot is ready to roast some CS2 players!'); - client.user.setActivity('CS2 players', { type: 'WATCHING' }); - - // Initialize and start automatic match tracking - matchTracker.initialize(client); - console.log('Automatic match tracking started - checking every 1 hour'); -}); - -client.on('messageCreate', async (message) => { - // Ignore bot messages - if (message.author.bot) { - return; - } - - // Check if message starts with prefix - if (!message.content.startsWith(config.prefix)) { - return; - } - - const args = message.content.slice(config.prefix.length).trim().split(/ +/); - const commandName = args.shift().toLowerCase(); - - const command = client.commands.get(commandName); - - if (!command) { - return; - } +// Load slash commands from slashCommands directory +const slashCommandsPath = path.join(__dirname, 'slashCommands'); +const slashCommandFiles = fs.readdirSync(slashCommandsPath).filter(file => file.endsWith('.js')); - try { - await command.execute(message, args); - } catch (error) { - console.error(`Error executing command ${commandName}:`, error); - message.reply('There was an error executing that command. Maybe I roasted myself too hard?'); +for (const file of slashCommandFiles) { + const filePath = path.join(slashCommandsPath, file); + const command = require(filePath); + if ('data' in command && 'execute' in command) { + client.slashCommands.set(command.data.name, command); + console.log(`[COMMAND] Loaded slash command: ${command.data.name}`); + } else { + console.log(`[WARNING] The command at ${filePath} is missing required "data" or "execute" property.`); } -}); - -// Handle bot joining a new guild -client.on('guildCreate', async (guild) => { - console.log(`Joined new guild: ${guild.name} (${guild.id})`); +} +/** + * Send onboarding/welcome message to a guild + * @param {Guild} guild - Discord guild object + */ +async function sendOnboardingMessage(guild) { try { // Find a suitable channel to send welcome message const channels = await guild.channels.fetch(); @@ -75,8 +56,8 @@ client.on('guildCreate', async (guild) => { ); if (!textChannel) { - console.log(`No suitable channel found in ${guild.name} to send welcome message`); - return; + console.log(`[ONBOARDING] No suitable channel found in ${guild.name} to send welcome message`); + return false; } // Send welcome embed @@ -87,12 +68,12 @@ client.on('guildCreate', async (guild) => { .addFields( { name: 'Step 1: Setup', - value: `An admin needs to run \`${config.prefix}setup <#channel>\` to set the channel where roasts will be posted.\n` + - `Example: \`${config.prefix}setup #roasts\``, + value: 'An admin needs to run `/setup channel` to set the channel where roasts will be posted.\n' + + 'Example: `/setup channel #roasts`', }, { name: 'Step 2: Link Accounts', - value: `Users can link their Steam accounts using \`${config.prefix}link \`\n` + + value: 'Users can link their Steam accounts using `/link`\n' + 'Find your Steam64 ID at: https://steamid.io/', }, { @@ -101,33 +82,155 @@ client.on('guildCreate', async (guild) => { }, { name: 'Other Commands', - value: `\`${config.prefix}stats [@user]\` - View stored stats\n` + - `\`${config.prefix}tracker status\` - View tracker status\n` + - `\`${config.prefix}setup status\` - View current setup`, + value: '`/stats [@user]` - View stored stats\n' + + '`/tracker status` - View tracker status\n' + + '`/setup status` - View current setup', }, ); await textChannel.send({ embeds: [embed] }); - console.log(`Sent welcome message to ${textChannel.name} in ${guild.name}`); + console.log(`[ONBOARDING] Sent welcome message to ${textChannel.name} in ${guild.name}`); + + // Mark onboarding as complete + markOnboardingComplete(guild.id); + return true; } catch (error) { - console.error(`Error sending welcome message to ${guild.name}:`, error); + console.error(`[ONBOARDING ERROR] Failed to send welcome message to ${guild.name}:`, error); + return false; + } +} + +/** + * Process guilds that need onboarding + * @param {Client} client - Discord client instance + */ +async function processOnboarding(client) { + const guildsNeedingOnboarding = getGuildsNeedingOnboarding(); + + if (guildsNeedingOnboarding.length === 0) { + return; + } + + console.log(`[ONBOARDING] Processing ${guildsNeedingOnboarding.length} guild(s) needing onboarding...`); + + for (const guildId of guildsNeedingOnboarding) { + try { + const guild = await client.guilds.fetch(guildId); + await sendOnboardingMessage(guild); + } catch (error) { + console.error(`[ONBOARDING ERROR] Failed to process onboarding for guild ${guildId}:`, error); + } + } +} + +client.once('clientReady', async () => { + console.log(`Logged in as ${client.user.tag}`); + console.log('Bot is ready to roast some CS2 players!'); + client.user.setActivity('CS2 players', { type: 'WATCHING' }); + + // Detect new guilds that joined while bot was offline + console.log('[STARTUP] Checking for new guilds joined while offline...'); + const newGuilds = await detectNewGuilds(client); + + if (newGuilds.length > 0) { + console.log(`[STARTUP] Detected ${newGuilds.length} new guild(s) joined while offline:`); + for (const guild of newGuilds) { + console.log(` - ${guild.name} (${guild.id})`); + } + + // Deploy commands to new guilds + for (const guild of newGuilds) { + console.log(`[STARTUP] Deploying commands to new guild: ${guild.name}`); + const success = await deployCommandsToGuild(guild.id); + if (success) { + markCommandsDeployed(guild.id); + } + } + } else { + console.log('[STARTUP] No new guilds detected.'); + } + + // Process any guilds that need onboarding + await processOnboarding(client); + + // Initialize and start automatic match tracking + matchTracker.initialize(client); +}); + +// Handle slash command interactions +client.on('interactionCreate', async (interaction) => { + if (!interaction.isChatInputCommand()) { + return; + } + + const command = client.slashCommands.get(interaction.commandName); + + if (!command) { + console.error(`[ERROR] No command matching ${interaction.commandName} was found.`); + return; + } + + try { + await command.execute(interaction); + } catch (error) { + console.error(`[ERROR] Error executing command ${interaction.commandName}:`, error); + + const errorMessage = { + content: 'There was an error executing that command. Maybe I roasted myself too hard?', + flags: [MessageFlags.Ephemeral], + }; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp(errorMessage); + } else { + await interaction.reply(errorMessage); + } + } +}); + +// Handle bot joining a new guild +client.on('guildCreate', async (guild) => { + console.log(`[GUILD JOIN] Joined new guild: ${guild.name} (${guild.id})`); + + // Track the guild + trackGuild(guild.id, guild.name, true); + + // Deploy slash commands to the new guild + console.log(`[GUILD JOIN] Deploying slash commands to ${guild.name}...`); + const success = await deployCommandsToGuild(guild.id); + + if (success) { + markCommandsDeployed(guild.id); + console.log(`[GUILD JOIN] Commands deployed successfully to ${guild.name}`); + + // Send onboarding message + await sendOnboardingMessage(guild); + } else { + console.error(`[GUILD JOIN] Failed to deploy commands to ${guild.name}`); } }); // Handle bot being removed from a guild -client.on('guildDelete', (guild) => { - console.log(`Removed from guild: ${guild.name} (${guild.id})`); +client.on('guildDelete', async (guild) => { + console.log(`[GUILD LEAVE] Removed from guild: ${guild.name} (${guild.id})`); try { + // Remove slash commands from the guild (cleanup) + await removeCommandsFromGuild(guild.id); + // Delete guild configuration deleteGuildConfig(guild.id); - console.log(`Deleted configuration for guild ${guild.id}`); + console.log(`[GUILD LEAVE] Deleted configuration for guild ${guild.id}`); // Remove guild from all user links removeGuildFromAllUsers(guild.id); - console.log(`Removed guild ${guild.id} from all user links`); + console.log(`[GUILD LEAVE] Removed guild ${guild.id} from all user links`); + + // Untrack the guild + untrackGuild(guild.id); + console.log(`[GUILD LEAVE] Untracked guild ${guild.id}`); } catch (error) { - console.error(`Error cleaning up data for guild ${guild.id}:`, error); + console.error(`[GUILD LEAVE ERROR] Error cleaning up data for guild ${guild.id}:`, error); } }); diff --git a/package.json b/package.json index 87c2c05..e8edb3d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node index.js", "dev": "node --watch index.js", "test": "npm run lint && npm run test:syntax", - "test:syntax": "node --check index.js && node --check config.js && node --check commands/link.js && node --check commands/setup.js && node --check commands/stats.js && node --check commands/tracker.js && node --check services/leetifyApi.js && node --check services/matchTracker.js && node --check utils/guildConfigManager.js && node --check utils/userLinksManager.js && node --check utils/cs2RoastGenerator.js", + "test:syntax": "node --check index.js && node --check config.js && node --check commands/link.js && node --check commands/setup.js && node --check commands/stats.js && node --check commands/tracker.js && node --check slashCommands/link.js && node --check slashCommands/setup.js && node --check slashCommands/stats.js && node --check slashCommands/tracker.js && node --check services/leetifyApi.js && node --check services/matchTracker.js && node --check utils/guildConfigManager.js && node --check utils/userLinksManager.js && node --check utils/cs2RoastGenerator.js && node --check utils/commandDeployer.js && node --check utils/guildTracker.js", "lint": "eslint .", "lint:fix": "eslint . --fix" }, diff --git a/services/matchTracker.js b/services/matchTracker.js index 00076fb..148c676 100644 --- a/services/matchTracker.js +++ b/services/matchTracker.js @@ -223,18 +223,20 @@ class MatchTracker { // Send roast message with stat comparison await this.sendRoastMessage(discordUserId, steam64Id, profileData, currentStats, previousStats); - // Update tracked data with new stats AND set cooldown + // Update tracked data with new stats - NO cooldown (they might play more games) this.trackedUsers[discordUserId].lastMatchCount = currentMatchCount; this.trackedUsers[discordUserId].lastChecked = new Date().toISOString(); this.trackedUsers[discordUserId].lastStats = currentStats; // Update stored stats - this.trackedUsers[discordUserId].lastMatchUpdate = new Date().toISOString(); // Start 3-hour cooldown + this.trackedUsers[discordUserId].lastMatchUpdate = null; // Clear cooldown - they might play another game this.saveTrackerData(); - console.log(`[COOLDOWN] ${profileData.name} is now on cooldown for 3 hours`); + console.log(`[NO COOLDOWN] ${profileData.name} - no cooldown applied (might play more games)`); } else { - // Just update last checked time (don't update stats if no new match) + // No new match - apply cooldown to avoid spamming API this.trackedUsers[discordUserId].lastChecked = new Date().toISOString(); + this.trackedUsers[discordUserId].lastMatchUpdate = new Date().toISOString(); // Start 3-hour cooldown this.saveTrackerData(); + console.log(`[COOLDOWN] ${profileData.name} - no new match, cooldown applied for 3 hours`); } } catch (error) { console.error(`Error checking matches for user ${discordUserId}:`, error.message); diff --git a/slashCommands/link.js b/slashCommands/link.js new file mode 100644 index 0000000..3f4df0d --- /dev/null +++ b/slashCommands/link.js @@ -0,0 +1,175 @@ +const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); +const leetifyApi = require('../services/leetifyApi'); +const cs2RoastGenerator = require('../utils/cs2RoastGenerator'); +const matchTracker = require('../services/matchTracker'); +const { loadUserLinks, linkUserToGuild, isUserLinkedInGuild } = require('../utils/userLinksManager'); +const { isGuildConfigured, getGuildConfig } = require('../utils/guildConfigManager'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('link') + .setDescription('Link a Discord user to their Steam64 ID') + .addStringOption(option => + option + .setName('steam64_id') + .setDescription('Your Steam64 ID (find it at steamid.io)') + .setRequired(true)) + .addUserOption(option => + option + .setName('user') + .setDescription('(Admin only) User to link') + .setRequired(false)), + + async execute(interaction) { + // Check if guild is configured + if (!isGuildConfigured(interaction.guild.id)) { + const embed = new EmbedBuilder() + .setColor('#ff9900') + .setTitle('Server Not Configured') + .setDescription('This server needs to be configured before users can link their accounts!') + .addFields({ + name: 'Setup Required', + value: 'An admin needs to run `/setup channel` to set the roast channel first.', + }); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + // Check if user has Administrator permission in this server + const isAdmin = interaction.member.permissions.has('Administrator'); + const mentionedUser = interaction.options.getUser('user'); + const steam64Id = interaction.options.getString('steam64_id'); + + // Determine target user + let targetUser; + if (mentionedUser) { + // User mentioned someone - must be admin + if (!isAdmin) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Permission Denied') + .setDescription('Only administrators can link other users!\nYou can only link yourself.'); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + targetUser = mentionedUser; + } else { + // No mention - user linking themselves + targetUser = interaction.user; + } + + // Validate Steam64 ID format + if (!/^7656119\d{10}$/.test(steam64Id)) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Invalid Steam64 ID') + .setDescription('The Steam64 ID should be a 17-digit number starting with 7656119.') + .addFields({ name: 'Find your Steam64 ID', value: '[steamid.io](https://steamid.io/)' }); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + // Check if user is already linked in this guild + const alreadyLinked = isUserLinkedInGuild(targetUser.id, interaction.guild.id); + + // Save the link (guild-specific) + if (!linkUserToGuild(targetUser.id, interaction.guild.id, steam64Id, targetUser.username, interaction.user.id)) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Error') + .setDescription('Failed to save the link. Please try again.'); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + // If user was already linked, just confirm + if (alreadyLinked) { + const userLinks = loadUserLinks(); + const userData = userLinks[targetUser.id]; + + // Check if Steam ID changed + if (userData.steam64Id !== steam64Id) { + // Update Steam ID for all guilds + userData.steam64Id = steam64Id; + const { saveUserLinks } = require('../utils/userLinksManager'); + saveUserLinks(userLinks); + + const embed = new EmbedBuilder() + .setColor('#00ff00') + .setTitle('Link Updated') + .setDescription(`Updated Steam64 ID for ${targetUser} to: \`${steam64Id}\``); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } else { + const embed = new EmbedBuilder() + .setColor('#00ff00') + .setTitle('Already Linked') + .setDescription(`${targetUser} is already linked to Steam64 ID: \`${steam64Id}\` in this server.`); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + } + + // Defer reply as this will take some time + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + // Fetch stats and send initial roast + try { + const profileData = await leetifyApi.getProfile(steam64Id); + const stats = cs2RoastGenerator.calculateStatsFromProfile(profileData); + const playerName = profileData.name || 'Unknown Player'; + const currentMatchCount = profileData.total_matches || 0; + + // Initialize tracking for this user immediately + matchTracker.trackedUsers[targetUser.id] = { + steam64Id: steam64Id, + lastMatchCount: currentMatchCount, + lastChecked: new Date().toISOString(), + lastStats: stats, + lastMatchUpdate: null, // No cooldown on initial link + }; + matchTracker.saveTrackerData(); + + // Generate roast (no previous stats for first roast) + const roasts = cs2RoastGenerator.generateRoastsWithComparison(stats, null); + const selectedRoast = roasts[Math.floor(Math.random() * roasts.length)]; + + // Build roast message (plain text, no embed) + let roastMessage = `${targetUser}, ${selectedRoast}`; + roastMessage += '\n-# [Data Provided by Leetify]()'; + if (steam64Id) { + roastMessage += ` • [Steam Profile]()`; + } + + // Get the configured roast channel and send the roast there + const guildConfig = getGuildConfig(interaction.guild.id); + const roastChannel = await interaction.guild.channels.fetch(guildConfig.roastChannelId).catch(() => null); + + if (roastChannel) { + await roastChannel.send(roastMessage); + } else { + // Fallback: send to command channel if roast channel not found + await interaction.channel.send(roastMessage); + } + + // Update the reply with embed + const successEmbed = new EmbedBuilder() + .setColor('#00ff00') + .setTitle('Link Successful') + .setDescription(`Successfully linked ${targetUser} to Steam64 ID: \`${steam64Id}\``) + .addFields( + { name: 'Player', value: playerName }, + { name: 'Matches Tracked', value: currentMatchCount.toString() }, + { name: 'Status', value: 'Initial roast sent! Automatic tracking is now active.' }, + ); + await interaction.editReply({ embeds: [successEmbed] }); + + console.log(`[LINK] Linked ${targetUser.username} (${targetUser.id}) - ${currentMatchCount} matches, stats saved`); + } catch (error) { + console.error('Error fetching stats for initial roast:', error); + const errorEmbed = new EmbedBuilder() + .setColor('#ff9900') + .setTitle('Link Successful (Stats Error)') + .setDescription(`Successfully linked ${targetUser} to Steam64 ID: \`${steam64Id}\``) + .addFields( + { name: 'Error', value: error.message }, + { name: 'Status', value: 'Automatic tracking will start on next check cycle.' }, + ); + await interaction.editReply({ embeds: [errorEmbed] }); + } + }, +}; diff --git a/slashCommands/setup.js b/slashCommands/setup.js new file mode 100644 index 0000000..911c9d2 --- /dev/null +++ b/slashCommands/setup.js @@ -0,0 +1,129 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags } = require('discord.js'); +const { setRoastChannel, getGuildConfig } = require('../utils/guildConfigManager'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('setup') + .setDescription('Configure the bot for this server (Admin only)') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(subcommand => + subcommand + .setName('channel') + .setDescription('Set the roast channel') + .addChannelOption(option => + option + .setName('channel') + .setDescription('The channel where roasts will be posted') + .setRequired(true))) + .addSubcommand(subcommand => + subcommand + .setName('status') + .setDescription('View current setup configuration')), + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'status') { + return this.showStatus(interaction); + } + + if (subcommand === 'channel') { + return this.setupChannel(interaction); + } + }, + + async setupChannel(interaction) { + const channel = interaction.options.getChannel('channel'); + + // Verify bot has permission to send messages in the channel + const botPermissions = channel.permissionsFor(interaction.guild.members.me); + if (!botPermissions.has(PermissionFlagsBits.SendMessages)) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Missing Permissions') + .setDescription(`I don't have permission to send messages in ${channel}!`) + .addFields({ + name: 'Required Permissions', + value: 'I need **Send Messages** permission in that channel.', + }); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + // Save the roast channel configuration + const success = setRoastChannel(interaction.guild.id, channel.id); + + if (!success) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Error') + .setDescription('Failed to save the configuration. Please try again.'); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + // Send success message + const embed = new EmbedBuilder() + .setColor('#00ff00') + .setTitle('Setup Complete') + .setDescription(`Roast channel has been set to ${channel}!`) + .addFields( + { name: 'Next Steps', value: 'Users can now use `/link` to link their accounts.' }, + { name: 'Automatic Roasts', value: 'When linked users finish a match, roasts will be posted automatically in this channel.' }, + ); + + await interaction.reply({ embeds: [embed] }); + + // Send a test message to the roast channel + const testEmbed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('CS2 Roaster Bot Ready') + .setDescription('This channel has been configured for automatic roasts!') + .addFields( + { name: 'How it works', value: 'When linked players finish CS2 matches, their stats will be analyzed and roasted here.' }, + { name: 'Get Started', value: 'Use `/link` to link your Steam account and start getting roasted!' }, + ); + + await channel.send({ embeds: [testEmbed] }); + }, + + async showStatus(interaction) { + const config = getGuildConfig(interaction.guild.id); + + if (!config || !config.roastChannelId) { + const embed = new EmbedBuilder() + .setColor('#ff9900') + .setTitle('Setup Status') + .setDescription('This server is not configured yet!') + .addFields({ + name: 'Setup Required', + value: 'Use `/setup channel` to set the roast channel.', + }); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + const channel = await interaction.guild.channels.fetch(config.roastChannelId).catch(() => null); + + const embed = new EmbedBuilder() + .setColor('#00ff00') + .setTitle('Setup Status') + .setDescription('This server is configured!') + .addFields( + { + name: 'Roast Channel', + value: channel ? `${channel}` : `<#${config.roastChannelId}> (channel not found)`, + }, + { + name: 'Setup Date', + value: new Date(config.setupAt).toLocaleString(), + }, + ); + + if (config.lastUpdated) { + embed.addFields({ + name: 'Last Updated', + value: new Date(config.lastUpdated).toLocaleString(), + }); + } + + interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + }, +}; diff --git a/slashCommands/stats.js b/slashCommands/stats.js new file mode 100644 index 0000000..f117ca5 --- /dev/null +++ b/slashCommands/stats.js @@ -0,0 +1,93 @@ +const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); +const matchTracker = require('../services/matchTracker'); +const { getUsersInGuild } = require('../utils/userLinksManager'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('stats') + .setDescription('View stored stats for a player') + .addUserOption(option => + option + .setName('user') + .setDescription('The user to check stats for (defaults to yourself)') + .setRequired(false)), + + async execute(interaction) { + const mentionedUser = interaction.options.getUser('user'); + const targetUser = mentionedUser || interaction.user; + const targetUserId = targetUser.id; + + const guildUsers = getUsersInGuild(interaction.guild.id); + const linkData = guildUsers[targetUserId]; + + if (!linkData) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('User Not Linked') + .setDescription('This user is not linked in this server! Use `/link` first.'); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + const trackingStatus = matchTracker.getTrackingStatus(); + const trackedData = trackingStatus.trackedUsers[targetUserId]; + + if (!trackedData || !trackedData.lastStats) { + const embed = new EmbedBuilder() + .setColor('#ff9900') + .setTitle('No Stats Available') + .setDescription('No stats available yet. Wait for the tracker to collect data!'); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + const stats = trackedData.lastStats; + const lastChecked = new Date(trackedData.lastChecked).toLocaleString(); + const steam64Id = linkData.steam64Id; + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Stats for ${targetUser.username}`) + .setDescription(`**Last updated:** ${lastChecked}\n**Total matches:** ${trackedData.lastMatchCount}`) + .addFields( + { + name: 'Aim & Shooting', + value: `Aim: ${stats.aimRating.toFixed(1)}\n` + + `Accuracy (Head): ${stats.headshotRate.toFixed(1)}%\n` + + `Accuracy (Enemy Spotted): ${stats.accuracy.toFixed(1)}%\n` + + `Spray Accuracy: ${stats.sprayAccuracy.toFixed(1)}%\n` + + `Preaim: ${stats.preaim.toFixed(1)}°\n` + + `Reaction Time: ${stats.reactionTime.toFixed(0)}ms`, + inline: false, + }, + { + name: 'Game Sense', + value: `Positioning: ${stats.positioningRating.toFixed(1)}\n` + + `Utility: ${stats.utilityRating.toFixed(1)}\n` + + `Counter Strafing Good Shots Ratio: ${stats.counterStrafing.toFixed(1)}%`, + inline: false, + }, + { + name: 'Performance', + value: `Winrate: ${stats.winRate.toFixed(1)}%\n` + + `Clutch: ${stats.clutchDeviation >= 0 ? '+' : ''}${stats.clutchDeviation.toFixed(2)}\n` + + `Opening: ${stats.openingDeviation >= 0 ? '+' : ''}${stats.openingDeviation.toFixed(2)}\n` + + `CT Leetify: ${stats.ctLeetifyDeviation >= 0 ? '+' : ''}${stats.ctLeetifyDeviation.toFixed(2)}\n` + + `T Leetify: ${stats.tLeetifyDeviation >= 0 ? '+' : ''}${stats.tLeetifyDeviation.toFixed(2)}`, + inline: false, + }, + { + name: 'Team Play', + value: `Trade Kills Success Percentage: ${stats.tradeKillsSuccessPercentage.toFixed(1)}%\n` + + `Flashbang Hit Foe Per Flashbang: ${stats.flashbangHitFoePerFlashbang.toFixed(2)}\n` + + `Flashbang Hit Friend Per Flashbang: ${stats.flashbangHitFriendPerFlashbang.toFixed(2)}`, + inline: false, + }, + { + name: '\u200b', + value: `**[View on Leetify](https://leetify.com/app/profile/${steam64Id})**`, + inline: false, + }, + ); + + interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + }, +}; diff --git a/slashCommands/tracker.js b/slashCommands/tracker.js new file mode 100644 index 0000000..47856f9 --- /dev/null +++ b/slashCommands/tracker.js @@ -0,0 +1,155 @@ +const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); +const matchTracker = require('../services/matchTracker'); +const { getUsersInGuild } = require('../utils/userLinksManager'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('tracker') + .setDescription('Manage and view match tracker status') + .addSubcommand(subcommand => + subcommand + .setName('status') + .setDescription('Show tracking status')) + .addSubcommand(subcommand => + subcommand + .setName('check') + .setDescription('Manually check for new matches') + .addUserOption(option => + option + .setName('user') + .setDescription('The user to check (defaults to yourself)') + .setRequired(false))), + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'status': + await this.showStatus(interaction); + break; + + case 'check': + await this.manualCheck(interaction); + break; + + default: { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Unknown Command') + .setDescription('Available tracker commands:') + .addFields( + { name: '/tracker status', value: 'Show tracking status' }, + { name: '/tracker check [@user]', value: 'Manually check for new matches' }, + ); + interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + } + }, + + async showStatus(interaction) { + const status = matchTracker.getTrackingStatus(); + const guildUsers = getUsersInGuild(interaction.guild.id); + const guildUserCount = Object.keys(guildUsers).length; + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Match Tracker Status') + .addFields( + { name: 'Tracking Active', value: status.isTracking ? 'Yes' : 'No', inline: true }, + { name: 'Check Interval', value: status.checkInterval, inline: true }, + { name: 'User Cooldown', value: '3 hours after match', inline: true }, + { name: 'Linked Users (This Server)', value: guildUserCount.toString(), inline: true }, + { name: 'Total Tracked Users (All Servers)', value: status.trackedUserCount.toString(), inline: true }, + ); + + if (guildUserCount > 0) { + let usersList = ''; + for (const [discordId, linkData] of Object.entries(guildUsers)) { + const trackedData = status.trackedUsers[discordId]; + const username = linkData.username || 'Unknown'; + + if (trackedData) { + const lastChecked = new Date(trackedData.lastChecked).toLocaleString(); + + // Check if user is in cooldown + const isInCooldown = matchTracker.isUserInCooldown(discordId); + let cooldownInfo = ''; + if (isInCooldown) { + const remainingMs = matchTracker.getCooldownRemaining(discordId); + const hours = Math.floor(remainingMs / (60 * 60 * 1000)); + const minutes = Math.ceil((remainingMs % (60 * 60 * 1000)) / 60000); + cooldownInfo = ` [COOLDOWN: ${hours}h ${minutes}m]`; + } + + usersList += `<@${discordId}> (${username}): ${trackedData.lastMatchCount} matches${cooldownInfo}\n`; + usersList += `└ Last checked: ${lastChecked}\n\n`; + } else { + usersList += `<@${discordId}> (${username}): Not yet tracked\n\n`; + } + } + + // Discord embeds have a 1024 character limit per field + if (usersList.length > 1024) { + usersList = usersList.substring(0, 1021) + '...'; + } + + embed.addFields({ name: 'Linked Users in This Server', value: usersList || 'None' }); + } else { + embed.addFields({ name: 'Linked Users in This Server', value: 'No users linked in this server. Use `/link` to get started.' }); + } + + interaction.reply({ embeds: [embed], ephemeral: true }); + }, + + async manualCheck(interaction) { + const mentionedUser = interaction.options.getUser('user'); + const targetUser = mentionedUser || interaction.user; + const targetUserId = targetUser.id; + + const guildUsers = getUsersInGuild(interaction.guild.id); + const linkData = guildUsers[targetUserId]; + + if (!linkData) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('User Not Linked') + .setDescription('This user is not linked in this server! Use `/link` first.'); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + // Check if user is in cooldown + const isInCooldown = matchTracker.isUserInCooldown(targetUserId); + if (isInCooldown) { + const remainingMs = matchTracker.getCooldownRemaining(targetUserId); + const hours = Math.floor(remainingMs / (60 * 60 * 1000)); + const minutes = Math.ceil((remainingMs % (60 * 60 * 1000)) / 60000); + const embed = new EmbedBuilder() + .setColor('#ff9900') + .setTitle('User in Cooldown') + .setDescription(`<@${targetUserId}> is on cooldown for ${hours}h ${minutes}m.`) + .addFields( + { name: 'Reason', value: 'This prevents spamming the API after a match is detected.' }, + { name: 'Reset', value: 'Cooldown resets 3 hours after each new match.' }, + ); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + // Defer reply as this may take some time + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + try { + await matchTracker.manualCheck(targetUserId); + const successEmbed = new EmbedBuilder() + .setColor('#00ff00') + .setTitle('Check Complete') + .setDescription(`Check complete for <@${targetUserId}>!`); + await interaction.editReply({ embeds: [successEmbed] }); + } catch (error) { + const errorEmbed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Error') + .setDescription(`Error checking matches: ${error.message}`); + await interaction.editReply({ embeds: [errorEmbed] }); + } + }, +}; diff --git a/utils/commandDeployer.js b/utils/commandDeployer.js new file mode 100644 index 0000000..74c6f44 --- /dev/null +++ b/utils/commandDeployer.js @@ -0,0 +1,114 @@ +const { REST, Routes } = require('discord.js'); +const fs = require('fs'); +const path = require('path'); +const config = require('../config'); + +/** + * Deploy slash commands to a specific guild + * @param {string} guildId - Guild ID to deploy commands to + * @returns {Promise} Success status + */ +async function deployCommandsToGuild(guildId) { + try { + const commands = []; + const commandsPath = path.join(__dirname, '../slashCommands'); + const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); + + // Load all slash command data + for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + const command = require(filePath); + if ('data' in command && 'execute' in command) { + commands.push(command.data.toJSON()); + } else { + console.log(`[WARNING] The command at ${filePath} is missing required "data" or "execute" property.`); + } + } + + // Construct and prepare an instance of the REST module + const rest = new REST().setToken(config.token); + + console.log(`[DEPLOY] Started refreshing ${commands.length} application (/) commands for guild ${guildId}.`); + + // Deploy commands to the specific guild + const data = await rest.put( + Routes.applicationGuildCommands(config.clientId, guildId), + { body: commands }, + ); + + console.log(`[DEPLOY] Successfully reloaded ${data.length} application (/) commands for guild ${guildId}.`); + return true; + } catch (error) { + console.error(`[DEPLOY ERROR] Failed to deploy commands to guild ${guildId}:`, error); + return false; + } +} + +/** + * Deploy slash commands to all guilds the bot is in + * @param {Client} client - Discord client instance + * @returns {Promise} Statistics about deployment + */ +async function deployCommandsToAllGuilds(client) { + const stats = { + total: 0, + success: 0, + failed: 0, + guilds: [], + }; + + try { + const guilds = await client.guilds.fetch(); + stats.total = guilds.size; + + console.log(`[DEPLOY] Deploying commands to ${guilds.size} guild(s)...`); + + for (const [guildId, guild] of guilds) { + const success = await deployCommandsToGuild(guildId); + + if (success) { + stats.success++; + stats.guilds.push({ id: guildId, name: guild.name, status: 'success' }); + } else { + stats.failed++; + stats.guilds.push({ id: guildId, name: guild.name, status: 'failed' }); + } + } + + console.log(`[DEPLOY] Deployment complete: ${stats.success} succeeded, ${stats.failed} failed`); + return stats; + } catch (error) { + console.error('[DEPLOY ERROR] Failed to deploy commands to all guilds:', error); + return stats; + } +} + +/** + * Remove all slash commands from a specific guild + * @param {string} guildId - Guild ID to remove commands from + * @returns {Promise} Success status + */ +async function removeCommandsFromGuild(guildId) { + try { + const rest = new REST().setToken(config.token); + + console.log(`[DEPLOY] Removing all application (/) commands from guild ${guildId}.`); + + await rest.put( + Routes.applicationGuildCommands(config.clientId, guildId), + { body: [] }, + ); + + console.log(`[DEPLOY] Successfully removed all commands from guild ${guildId}.`); + return true; + } catch (error) { + console.error(`[DEPLOY ERROR] Failed to remove commands from guild ${guildId}:`, error); + return false; + } +} + +module.exports = { + deployCommandsToGuild, + deployCommandsToAllGuilds, + removeCommandsFromGuild, +}; diff --git a/utils/guildTracker.js b/utils/guildTracker.js new file mode 100644 index 0000000..b06c110 --- /dev/null +++ b/utils/guildTracker.js @@ -0,0 +1,241 @@ +const fs = require('fs'); +const path = require('path'); + +const GUILD_TRACKER_PATH = path.join(__dirname, '../data/guilds.json'); + +/** + * Ensure the guild tracker file exists + */ +function ensureGuildTrackerFile() { + const dataDir = path.dirname(GUILD_TRACKER_PATH); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + if (!fs.existsSync(GUILD_TRACKER_PATH)) { + fs.writeFileSync(GUILD_TRACKER_PATH, JSON.stringify({ guilds: {} }, null, 2)); + } +} + +/** + * Load guild tracker data + * @returns {Object} Guild tracker data + */ +function loadGuildTracker() { + try { + ensureGuildTrackerFile(); + const data = fs.readFileSync(GUILD_TRACKER_PATH, 'utf8'); + return JSON.parse(data); + } catch (error) { + console.error('Error loading guild tracker:', error); + return { guilds: {} }; + } +} + +/** + * Save guild tracker data + * @param {Object} data - Guild tracker data to save + * @returns {boolean} Success status + */ +function saveGuildTracker(data) { + try { + ensureGuildTrackerFile(); + fs.writeFileSync(GUILD_TRACKER_PATH, JSON.stringify(data, null, 2)); + return true; + } catch (error) { + console.error('Error saving guild tracker:', error); + return false; + } +} + +/** + * Add or update a guild in the tracker + * @param {string} guildId - Guild ID + * @param {string} guildName - Guild name + * @param {boolean} isNew - Whether this is a new guild + * @returns {boolean} Success status + */ +function trackGuild(guildId, guildName, isNew = false) { + try { + const data = loadGuildTracker(); + + if (!data.guilds[guildId]) { + data.guilds[guildId] = { + id: guildId, + name: guildName, + joinedAt: new Date().toISOString(), + commandsDeployed: false, + onboardingComplete: false, + }; + } else { + // Update guild name if it changed + data.guilds[guildId].name = guildName; + data.guilds[guildId].lastSeen = new Date().toISOString(); + } + + if (isNew) { + data.guilds[guildId].isNew = true; + } + + saveGuildTracker(data); + return true; + } catch (error) { + console.error('Error tracking guild:', error); + return false; + } +} + +/** + * Mark guild commands as deployed + * @param {string} guildId - Guild ID + * @returns {boolean} Success status + */ +function markCommandsDeployed(guildId) { + try { + const data = loadGuildTracker(); + + if (data.guilds[guildId]) { + data.guilds[guildId].commandsDeployed = true; + data.guilds[guildId].commandsDeployedAt = new Date().toISOString(); + saveGuildTracker(data); + return true; + } + + return false; + } catch (error) { + console.error('Error marking commands deployed:', error); + return false; + } +} + +/** + * Mark guild onboarding as complete + * @param {string} guildId - Guild ID + * @returns {boolean} Success status + */ +function markOnboardingComplete(guildId) { + try { + const data = loadGuildTracker(); + + if (data.guilds[guildId]) { + data.guilds[guildId].onboardingComplete = true; + data.guilds[guildId].onboardingCompletedAt = new Date().toISOString(); + delete data.guilds[guildId].isNew; // Remove new flag + saveGuildTracker(data); + return true; + } + + return false; + } catch (error) { + console.error('Error marking onboarding complete:', error); + return false; + } +} + +/** + * Remove a guild from the tracker + * @param {string} guildId - Guild ID + * @returns {boolean} Success status + */ +function untrackGuild(guildId) { + try { + const data = loadGuildTracker(); + + if (data.guilds[guildId]) { + delete data.guilds[guildId]; + saveGuildTracker(data); + return true; + } + + return false; + } catch (error) { + console.error('Error untracking guild:', error); + return false; + } +} + +/** + * Get all tracked guilds + * @returns {Object} All tracked guilds + */ +function getAllTrackedGuilds() { + const data = loadGuildTracker(); + return data.guilds; +} + +/** + * Get guilds that need command deployment + * @returns {Array} Array of guild IDs that need commands deployed + */ +function getGuildsNeedingCommands() { + const data = loadGuildTracker(); + const needingCommands = []; + + for (const [guildId, guildData] of Object.entries(data.guilds)) { + if (!guildData.commandsDeployed) { + needingCommands.push(guildId); + } + } + + return needingCommands; +} + +/** + * Get guilds that need onboarding + * @returns {Array} Array of guild IDs that need onboarding + */ +function getGuildsNeedingOnboarding() { + const data = loadGuildTracker(); + const needingOnboarding = []; + + for (const [guildId, guildData] of Object.entries(data.guilds)) { + if (guildData.isNew && !guildData.onboardingComplete) { + needingOnboarding.push(guildId); + } + } + + return needingOnboarding; +} + +/** + * Detect new guilds that bot joined while offline + * @param {Client} client - Discord client instance + * @returns {Array} Array of new guild IDs + */ +async function detectNewGuilds(client) { + try { + const trackedGuilds = getAllTrackedGuilds(); + const currentGuilds = await client.guilds.fetch(); + const newGuilds = []; + + for (const [guildId, guild] of currentGuilds) { + if (!trackedGuilds[guildId]) { + newGuilds.push({ + id: guildId, + name: guild.name, + }); + // Track the guild as new + trackGuild(guildId, guild.name, true); + } else { + // Update last seen and name + trackGuild(guildId, guild.name, false); + } + } + + return newGuilds; + } catch (error) { + console.error('Error detecting new guilds:', error); + return []; + } +} + +module.exports = { + trackGuild, + markCommandsDeployed, + markOnboardingComplete, + untrackGuild, + getAllTrackedGuilds, + getGuildsNeedingCommands, + getGuildsNeedingOnboarding, + detectNewGuilds, +}; From aac6a57f4f212bd5f15f30f6b9081c946ea71cd0 Mon Sep 17 00:00:00 2001 From: - Date: Tue, 21 Oct 2025 18:31:32 -0700 Subject: [PATCH 2/3] remove prefix --- .env.example | 5 ++-- README.md | 73 ++++++++++++++++++++++++++++++++++++++-------------- config.js | 1 - 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index f4e644d..538e562 100644 --- a/.env.example +++ b/.env.example @@ -6,5 +6,6 @@ CLIENT_ID=your_discord_client_id_here LEETIFY_API_KEY=your_leetify_api_key_here LEETIFY_API_BASE_URL=https://api-public.cs-prod.leetify.com -# Bot Settings -PREFIX=! +# Match Tracker Settings +CHECK_INTERVAL_MINUTES=60 # How often to check for new matches (default: 60 minutes) +USER_COOLDOWN_HOURS=3 # Cooldown period after detecting a match (default: 3 hours) diff --git a/README.md b/README.md index b3c60bf..7cad7bf 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,21 @@ A Discord bot that automatically tracks CS2 players and roasts them based on the ## Features +- **Slash Commands**: Modern Discord UI with autocomplete and inline help +- **Automatic Command Deployment**: Commands automatically register per server - **Multi-Server Support**: Works across unlimited Discord servers +- **Auto-Onboarding**: Automatically sets up new servers with welcome messages - **Per-Server Configuration**: Each server sets its own roast channel - **Fully Automated Match Tracking**: Checks all linked players every hour - **Cross-Server User Support**: Users can link in multiple servers - **Smart API Caching**: Fetches Leetify data once, sends to all relevant servers - **Stat Comparison**: Compares performance between matches - **Performance-Based Roasts**: Roasts based on stat degradation -- **Cooldown System**: 3-hour cooldown per user after match detection +- **Intelligent Cooldown**: Only applies when no new matches detected (allows consecutive games) +- **Offline Guild Detection**: Detects servers joined while bot was offline - **Auto-Cleanup**: Removes all data when bot leaves a server - **State Persistence**: Restores previous state on startup +- **Configurable Timers**: Adjust check intervals via environment variables ## Installation @@ -32,52 +37,61 @@ npm install ## Initial Setup (Server Admins) -### 1. Configure the Bot -After adding the bot to your server, an admin must set up the roast channel: +### 1. Add the Bot +When you add the bot to your server, it will: +- Automatically deploy slash commands +- Send a welcome message with setup instructions +- Be ready to use immediately + +### 2. Configure the Bot +An admin must set up the roast channel using: ``` -!setup #roasts +/setup channel #roasts ``` This sets where automatic roasts will be posted. -### 2. Check Setup Status +### 3. Check Setup Status ``` -!setup status +/setup status ``` ## Commands +All commands are **slash commands** - start typing `/` in Discord to see them with autocomplete! + ### User Commands **Link your Steam account:** ``` -!link +/link steam64_id:76561198123456789 ``` Find your Steam64 ID at [steamid.io](https://steamid.io/) **View your stats:** ``` -!stats -!stats @user +/stats +/stats user:@username ``` **Check tracker status:** ``` -!tracker status -!tracker check [@user] +/tracker status +/tracker check +/tracker check user:@username ``` ### Admin Commands **Setup roast channel (requires Manage Server permission):** ``` -!setup #channel -!setup status +/setup channel #roasts +/setup status ``` -**Link other users (admin only):** +**Link other users (requires Administrator permission):** ``` -!link @user +/link steam64_id:76561198123456789 user:@username ``` ## Multi-Server Behavior @@ -88,16 +102,37 @@ Find your Steam64 ID at [steamid.io](https://steamid.io/) - API is called only **once per user**, not once per server (efficient!) - When bot leaves a server, all data for that server is automatically deleted - Users linked in other servers remain unaffected +- Commands are automatically deployed per server when bot joins +- If bot was offline when added to a server, it detects and deploys commands on startup + +## Cooldown System + +The bot uses an intelligent cooldown system: +- **Match detected**: No cooldown applied (allows players to play consecutive games) +- **No match detected**: 3-hour cooldown applied (prevents API spam for inactive players) + +This means players can play multiple CS2 games in a row and get roasted after each one! ## Configuration -Edit `services/matchTracker.js` to adjust timing: +Create a `.env` file with the following variables: -```javascript -const CHECK_INTERVAL = 60 * 60 * 1000; // How often to check for new matches -const USER_COOLDOWN = 3 * 60 * 60 * 1000; // Cooldown after detecting a match +```bash +# Discord Bot Configuration +DISCORD_TOKEN=your_discord_bot_token_here +CLIENT_ID=your_discord_client_id_here + +# Leetify API Configuration +LEETIFY_API_KEY=your_leetify_api_key_here +LEETIFY_API_BASE_URL=https://api-public.cs-prod.leetify.com + +# Match Tracker Settings +CHECK_INTERVAL_MINUTES=60 # How often to check for new matches (default: 60 minutes) +USER_COOLDOWN_HOURS=3 # Cooldown period after no new match detected (default: 3 hours) ``` +You can adjust the timing by changing the environment variables in `.env`. + ## Requirements - Node.js v16.9.0+ diff --git a/config.js b/config.js index 883de00..c09f3e8 100644 --- a/config.js +++ b/config.js @@ -2,7 +2,6 @@ module.exports = { // Discord Configuration token: process.env.DISCORD_TOKEN, clientId: process.env.CLIENT_ID, - prefix: process.env.PREFIX || '!', // Leetify API Configuration leetifyApiKey: process.env.LEETIFY_API_KEY, From 25c235397b44be92bfd65dead1442fdf6cf3d333 Mon Sep 17 00:00:00 2001 From: - Date: Tue, 21 Oct 2025 18:40:50 -0700 Subject: [PATCH 3/3] Resolved issues in code review --- index.js | 34 ++++++++++++++++++++++++---------- services/matchTracker.js | 11 ++++++++--- slashCommands/link.js | 6 +++++- utils/guildTracker.js | 14 ++++++++++++++ 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/index.js b/index.js index 931b546..5dd83ef 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ require('dotenv').config(); -const { Client, GatewayIntentBits, Collection, EmbedBuilder, MessageFlags } = require('discord.js'); +const { Client, GatewayIntentBits, Collection, EmbedBuilder, MessageFlags, ActivityType } = require('discord.js'); const fs = require('fs'); const path = require('path'); const config = require('./config'); @@ -126,7 +126,7 @@ async function processOnboarding(client) { client.once('clientReady', async () => { console.log(`Logged in as ${client.user.tag}`); console.log('Bot is ready to roast some CS2 players!'); - client.user.setActivity('CS2 players', { type: 'WATCHING' }); + client.user.setActivity('CS2 players', { type: ActivityType.Watching }); // Detect new guilds that joined while bot was offline console.log('[STARTUP] Checking for new guilds joined while offline...'); @@ -138,14 +138,28 @@ client.once('clientReady', async () => { console.log(` - ${guild.name} (${guild.id})`); } - // Deploy commands to new guilds - for (const guild of newGuilds) { - console.log(`[STARTUP] Deploying commands to new guild: ${guild.name}`); - const success = await deployCommandsToGuild(guild.id); - if (success) { - markCommandsDeployed(guild.id); - } - } + // Deploy commands to new guilds in parallel + console.log(`[STARTUP] Deploying commands to ${newGuilds.length} new guild(s) in parallel...`); + const deploymentPromises = newGuilds.map(guild => + deployCommandsToGuild(guild.id) + .then(success => { + if (success) { + markCommandsDeployed(guild.id); + console.log(`[STARTUP] Successfully deployed commands to ${guild.name}`); + } else { + console.error(`[STARTUP] Failed to deploy commands to ${guild.name}`); + } + return { guild, success }; + }) + .catch(error => { + console.error(`[STARTUP] Error deploying to ${guild.name}:`, error); + return { guild, success: false, error }; + }), + ); + + const results = await Promise.allSettled(deploymentPromises); + const successCount = results.filter(r => r.status === 'fulfilled' && r.value.success).length; + console.log(`[STARTUP] Command deployment complete: ${successCount}/${newGuilds.length} successful`); } else { console.log('[STARTUP] No new guilds detected.'); } diff --git a/services/matchTracker.js b/services/matchTracker.js index 148c676..54096e4 100644 --- a/services/matchTracker.js +++ b/services/matchTracker.js @@ -6,8 +6,13 @@ const { loadUserLinks } = require('../utils/userLinksManager'); const { getGuildConfig } = require('../utils/guildConfigManager'); const TRACKER_DATA_PATH = path.join(__dirname, '../data/matchTrackerData.json'); -const CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds -const USER_COOLDOWN = 3 * 60 * 60 * 1000; // 3 hours in milliseconds + +// Load timer configurations from environment variables (with defaults) +const CHECK_INTERVAL_MINUTES = parseInt(process.env.CHECK_INTERVAL_MINUTES, 10) || 60; +const USER_COOLDOWN_HOURS = parseInt(process.env.USER_COOLDOWN_HOURS, 10) || 3; + +const CHECK_INTERVAL = CHECK_INTERVAL_MINUTES * 60 * 1000; // Convert minutes to milliseconds +const USER_COOLDOWN = USER_COOLDOWN_HOURS * 60 * 60 * 1000; // Convert hours to milliseconds class MatchTracker { constructor() { @@ -88,7 +93,7 @@ class MatchTracker { * Start the automatic tracking interval */ startTracking() { - console.log('Starting match tracker - checking every 1 hour'); + console.log(`Starting match tracker - checking every ${CHECK_INTERVAL_MINUTES} minute${CHECK_INTERVAL_MINUTES !== 1 ? 's' : ''} (cooldown: ${USER_COOLDOWN_HOURS} hour${USER_COOLDOWN_HOURS !== 1 ? 's' : ''})`); // Run initial check this.checkAllUsers(); diff --git a/slashCommands/link.js b/slashCommands/link.js index 3f4df0d..b5ef7cb 100644 --- a/slashCommands/link.js +++ b/slashCommands/link.js @@ -47,7 +47,11 @@ module.exports = { const embed = new EmbedBuilder() .setColor('#ff0000') .setTitle('Permission Denied') - .setDescription('Only administrators can link other users!\nYou can only link yourself.'); + .setDescription('Only administrators can link other users!') + .addFields({ + name: 'What you can do', + value: 'You can only link yourself using `/link steam64_id:YOUR_ID`', + }); return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); } targetUser = mentionedUser; diff --git a/utils/guildTracker.js b/utils/guildTracker.js index b06c110..7c74e07 100644 --- a/utils/guildTracker.js +++ b/utils/guildTracker.js @@ -56,6 +56,17 @@ function saveGuildTracker(data) { * @returns {boolean} Success status */ function trackGuild(guildId, guildName, isNew = false) { + // Validate inputs + if (!guildId || typeof guildId !== 'string') { + console.error('[GUILD TRACKER] Invalid guild ID:', guildId); + return false; + } + + if (!guildName || typeof guildName !== 'string') { + console.warn('[GUILD TRACKER] Invalid guild name for', guildId, '- using fallback'); + guildName = 'Unknown Server'; + } + try { const data = loadGuildTracker(); @@ -229,6 +240,9 @@ async function detectNewGuilds(client) { } } +// Ensure guild tracker file exists on module load +ensureGuildTrackerFile(); + module.exports = { trackGuild, markCommandsDeployed,