diff --git a/README.md b/README.md
index b37c284..11b4bc8 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,47 @@ A Discord bot that automatically tracks CS2 players and roasts them based on the
Find your Steam64 ID at [steamid.io](https://steamid.io/)
+## DM Notifications
+
+### Automatic DM Roasts
+
+When you link your account with `/link`, the bot will send you a test message to confirm it can DM you. If successful:
+- ✅ You'll receive roasts in DMs when new matches are detected
+- ✅ You'll also get roasted in server channels (if you're linked there)
+- ✅ Immediate notifications even when away from servers
+
+**Note:** Make sure your Discord DMs are open:
+- Settings → Privacy & Safety → Allow direct messages from server members
+
+### Opting Out
+
+Don't want DM roasts? Use `/optout dm_roasts` to disable them.
+- You'll still get roasted in servers
+- Re-run `/link` to re-enable DM notifications
+
+### Admin-Linked Users
+
+If a server admin links your account, you'll only receive DMs if:
+1. You have the bot installed to your account, AND
+2. Your DMs are open
+
+Otherwise, you'll only be roasted in that server's channels.
+
+### Troubleshooting
+
+**Not receiving DMs?**
+
+1. Check your Discord settings:
+ - Settings → Privacy & Safety → Allow direct messages from server members
+2. Make sure you haven't blocked the bot
+3. Re-run `/link steam64_id:YOUR_ID` to test the DM connection
+4. If the bot says "DM notifications enabled" but you still don't receive them, check your message requests folder
+
+**Still having issues?**
+- DM notifications require the bot to be able to send you messages
+- Some users may have privacy settings that prevent bots from DMing them
+- You'll still receive roasts in server channels even if DMs don't work
+
## Commands
### Available Everywhere
@@ -45,6 +86,7 @@ Find your Steam64 ID at [steamid.io](https://steamid.io/)
- `/roast` - Roast yourself
- `/roast user:@username` - Roast someone else
- `/tracker check` - Manually check for new matches
+- `/optout dm_roasts` - Disable DM roast notifications
### Server Only
diff --git a/services/matchTracker.js b/services/matchTracker.js
index 22b42fa..8912592 100644
--- a/services/matchTracker.js
+++ b/services/matchTracker.js
@@ -6,6 +6,7 @@ const chatGPTRoastGenerator = require('./chatGPTRoastGenerator');
const config = require('../config');
const { loadUserLinks } = require('../utils/userLinksManager');
const { getGuildConfig } = require('../utils/guildConfigManager');
+const { isDMRoastsEnabled } = require('../utils/userPreferencesManager');
const TRACKER_DATA_PATH = path.join(__dirname, '../data/matchTrackerData.json');
@@ -374,15 +375,10 @@ class MatchTracker {
const userLinks = loadUserLinks();
const userData = userLinks[discordUserId];
- if (!userData || !userData.guilds || userData.guilds.length === 0) {
- console.log(`User ${discordUserId} has no guild associations`);
- return;
- }
-
const playerName = profileData.name || 'Unknown Player';
const currentMatchCount = profileData.total_matches || 0;
- // Generate roast once (cached for all guilds) - ensures same roast for same user
+ // Generate roast once (cached for DM and all guilds) - ensures same roast for same user
const cacheKey = `${discordUserId}-${Date.now()}`;
let selectedRoast;
@@ -422,8 +418,38 @@ class MatchTracker {
selectedRoast = this.roastCache[cacheKey];
}
+ // Try to send DM if user has opted in
+ if (isDMRoastsEnabled(discordUserId)) {
+ try {
+ const user = await this.client.users.fetch(discordUserId);
+ const dmChannel = await user.createDM();
+
+ await dmChannel.send({
+ content: `${selectedRoast}\n-# [Data Provided by Leetify]() • [Steam Profile]()`,
+ });
+
+ console.log(`[DM] Sent roast to ${playerName} via DM`);
+ } catch (error) {
+ // Discord error code 50007 = Cannot send messages to this user (DMs disabled or bot blocked)
+ if (error.code === 50007) {
+ console.log(`[DM] User ${playerName} has DMs disabled or blocked bot`);
+ } else {
+ console.log(`[DM] Failed to send roast to ${playerName}: ${error.message} (code: ${error.code || 'unknown'})`);
+ }
+ // Continue to guild notifications even if DM fails
+ }
+ }
+
// Send roast to each guild where user is linked
let successCount = 0;
+ const hasGuilds = userData && userData.guilds && userData.guilds.length > 0;
+
+ if (!hasGuilds) {
+ const dmStatus = isDMRoastsEnabled(discordUserId) ? '(DM sent)' : '(no DM enabled)';
+ console.log(`User ${discordUserId} has no guild associations ${dmStatus}`);
+ return; // Exit if no guilds (DM was already sent above if enabled)
+ }
+
for (const guildId of userData.guilds) {
try {
const guildConfig = getGuildConfig(guildId);
diff --git a/slashCommands/link.js b/slashCommands/link.js
index b1c478a..de8a88d 100644
--- a/slashCommands/link.js
+++ b/slashCommands/link.js
@@ -74,17 +74,27 @@ module.exports = {
return interaction.editReply({ embeds: [embed] });
} else {
// Update Steam64 ID
- linkUserGlobally(targetUser.id, steam64Id, targetUser.username);
+ const result = await linkUserGlobally(targetUser.id, steam64Id, targetUser.username, interaction.client);
+
+ let description = `Updated your Steam64 ID from \`${existingSteam64}\` to \`${steam64Id}\``;
+ if (result.dmEnabled) {
+ description += '\n\n✅ DM notifications enabled! Check your DMs for confirmation.';
+ } else if (result.dmError) {
+ description += '\n\n⚠️ **Warning:** Couldn\'t send you a DM. Please enable "Allow direct messages from server members" in your privacy settings if you want DM notifications.';
+ }
+
const embed = new EmbedBuilder()
.setColor('#00ff00')
.setTitle('Link Updated')
- .setDescription(`Updated your Steam64 ID from \`${existingSteam64}\` to \`${steam64Id}\``);
+ .setDescription(description);
return interaction.editReply({ embeds: [embed] });
}
}
- // Link user globally
- if (!linkUserGlobally(targetUser.id, steam64Id, targetUser.username)) {
+ // Link user globally with DM testing
+ const linkResult = await linkUserGlobally(targetUser.id, steam64Id, targetUser.username, interaction.client);
+
+ if (!linkResult.success) {
const embed = new EmbedBuilder()
.setColor('#ff0000')
.setTitle('Error')
@@ -109,6 +119,13 @@ module.exports = {
};
matchTracker.saveTrackerData();
+ let note = 'Automatic roasting only works in servers where the bot is configured.';
+ if (linkResult.dmEnabled) {
+ note += '\n✅ DM notifications enabled! Check your DMs for confirmation.';
+ } else if (linkResult.dmError) {
+ note += '\n⚠️ DM notifications couldn\'t be enabled. Please enable "Allow direct messages from server members" in your privacy settings.';
+ }
+
const successEmbed = new EmbedBuilder()
.setColor('#00ff00')
.setTitle('Global Link Successful')
@@ -117,7 +134,7 @@ module.exports = {
{ 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.' },
+ { name: 'Note', value: note },
);
await interaction.editReply({ embeds: [successEmbed] });
@@ -177,8 +194,10 @@ module.exports = {
// 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)) {
+ // Save the link (guild-specific) with DM testing
+ const linkResult = await linkUserToGuild(targetUser.id, interaction.guild.id, steam64Id, targetUser.username, interaction.user.id, interaction.client);
+
+ if (!linkResult.success) {
const embed = new EmbedBuilder()
.setColor('#ff0000')
.setTitle('Error')
@@ -255,6 +274,15 @@ module.exports = {
}
// Update the reply with embed
+ let status = 'Initial roast sent! Automatic tracking is now active.';
+ if (linkResult.dmEnabled) {
+ status += '\n\n✅ DM notifications enabled! User will receive DMs when new matches are detected.';
+ } else if (linkResult.dmError && mentionedUser) {
+ status += '\n\n⚠️ DM notifications couldn\'t be enabled for this user (they may need to enable DMs or install the app).';
+ } else if (linkResult.dmError && !mentionedUser) {
+ status += '\n\n⚠️ DM notifications couldn\'t be enabled. Please enable "Allow direct messages from server members" in your privacy settings.';
+ }
+
const successEmbed = new EmbedBuilder()
.setColor('#00ff00')
.setTitle('Link Successful')
@@ -262,7 +290,7 @@ module.exports = {
.addFields(
{ name: 'Player', value: playerName },
{ name: 'Matches Tracked', value: currentMatchCount.toString() },
- { name: 'Status', value: 'Initial roast sent! Automatic tracking is now active.' },
+ { name: 'Status', value: status },
);
await interaction.editReply({ embeds: [successEmbed] });
diff --git a/slashCommands/optout.js b/slashCommands/optout.js
new file mode 100644
index 0000000..66fbbe3
--- /dev/null
+++ b/slashCommands/optout.js
@@ -0,0 +1,68 @@
+const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js');
+const { disableDMRoasts, isDMRoastsEnabled } = require('../utils/userPreferencesManager');
+
+module.exports = {
+ data: new SlashCommandBuilder()
+ .setName('optout')
+ .setDescription('Opt out of DM roast notifications')
+ .addSubcommand(subcommand =>
+ subcommand
+ .setName('dm_roasts')
+ .setDescription('Stop receiving roasts in DMs')),
+
+ // Mark as user-installable (works in DMs and guilds)
+ userInstallable: true,
+ guildOnly: false,
+
+ async execute(interaction) {
+ const subcommand = interaction.options.getSubcommand();
+
+ if (subcommand === 'dm_roasts') {
+ return this.handleDMRoastsOptOut(interaction);
+ }
+ },
+
+ async handleDMRoastsOptOut(interaction) {
+ const userId = interaction.user.id;
+
+ // Check if already opted out
+ if (!isDMRoastsEnabled(userId)) {
+ const embed = new EmbedBuilder()
+ .setColor('#ff9900')
+ .setTitle('Already Opted Out')
+ .setDescription('You\'re not currently receiving roasts in DMs.')
+ .addFields({
+ name: 'Want to opt back in?',
+ value: 'Run `/link` again with your Steam64 ID to re-enable DM notifications.',
+ });
+ return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] });
+ }
+
+ // Disable DM roasts
+ if (!disableDMRoasts(userId)) {
+ const embed = new EmbedBuilder()
+ .setColor('#ff0000')
+ .setTitle('Error')
+ .setDescription('Failed to opt out. Please try again.');
+ return interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] });
+ }
+
+ const embed = new EmbedBuilder()
+ .setColor('#00ff00')
+ .setTitle('Opted Out Successfully')
+ .setDescription('✅ You will no longer receive roasts in DMs.')
+ .addFields(
+ {
+ name: 'Server Roasts',
+ value: 'You\'ll still get roasted in servers where you\'re linked.',
+ },
+ {
+ name: 'Re-enable DM Notifications',
+ value: 'Run `/link` again with your Steam64 ID to opt back in.',
+ },
+ );
+
+ await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] });
+ console.log(`[OPTOUT] User ${interaction.user.username} (${userId}) opted out of DM roasts`);
+ },
+};
diff --git a/utils/userLinksManager.js b/utils/userLinksManager.js
index daa8f0b..e7a4627 100644
--- a/utils/userLinksManager.js
+++ b/utils/userLinksManager.js
@@ -1,8 +1,22 @@
const fs = require('fs');
const path = require('path');
+const { enableDMRoasts } = require('./userPreferencesManager');
const USER_LINKS_PATH = path.join(__dirname, '../data/userLinks.json');
+// DM notification messages
+const DM_MESSAGES = {
+ SELF_LINK: '✅ **Account Linked Successfully!**\n\n' +
+ 'You\'ll now receive roasts in DMs when new matches are detected.\n' +
+ 'Use `/optout dm_roasts` to disable DM notifications.\n\n' +
+ '📊 Your stats are being tracked automatically.',
+ ADMIN_LINK: '✅ **Your CS2 Account Has Been Linked**\n\n' +
+ 'A server admin has linked your account to track your matches.\n' +
+ 'You\'ll receive roast notifications in DMs when new matches are detected.\n' +
+ 'Use `/optout dm_roasts` to disable DM notifications.\n\n' +
+ '📊 Your stats are being tracked automatically.',
+};
+
// Ensure data directory exists
const dataDir = path.dirname(USER_LINKS_PATH);
if (!fs.existsSync(dataDir)) {
@@ -48,9 +62,10 @@ function saveUserLinks(links) {
* @param {string} steam64Id - Steam64 ID
* @param {string} username - Discord username
* @param {string} linkedBy - Discord ID of who linked them
- * @returns {boolean} Success status
+ * @param {Client} client - Discord client (optional, for admin-link DM testing)
+ * @returns {Promise<{success: boolean, dmEnabled: boolean, dmError: string|null}>} Result object
*/
-function linkUserToGuild(discordUserId, guildId, steam64Id, username, linkedBy) {
+async function linkUserToGuild(discordUserId, guildId, steam64Id, username, linkedBy, client = null) {
const userLinks = loadUserLinks();
// Initialize user data if not exists
@@ -72,7 +87,38 @@ function linkUserToGuild(discordUserId, guildId, steam64Id, username, linkedBy)
// Update username in case it changed
userLinks[discordUserId].username = username;
- return saveUserLinks(userLinks);
+ const linkSuccess = saveUserLinks(userLinks);
+
+ // If admin force-linked (linkedBy !== discordUserId), try to enable DM roasts if user has app
+ let dmEnabled = false;
+ let dmError = null;
+
+ if (client && linkedBy !== discordUserId) {
+ // Admin force-link - check if user can receive DMs
+ const canDM = await canSendDM(discordUserId, client);
+
+ if (canDM) {
+ try {
+ const user = await client.users.fetch(discordUserId);
+ await user.send({
+ content: DM_MESSAGES.ADMIN_LINK,
+ });
+
+ // DM succeeded - enable DM roasts
+ enableDMRoasts(discordUserId);
+ dmEnabled = true;
+ } catch (error) {
+ dmError = error.message;
+ dmEnabled = false;
+ }
+ }
+ }
+
+ return {
+ success: linkSuccess,
+ dmEnabled: dmEnabled,
+ dmError: dmError,
+ };
}
/**
@@ -176,15 +222,32 @@ function getUserGuilds(discordUserId) {
return userData.guilds;
}
+/**
+ * Check if user has bot/app installed and can receive DMs
+ * @param {string} discordUserId - Discord user ID
+ * @param {Client} client - Discord client
+ * @returns {Promise} Whether user can receive DMs
+ */
+async function canSendDM(discordUserId, client) {
+ try {
+ const user = await client.users.fetch(discordUserId);
+ await user.createDM();
+ return true;
+ } catch (error) {
+ return false;
+ }
+}
+
/**
* 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
+ * @param {Client} client - Discord client (optional, for DM testing)
+ * @returns {Promise<{success: boolean, dmEnabled: boolean, dmError: string|null}>} Result object
*/
-function linkUserGlobally(discordUserId, steam64Id, username) {
+async function linkUserGlobally(discordUserId, steam64Id, username, client = null) {
const userLinks = loadUserLinks();
// Initialize user data if not exists
@@ -205,7 +268,34 @@ function linkUserGlobally(discordUserId, steam64Id, username) {
userLinks[discordUserId].globalLinkedAt = new Date().toISOString();
}
- return saveUserLinks(userLinks);
+ const linkSuccess = saveUserLinks(userLinks);
+
+ // Try to send test DM and enable DM roasts if user provided client
+ let dmEnabled = false;
+ let dmError = null;
+
+ if (client) {
+ try {
+ const user = await client.users.fetch(discordUserId);
+ await user.send({
+ content: DM_MESSAGES.SELF_LINK,
+ });
+
+ // DM succeeded - enable DM roasts
+ enableDMRoasts(discordUserId);
+ dmEnabled = true;
+ } catch (error) {
+ // DM failed - user has DMs disabled or blocked bot
+ dmError = error.message;
+ dmEnabled = false;
+ }
+ }
+
+ return {
+ success: linkSuccess,
+ dmEnabled: dmEnabled,
+ dmError: dmError,
+ };
}
/**
@@ -253,4 +343,5 @@ module.exports = {
linkUserGlobally,
isUserLinkedGlobally,
getUserSteam64Id,
+ canSendDM,
};
diff --git a/utils/userPreferencesManager.js b/utils/userPreferencesManager.js
new file mode 100644
index 0000000..9391280
--- /dev/null
+++ b/utils/userPreferencesManager.js
@@ -0,0 +1,113 @@
+const fs = require('fs');
+const path = require('path');
+
+const USER_PREFERENCES_PATH = path.join(__dirname, '../data/userPreferences.json');
+
+// Ensure data directory exists
+const dataDir = path.dirname(USER_PREFERENCES_PATH);
+if (!fs.existsSync(dataDir)) {
+ fs.mkdirSync(dataDir, { recursive: true });
+}
+
+/**
+ * Load user preferences from file
+ * @returns {Object} User preferences object
+ */
+function loadUserPreferences() {
+ try {
+ if (!fs.existsSync(USER_PREFERENCES_PATH)) {
+ return {};
+ }
+ const data = fs.readFileSync(USER_PREFERENCES_PATH, 'utf8');
+ return JSON.parse(data);
+ } catch (error) {
+ console.error('Error loading user preferences:', error);
+ return {};
+ }
+}
+
+/**
+ * Save user preferences to file
+ * @param {Object} preferences - User preferences object
+ * @returns {boolean} Success status
+ */
+function saveUserPreferences(preferences) {
+ try {
+ fs.writeFileSync(USER_PREFERENCES_PATH, JSON.stringify(preferences, null, 2));
+ return true;
+ } catch (error) {
+ console.error('Error saving user preferences:', error);
+ return false;
+ }
+}
+
+/**
+ * Enable DM roasts for a user
+ * @param {string} discordUserId - Discord user ID
+ * @returns {boolean} Success status
+ */
+function enableDMRoasts(discordUserId) {
+ const preferences = loadUserPreferences();
+
+ if (!preferences[discordUserId]) {
+ preferences[discordUserId] = {};
+ }
+
+ preferences[discordUserId].dmRoastsEnabled = true;
+ preferences[discordUserId].dmEnabledAt = new Date().toISOString();
+
+ return saveUserPreferences(preferences);
+}
+
+/**
+ * Disable DM roasts for a user
+ * @param {string} discordUserId - Discord user ID
+ * @returns {boolean} Success status
+ */
+function disableDMRoasts(discordUserId) {
+ const preferences = loadUserPreferences();
+
+ if (!preferences[discordUserId]) {
+ preferences[discordUserId] = {};
+ }
+
+ preferences[discordUserId].dmRoastsEnabled = false;
+ preferences[discordUserId].dmDisabledAt = new Date().toISOString();
+
+ return saveUserPreferences(preferences);
+}
+
+/**
+ * Check if DM roasts are enabled for a user
+ * @param {string} discordUserId - Discord user ID
+ * @returns {boolean} Whether DM roasts are enabled
+ */
+function isDMRoastsEnabled(discordUserId) {
+ const preferences = loadUserPreferences();
+ const userPrefs = preferences[discordUserId];
+
+ if (!userPrefs) {
+ return false;
+ }
+
+ return userPrefs.dmRoastsEnabled === true;
+}
+
+/**
+ * Get user preferences
+ * @param {string} discordUserId - Discord user ID
+ * @returns {Object|null} User preferences or null if not found
+ */
+function getUserPreferences(discordUserId) {
+ const preferences = loadUserPreferences();
+ return preferences[discordUserId] || null;
+}
+
+module.exports = {
+ loadUserPreferences,
+ saveUserPreferences,
+ enableDMRoasts,
+ disableDMRoasts,
+ isDMRoastsEnabled,
+ getUserPreferences,
+};