diff --git a/.env.example b/.env.example index e85ec06..d113444 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ DISCORD_TOKEN= -DISCORD_CLIENT_ID= \ No newline at end of file +DISCORD_CLIENT_ID= +DATA_FILE=./key_data.json +ERROR_FILE=./error_log.json \ No newline at end of file diff --git a/.gitignore b/.gitignore index bf6c280..ff6d1b8 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ dist # Application-specific files key_data.json +error_log.json \ No newline at end of file diff --git a/index.js b/index.js index b2890fd..25cebe7 100644 --- a/index.js +++ b/index.js @@ -11,10 +11,11 @@ import { EmbedBuilder } from 'discord.js'; import fs from 'fs'; -import { NEWS_CHANNEL_MESSAGE, INELLIGIBLE_MESSAGE, KEY_DM_MESSAGE } from './messages.js'; +import { NEWS_CHANNEL_MESSAGE, INELLIGIBLE_MESSAGE, formatNoKeysMessage, formatKeyMessage, formatFailedDMMessage } from './messages.js'; const TOKEN = process.env.DISCORD_TOKEN; -const DATA_FILE = './key_data.json'; +const DATA_FILE = process.env.DATA_FILE; +const ERROR_FILE = process.env.ERROR_FILE; function loadData() { if (!fs.existsSync(DATA_FILE)) { @@ -28,15 +29,55 @@ function saveData(data) { fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); } +function loadErrorLog() { + if (!fs.existsSync(ERROR_FILE)) { + return []; + } + return JSON.parse(fs.readFileSync(ERROR_FILE, 'utf8')); +} +function saveErrorLog(errors) { + fs.writeFileSync(ERROR_FILE, JSON.stringify(errors, null, 2)); +} + +function handleFailedLoggingChannel(tag, id, key, err) { + errors.push({ + timestamp: new Date().toISOString(), + message: `Unable to send error message in logging channel when attempting to DM to user ${tag} (${id}) with key ${key}. Please see error log for details.`, + userId: interaction.user.id, + error: JSON.stringify(err, null, 2) + }) +} + let data = loadData(); +let errors = loadErrorLog(); const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages], partials: [Partials.Channel] }); -client.once(Events.ClientReady, () => { +async function deleteLatestBotDMs(userId) { + try { + const user = await client.users.fetch(userId); + const dm = await user.createDM(); + // Fetch up to 100 messages (Discord API limit per request) + const messages = await dm.messages.fetch({ limit: 100 }); + let deleted = 0; + for (const msg of messages.values()) { + if (msg.author.id === client.user.id) { + await msg.delete(); + deleted++; + } + } + console.log(`Deleted ${deleted} DMs sent by the bot.`); + } catch (err) { + console.error('Error deleting DMs:', err); + } +} + +client.once(Events.ClientReady, async () => { console.log(`Logged in as ${client.user.tag}`); + await deleteLatestBotDMs('265334763324178433'); }); client.on(Events.InteractionCreate, async interaction => { @@ -74,58 +115,100 @@ client.on(Events.InteractionCreate, async interaction => { await interaction.reply({ content: 'Registration message sent!', ephemeral: true }); } - // Slash command: /load - if (interaction.isChatInputCommand() && interaction.commandName === 'load') { + // Slash command: /key + if (interaction.isChatInputCommand() && interaction.commandName === 'keys') { if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { return interaction.reply({ content: 'You must be an administrator.', ephemeral: true }); } - const file = interaction.options.getAttachment('file'); - if (!file) { - return interaction.reply({ content: 'You must attach a file.', ephemeral: true }); + + const sub = interaction.options.getSubcommand(); + + // /key load + if (sub === 'load') { + const file = interaction.options.getAttachment('file'); + if (!file) { + return interaction.reply({ content: 'You must attach a file.', ephemeral: true }); + } + const response = await fetch(file.url); + const text = await response.text(); + const keys = text.split('\n').map(line => line.trim()).filter(line => line.length > 0); + + const yesButton = new ButtonBuilder() + .setCustomId('key_load_yes') + .setLabel('Yes') + .setStyle(ButtonStyle.Success); + const noButton = new ButtonBuilder() + .setCustomId('key_load_no') + .setLabel('No') + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(yesButton, noButton); + client.tempKeyLoads = client.tempKeyLoads || {}; + client.tempKeyLoads[interaction.user.id] = keys; + + await interaction.reply({ + content: `Found ${keys.length} steam keys. Do you wish to load these into the key vault?`, + components: [row], + ephemeral: true + }); + return; } - // Download the file - const response = await fetch(file.url); - const text = await response.text(); - const keys = text.split('\n').map(line => line.trim()).filter(line => line.length > 0); + // /key get + if (sub === 'get') { + const user = interaction.options.getUser('user'); + if (!user) { + return interaction.reply({ content: 'Invalid user.', ephemeral: true }); + } + data = loadData(); + const found = data.users.find(u => u.id === user.id); + if (found && found.key) { + return interaction.reply({ content: `User <@${user.id}> has registered key: \`${found.key}\``, ephemeral: true }); + } else { + return interaction.reply({ content: `User <@${user.id}> does not have a registered key.`, ephemeral: true }); + } + } - const yesButton = new ButtonBuilder() - .setCustomId('load_yes') - .setLabel('Yes') - .setStyle(ButtonStyle.Success); - const noButton = new ButtonBuilder() - .setCustomId('load_no') - .setLabel('No') - .setStyle(ButtonStyle.Danger); - const row = new ActionRowBuilder().addComponents(yesButton, noButton); + // /key check + if (sub === 'check') { + data = loadData(); + const count = data.keys ? data.keys.length : 0; + return interaction.reply({ content: `There are ${count} keys left in the vault.`, ephemeral: true }); + } - // Store keys temporarily in memory for this user - client.tempKeyLoads = client.tempKeyLoads || {}; - client.tempKeyLoads[interaction.user.id] = keys; - - await interaction.reply({ - content: `Found ${keys.length} steam keys. Do you wish to load these into the key vault?`, - components: [row], - ephemeral: true - }); + // /keys logging + if (sub === 'logging') { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + return interaction.reply({ content: 'You must be an administrator.', ephemeral: true }); + } + const channel = interaction.options.getChannel('channel'); + if (!channel) { + return interaction.reply({ content: 'Invalid channel.', ephemeral: true }); + } + data = loadData(); + data.loggingChannel = channel.id; + saveData(data); + await channel.send(`Successfully set logging channel for all Swordcery Key Bot-related messages to <#${channel.id}>.`); + return interaction.reply({ content: `Logging channel set to <#${channel.id}>.`, ephemeral: true }); + } } - // Handle load confirmation buttons - if (interaction.isButton() && (interaction.customId === 'load_yes' || interaction.customId === 'load_no')) { + // Handle key load confirmation buttons + if (interaction.isButton() && (interaction.customId === 'key_load_yes' || interaction.customId === 'key_load_no')) { if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { return interaction.reply({ content: 'You must be an administrator.', ephemeral: true }); } if (!client.tempKeyLoads || !client.tempKeyLoads[interaction.user.id]) { return interaction.reply({ content: 'No pending key load found.', ephemeral: true }); } - if (interaction.customId === 'load_no') { + if (interaction.customId === 'key_load_no') { delete client.tempKeyLoads[interaction.user.id]; await interaction.update({ content: 'Key load cancelled.', components: [] }); return; } // Yes pressed const keys = client.tempKeyLoads[interaction.user.id]; - data = loadData(); // Reload in case of concurrent changes + data = loadData(); data.keys = data.keys || []; data.keys.push(...keys); saveData(data); @@ -136,12 +219,12 @@ client.on(Events.InteractionCreate, async interaction => { // Button interaction for register if (interaction.isButton() && interaction.customId === 'register_button') { if (interaction.message.id !== data.messageId) return; + const channel = await interaction.guild.channels.fetch(data.loggingChannel).catch(() => handleFailedLoggingChannel(interaction.user.tag, interaction.user.id, null, 'No logging channel found')); // Already registered? let user = data.users.find(u => u.id === interaction.user.id); if (user && user.key) { - const message = KEY_DM_MESSAGE.replace('{key}', `https://store.steampowered.com/account/registerkey?key=${user.key}`); - await interaction.user.send(message); + await interaction.user.send(formatKeyMessage(user.key)); return interaction.reply({ content: 'Key successfully re-sent! Please check your DMs.', ephemeral: true }); } @@ -150,10 +233,9 @@ client.on(Events.InteractionCreate, async interaction => { const joinDate = member.joinedAt; const cutoffDate = data.joinCutoff ? new Date(data.joinCutoff) : null; if (cutoffDate) { - // Compare only the date parts (YYYY-MM-DD) const join = joinDate.toISOString().slice(0, 10); const cutoff = cutoffDate.toISOString().slice(0, 10); - if (new Date(join) > new Date(cutoff)) { + if (new Date(join).getTime() > new Date(cutoff).getTime()) { return interaction.reply({ content: INELLIGIBLE_MESSAGE, ephemeral: true @@ -161,14 +243,14 @@ client.on(Events.InteractionCreate, async interaction => { } } - // Assign key - data = loadData(); // Reload in case of concurrent changes - if (!data.keys || data.keys.length === 0) { - // No keys left + // Check for available keys + data = loadData(); + const keyCount = data.keys ? data.keys.length : 0; + if (keyCount === 0) { if (data.loggingChannel) { - const channel = await interaction.guild.channels.fetch(data.loggingChannel).catch(() => console.log(`Failed to fetch logging channel ${data.loggingChannel}`)); + if (channel) { - channel.send('WARNING: No more Steam keys available for registration! Please run `/load` to add more keys.'); + channel.send(formatNoKeysMessage(interaction.user.id, interaction.user.tag)); } } return interaction.reply({ content: 'There was an error retrieving your keys. Please reach out to <@404872989188816906> in <#580122303342313473>!', ephemeral: true }); @@ -180,12 +262,25 @@ client.on(Events.InteractionCreate, async interaction => { data.users.push(user); saveData(data); + // Send warnings for low key count + if (data.loggingChannel) { + if (channel) { + if (keyCount <= 10) { + channel.send(`⚠️ Only ${data.keys.length} keys left in the vault!`); + } else if (keyCount <= 50) { + channel.send(`⚠️ Only ${data.keys.length} keys left in the vault.`); + } + } + } + await interaction.reply({ content: 'Successfully claimed key! Check your DMs.', ephemeral: true }); try { - const message = KEY_DM_MESSAGE.replace('{key}', `https://store.steampowered.com/account/registerkey?key=${key}`); - await interaction.user.send(message); + await interaction.user.send(formatKeyMessage(key)); } catch (e) { - return interaction.reply({ content: 'Unable to send DM. Please change your privacy settings or reach out for support!', ephemeral: true }); + if (channel) { + channel.send(formatFailedDMMessage(interaction.user.id, interaction.user.tag)); + } + return interaction.reply({ content: 'Unable to send DM. Please change your privacy settings or reach out to <@404872989188816906> in <#580122303342313473> for support!', ephemeral: true }); } } @@ -201,8 +296,71 @@ client.on(Events.InteractionCreate, async interaction => { data = loadData(); data.loggingChannel = channel.id; saveData(data); + await channel.send(`Successfully set logging channel for all Swordcery Key Bot-related messages to <#${channel.id}>.`); return interaction.reply({ content: `Logging channel set to <#${channel.id}>.`, ephemeral: true }); } + + // Slash command: /getkey + if (interaction.isChatInputCommand() && interaction.commandName === 'getkey') { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + return interaction.reply({ content: 'You must be an administrator.', ephemeral: true }); + } + const user = interaction.options.getUser('user'); + if (!user) { + return interaction.reply({ content: 'Invalid user.', ephemeral: true }); + } + data = loadData(); + const found = data.users.find(u => u.id === user.id); + if (found && found.key) { + return interaction.reply({ content: `User <@${user.id}> has registered key: \`${found.key}\``, ephemeral: true }); + } else { + return interaction.reply({ content: `User <@${user.id}> does not have a registered key.`, ephemeral: true }); + } + } }); -client.login(TOKEN); \ No newline at end of file +client.login(TOKEN); + +// Handle uncaught exceptions +process.on('uncaughtException', (err) => { + console.error('Uncaught Exception:', err); + errors.push({ + userId: null, + timestamp: new Date().toISOString(), + message: 'Uncaught Exception', + error: JSON.stringify(err, null, 2) + }); + saveErrorLog(errors); +}); + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + errors.push({ + userId: null, + timestamp: new Date().toISOString(), + message: 'Unhandled Rejection', + error: JSON.stringify(reason, null, 2) + }); + saveErrorLog(errors); +}); + +// Handle SIGINT (Ctrl+C) & SIGTERM (kill command) +function gracefulShutdown() { + console.log('Shutting down gracefully...'); + saveData(data); + saveErrorLog(errors); + client.destroy(); + process.exit(0); +} + +process.on('SIGINT', gracefulShutdown); +process.on('SIGTERM', gracefulShutdown); + +let lastErrorCount = errors.length; +setInterval(() => { + if (errors.length > lastErrorCount) { + saveErrorLog(errors); + lastErrorCount = errors.length; + } +}, 10000); // Check every 10 seconds \ No newline at end of file diff --git a/messages.js b/messages.js index 66a1c24..7a2354a 100644 --- a/messages.js +++ b/messages.js @@ -1,12 +1,31 @@ +// Message to be sent in news channel for users to see to claim the game key export const NEWS_CHANNEL_MESSAGE = `# CLAIM YOUR SWORDCERY SECRET PLAYTEST STEAM KEY! -Any additional playtests we run, can be accessed with this key as well.`; +Any additional playtests we run can be accessed with this key as well.`; +// Message users will see if they are inelligible for the playtest export const INELLIGIBLE_MESSAGE = `**Sorry! You missed the cutoff date for this Swordcery Secret Playtest.** Please stay tuned though as you'll be eligible for any subsequent playtests we run!`; -export const KEY_DM_MESSAGE = `# Thanks so much for choosing to be part of the Swordcery Secret Playtest! +// Message to be sent in logging channel if no more keys are available +const NO_MORE_KEYS = `# **⚠️ WARNING - NO MORE DEMO KEYS ⚠️** +User {user} tried to register for their game key, but there are none left. Please run \`/load\` to add more keys!`; +export function formatNoKeysMessage(id, tag) { + return NO_MORE_KEYS.replace('{user}', `${tag} (<@${id}>)`); +} + +// Message to be sent in DM to users when they successfully register for a key +const KEY_DM_MESSAGE = `# Thanks so much for choosing to be part of the Swordcery Secret Playtest! We're excited for you to try the game! Please note that the game is still a work in progress so you may run into some bugs, but we hope the experience is enjoyable. Thanks again for your support! -Redeem your key: {key}`; \ No newline at end of file +Redeem your key: {key}`; +export function formatKeyMessage(key) { + return KEY_DM_MESSAGE.replace('{key}', `https://store.steampowered.com/account/registerkey?key=${key} (Or enter manually: \`${key}\`)`); +} + +const KEY_DM_FAILED = `# ** ⚠️ WARNING - UNABLE TO SEND DM ⚠️** +User {user} tried to register for their game key, but Swordcery Key Bot was unable to send them a DM.` +export function formatFailedDMMessage(id, tag) { + return KEY_DM_FAILED.replace('{user}', `${tag} (<@${id}>)`); +} \ No newline at end of file diff --git a/register.js b/register.js index dad00f6..972d8c6 100644 --- a/register.js +++ b/register.js @@ -3,62 +3,86 @@ import { REST, Routes } from 'discord.js'; const TOKEN = process.env.DISCORD_TOKEN; const CLIENT_ID = process.env.DISCORD_CLIENT_ID; +const ADMIN_PERM = 0x00000008; const commands = [ - { - name: 'register', - description: 'Send a registration message with a button' - }, - { + { + name: 'register', + description: 'Send a registration message with a button', + default_member_permissions: ADMIN_PERM.toString() + }, + { + name: 'keys', + description: 'Manage Steam keys', + default_member_permissions: ADMIN_PERM.toString(), + options: [ + { + type: 1, // SUB_COMMAND name: 'load', description: 'Load a list of Steam keys from a file', options: [ - { - name: 'file', - description: 'Text file with one Steam key per line', - type: 11, // Attachment - required: true - } + { + name: 'file', + description: 'Text file with one Steam key per line', + type: 11, // Attachment + required: true + } ] - }, - { - name: 'setlogging', + }, + { + type: 1, // SUB_COMMAND + name: 'get', + description: 'Get a user\'s registered Steam key', + options: [ + { + name: 'user', + description: 'User to look up', + type: 6, // USER type + required: true + } + ] + }, + { + type: 1, // SUB_COMMAND + name: 'check', + description: 'Check how many keys are left' + }, + { + type: 1, // SUB_COMMAND + name: 'logging', description: 'Set the logging channel for key warnings', options: [ - { - name: 'channel', - description: 'Channel to send logging messages to', - type: 7, // Channel - required: true - } + { + name: 'channel', + description: 'Channel to send logging messages to', + type: 7, // Channel + required: true + } ] - } + } + ] + } ]; const rest = new REST({ version: '10' }).setToken(TOKEN); (async () => { - try { - // Fetch all current global commands - const currentCommands = await rest.get( - Routes.applicationCommands(CLIENT_ID) - ); - - // Delete each existing command - for (const cmd of currentCommands) { - await rest.delete( - Routes.applicationCommand(CLIENT_ID, cmd.id) - ); - console.log(`Deleted command: ${cmd.name}`); - } - - // Register new commands - await rest.put( - Routes.applicationCommands(CLIENT_ID), - { body: commands } - ); - console.log('Global slash commands registered.'); - } catch (error) { - console.error(error); + try { + const currentCommands = await rest.get( + Routes.applicationCommands(CLIENT_ID) + ); + for (const cmd of currentCommands) { + await rest.delete( + Routes.applicationCommand(CLIENT_ID, cmd.id) + ); + console.log(`Deleted command: ${cmd.name}`); } + await rest.put( + Routes.applicationCommands(CLIENT_ID), + { body: commands } + ); + console.log('Global slash commands registered.'); + } catch (error) { + console.error(error); + } })(); \ No newline at end of file