From a8cd84b8291990265dbdddcdaa079886234180a1 Mon Sep 17 00:00:00 2001 From: "Alex Pooley (@zuedev)" Date: Wed, 15 Jul 2026 13:56:24 +0100 Subject: refactor codebase for dry --- 174bg/discord-bot/.gitignore | 3 +- 174bg/discord-bot/commands/_index.js | 6 + 174bg/discord-bot/commands/close.js | 55 ++++ 174bg/discord-bot/commands/requisition.js | 32 +++ 174bg/discord-bot/main.js | 323 +++--------------------- 174bg/discord-bot/utilities/createTicket.js | 52 ++++ 174bg/discord-bot/utilities/notifyRoleByName.js | 19 ++ 7 files changed, 196 insertions(+), 294 deletions(-) create mode 100644 174bg/discord-bot/commands/_index.js create mode 100644 174bg/discord-bot/commands/close.js create mode 100644 174bg/discord-bot/commands/requisition.js create mode 100644 174bg/discord-bot/utilities/createTicket.js create mode 100644 174bg/discord-bot/utilities/notifyRoleByName.js (limited to '174bg') diff --git a/174bg/discord-bot/.gitignore b/174bg/discord-bot/.gitignore index 511bcd0..9b6ab89 100644 --- a/174bg/discord-bot/.gitignore +++ b/174bg/discord-bot/.gitignore @@ -1,3 +1,2 @@ /.env -/node_modules/ -/uex-cache/ \ No newline at end of file +/node_modules/ \ No newline at end of file diff --git a/174bg/discord-bot/commands/_index.js b/174bg/discord-bot/commands/_index.js new file mode 100644 index 0000000..06615dd --- /dev/null +++ b/174bg/discord-bot/commands/_index.js @@ -0,0 +1,6 @@ +import requisition from "./requisition.js"; +import close from "./close.js"; + +export { requisition, close }; + +export default [requisition, close]; diff --git a/174bg/discord-bot/commands/close.js b/174bg/discord-bot/commands/close.js new file mode 100644 index 0000000..25bdd41 --- /dev/null +++ b/174bg/discord-bot/commands/close.js @@ -0,0 +1,55 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; + +export default { + data: new SlashCommandBuilder() + .setName("close") + .setDescription( + "Closes the current ticket by logging the contents and deleting the channel.", + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { + const channel = interaction.channel; + + if (!channel || !channel.name.startsWith("ticket-")) { + return await interaction.reply({ + content: "This command can only be used in a ticket channel.", + ephemeral: true, + }); + } + + // log the contents of the ticket and send to the logs channel if it exists + const messages = await channel.messages.fetch({ limit: 100 }); + const log = messages + .map((message) => `${message.author.tag}: ${message.content}`) + .reverse() + .join("\n"); + + console.log(`Ticket log for ${channel.name}:\n${log}`); + + const logsChannel = interaction.guild.channels.cache.find( + (ch) => ch.name === "logs" && ch.isTextBased(), + ); + + if (logsChannel) + await logsChannel.send({ + content: `# Ticket log for ${channel.name}`, + files: [ + { + name: `${channel.name}.txt`, + attachment: Buffer.from( + messages + .map( + (message) => + `${message.author.tag}/${message.author.id}: ${message.content}`, + ) + .reverse() + .join("\n"), + ), + }, + ], + }); + + // delete the channel + await channel.delete(); + }, +}; diff --git a/174bg/discord-bot/commands/requisition.js b/174bg/discord-bot/commands/requisition.js new file mode 100644 index 0000000..be3cb8a --- /dev/null +++ b/174bg/discord-bot/commands/requisition.js @@ -0,0 +1,32 @@ +import { SlashCommandBuilder } from "discord.js"; +import createTicket from "../utilities/createTicket.js"; +import notifyRoleByName from "../utilities/notifyRoleByName.js"; + +export default { + data: new SlashCommandBuilder() + .setName("requisition") + .setDescription( + "Request items from the quartermaster. A new requisition ticket will be created.", + ) + .addStringOption((option) => + option + .setName("contents") + .setDescription("What are you requesting? Be as specific as possible.") + .setRequired(true), + ), + execute: async (interaction) => { + const contents = interaction.options.getString("contents"); + + const ticket = await createTicket(interaction, ["quartermaster"]); + + await ticket.send({ + content: `**New requisition request from <@${interaction.user.id}>**: ${contents}`, + }); + + // reply to the user + await interaction.reply({ + content: `Your requisition ticket has been created: <#${ticket.id}>`, + ephemeral: true, + }); + }, +}; diff --git a/174bg/discord-bot/main.js b/174bg/discord-bot/main.js index 30d2a1d..fc4da76 100644 --- a/174bg/discord-bot/main.js +++ b/174bg/discord-bot/main.js @@ -4,175 +4,15 @@ import { GatewayIntentBits, REST, Routes, + ChannelType, PermissionFlagsBits, SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, } from "discord.js"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const commands = [ - { - data: new SlashCommandBuilder() - .setName("scope") - .setDescription("Returns the current scope of the bot."), - execute: async (interaction) => { - await interaction.reply(codewrap("json", JSON.stringify({ - guildIdFromEnv: process.env.DISCORD_GUILD_ID, - guildIdFromInteraction: interaction.guild.id, - doGuildIdsMatch: - process.env.DISCORD_GUILD_ID === interaction.guild.id, - }, null, 2))); - } - }, - { - data: new SlashCommandBuilder() - .setName("create-requisition-ticket") - .setDescription( - "Creates a requisition ticket for the quartermaster to review.", - ) - .addStringOption((option) => - option - .setName("contents") - .setDescription( - "What are you requesting? Be as specific as possible.", - ) - .setRequired(true), - ), - execute: async (interaction) => { - // extract options - const [contents] = [interaction.options.getString("contents")]; - - // get "tickets" category - const ticketsCategory = interaction.guild.channels.cache.find( - (channel) => - channel.name.toLowerCase() === "tickets" && channel.type === 4, - ); - - if (!ticketsCategory) - return await interaction.reply("Tickets category not found."); - - // get the quartermaster role id - let quartermasterRole = interaction.guild.roles.cache.find( - (role) => role.name.toLowerCase() === "quartermaster", - ); - - if (!quartermasterRole) - { - // no qm role? make it - const newRole = await interaction.guild.roles.create({ - name: "Quartermaster", - color: "Blue", - }); - - quartermasterRole = newRole; - } - - // create a new text channel under the "tickets" category with the following format: - // req- - const timestamp = new Date() - .toISOString() - .replace(/[-:.T]/g, "") - .slice(2, 16); // YYMMDDHHMMSS - const channelName = `req-${timestamp}`; - - const newChannel = await interaction.guild.channels.create({ - name: channelName, - type: 0, // text channel - parent: ticketsCategory.id, - // set permissions so that only the user who created the ticket and the quartermaster role can view it - permissionOverwrites: [ - { - id: interaction.user.id, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - ], - }, - { - id: quartermasterRole.id, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - ], - }, - ], - }); - - // send a normal message to the new channel with a close button that only the quartermaster role can see - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("close_req_ticket") - .setLabel("Close Ticket") - .setStyle(ButtonStyle.Danger) - .setDisabled(false), - ); - - await newChannel.send({ - content: `**Requisition Ticket #${timestamp}**\n**Contents:** ${contents}\n**Created by:** <@${interaction.user.id}>`, - components: [row], - }); - - // reply to the user - await interaction.reply({ - content: `Your requisition ticket has been created: <#${newChannel.id}>`, - ephemeral: true, - }); - - // log - sendMessageToLogsChannel( - `Requisition ticket <#${newChannel.id}> created by <@${interaction.user.id}>`, - ); - - // notify all members with the quartermaster role if it exists - // (member cache is kept up to date via the GuildMembers intent, so no - // need to re-fetch all members here) - const quartermasterMembers = quartermasterRole.members; - for (const member of quartermasterMembers.values()) { - await member.send( - `A new requisition ticket has been created: <#${newChannel.id}>`, - ); - } - }, - }, - { - data: new SlashCommandBuilder() - .setName("delete-all-requisition-tickets") - .setDescription("Deletes all requisition tickets.") - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - execute: async (interaction) => { - // get "tickets" category - const ticketsCategory = interaction.guild.channels.cache.find( - (channel) => - channel.name.toLowerCase() === "tickets" && channel.type === 4, - ); - - if (!ticketsCategory) - return await interaction.reply("Tickets category not found."); - - // get all channels under the "tickets" category that start with "req-" - const requisitionTickets = ticketsCategory.children.cache.filter( - (channel) => channel.name.startsWith("req-"), - ); - - // delete all requisition tickets - for (const ticket of requisitionTickets.values()) { - await ticket.delete(); - } - - // log - sendMessageToLogsChannel( - `<@${interaction.user.id}> used \`/delete-all-requisition-tickets\` and deleted **${requisitionTickets.size}** requisition tickets.`, - ); - }, - }, -]; +import commands from "./commands/_index.js"; const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], @@ -181,41 +21,7 @@ const client = new Client({ client.on(Events.ClientReady, async (readyClient) => { console.log(`Logged in as ${readyClient.user.tag}!`); - try { - const rest = new REST({ version: "10" }).setToken( - process.env.DISCORD_BOT_TOKEN, - ); - - // clear global commands - await rest.put(Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), { - body: [], - }); - - console.log("Cleared global application commands."); - - // register guild-scoped commands - await rest.put(Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.DISCORD_GUILD_ID), { - body: commands.map((command) => command.data.toJSON()), - }); - - const guild = await client.guilds.fetch(process.env.DISCORD_GUILD_ID); - - // fetch all members once so the cache stays populated; the GuildMembers - // intent keeps it in sync afterward via gateway events, avoiding repeated - // opcode 8 (Request Guild Members) calls that can get rate limited - await guild.members.fetch(); - - console.log(`Successfully registered application commands for guild ${process.env.DISCORD_GUILD_ID} (${guild ? guild.name : "Unknown"}).`); - } catch (error) { - console.error(error); - } - - cacheUexData(); - - // every hour - setInterval(async () => { - await cacheUexData(); - }, 60 * 60 * 1000); // 1 hour + await registerCommands(); }); client.on(Events.InteractionCreate, async (interaction) => { @@ -232,109 +38,42 @@ client.on(Events.InteractionCreate, async (interaction) => { await command.execute(interaction); } } - - if (interaction.isButton()) { - if (interaction.customId === "close_req_ticket") { - // get the quartermaster role id - const quartermasterRole = interaction.guild.roles.cache.find( - (role) => role.name.toLowerCase() === "quartermaster", - ); - - if (!quartermasterRole) - return await interaction.reply("Quartermaster role not found."); - - // check if the user has the quartermaster role or is an administrator - if ( - !interaction.member.roles.cache.has(quartermasterRole.id) && - !interaction.member.permissions.has(PermissionFlagsBits.Administrator) - ) { - return await interaction.reply({ - content: "You do not have permission to close this ticket.", - ephemeral: true, - }); - } - - // fetch all messages in the channel - const messages = await interaction.channel.messages.fetch(); - - // capture channel info before it's deleted (and removed from cache) - const channelId = interaction.channel.id; - const channelName = interaction.channel.name; - - // delete the channel - await interaction.channel.delete(); - - // log - sendMessageToLogsChannel({ - content: `<#${channelId}> closed by <@${interaction.user.id}>`, - files: [ - { - name: `${channelName}.txt`, - attachment: Buffer.from( - messages - .map( - (message) => - `${message.author.tag}/${message.author.id}: ${message.content}`, - ) - .reverse() - .join("\n"), - ), - }, - ], - }); - } - } }); client.login(process.env.DISCORD_BOT_TOKEN); -function sendMessageToLogsChannel(message) { - const logsChannel = client.channels.cache.find( - (channel) => channel.guild.id === process.env.DISCORD_GUILD_ID && channel.name === "logs" && channel.type === 0, - ); - - if (!logsChannel) return console.error("Logs channel not found."); - - logsChannel.send(message); -} - -function codewrap(language, content) { - return "```" + language + "\n" + content + "\n```"; -} - -async function cacheUexData() { - const list = [ - "https://api.uexcorp.uk/2.0/commodities_raw_prices_all", - "https://api.uexcorp.uk/2.0/items_prices_all", - "https://api.uexcorp.uk/2.0/fuel_prices_all", - "https://api.uexcorp.uk/2.0/commodities_prices_all", - "https://api.uexcorp.uk/2.0/vehicles_purchases_prices_all", - "https://api.uexcorp.uk/2.0/vehicles_rentals_prices_all" - ]; - - const cacheDir = path.join(__dirname, "uex-cache"); - fs.mkdirSync(cacheDir, { recursive: true }); +async function registerCommands() { + try { + const rest = new REST({ version: "10" }).setToken( + process.env.DISCORD_BOT_TOKEN, + ); - for (const url of list) { - try { - const response = await fetch(url); + // clear global commands + await rest.put(Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), { + body: [], + }); - if (!response.ok) { - throw new Error(`Received status ${response.status}`); - } + console.log("Cleared global application commands."); - const data = await response.json(); + // register guild-scoped commands + await rest.put( + Routes.applicationGuildCommands( + process.env.DISCORD_CLIENT_ID, + process.env.DISCORD_GUILD_ID, + ), + { + body: commands.map((command) => command.data.toJSON()), + }, + ); - if (data === null || typeof data !== "object") { - throw new Error("Response was not a JSON object/array"); - } + const guild = await client.guilds.fetch(process.env.DISCORD_GUILD_ID); - // write to file - const filePath = path.join(cacheDir, `${url.split("/").pop()}.json`); + await guild.members.fetch(); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); - } catch (error) { - console.error(`Error fetching ${url}:`, error); - } + console.log( + `Successfully registered application commands for guild ${process.env.DISCORD_GUILD_ID} (${guild ? guild.name : "Unknown"}).`, + ); + } catch (error) { + console.error(error); } -} \ No newline at end of file +} diff --git a/174bg/discord-bot/utilities/createTicket.js b/174bg/discord-bot/utilities/createTicket.js new file mode 100644 index 0000000..9b573d6 --- /dev/null +++ b/174bg/discord-bot/utilities/createTicket.js @@ -0,0 +1,52 @@ +import { ChannelType, PermissionFlagsBits } from "discord.js"; + +export default async (interaction, roleNamesWithAccess) => { + const ticketsCategory = interaction.guild.channels.cache.find( + (channel) => + channel.name.toLowerCase() === "tickets" && + channel.type === ChannelType.GuildCategory, + ); + + if (!ticketsCategory) + return await interaction.reply("Tickets category not found."); + + // get the roles that should have access to this ticket + const rolesWithAccess = roleNamesWithAccess.map((roleName) => { + return interaction.guild.roles.cache.find( + (role) => role.name.toLowerCase() === roleName.toLowerCase(), + ); + }); + + // create a new text channel under the "tickets" category with the following format: ticket- + const timestamp = new Date() + .toISOString() + .replace(/[-:.T]/g, "") + .slice(2, 16); + + const channelName = `ticket-${timestamp}`; + + const newChannel = await interaction.guild.channels.create({ + name: channelName, + type: ChannelType.GuildText, + parent: ticketsCategory.id, + // set permissions so that only the user who created the ticket and the roles with access can view it + permissionOverwrites: [ + { + id: interaction.user.id, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + ], + }, + ...rolesWithAccess.map((role) => ({ + id: role.id, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + ], + })), + ], + }); + + return newChannel; +}; diff --git a/174bg/discord-bot/utilities/notifyRoleByName.js b/174bg/discord-bot/utilities/notifyRoleByName.js new file mode 100644 index 0000000..1b2b82a --- /dev/null +++ b/174bg/discord-bot/utilities/notifyRoleByName.js @@ -0,0 +1,19 @@ +export default async (guild, roleName, message) => { + // get the role by name + const role = guild.roles.cache.find( + (role) => role.name.toLowerCase() === roleName.toLowerCase(), + ); + + if (!role) { + console.error(`Role "${roleName}" not found.`); + return; + } + + // get all members with the role + const membersWithRole = role.members; + + // send a DM to each member with the role + for (const member of membersWithRole.values()) { + await member.send(message); + } +}; -- cgit v1.2.3