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, +};