Added errorh andling, organized commands

This commit is contained in:
2025-07-31 15:08:44 -04:00
parent 54b8c67dd9
commit a0a7f8679a
5 changed files with 299 additions and 95 deletions

View File

@ -1,2 +1,4 @@
DISCORD_TOKEN= DISCORD_TOKEN=
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DATA_FILE=./key_data.json
ERROR_FILE=./error_log.json

1
.gitignore vendored
View File

@ -138,3 +138,4 @@ dist
# Application-specific files # Application-specific files
key_data.json key_data.json
error_log.json

214
index.js
View File

@ -11,10 +11,11 @@ import {
EmbedBuilder EmbedBuilder
} from 'discord.js'; } from 'discord.js';
import fs from 'fs'; 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 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() { function loadData() {
if (!fs.existsSync(DATA_FILE)) { if (!fs.existsSync(DATA_FILE)) {
@ -28,15 +29,55 @@ function saveData(data) {
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); 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 data = loadData();
let errors = loadErrorLog();
const client = new Client({ const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages], intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages],
partials: [Partials.Channel] 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}`); console.log(`Logged in as ${client.user.tag}`);
await deleteLatestBotDMs('265334763324178433');
}); });
client.on(Events.InteractionCreate, async interaction => { client.on(Events.InteractionCreate, async interaction => {
@ -74,32 +115,34 @@ client.on(Events.InteractionCreate, async interaction => {
await interaction.reply({ content: 'Registration message sent!', ephemeral: true }); await interaction.reply({ content: 'Registration message sent!', ephemeral: true });
} }
// Slash command: /load // Slash command: /key
if (interaction.isChatInputCommand() && interaction.commandName === 'load') { if (interaction.isChatInputCommand() && interaction.commandName === 'keys') {
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
return interaction.reply({ content: 'You must be an administrator.', ephemeral: true }); return interaction.reply({ content: 'You must be an administrator.', ephemeral: true });
} }
const sub = interaction.options.getSubcommand();
// /key load
if (sub === 'load') {
const file = interaction.options.getAttachment('file'); const file = interaction.options.getAttachment('file');
if (!file) { if (!file) {
return interaction.reply({ content: 'You must attach a file.', ephemeral: true }); return interaction.reply({ content: 'You must attach a file.', ephemeral: true });
} }
// Download the file
const response = await fetch(file.url); const response = await fetch(file.url);
const text = await response.text(); const text = await response.text();
const keys = text.split('\n').map(line => line.trim()).filter(line => line.length > 0); const keys = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
const yesButton = new ButtonBuilder() const yesButton = new ButtonBuilder()
.setCustomId('load_yes') .setCustomId('key_load_yes')
.setLabel('Yes') .setLabel('Yes')
.setStyle(ButtonStyle.Success); .setStyle(ButtonStyle.Success);
const noButton = new ButtonBuilder() const noButton = new ButtonBuilder()
.setCustomId('load_no') .setCustomId('key_load_no')
.setLabel('No') .setLabel('No')
.setStyle(ButtonStyle.Danger); .setStyle(ButtonStyle.Danger);
const row = new ActionRowBuilder().addComponents(yesButton, noButton);
// Store keys temporarily in memory for this user const row = new ActionRowBuilder().addComponents(yesButton, noButton);
client.tempKeyLoads = client.tempKeyLoads || {}; client.tempKeyLoads = client.tempKeyLoads || {};
client.tempKeyLoads[interaction.user.id] = keys; client.tempKeyLoads[interaction.user.id] = keys;
@ -108,24 +151,64 @@ client.on(Events.InteractionCreate, async interaction => {
components: [row], components: [row],
ephemeral: true ephemeral: true
}); });
return;
} }
// Handle load confirmation buttons // /key get
if (interaction.isButton() && (interaction.customId === 'load_yes' || interaction.customId === 'load_no')) { 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 });
}
}
// /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 });
}
// /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 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)) { if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
return interaction.reply({ content: 'You must be an administrator.', ephemeral: true }); return interaction.reply({ content: 'You must be an administrator.', ephemeral: true });
} }
if (!client.tempKeyLoads || !client.tempKeyLoads[interaction.user.id]) { if (!client.tempKeyLoads || !client.tempKeyLoads[interaction.user.id]) {
return interaction.reply({ content: 'No pending key load found.', ephemeral: true }); 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]; delete client.tempKeyLoads[interaction.user.id];
await interaction.update({ content: 'Key load cancelled.', components: [] }); await interaction.update({ content: 'Key load cancelled.', components: [] });
return; return;
} }
// Yes pressed // Yes pressed
const keys = client.tempKeyLoads[interaction.user.id]; const keys = client.tempKeyLoads[interaction.user.id];
data = loadData(); // Reload in case of concurrent changes data = loadData();
data.keys = data.keys || []; data.keys = data.keys || [];
data.keys.push(...keys); data.keys.push(...keys);
saveData(data); saveData(data);
@ -136,12 +219,12 @@ client.on(Events.InteractionCreate, async interaction => {
// Button interaction for register // Button interaction for register
if (interaction.isButton() && interaction.customId === 'register_button') { if (interaction.isButton() && interaction.customId === 'register_button') {
if (interaction.message.id !== data.messageId) return; 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? // Already registered?
let user = data.users.find(u => u.id === interaction.user.id); let user = data.users.find(u => u.id === interaction.user.id);
if (user && user.key) { if (user && user.key) {
const message = KEY_DM_MESSAGE.replace('{key}', `https://store.steampowered.com/account/registerkey?key=${user.key}`); await interaction.user.send(formatKeyMessage(user.key));
await interaction.user.send(message);
return interaction.reply({ content: 'Key successfully re-sent! Please check your DMs.', ephemeral: true }); 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 joinDate = member.joinedAt;
const cutoffDate = data.joinCutoff ? new Date(data.joinCutoff) : null; const cutoffDate = data.joinCutoff ? new Date(data.joinCutoff) : null;
if (cutoffDate) { if (cutoffDate) {
// Compare only the date parts (YYYY-MM-DD)
const join = joinDate.toISOString().slice(0, 10); const join = joinDate.toISOString().slice(0, 10);
const cutoff = cutoffDate.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({ return interaction.reply({
content: INELLIGIBLE_MESSAGE, content: INELLIGIBLE_MESSAGE,
ephemeral: true ephemeral: true
@ -161,14 +243,14 @@ client.on(Events.InteractionCreate, async interaction => {
} }
} }
// Assign key // Check for available keys
data = loadData(); // Reload in case of concurrent changes data = loadData();
if (!data.keys || data.keys.length === 0) { const keyCount = data.keys ? data.keys.length : 0;
// No keys left if (keyCount === 0) {
if (data.loggingChannel) { if (data.loggingChannel) {
const channel = await interaction.guild.channels.fetch(data.loggingChannel).catch(() => console.log(`Failed to fetch logging channel ${data.loggingChannel}`));
if (channel) { 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 }); 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); data.users.push(user);
saveData(data); 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 }); await interaction.reply({ content: 'Successfully claimed key! Check your DMs.', ephemeral: true });
try { try {
const message = KEY_DM_MESSAGE.replace('{key}', `https://store.steampowered.com/account/registerkey?key=${key}`); await interaction.user.send(formatKeyMessage(key));
await interaction.user.send(message);
} catch (e) { } 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 = loadData();
data.loggingChannel = channel.id; data.loggingChannel = channel.id;
saveData(data); 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 }); 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); 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

View File

@ -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! 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.** 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!`; 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. 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! Thanks again for your support!
Redeem your key: {key}`; 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}>)`);
}

View File

@ -3,13 +3,21 @@ import { REST, Routes } from 'discord.js';
const TOKEN = process.env.DISCORD_TOKEN; const TOKEN = process.env.DISCORD_TOKEN;
const CLIENT_ID = process.env.DISCORD_CLIENT_ID; const CLIENT_ID = process.env.DISCORD_CLIENT_ID;
const ADMIN_PERM = 0x00000008;
const commands = [ const commands = [
{ {
name: 'register', name: 'register',
description: 'Send a registration message with a button' 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', name: 'load',
description: 'Load a list of Steam keys from a file', description: 'Load a list of Steam keys from a file',
options: [ options: [
@ -22,7 +30,26 @@ const commands = [
] ]
}, },
{ {
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', description: 'Set the logging channel for key warnings',
options: [ options: [
{ {
@ -33,26 +60,23 @@ const commands = [
} }
] ]
} }
]
}
]; ];
const rest = new REST({ version: '10' }).setToken(TOKEN); const rest = new REST({ version: '10' }).setToken(TOKEN);
(async () => { (async () => {
try { try {
// Fetch all current global commands
const currentCommands = await rest.get( const currentCommands = await rest.get(
Routes.applicationCommands(CLIENT_ID) Routes.applicationCommands(CLIENT_ID)
); );
// Delete each existing command
for (const cmd of currentCommands) { for (const cmd of currentCommands) {
await rest.delete( await rest.delete(
Routes.applicationCommand(CLIENT_ID, cmd.id) Routes.applicationCommand(CLIENT_ID, cmd.id)
); );
console.log(`Deleted command: ${cmd.name}`); console.log(`Deleted command: ${cmd.name}`);
} }
// Register new commands
await rest.put( await rest.put(
Routes.applicationCommands(CLIENT_ID), Routes.applicationCommands(CLIENT_ID),
{ body: commands } { body: commands }