From a8b4ead4946354219403c91661b694ceba2b872e Mon Sep 17 00:00:00 2001 From: - Date: Tue, 21 Oct 2025 20:57:14 -0700 Subject: [PATCH 1/2] User App Created --- README.md | 180 +++++++++++++-------------------- index.js | 55 ++++------ slashCommands/link.js | 135 ++++++++++++++++++++++--- slashCommands/roast.js | 86 ++++++++++++++++ slashCommands/setup.js | 12 +++ slashCommands/stats.js | 45 +++++++-- slashCommands/tracker.js | 59 ++++++++--- utils/globalCommandDeployer.js | 98 ++++++++++++++++++ utils/userLinksManager.js | 68 +++++++++++++ 9 files changed, 560 insertions(+), 178 deletions(-) create mode 100644 slashCommands/roast.js create mode 100644 utils/globalCommandDeployer.js diff --git a/README.md b/README.md index 7cad7bf..6dee46f 100644 --- a/README.md +++ b/README.md @@ -2,153 +2,115 @@ A Discord bot that automatically tracks CS2 players and roasts them based on their match statistics. -## Add to Your Server +## Installation Links -**[Add CS2 Roast Bot to Your Discord Server](https://discord.com/oauth2/authorize?scope=bot+applications.commands&client_id=1430077771920441387)** +**[Add to Server](https://discord.com/oauth2/authorize?scope=bot+applications.commands&client_id=1430077771920441387)** - Enable automatic roasting in your server + +**[Install to Account](https://discord.com/oauth2/authorize?scope=applications.commands&client_id=1430077771920441387&integration_type=1)** - Use commands in DMs and anywhere ## 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 -- **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 - -1. Install dependencies -```bash -npm install -``` +- Automatic match tracking and roasting +- Works in servers, DMs, and group DMs +- Global and per-server account linking +- Slash commands with autocomplete +- Multi-server support with independent configuration +- Smart API caching and cooldown system +- Automatic onboarding for new servers -2. Create `.env` file +## Quick Start -3. Run the bot +### For Servers -## Initial Setup (Server Admins) +1. Add the bot to your server +2. Run `/setup channel #roasts` to configure the roast channel +3. Run `/link steam64_id:YOUR_ID` to link your Steam account +4. Get roasted automatically after matches -### 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 +### For Personal Use -### 2. Configure the Bot -An admin must set up the roast channel using: -``` -/setup channel #roasts -``` - -This sets where automatic roasts will be posted. +1. Install the bot to your account +2. Run `/link steam64_id:YOUR_ID` in DMs +3. Use `/stats`, `/roast`, and `/tracker check` anywhere -### 3. Check Setup Status -``` -/setup status -``` +Find your Steam64 ID at [steamid.io](https://steamid.io/) ## Commands -All commands are **slash commands** - start typing `/` in Discord to see them with autocomplete! +### Available Everywhere -### User Commands +- `/link steam64_id:YOUR_ID` - Link your Steam account +- `/stats` - View your stats +- `/stats user:@username` - View someone else's stats +- `/roast` - Roast yourself +- `/roast user:@username` - Roast someone else +- `/tracker check` - Manually check for new matches -**Link your Steam account:** -``` -/link steam64_id:76561198123456789 -``` -Find your Steam64 ID at [steamid.io](https://steamid.io/) +### Server Only -**View your stats:** -``` -/stats -/stats user:@username -``` +- `/setup channel #channel` - Configure roast channel (Admin) +- `/setup status` - View server configuration +- `/tracker status` - View tracking status +- `/link steam64_id:ID user:@username` - Link another user (Admin) -**Check tracker status:** -``` -/tracker status -/tracker check -/tracker check user:@username -``` +## How It Works -### Admin Commands +### Automatic Roasting -**Setup roast channel (requires Manage Server permission):** -``` -/setup channel #roasts -/setup status -``` +- Bot checks for new matches every hour +- When a match is detected, stats are fetched and analyzed +- A roast is generated based on performance +- The roast is posted in configured server channels -**Link other users (requires Administrator permission):** -``` -/link steam64_id:76561198123456789 user:@username -``` +### Cooldown System -## Multi-Server Behavior +- No cooldown when matches are detected +- 3-hour cooldown when no new matches found +- Prevents API spam while allowing consecutive games -- Users can link their account in multiple servers -- Each server has its own configured roast channel -- When a user finishes a match, the **same roast** is sent to all servers where they're linked -- 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 +### Multi-Server Support -## Cooldown System +- Link once, roast everywhere +- Each server has independent configuration +- API called once per user, roast sent to all servers +- Data automatically cleaned up when bot leaves -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) +## Development Setup -This means players can play multiple CS2 games in a row and get roasted after each one! +### Requirements -## Configuration +- Node.js v16.9.0+ +- Discord bot token +- Leetify API key from [api-public-docs.cs-prod.leetify.com](https://api-public-docs.cs-prod.leetify.com) -Create a `.env` file with the following variables: +### Installation ```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) +npm install ``` -You can adjust the timing by changing the environment variables in `.env`. +### Configuration -## Requirements +Create a `.env` file: -- Node.js v16.9.0+ -- Discord bot token -- Leetify API key +```bash +DISCORD_TOKEN=your_discord_bot_token +CLIENT_ID=your_discord_client_id +LEETIFY_API_KEY=your_leetify_api_key +LEETIFY_API_BASE_URL=https://api-public.cs-prod.leetify.com +CHECK_INTERVAL_MINUTES=60 +USER_COOLDOWN_HOURS=3 +``` -## Get Leetify API Key +### Run -Visit [Leetify API Docs](https://api-public-docs.cs-prod.leetify.com) +```bash +npm start +``` ## Attribution -This application uses data **Powered by Leetify**. CS2 Roast Bot is an independent application and is not an official Leetify app or endorsed by Leetify. - -- All player statistics are sourced from Leetify -- Visit [Leetify](https://leetify.com) for detailed CS2 player analytics +Powered by Leetify. All player statistics are sourced from [Leetify](https://leetify.com). This is an independent application and is not officially endorsed by Leetify. ![Powered by Leetify](assets/Leetify%20Badge%20White%20Small.png) diff --git a/index.js b/index.js index 5dd83ef..30d5173 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,7 @@ const matchTracker = require('./services/matchTracker'); const { deleteGuildConfig } = require('./utils/guildConfigManager'); const { removeGuildFromAllUsers } = require('./utils/userLinksManager'); const { deployCommandsToGuild, removeCommandsFromGuild } = require('./utils/commandDeployer'); +const { deployGlobalCommands } = require('./utils/globalCommandDeployer'); const { trackGuild, markCommandsDeployed, @@ -128,6 +129,15 @@ client.once('clientReady', async () => { console.log('Bot is ready to roast some CS2 players!'); client.user.setActivity('CS2 players', { type: ActivityType.Watching }); + // Deploy commands globally with user install support + console.log('[STARTUP] Deploying global commands...'); + const success = await deployGlobalCommands(); + if (success) { + console.log('[STARTUP] Global commands deployed successfully'); + } else { + console.error('[STARTUP] Failed to deploy global commands'); + } + // Detect new guilds that joined while bot was offline console.log('[STARTUP] Checking for new guilds joined while offline...'); const newGuilds = await detectNewGuilds(client); @@ -137,29 +147,11 @@ client.once('clientReady', async () => { for (const guild of newGuilds) { console.log(` - ${guild.name} (${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`); + // Note: Commands are now deployed globally, so no per-guild deployment needed + // Just mark them as detected for tracking purposes + for (const guild of newGuilds) { + markCommandsDeployed(guild.id); + } } else { console.log('[STARTUP] No new guilds detected.'); } @@ -209,19 +201,12 @@ client.on('guildCreate', async (guild) => { // 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}`); + // Note: Commands are deployed globally, no per-guild deployment needed + markCommandsDeployed(guild.id); + console.log(`[GUILD JOIN] Guild tracked. Commands are available globally.`); - // Send onboarding message - await sendOnboardingMessage(guild); - } else { - console.error(`[GUILD JOIN] Failed to deploy commands to ${guild.name}`); - } + // Send onboarding message + await sendOnboardingMessage(guild); }); // Handle bot being removed from a guild diff --git a/slashCommands/link.js b/slashCommands/link.js index b5ef7cb..616a04a 100644 --- a/slashCommands/link.js +++ b/slashCommands/link.js @@ -2,13 +2,20 @@ 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 { + linkUserToGuild, + linkUserGlobally, + isUserLinkedInGuild, + getUserSteam64Id, + loadUserLinks, + saveUserLinks, +} = 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') + .setDescription('Link your Discord account to your Steam64 ID') .addStringOption(option => option .setName('steam64_id') @@ -17,10 +24,120 @@ module.exports = { .addUserOption(option => option .setName('user') - .setDescription('(Admin only) User to link') + .setDescription('(Admin only, Guild only) User to link') .setRequired(false)), + // Mark as user-installable (works in DMs and guilds) + userInstallable: true, + guildOnly: false, + async execute(interaction) { + const isGuildContext = !!interaction.guild; + const steam64Id = interaction.options.getString('steam64_id'); + const mentionedUser = interaction.options.getUser('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] }); + } + + // Route to appropriate handler + if (!isGuildContext) { + return this.handleGlobalLink(interaction, steam64Id); + } + return this.handleGuildLink(interaction, steam64Id, mentionedUser); + }, + + async handleGlobalLink(interaction, steam64Id) { + const targetUser = interaction.user; + + // Defer reply as this will take some time + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + // Check if already linked globally + const existingSteam64 = getUserSteam64Id(targetUser.id); + + if (existingSteam64) { + if (existingSteam64 === steam64Id) { + const embed = new EmbedBuilder() + .setColor('#00ff00') + .setTitle('Already Linked') + .setDescription(`You're already linked to Steam64 ID: \`${steam64Id}\``) + .addFields({ + name: 'Use in servers', + value: 'You can now use commands in any server where this bot is installed!', + }); + return interaction.editReply({ embeds: [embed] }); + } else { + // Update Steam64 ID + linkUserGlobally(targetUser.id, steam64Id, targetUser.username); + const embed = new EmbedBuilder() + .setColor('#00ff00') + .setTitle('Link Updated') + .setDescription(`Updated your Steam64 ID from \`${existingSteam64}\` to \`${steam64Id}\``); + return interaction.editReply({ embeds: [embed] }); + } + } + + // Link user globally + if (!linkUserGlobally(targetUser.id, steam64Id, targetUser.username)) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Error') + .setDescription('Failed to save the link. Please try again.'); + return interaction.editReply({ embeds: [embed] }); + } + + // Fetch stats to verify the link works + 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(); + + const successEmbed = new EmbedBuilder() + .setColor('#00ff00') + .setTitle('Global Link Successful') + .setDescription(`Successfully linked your account to Steam64 ID: \`${steam64Id}\``) + .addFields( + { name: 'Player', value: playerName }, + { name: 'Matches Tracked', value: currentMatchCount.toString() }, + { name: 'Available Commands', value: '`/stats` - View your stats\n`/roast` - Get instant roasts\n`/tracker check` - Check for new matches' }, + { name: 'Note', value: 'Automatic roasting only works in servers where the bot is configured.' }, + ); + await interaction.editReply({ embeds: [successEmbed] }); + + console.log(`[LINK GLOBAL] Linked ${targetUser.username} (${targetUser.id}) globally - ${currentMatchCount} matches`); + } catch (error) { + console.error('Error fetching stats for global link:', error); + const errorEmbed = new EmbedBuilder() + .setColor('#ff9900') + .setTitle('Link Successful (Stats Error)') + .setDescription(`Successfully linked your account to Steam64 ID: \`${steam64Id}\``) + .addFields( + { name: 'Error', value: error.message }, + { name: 'Status', value: 'Link saved. You can try using commands now.' }, + ); + await interaction.editReply({ embeds: [errorEmbed] }); + } + }, + + async handleGuildLink(interaction, steam64Id, mentionedUser) { // Check if guild is configured if (!isGuildConfigured(interaction.guild.id)) { const embed = new EmbedBuilder() @@ -36,8 +153,6 @@ module.exports = { // 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; @@ -60,16 +175,6 @@ module.exports = { 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); diff --git a/slashCommands/roast.js b/slashCommands/roast.js new file mode 100644 index 0000000..221bb30 --- /dev/null +++ b/slashCommands/roast.js @@ -0,0 +1,86 @@ +const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); +const leetifyApi = require('../services/leetifyApi'); +const cs2RoastGenerator = require('../utils/cs2RoastGenerator'); +const { isUserLinkedGlobally, getUserSteam64Id } = require('../utils/userLinksManager'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('roast') + .setDescription('Get roasted based on CS2 stats instantly!') + .addUserOption(option => + option + .setName('user') + .setDescription('The user to roast (defaults to yourself)') + .setRequired(false)), + + // Mark as user-installable (works in DMs and guilds) + userInstallable: true, + guildOnly: false, + + async execute(interaction) { + const mentionedUser = interaction.options.getUser('user'); + const targetUser = mentionedUser || interaction.user; + const userId = targetUser.id; + + // Check if user is linked (globally or in a guild) + const steam64Id = getUserSteam64Id(userId); + + if (!steam64Id) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Not Linked') + .setDescription( + targetUser.id === interaction.user.id + ? 'You need to link your Steam account first!' + : `${targetUser.username} needs to link their Steam account first!` + ) + .addFields({ + name: 'How to link', + value: interaction.guild + ? 'Use `/link steam64_id:YOUR_ID` in this server or in DMs with me.' + : 'Use `/link steam64_id:YOUR_ID` to link your Steam account.', + }); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + + // Defer reply as fetching stats may take time + // Always public so everyone can see the roast! + await interaction.deferReply(); + + try { + // Fetch latest stats from Leetify + const profileData = await leetifyApi.getProfile(steam64Id); + const stats = cs2RoastGenerator.calculateStatsFromProfile(profileData); + const playerName = profileData.name || targetUser.username; + + // Generate roast (no previous stats for comparison) + const roasts = cs2RoastGenerator.generateRoastsWithComparison(stats, null); + const selectedRoast = roasts[Math.floor(Math.random() * roasts.length)]; + + // Build roast message + let roastMessage = `${targetUser}, ${selectedRoast}`; + roastMessage += '\n-# [Data Provided by Leetify]()'; + if (steam64Id) { + roastMessage += ` • [Steam Profile]()`; + } + + // Send the roast + await interaction.editReply({ content: roastMessage }); + + console.log(`[ROAST] ${playerName} (${userId}) got roasted by ${interaction.user.username} ${interaction.guild ? `in guild ${interaction.guild.id}` : 'in DM'}`); + } catch (error) { + console.error('Error fetching stats for roast:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Error Fetching Stats') + .setDescription(`Failed to fetch ${targetUser.id === interaction.user.id ? 'your' : targetUser.username + "'s"} CS2 stats from Leetify.`) + .addFields({ + name: 'Error Details', + value: error.message || 'Unknown error', + }); + + await interaction.editReply({ embeds: [errorEmbed] }); + } + }, +}; diff --git a/slashCommands/setup.js b/slashCommands/setup.js index 911c9d2..5ffc7d6 100644 --- a/slashCommands/setup.js +++ b/slashCommands/setup.js @@ -20,7 +20,19 @@ module.exports = { .setName('status') .setDescription('View current setup configuration')), + // Mark as guild-only (explicitly disable user install) + userInstallable: false, + guildOnly: true, + async execute(interaction) { + // Ensure this command only works in guilds + if (!interaction.guild) { + return interaction.reply({ + content: 'This command can only be used in servers!', + flags: [MessageFlags.Ephemeral], + }); + } + const subcommand = interaction.options.getSubcommand(); if (subcommand === 'status') { diff --git a/slashCommands/stats.js b/slashCommands/stats.js index f117ca5..2ce714a 100644 --- a/slashCommands/stats.js +++ b/slashCommands/stats.js @@ -1,6 +1,6 @@ const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); const matchTracker = require('../services/matchTracker'); -const { getUsersInGuild } = require('../utils/userLinksManager'); +const { getUsersInGuild, getUserSteam64Id } = require('../utils/userLinksManager'); module.exports = { data: new SlashCommandBuilder() @@ -12,19 +12,50 @@ module.exports = { .setDescription('The user to check stats for (defaults to yourself)') .setRequired(false)), + // Mark as user-installable (works in DMs and guilds) + userInstallable: true, + guildOnly: false, + async execute(interaction) { + const isGuildContext = !!interaction.guild; const mentionedUser = interaction.options.getUser('user'); const targetUser = mentionedUser || interaction.user; + + // Universal stats display - works in both DMs and guilds + return this.showStats(interaction, targetUser, isGuildContext); + }, + + async showStats(interaction, targetUser, isGuildContext) { const targetUserId = targetUser.id; + let steam64Id; - const guildUsers = getUsersInGuild(interaction.guild.id); - const linkData = guildUsers[targetUserId]; + // Try to get Steam64 ID from guild link first, then global link + if (isGuildContext) { + const guildUsers = getUsersInGuild(interaction.guild.id); + const linkData = guildUsers[targetUserId]; + if (linkData) { + steam64Id = linkData.steam64Id; + } + } + + // Fall back to global link if no guild link found + if (!steam64Id) { + steam64Id = getUserSteam64Id(targetUserId); + } - if (!linkData) { + if (!steam64Id) { const embed = new EmbedBuilder() .setColor('#ff0000') .setTitle('User Not Linked') - .setDescription('This user is not linked in this server! Use `/link` first.'); + .setDescription( + targetUser.id === interaction.user.id + ? 'You need to link your account first! Use `/link steam64_id:YOUR_ID`' + : `${targetUser.username} is not linked${isGuildContext ? ' in this server' : ''}! They need to use \`/link\` first.` + ) + .addFields({ + name: 'Find your Steam64 ID', + value: '[steamid.io](https://steamid.io/)', + }); return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); } @@ -41,7 +72,6 @@ module.exports = { const stats = trackedData.lastStats; const lastChecked = new Date(trackedData.lastChecked).toLocaleString(); - const steam64Id = linkData.steam64Id; const embed = new EmbedBuilder() .setColor('#0099ff') @@ -88,6 +118,7 @@ module.exports = { }, ); - interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + // Public message (not ephemeral) so everyone can see the stats + interaction.reply({ embeds: [embed] }); }, }; diff --git a/slashCommands/tracker.js b/slashCommands/tracker.js index 47856f9..03def8b 100644 --- a/slashCommands/tracker.js +++ b/slashCommands/tracker.js @@ -1,6 +1,6 @@ const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); const matchTracker = require('../services/matchTracker'); -const { getUsersInGuild } = require('../utils/userLinksManager'); +const { getUsersInGuild, getUserSteam64Id } = require('../utils/userLinksManager'); module.exports = { data: new SlashCommandBuilder() @@ -20,6 +20,10 @@ module.exports = { .setDescription('The user to check (defaults to yourself)') .setRequired(false))), + // Mark as user-installable (works in DMs and guilds) + userInstallable: true, + guildOnly: false, + async execute(interaction) { const subcommand = interaction.options.getSubcommand(); @@ -47,6 +51,19 @@ module.exports = { }, async showStatus(interaction) { + // Guild-only command + if (!interaction.guild) { + const embed = new EmbedBuilder() + .setColor('#ff9900') + .setTitle('Server Only') + .setDescription('This command only works in servers!') + .addFields({ + name: 'Available in DMs', + value: 'Use `/tracker check` to check for new matches.', + }); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } + const status = matchTracker.getTrackingStatus(); const guildUsers = getUsersInGuild(interaction.guild.id); const guildUserCount = Object.keys(guildUsers).length; @@ -102,19 +119,37 @@ module.exports = { }, async manualCheck(interaction) { + const isGuildContext = !!interaction.guild; 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 linked (works for both global and guild links) + let steam64Id; + if (isGuildContext) { + 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] }); + } + steam64Id = linkData.steam64Id; + } else { + steam64Id = getUserSteam64Id(targetUserId); + if (!steam64Id) { + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle('Not Linked') + .setDescription('You need to link your account first! Use `/link steam64_id:YOUR_ID`') + .addFields({ + name: 'Find your Steam64 ID', + value: '[steamid.io](https://steamid.io/)', + }); + return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + } } // Check if user is in cooldown @@ -126,7 +161,7 @@ module.exports = { const embed = new EmbedBuilder() .setColor('#ff9900') .setTitle('User in Cooldown') - .setDescription(`<@${targetUserId}> is on cooldown for ${hours}h ${minutes}m.`) + .setDescription(`${isGuildContext ? `<@${targetUserId}>` : 'You'} ${isGuildContext ? 'is' : 'are'} 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.' }, @@ -142,7 +177,7 @@ module.exports = { const successEmbed = new EmbedBuilder() .setColor('#00ff00') .setTitle('Check Complete') - .setDescription(`Check complete for <@${targetUserId}>!`); + .setDescription(`Check complete for ${isGuildContext ? `<@${targetUserId}>` : 'you'}!`); await interaction.editReply({ embeds: [successEmbed] }); } catch (error) { const errorEmbed = new EmbedBuilder() diff --git a/utils/globalCommandDeployer.js b/utils/globalCommandDeployer.js new file mode 100644 index 0000000..7979581 --- /dev/null +++ b/utils/globalCommandDeployer.js @@ -0,0 +1,98 @@ +const { REST, Routes } = require('discord.js'); +const fs = require('fs'); +const path = require('path'); +const config = require('../config'); + +/** + * Deploy global slash commands with user install support + * @returns {Promise} Success status + */ +async function deployGlobalCommands() { + 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) { + const commandData = command.data.toJSON(); + + // Add integration_types and contexts for user-installable apps + // Since Discord.js builders don't support this yet, we add it manually + + // Determine if command should be user-installable based on metadata + const isUserInstallable = command.userInstallable !== false; // Default to true unless explicitly disabled + const isGuildOnly = command.guildOnly === true; // Default to false unless explicitly enabled + + if (isGuildOnly) { + // Guild-only commands (like setup) + commandData.integration_types = [0]; // GUILD_INSTALL only + commandData.contexts = [0]; // GUILD only + } else if (isUserInstallable) { + // Hybrid commands (works in both guild and user install) + commandData.integration_types = [0, 1]; // GUILD_INSTALL and USER_INSTALL + commandData.contexts = [0, 1, 2]; // GUILD, BOT_DM, PRIVATE_CHANNEL + } else { + // Default to guild install only + commandData.integration_types = [0]; // GUILD_INSTALL only + commandData.contexts = [0]; // GUILD only + } + + commands.push(commandData); + console.log(`[GLOBAL DEPLOY] Loaded command: ${commandData.name} (Integration: ${commandData.integration_types}, Contexts: ${commandData.contexts})`); + } 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(`[GLOBAL DEPLOY] Started refreshing ${commands.length} global application (/) commands.`); + + // Deploy commands globally + const data = await rest.put( + Routes.applicationCommands(config.clientId), + { body: commands }, + ); + + console.log(`[GLOBAL DEPLOY] Successfully reloaded ${data.length} global application (/) commands.`); + console.log(`[GLOBAL DEPLOY] Commands are now available for both guild and user installation.`); + return true; + } catch (error) { + console.error(`[GLOBAL DEPLOY ERROR] Failed to deploy global commands:`, error); + return false; + } +} + +/** + * Remove all global slash commands + * @returns {Promise} Success status + */ +async function removeGlobalCommands() { + try { + const rest = new REST().setToken(config.token); + + console.log(`[GLOBAL DEPLOY] Removing all global application (/) commands.`); + + await rest.put( + Routes.applicationCommands(config.clientId), + { body: [] }, + ); + + console.log(`[GLOBAL DEPLOY] Successfully removed all global commands.`); + return true; + } catch (error) { + console.error(`[GLOBAL DEPLOY ERROR] Failed to remove global commands:`, error); + return false; + } +} + +module.exports = { + deployGlobalCommands, + removeGlobalCommands, +}; diff --git a/utils/userLinksManager.js b/utils/userLinksManager.js index 24c98f7..daa8f0b 100644 --- a/utils/userLinksManager.js +++ b/utils/userLinksManager.js @@ -176,6 +176,71 @@ function getUserGuilds(discordUserId) { return userData.guilds; } +/** + * Link a user globally (no guild association) + * Used for user-install context (DMs) + * @param {string} discordUserId - Discord user ID + * @param {string} steam64Id - Steam64 ID + * @param {string} username - Discord username + * @returns {boolean} Success status + */ +function linkUserGlobally(discordUserId, steam64Id, username) { + const userLinks = loadUserLinks(); + + // Initialize user data if not exists + if (!userLinks[discordUserId]) { + userLinks[discordUserId] = { + steam64Id: steam64Id, + username: username, + guilds: [], + linkedAt: new Date().toISOString(), + linkedBy: discordUserId, // Self-linked in DM + globalLink: true, + }; + } else { + // Update existing user data + userLinks[discordUserId].steam64Id = steam64Id; + userLinks[discordUserId].username = username; + userLinks[discordUserId].globalLink = true; + userLinks[discordUserId].globalLinkedAt = new Date().toISOString(); + } + + return saveUserLinks(userLinks); +} + +/** + * Check if a user has a global link + * @param {string} discordUserId - Discord user ID + * @returns {boolean} Whether user has global link + */ +function isUserLinkedGlobally(discordUserId) { + const userLinks = loadUserLinks(); + const userData = userLinks[discordUserId]; + + if (!userData) { + return false; + } + + // User is globally linked if they have globalLink flag OR have a steam64Id + return userData.globalLink === true || !!userData.steam64Id; +} + +/** + * Get user's Steam64 ID (works for both global and guild links) + * @param {string} discordUserId - Discord user ID + * @returns {string|null} Steam64 ID or null if not linked + */ +function getUserSteam64Id(discordUserId) { + const userLinks = loadUserLinks(); + const userData = userLinks[discordUserId]; + + if (!userData) { + return null; + } + + return userData.steam64Id || null; +} + module.exports = { loadUserLinks, saveUserLinks, @@ -185,4 +250,7 @@ module.exports = { getUsersInGuild, isUserLinkedInGuild, getUserGuilds, + linkUserGlobally, + isUserLinkedGlobally, + getUserSteam64Id, }; From 78d6f478287ff210f53fe04372de7d611674208f Mon Sep 17 00:00:00 2001 From: - Date: Tue, 21 Oct 2025 21:01:13 -0700 Subject: [PATCH 2/2] lint --- index.js | 4 ++-- slashCommands/link.js | 1 - slashCommands/roast.js | 4 ++-- slashCommands/stats.js | 2 +- utils/globalCommandDeployer.js | 10 +++++----- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/index.js b/index.js index 30d5173..61a9eaa 100644 --- a/index.js +++ b/index.js @@ -6,7 +6,7 @@ const config = require('./config'); const matchTracker = require('./services/matchTracker'); const { deleteGuildConfig } = require('./utils/guildConfigManager'); const { removeGuildFromAllUsers } = require('./utils/userLinksManager'); -const { deployCommandsToGuild, removeCommandsFromGuild } = require('./utils/commandDeployer'); +const { removeCommandsFromGuild } = require('./utils/commandDeployer'); const { deployGlobalCommands } = require('./utils/globalCommandDeployer'); const { trackGuild, @@ -203,7 +203,7 @@ client.on('guildCreate', async (guild) => { // Note: Commands are deployed globally, no per-guild deployment needed markCommandsDeployed(guild.id); - console.log(`[GUILD JOIN] Guild tracked. Commands are available globally.`); + console.log('[GUILD JOIN] Guild tracked. Commands are available globally.'); // Send onboarding message await sendOnboardingMessage(guild); diff --git a/slashCommands/link.js b/slashCommands/link.js index 616a04a..b1c478a 100644 --- a/slashCommands/link.js +++ b/slashCommands/link.js @@ -8,7 +8,6 @@ const { isUserLinkedInGuild, getUserSteam64Id, loadUserLinks, - saveUserLinks, } = require('../utils/userLinksManager'); const { isGuildConfigured, getGuildConfig } = require('../utils/guildConfigManager'); diff --git a/slashCommands/roast.js b/slashCommands/roast.js index 221bb30..bfe693d 100644 --- a/slashCommands/roast.js +++ b/slashCommands/roast.js @@ -1,7 +1,7 @@ const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); const leetifyApi = require('../services/leetifyApi'); const cs2RoastGenerator = require('../utils/cs2RoastGenerator'); -const { isUserLinkedGlobally, getUserSteam64Id } = require('../utils/userLinksManager'); +const { getUserSteam64Id } = require('../utils/userLinksManager'); module.exports = { data: new SlashCommandBuilder() @@ -32,7 +32,7 @@ module.exports = { .setDescription( targetUser.id === interaction.user.id ? 'You need to link your Steam account first!' - : `${targetUser.username} needs to link their Steam account first!` + : `${targetUser.username} needs to link their Steam account first!`, ) .addFields({ name: 'How to link', diff --git a/slashCommands/stats.js b/slashCommands/stats.js index 2ce714a..32aca52 100644 --- a/slashCommands/stats.js +++ b/slashCommands/stats.js @@ -50,7 +50,7 @@ module.exports = { .setDescription( targetUser.id === interaction.user.id ? 'You need to link your account first! Use `/link steam64_id:YOUR_ID`' - : `${targetUser.username} is not linked${isGuildContext ? ' in this server' : ''}! They need to use \`/link\` first.` + : `${targetUser.username} is not linked${isGuildContext ? ' in this server' : ''}! They need to use \`/link\` first.`, ) .addFields({ name: 'Find your Steam64 ID', diff --git a/utils/globalCommandDeployer.js b/utils/globalCommandDeployer.js index 7979581..0af2c44 100644 --- a/utils/globalCommandDeployer.js +++ b/utils/globalCommandDeployer.js @@ -61,10 +61,10 @@ async function deployGlobalCommands() { ); console.log(`[GLOBAL DEPLOY] Successfully reloaded ${data.length} global application (/) commands.`); - console.log(`[GLOBAL DEPLOY] Commands are now available for both guild and user installation.`); + console.log('[GLOBAL DEPLOY] Commands are now available for both guild and user installation.'); return true; } catch (error) { - console.error(`[GLOBAL DEPLOY ERROR] Failed to deploy global commands:`, error); + console.error('[GLOBAL DEPLOY ERROR] Failed to deploy global commands:', error); return false; } } @@ -77,17 +77,17 @@ async function removeGlobalCommands() { try { const rest = new REST().setToken(config.token); - console.log(`[GLOBAL DEPLOY] Removing all global application (/) commands.`); + console.log('[GLOBAL DEPLOY] Removing all global application (/) commands.'); await rest.put( Routes.applicationCommands(config.clientId), { body: [] }, ); - console.log(`[GLOBAL DEPLOY] Successfully removed all global commands.`); + console.log('[GLOBAL DEPLOY] Successfully removed all global commands.'); return true; } catch (error) { - console.error(`[GLOBAL DEPLOY ERROR] Failed to remove global commands:`, error); + console.error('[GLOBAL DEPLOY ERROR] Failed to remove global commands:', error); return false; } }