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.

diff --git a/index.js b/index.js
index 5dd83ef..61a9eaa 100644
--- a/index.js
+++ b/index.js
@@ -6,7 +6,8 @@ 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,
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..b1c478a 100644
--- a/slashCommands/link.js
+++ b/slashCommands/link.js
@@ -2,13 +2,19 @@ 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,
+} = 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 +23,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 +152,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 +174,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..bfe693d
--- /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 { 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..32aca52 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..0af2c44
--- /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,
};