From 72487019b3f255cece40a8073bbad8bc9968a3b7 Mon Sep 17 00:00:00 2001 From: "Alex Pooley (@zuedev)" Date: Wed, 22 Jul 2026 15:32:57 +0100 Subject: major code refactor, add uex lookup --- 174bg/discord-bot/source/commands/_index.js | 8 +++ 174bg/discord-bot/source/commands/close-ticket.js | 55 +++++++++++++++ 174bg/discord-bot/source/commands/oncall-check.js | 51 ++++++++++++++ 174bg/discord-bot/source/commands/requisition.js | 42 +++++++++++ 174bg/discord-bot/source/commands/uex.js | 81 ++++++++++++++++++++++ 174bg/discord-bot/source/controllers/http.js | 25 +++++++ 174bg/discord-bot/source/controllers/pocketbase.js | 40 +++++++++++ 174bg/discord-bot/source/controllers/uex.js | 29 ++++++++ 174bg/discord-bot/source/main.js | 64 +++++++++++++++++ 174bg/discord-bot/source/utilities/createTicket.js | 55 +++++++++++++++ .../source/utilities/notifyRoleByName.js | 19 +++++ 11 files changed, 469 insertions(+) create mode 100644 174bg/discord-bot/source/commands/_index.js create mode 100644 174bg/discord-bot/source/commands/close-ticket.js create mode 100644 174bg/discord-bot/source/commands/oncall-check.js create mode 100644 174bg/discord-bot/source/commands/requisition.js create mode 100644 174bg/discord-bot/source/commands/uex.js create mode 100644 174bg/discord-bot/source/controllers/http.js create mode 100644 174bg/discord-bot/source/controllers/pocketbase.js create mode 100644 174bg/discord-bot/source/controllers/uex.js create mode 100644 174bg/discord-bot/source/main.js create mode 100644 174bg/discord-bot/source/utilities/createTicket.js create mode 100644 174bg/discord-bot/source/utilities/notifyRoleByName.js (limited to '174bg/discord-bot/source') diff --git a/174bg/discord-bot/source/commands/_index.js b/174bg/discord-bot/source/commands/_index.js new file mode 100644 index 0000000..5fea200 --- /dev/null +++ b/174bg/discord-bot/source/commands/_index.js @@ -0,0 +1,8 @@ +import requisition from "./requisition.js"; +import closeTicket from "./close-ticket.js"; +import oncallCheck from "./oncall-check.js"; +import uex from "./uex.js"; + +export { requisition, closeTicket, oncallCheck, uex }; + +export default [requisition, closeTicket, oncallCheck, uex]; diff --git a/174bg/discord-bot/source/commands/close-ticket.js b/174bg/discord-bot/source/commands/close-ticket.js new file mode 100644 index 0000000..ca3040e --- /dev/null +++ b/174bg/discord-bot/source/commands/close-ticket.js @@ -0,0 +1,55 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; + +export default { + data: new SlashCommandBuilder() + .setName("close-ticket") + .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/source/commands/oncall-check.js b/174bg/discord-bot/source/commands/oncall-check.js new file mode 100644 index 0000000..7a3886d --- /dev/null +++ b/174bg/discord-bot/source/commands/oncall-check.js @@ -0,0 +1,51 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; +import { login } from "../controllers/pocketbase.js"; + +export default { + data: new SlashCommandBuilder() + .setName("oncall-check") + .setDescription("Returns a list of members who are currently on call.") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { + const date = new Date(); + const dateUTCHours = date.getUTCHours(); + const dateUTCMinutes = date.getUTCMinutes(); + const dateUTCDay = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][date.getUTCDay()]; + + const pb = await login(); + + const members = await pb.collection("members").getFullList(); + + let membersOnCall = []; + + for (const member of members) { + try { + if (member.onCallSchedule) { + const available = member.onCallSchedule[dateUTCDay]?.available || false; + + if (available) { + const start = member.onCallSchedule[dateUTCDay].start; + const startHours = start.split(":")[0]; + const startMinutes = start.split(":")[1]; + const end = member.onCallSchedule[dateUTCDay].end; + const endHours = end.split(":")[0]; + const endMinutes = end.split(":")[1]; + + console.log(`Checking on-call schedule for member ${member.name}: ${start} - ${end} (UTC)`); + + if ((dateUTCHours > startHours || (dateUTCHours === startHours && dateUTCMinutes >= startMinutes)) && (dateUTCHours < endHours || (dateUTCHours === endHours && dateUTCMinutes <= endMinutes))) { + membersOnCall.push(member); + } + } + } + } catch (error) { + console.error(`Error checking on-call schedule for member ${member.name}:`, error); + } + } + + await interaction.reply({ + content: `${membersOnCall.map((member) => `- <@${member.discordId}>`).join("\n") || "No members are currently on call."}`, + ephemeral: true, + }); + }, +}; diff --git a/174bg/discord-bot/source/commands/requisition.js b/174bg/discord-bot/source/commands/requisition.js new file mode 100644 index 0000000..02c7699 --- /dev/null +++ b/174bg/discord-bot/source/commands/requisition.js @@ -0,0 +1,42 @@ +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, { + roleNamesWithAccess: ["quartermaster"], + ticketNamePrefix: "req-", + }); + + 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, + }); + + // notify quartermasters + await notifyRoleByName( + interaction.guild, + "quartermaster", + `New requisition request from <@${interaction.user.id}>: ${contents}`, + ); + }, +}; diff --git a/174bg/discord-bot/source/commands/uex.js b/174bg/discord-bot/source/commands/uex.js new file mode 100644 index 0000000..f2f654f --- /dev/null +++ b/174bg/discord-bot/source/commands/uex.js @@ -0,0 +1,81 @@ +import { SlashCommandBuilder, MessageFlags } from "discord.js"; +import { items } from "../controllers/uex.js"; + +export default { + data: new SlashCommandBuilder() + .setName("uex") + .setDescription( + "Commands for interacting with UEX.", + ) + .addSubcommandGroup((subcommandGroup) => + subcommandGroup + .setName("lookup") + .setDescription("Lookup UEX information.") + .addSubcommand((subcommand) => + subcommand + .setName("items") + .setDescription("Perform UEX lookup for Star Citizen items, including ship components, weapons, and more.") + .addStringOption((option) => + option + .setName("query") + .setDescription( + "The item name or partial name to search for.", + ) + .setRequired(true), + ), + ) + ), + execute: async (interaction) => { + const subcommandGroup = interaction.options.getSubcommandGroup(); + const subcommand = interaction.options.getSubcommand(); + + switch (subcommandGroup) { + case "lookup": + switch (subcommand) { + case "items": + await uex_lookup_items(interaction); + break; + default: + await interaction.reply({ + content: "Unknown subcommand.", + ephemeral: true, + }); + break; + } + break; + default: + await interaction.reply({ + content: "Unknown subcommand group.", + ephemeral: true, + }); + break; + } + }, +}; + +async function uex_lookup_items(interaction) { + const query = interaction.options.getString("query"); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const allItems = await items(); + + const matchingItems = allItems.filter((item) => + item.name.toLowerCase().includes(query.toLowerCase()), + ); + + if (matchingItems.length === 0) { + await interaction.editReply({ + content: `No items found matching "${query}".`, + }); + return; + } + + const itemList = matchingItems + .map((item) => `- ${item.name} (ID: ${item.id})`) + .join("\n"); + + await interaction.editReply({ + content: `Found ${matchingItems.length} item(s) matching "${query}":\n${itemList}`, + }); +} \ No newline at end of file diff --git a/174bg/discord-bot/source/controllers/http.js b/174bg/discord-bot/source/controllers/http.js new file mode 100644 index 0000000..1472b18 --- /dev/null +++ b/174bg/discord-bot/source/controllers/http.js @@ -0,0 +1,25 @@ +/** + * Controller module for interacting with HTTP resources, mainly for fetching JSON data from external APIs. + */ + +/** + * Fetches data from a specified URL and returns the response as JSON or text. + * @param {string} url - The URL to fetch data from. + * @returns {Promise} A Promise that resolves to the parsed JSON object, text, or null if the request fails. + */ +export async function get(url) { + const response = await fetch(url); + + if (!response.ok) return null; + + // is it json? + const contentType = response.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + return await response.json(); + } + + // fallback to text + return await response.text(); +}; + +export default { get }; \ No newline at end of file diff --git a/174bg/discord-bot/source/controllers/pocketbase.js b/174bg/discord-bot/source/controllers/pocketbase.js new file mode 100644 index 0000000..4046391 --- /dev/null +++ b/174bg/discord-bot/source/controllers/pocketbase.js @@ -0,0 +1,40 @@ +/** + * Controller module for interacting with the PocketBase API. + */ + +import PocketBase from "pocketbase"; + +const { POCKETBASE_URL, POCKETBASE_SUPERUSER_EMAIL, POCKETBASE_SUPERUSER_PASSWORD } = process.env; + +/** + * Logs in to the PocketBase API using the superuser credentials. + * @returns {Promise} A Promise that resolves to the authenticated PocketBase instance. + */ +export async function login() { + const pb = new PocketBase(POCKETBASE_URL); + await pb.collection("_superusers").authWithPassword(POCKETBASE_SUPERUSER_EMAIL, POCKETBASE_SUPERUSER_PASSWORD); + return pb; +} + +/** + * Logs out of the PocketBase API by clearing the authentication store. + * @param {PocketBase} pb - The PocketBase instance to log out from. + * @returns {Promise} A Promise that resolves when the logout is complete. + */ +export async function logout(pb) { + await pb.authStore.clear(); +} + +/** + * Retrieves the full list of records from a specified collection in the PocketBase API. + * @param {string} collectionName - The name of the collection to retrieve records from. + * @returns {Promise} A Promise that resolves to an array of records from the specified collection. + */ +export async function getFullList(collectionName) { + const pb = await login(); + const data = await pb.collection(collectionName).getFullList(); + await logout(pb); + return data; +} + +export default { login, logout, getFullList } \ No newline at end of file diff --git a/174bg/discord-bot/source/controllers/uex.js b/174bg/discord-bot/source/controllers/uex.js new file mode 100644 index 0000000..615b517 --- /dev/null +++ b/174bg/discord-bot/source/controllers/uex.js @@ -0,0 +1,29 @@ +import { get } from "./http.js"; + +export async function categories() { + return await get("https://api.uexcorp.uk/2.0/categories"); +} + +export async function items(id_category) { + let items = []; + + if (!id_category) { + const { data: categories } = await get("https://api.uexcorp.uk/2.0/categories"); + + for (const category of categories) { + const { data: categoryItems } = await get(`https://api.uexcorp.uk/2.0/items?id_category=${category.id}`); + + if (!categoryItems) continue; + + items = items.concat(categoryItems); + } + } else { + const { data } = await get(`https://api.uexcorp.uk/2.0/items?category=${id_category}`); + + items = data; + } + + return items; +} + +export default { categories, items }; \ No newline at end of file diff --git a/174bg/discord-bot/source/main.js b/174bg/discord-bot/source/main.js new file mode 100644 index 0000000..1fcf9c1 --- /dev/null +++ b/174bg/discord-bot/source/main.js @@ -0,0 +1,64 @@ +import { + Client, + Events, + GatewayIntentBits, + REST, + Routes, + ChannelType, + PermissionFlagsBits, + SlashCommandBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} from "discord.js"; + +import commands from "./commands/_index.js"; + +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], +}); + +client.on(Events.ClientReady, async (readyClient) => { + console.log(`Logged in as ${readyClient.user.tag}!`); + + await registerCommands(); +}); + +client.on(Events.InteractionCreate, async (interaction) => { + if (interaction.guild.id !== process.env.DISCORD_GUILD_ID) return; + + if (interaction.isChatInputCommand()) { + if ( + commands.some((command) => command.data.name === interaction.commandName) + ) { + const command = commands.find( + (command) => command.data.name === interaction.commandName, + ); + await command.execute(interaction); + } + } +}); + +client.login(process.env.DISCORD_BOT_TOKEN); + +async function registerCommands() { + 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: [], + }); + + // 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()), + }, + ); +} \ No newline at end of file diff --git a/174bg/discord-bot/source/utilities/createTicket.js b/174bg/discord-bot/source/utilities/createTicket.js new file mode 100644 index 0000000..6289715 --- /dev/null +++ b/174bg/discord-bot/source/utilities/createTicket.js @@ -0,0 +1,55 @@ +import { ChannelType, PermissionFlagsBits } from "discord.js"; + +export default async ( + interaction, + options = { roleNamesWithAccess: [], ticketNamePrefix: "ticket-" }, +) => { + 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 = options.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 = `${options.ticketNamePrefix}${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/source/utilities/notifyRoleByName.js b/174bg/discord-bot/source/utilities/notifyRoleByName.js new file mode 100644 index 0000000..1b2b82a --- /dev/null +++ b/174bg/discord-bot/source/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