aboutsummaryrefslogtreecommitdiff
path: root/174bg/discord-bot/source
diff options
context:
space:
mode:
authorAlex Pooley (@zuedev) <zuedev@gmail.com>2026-07-22 15:32:57 +0100
committerAlex Pooley (@zuedev) <zuedev@gmail.com>2026-07-22 15:32:57 +0100
commit72487019b3f255cece40a8073bbad8bc9968a3b7 (patch)
treeb6590e63039cdc8b1820bd345cdfe83f28ff839b /174bg/discord-bot/source
parentda0509c91376f56f133872eddd2e4282d76f215e (diff)
downloadunnamed-group-72487019b3f255cece40a8073bbad8bc9968a3b7.tar
unnamed-group-72487019b3f255cece40a8073bbad8bc9968a3b7.tar.gz
unnamed-group-72487019b3f255cece40a8073bbad8bc9968a3b7.tar.bz2
unnamed-group-72487019b3f255cece40a8073bbad8bc9968a3b7.tar.xz
unnamed-group-72487019b3f255cece40a8073bbad8bc9968a3b7.zip
major code refactor, add uex lookup
Diffstat (limited to '174bg/discord-bot/source')
-rw-r--r--174bg/discord-bot/source/commands/_index.js8
-rw-r--r--174bg/discord-bot/source/commands/close-ticket.js55
-rw-r--r--174bg/discord-bot/source/commands/oncall-check.js51
-rw-r--r--174bg/discord-bot/source/commands/requisition.js42
-rw-r--r--174bg/discord-bot/source/commands/uex.js81
-rw-r--r--174bg/discord-bot/source/controllers/http.js25
-rw-r--r--174bg/discord-bot/source/controllers/pocketbase.js40
-rw-r--r--174bg/discord-bot/source/controllers/uex.js29
-rw-r--r--174bg/discord-bot/source/main.js64
-rw-r--r--174bg/discord-bot/source/utilities/createTicket.js55
-rw-r--r--174bg/discord-bot/source/utilities/notifyRoleByName.js19
11 files changed, 469 insertions, 0 deletions
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<Object|string|null>} 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<PocketBase>} 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<void>} 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<RecordModel[]>} 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-<timestamp_short>
+ 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);
+ }
+};