├── .gitignore ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20240107014732_init │ │ └── migration.sql │ └── 20240108035848_feat_course │ │ └── migration.sql └── schema.prisma ├── docker-compose.yml ├── .prettierrc ├── .github ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE │ ├── code-maintenance.md │ ├── bug-report.md │ └── feature-request.md ├── Dockerfile ├── src ├── events │ ├── ready.ts │ ├── messageCreate.ts │ ├── index.ts │ ├── guildMemberAdd.ts │ ├── messageReactionAdd.ts │ └── interactionCreate.ts ├── index.ts ├── config.ts ├── helpers │ ├── index.ts │ ├── buildings.ts │ ├── LatexHelpers.ts │ ├── ASCIIArts.ts │ └── seed.ts ├── types │ └── global.d.ts └── commands │ ├── free.ts │ ├── jail.ts │ ├── art.ts │ ├── say.ts │ ├── help.ts │ ├── index.ts │ ├── purge.ts │ ├── prompt.ts │ ├── equation.ts │ ├── optin.ts │ ├── optout.ts │ ├── ping.ts │ ├── year.ts │ ├── google.ts │ ├── notify.ts │ ├── course.ts │ ├── edit.ts │ ├── whereis.ts │ ├── timeout.ts │ ├── link.ts │ ├── linkAdmin.ts │ ├── deleteMsgs.ts │ └── minigame.ts ├── tsconfig.json ├── package.json ├── license ├── docs ├── ARCHITECTURE.md ├── FEATURES.md └── CONTRIBUTING.md ├── README.md └── pnpm-lock.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | node_modules/ 3 | yarn-error.log 4 | .env 5 | config.json 6 | /build 7 | /data 8 | /logs -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bot: 3 | restart: unless-stopped 4 | build: 5 | dockerfile: Dockerfile 6 | context: . 7 | volumes: 8 | - ./logs:/app/logs 9 | - ./data:/app/data 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": true, 5 | "singleQuote": false, 6 | "quoteProps": "as-needed", 7 | "trailingComma": "es5", 8 | "bracketSpacing": false, 9 | "arrowParens": "always" 10 | } 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What are you trying to accomplish? 2 | 3 | 4 | ### How are you accomplishing it? 5 | 6 | 7 | ### Is there anything reviewers should know? 8 | 9 | 10 | 11 | - [x] Is it safe to rollback this change if anything goes wrong? 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/code-maintenance.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Code Maintenance 3 | about: Project cleanup, improve documentation, refactor code 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | #### Describe Problem 10 | 11 | #### Suggest Changes 12 | 13 | #### Provide Examples 14 | 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report something that is broken or not working as intended 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | #### Expected Behaviour 10 | 11 | #### Actual Behaviour 12 | 13 | #### Steps to Reproduce 14 | - 15 | 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for a new feature or enhancement to existing features 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | #### Describe Problem 10 | 11 | #### Suggest Solution 12 | 13 | #### Additional Details 14 | 15 | -------------------------------------------------------------------------------- /prisma/migrations/20240107014732_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Link" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "name" TEXT NOT NULL, 5 | "description" TEXT NOT NULL, 6 | "url" TEXT NOT NULL, 7 | "authorID" TEXT NOT NULL, 8 | "authorUsername" TEXT NOT NULL, 9 | "authorDisplayName" TEXT NOT NULL, 10 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "Link_name_key" ON "Link"("name"); 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21-alpine 2 | 3 | WORKDIR /app 4 | 5 | ENV PNPM_HOME="/pnpm" \ 6 | PATH="$PNPM_HOME:$PATH" 7 | 8 | RUN corepack enable 9 | 10 | COPY ./package.json /app/package.json 11 | COPY ./pnpm-lock.yaml /app/pnpm-lock.yaml 12 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 13 | 14 | COPY ./src /app/src 15 | COPY ./prisma /app/prisma 16 | COPY ./tsconfig.json /app/tsconfig.json 17 | COPY ./config.json /app/config.json 18 | RUN pnpm prisma generate 19 | 20 | CMD ["pnpm", "run", "start:prod"] 21 | -------------------------------------------------------------------------------- /src/events/ready.ts: -------------------------------------------------------------------------------- 1 | import {logger, Config} from "@/config"; 2 | import {Client, Events} from "discord.js"; 3 | 4 | module.exports = { 5 | name: Events.ClientReady, 6 | once: true, 7 | async execute(client: Client) { 8 | if (!client.user || !client.application) { 9 | return; 10 | } 11 | 12 | // Set status 13 | if (Config.discord.status && Config.discord.status !== "") { 14 | client.user.setActivity({ 15 | name: "status", 16 | type: 4, 17 | state: Config.discord.status, 18 | }); 19 | } 20 | 21 | logger.info(`${client.user.username} is online.`); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "lib": ["ES2022", "DOM"], 6 | "strict": true, 7 | "allowJs": false, 8 | "moduleResolution": "Node", 9 | "importHelpers": true, 10 | "experimentalDecorators": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "allowSyntheticDefaultImports": true, 14 | "resolveJsonModule": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "removeComments": true, 17 | "sourceMap": false, 18 | "rootDir": "./src", 19 | "outDir": "./build", 20 | "baseUrl": "./", 21 | "paths": { 22 | "@/*": ["src/*"] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/events/messageCreate.ts: -------------------------------------------------------------------------------- 1 | import {Config} from "@/config"; 2 | import {Events, Client, Message, type GuildTextBasedChannel} from "discord.js"; 3 | 4 | module.exports = { 5 | name: Events.MessageCreate, 6 | async execute(client: Client, message: Message) { 7 | try { 8 | if (message.author.bot || !Config.features.april_fools) return; 9 | 10 | // april fools, react with skull emoji to every message in general channel 11 | const channel = message.channel as GuildTextBasedChannel; 12 | if (channel.name === "general") await message.react("💀"); 13 | } catch (error) { 14 | console.error("Something went wrong when fetching the message:", error); 15 | return; 16 | } 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/events/index.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from "fs"; 2 | import path from "path"; 3 | import {logger} from "@/config"; 4 | 5 | export default async (client: ClientType) => { 6 | logger.debug("Loading events..."); 7 | 8 | // all event files except index.ts 9 | const eventFiles: string[] = (await fs.readdir(__dirname)) 10 | .filter((file: string) => file.endsWith(".ts") || file.endsWith(".js")) 11 | .filter((file: string) => file !== "index.ts" && file !== "index.js"); 12 | 13 | // event loader 14 | for (const file of eventFiles) { 15 | logger.debug(`Loading event: ${file}`); 16 | const filePath = path.join(__dirname, file); 17 | const event = require(filePath); 18 | if (event.once) { 19 | client.once(event.name, (...args) => event.execute(client, ...args)); 20 | } else { 21 | client.on(event.name, (...args) => event.execute(client, ...args)); 22 | } 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-uwindsor-bot", 3 | "version": "2.2.0", 4 | "description": "Bot for UWindsor CSS discord", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "dev": "tsc && tsx watch ./src/index.ts | pino-pretty", 8 | "start": "tsx ./src/index.ts", 9 | "format": "prettier --write src/**/*.ts", 10 | "start:prod": "pnpm prisma migrate deploy && pnpm run start" 11 | }, 12 | "author": "CSS Software Devs css@uwindsor.ca", 13 | "license": "MIT", 14 | "repository": "git@github.com:UWindsorCSS/uwindsor-discord-bot.git", 15 | "dependencies": { 16 | "@prisma/client": "^5.19.1", 17 | "@resvg/resvg-js": "^2.6.2", 18 | "discord.js": "^14.16.1", 19 | "fuse.js": "^7.0.0", 20 | "mathjax-node": "^2.1.1", 21 | "pino": "^9.4.0", 22 | "pino-pretty": "^11.2.2", 23 | "pino-roll": "1.3.0", 24 | "prisma": "^5.19.1" 25 | }, 26 | "devDependencies": { 27 | "@types/mathjax-node": "^2.1.0", 28 | "prettier": "^3.3.3", 29 | "tsx": "^4.19.0", 30 | "typescript": "^5.5.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {Client, GatewayIntentBits, Partials} from "discord.js"; 2 | import {logger, prisma, Config} from "@/config"; 3 | import events from "./events"; 4 | import commands from "./commands"; 5 | import process from "process"; 6 | 7 | // Gracefully exit on SIGINT 8 | process.on("SIGINT", function () { 9 | logger.info("Gracefully shutting down..."); 10 | client.destroy(); 11 | prisma.$disconnect(); 12 | process.exit(); 13 | }); 14 | 15 | const client = new Client({ 16 | intents: [ 17 | GatewayIntentBits.Guilds, 18 | GatewayIntentBits.GuildMessages, 19 | GatewayIntentBits.GuildMembers, 20 | GatewayIntentBits.MessageContent, 21 | GatewayIntentBits.GuildMessageReactions, 22 | ], 23 | partials: [ 24 | Partials.Message, 25 | Partials.Channel, 26 | Partials.Reaction, 27 | Partials.User, 28 | ], 29 | }) as ClientType; 30 | 31 | (async () => { 32 | await events(client); 33 | await commands(client); 34 | logger.info("Logging in..."); 35 | await client.login(Config.discord.api_token); 36 | })(); 37 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright 2021 University of Windsor Computer Science Society 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE 8 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import ConfigJson from "../config.json"; 2 | export const Config = ConfigJson; 3 | import pino from "pino"; 4 | import {PrismaClient} from "@prisma/client"; 5 | import {join} from "path"; 6 | import {seedDatabase} from "./helpers/seed"; 7 | 8 | const transport = pino.transport({ 9 | targets: [ 10 | { 11 | target: "pino-roll", 12 | options: { 13 | file: join("logs", "log"), 14 | frequency: 604800016, // Every 7 days or 15 | size: "10m", // 10MB 16 | mkdir: true, 17 | }, 18 | level: "info", 19 | }, 20 | { 21 | target: "pino-pretty", 22 | options: {colorize: true, translateTime: "yyyy-mm-dd hh:MM:ss"}, 23 | level: Config.debug ? "debug" : "info", 24 | }, 25 | ], 26 | }); 27 | 28 | // create a logger instance 29 | export const logger = pino( 30 | { 31 | level: Config.debug ? "debug" : "info", 32 | base: null, 33 | }, 34 | transport 35 | ); 36 | 37 | logger.debug({Config}); 38 | 39 | // create a new prisma client 40 | export const prisma = new PrismaClient(); 41 | if (Config.seed) seedDatabase(); 42 | -------------------------------------------------------------------------------- /src/events/guildMemberAdd.ts: -------------------------------------------------------------------------------- 1 | import {Config} from "@/config"; 2 | import {Client, Events, GuildMember, type RoleResolvable} from "discord.js"; 3 | 4 | module.exports = { 5 | name: Events.GuildMemberAdd, 6 | async execute(client: Client, member: GuildMember) { 7 | try { 8 | if (Config?.features?.assign_role_on_join) { 9 | await assignEventPingRole(member); 10 | } 11 | } catch (error) { 12 | console.error( 13 | `Error adding Event Ping role to ${member.user.tag}:`, 14 | error 15 | ); 16 | } 17 | }, 18 | }; 19 | 20 | async function assignEventPingRole(member: GuildMember): Promise { 21 | const eventPingRoleID: RoleResolvable | undefined = 22 | Config.roles.other.event_ping; 23 | 24 | if (!eventPingRoleID) { 25 | console.warn("Event Ping role ID is not defined in the config."); 26 | return; 27 | } 28 | 29 | try { 30 | await member.roles.add(eventPingRoleID); 31 | } catch (error) { 32 | console.error( 33 | `Failed to assign Event Ping role to ${member.user.tag}:`, 34 | error 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /prisma/migrations/20240108035848_feat_course/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Course" ( 3 | "code" TEXT NOT NULL PRIMARY KEY, 4 | "name" TEXT NOT NULL, 5 | "description" TEXT NOT NULL, 6 | "notes" TEXT, 7 | "lectureHours" DECIMAL, 8 | "labHours" DECIMAL 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "Prerequisite" ( 13 | "requirement" TEXT NOT NULL, 14 | "courseCode" TEXT NOT NULL, 15 | 16 | PRIMARY KEY ("requirement", "courseCode"), 17 | CONSTRAINT "Prerequisite_courseCode_fkey" FOREIGN KEY ("courseCode") REFERENCES "Course" ("code") ON DELETE CASCADE ON UPDATE CASCADE 18 | ); 19 | 20 | -- CreateTable 21 | CREATE TABLE "Corequisite" ( 22 | "requirement" TEXT NOT NULL, 23 | "courseCode" TEXT NOT NULL, 24 | 25 | PRIMARY KEY ("requirement", "courseCode"), 26 | CONSTRAINT "Corequisite_courseCode_fkey" FOREIGN KEY ("courseCode") REFERENCES "Course" ("code") ON DELETE CASCADE ON UPDATE CASCADE 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "Antirequisite" ( 31 | "requirement" TEXT NOT NULL, 32 | "courseCode" TEXT NOT NULL, 33 | 34 | PRIMARY KEY ("requirement", "courseCode"), 35 | CONSTRAINT "Antirequisite_courseCode_fkey" FOREIGN KEY ("courseCode") REFERENCES "Course" ("code") ON DELETE CASCADE ON UPDATE CASCADE 36 | ); 37 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatInputCommandInteraction, 3 | type ColorResolvable, 4 | Colors, 5 | EmbedBuilder, 6 | } from "discord.js"; 7 | 8 | export const handleEmbedResponse = async ( 9 | interaction: ChatInputCommandInteraction, 10 | error: boolean, 11 | options?: { 12 | embed?: EmbedBuilder; 13 | title?: string; 14 | message?: string; 15 | color?: ColorResolvable; 16 | ephemeral?: boolean; 17 | } 18 | ) => { 19 | let {embed, title, message, color, ephemeral} = options ?? {}; 20 | 21 | if (!embed) { 22 | embed = createEmbed( 23 | title ?? (error ? ":x: Error" : ":white_check_mark: Success"), 24 | message ?? 25 | (error 26 | ? "An error occurred, please try again later." 27 | : "Command successful."), 28 | color ?? (error ? Colors.Red : Colors.Green) 29 | ); 30 | } 31 | 32 | return await interaction.reply({ 33 | embeds: [embed], 34 | ephemeral: ephemeral ?? true, 35 | }); 36 | }; 37 | 38 | export const createEmbed = ( 39 | title: string, 40 | description: string, 41 | color: ColorResolvable 42 | ) => { 43 | return new EmbedBuilder() 44 | .setTitle(title) 45 | .setDescription(description) 46 | .setColor(color); 47 | }; 48 | 49 | export const standardizeLinkName = (name: string) => { 50 | return name.trim().toLowerCase().replace(/ /g, "-"); 51 | }; 52 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SlashCommandBuilder, 3 | type SlashCommandSubcommandsOnlyBuilder, 4 | Client, 5 | Collection, 6 | type Awaitable, 7 | type CacheType, 8 | Message, 9 | AutocompleteInteraction, 10 | ChatInputCommandInteraction, 11 | type SlashCommandOptionsOnlyBuilder, 12 | } from "discord.js"; 13 | 14 | declare global { 15 | interface buildingType { 16 | code: string; 17 | name: string; 18 | } 19 | 20 | interface CommandType { 21 | data: 22 | | SlashCommandBuilder // normal slash command builder instance 23 | | SlashCommandOptionsOnlyBuilder 24 | | SlashCommandSubcommandsOnlyBuilder 25 | | Omit; // slash command without any subcommands 26 | execute: ( 27 | interaction: ChatInputCommandInteraction, 28 | message?: Message | null 29 | ) => Promise; 30 | autoComplete?: (interaction: AutocompleteInteraction) => Promise; 31 | } 32 | 33 | interface EventType { 34 | name: string; 35 | once: boolean; 36 | execute: (...arg: any[]) => Awaitable | void; 37 | } 38 | 39 | interface ClientType extends Client { 40 | commands: Collection; 41 | } 42 | 43 | interface ASCIIArt { 44 | [key: string]: { 45 | art: string; 46 | defaultString: string; 47 | }; 48 | } 49 | } 50 | 51 | export {}; 52 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = "file:../data/database.sqlite" 8 | } 9 | 10 | model Link { 11 | id String @id 12 | name String @unique 13 | description String 14 | url String 15 | authorID String 16 | authorUsername String 17 | authorDisplayName String 18 | createdAt DateTime @default(now()) 19 | } 20 | 21 | model Course { 22 | code String @id 23 | name String 24 | description String 25 | notes String? 26 | lectureHours Decimal? 27 | labHours Decimal? 28 | prerequisites Prerequisite[] 29 | corequisites Corequisite[] 30 | antirequisites Antirequisite[] 31 | } 32 | 33 | model Prerequisite { 34 | requirement String 35 | Course Course @relation(fields: [courseCode], references: [code], onDelete: Cascade) 36 | courseCode String 37 | 38 | @@id([requirement, courseCode]) 39 | } 40 | 41 | model Corequisite { 42 | requirement String 43 | Course Course @relation(fields: [courseCode], references: [code], onDelete: Cascade) 44 | courseCode String 45 | 46 | @@id([requirement, courseCode]) 47 | } 48 | 49 | model Antirequisite { 50 | requirement String 51 | Course Course @relation(fields: [courseCode], references: [code], onDelete: Cascade) 52 | courseCode String 53 | 54 | @@id([requirement, courseCode]) 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/free.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import { 3 | type CacheType, 4 | type UserResolvable, 5 | SlashCommandBuilder, 6 | ChatInputCommandInteraction, 7 | } from "discord.js"; 8 | 9 | const freeModule: CommandType = { 10 | data: new SlashCommandBuilder() 11 | .setName("free") 12 | .setDescription("Free a user from jail (fun command)") 13 | .addUserOption((option) => 14 | option 15 | .setName("user") 16 | .setDescription("The user you want to free") 17 | .setRequired(true) 18 | ), 19 | execute: async (interaction: ChatInputCommandInteraction) => { 20 | try { 21 | const member = interaction.options.getUser("user") as UserResolvable; 22 | 23 | const memberDisplayName = (await interaction.guild?.members.fetch(member)) 24 | ?.displayName; 25 | 26 | if (!memberDisplayName) { 27 | return interaction.reply({ 28 | content: "Cannot let this member out of jail", 29 | ephemeral: true, 30 | }); 31 | } 32 | 33 | const line = "-".repeat(12 + memberDisplayName?.length); 34 | const response = 35 | "Okay, time's up, you're free to go (•‿•)\n" + 36 | "```\n" + 37 | line + 38 | "\n" + 39 | "||| " + 40 | memberDisplayName + 41 | "\n" + 42 | line + 43 | "\n```"; 44 | 45 | await interaction.reply(response); 46 | } catch (error) { 47 | logger.error(`Free command failed: ${error}`); 48 | } 49 | }, 50 | }; 51 | 52 | export {freeModule as command}; 53 | -------------------------------------------------------------------------------- /src/commands/jail.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import { 3 | ChatInputCommandInteraction, 4 | SlashCommandBuilder, 5 | type CacheType, 6 | type UserResolvable, 7 | } from "discord.js"; 8 | 9 | const jailModule: CommandType = { 10 | data: new SlashCommandBuilder() 11 | .setName("jail") 12 | .setDescription("Put a user in jail for misbehaving (fun command)") 13 | .addUserOption((option) => 14 | option 15 | .setName("user") 16 | .setDescription("The user you want to put in jail") 17 | .setRequired(true) 18 | ), 19 | execute: async (interaction: ChatInputCommandInteraction) => { 20 | try { 21 | const member = interaction.options.getUser("user") as UserResolvable; 22 | 23 | const memberDisplayName = (await interaction.guild?.members.fetch(member)) 24 | ?.displayName; 25 | 26 | if (!memberDisplayName) { 27 | return interaction.reply({ 28 | content: "Cannot put this member in jail. Not sure why", 29 | ephemeral: true, 30 | }); 31 | } 32 | 33 | const line = "-".repeat(12 + memberDisplayName?.length); 34 | const response = 35 | "If you can't do the time, don't do the crime ヽ(ಠ_ಠ)ノ\n" + 36 | "```\n" + 37 | line + 38 | "\n" + 39 | "||| " + 40 | memberDisplayName + 41 | " |||\n" + 42 | line + 43 | "\n```"; 44 | 45 | await interaction.reply(response); 46 | } catch (error) { 47 | logger.error(`Jail command failed: ${error}`); 48 | } 49 | }, 50 | }; 51 | 52 | export {jailModule as command}; 53 | -------------------------------------------------------------------------------- /src/events/messageReactionAdd.ts: -------------------------------------------------------------------------------- 1 | import {logger, Config} from "@/config"; 2 | import { 3 | Events, 4 | User, 5 | MessageReaction, 6 | TextChannel, 7 | GuildChannel, 8 | Client, 9 | } from "discord.js"; 10 | 11 | module.exports = { 12 | name: Events.MessageReactionAdd, 13 | async execute(client: Client, reaction: MessageReaction, user: User) { 14 | try { 15 | if (user.bot || !Config.pin.enabled) return; 16 | 17 | if (reaction.emoji.name === "📌") { 18 | await tryPinMessage(reaction, user); 19 | } 20 | } catch (error) { 21 | logger.error("Something went wrong when fetching the message:", error); 22 | } 23 | }, 24 | }; 25 | 26 | async function shouldPinInChannel(channel: GuildChannel) { 27 | return ( 28 | channel.parent !== null && 29 | Config.pin.categories.toString().includes(channel.parent.id) 30 | ); 31 | } 32 | 33 | async function tryPinMessage(reaction: MessageReaction, user: User) { 34 | if (reaction.partial) await reaction.fetch(); 35 | if (!reaction.message.pinnable || reaction.message.pinned) return; 36 | 37 | const channel = reaction.message.channel as TextChannel; 38 | if (!(channel instanceof TextChannel) || !(await shouldPinInChannel(channel))) 39 | return; 40 | 41 | const isGeneralChannel = channel.name === "general"; 42 | const reactionThreshold = isGeneralChannel 43 | ? Config.pin.general_count 44 | : Config.pin.count; 45 | if (reaction.count >= reactionThreshold) { 46 | await reaction.message.pin(); 47 | logger.info( 48 | `Pinned message | channel: ${channel.name}, message ID: ${reaction.message.id}` 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/commands/art.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import { 3 | type CacheType, 4 | ChatInputCommandInteraction, 5 | SlashCommandBuilder, 6 | } from "discord.js"; 7 | import {ASCIIArts} from "../helpers/ASCIIArts"; 8 | 9 | const choices: {name: string; value: string}[] = Object.keys(ASCIIArts).map( 10 | (key) => ({ 11 | name: key, 12 | value: key, 13 | }) 14 | ); 15 | 16 | const artModule: CommandType = { 17 | data: new SlashCommandBuilder() 18 | .setName("art") 19 | .setDescription("Some cool ASCII Art") 20 | .addStringOption((option) => { 21 | option 22 | .setName("name") 23 | .setDescription("Choose your ASCII Art") 24 | .setRequired(true) 25 | .addChoices(...choices); 26 | return option; 27 | }) 28 | .addStringOption((option) => { 29 | option 30 | .setName("string") 31 | .setDescription("Add a string to the ASCII Art") 32 | .setMinLength(1) 33 | .setMaxLength(20) 34 | .setRequired(false); 35 | return option; 36 | }), 37 | execute: async (interaction: ChatInputCommandInteraction) => { 38 | try { 39 | const args: string = interaction.options.getString( 40 | "name", 41 | true 42 | ) as string; 43 | const string: string = interaction.options.getString( 44 | "string", 45 | false 46 | ) as string; 47 | 48 | const codeBlockAdded = 49 | "```" + 50 | ASCIIArts[args]?.art.replace( 51 | /%s/g, 52 | string ?? ASCIIArts[args].defaultString 53 | ) + 54 | "```"; 55 | 56 | await interaction.reply(codeBlockAdded); 57 | } catch (error) { 58 | logger.error(`Art command failed: ${error}`); 59 | } 60 | }, 61 | }; 62 | 63 | export {artModule as command}; 64 | -------------------------------------------------------------------------------- /docs/ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | **This document describes the high-level architecture of this project** 4 | 5 | If you want to familiarize yourself with the code base and _generally_ how it works, this is a good place to be. 6 | 7 | ## High Level TLDR 8 | 9 | The bot works on an event loop. At boot up it reads from the config module to decide whether to add each module to the event loop. Each module contains a single "feature" or related collection of "features". When the bot gets an event, it matches it to its appropriate module and then runs the part of the module. 10 | 11 | ## Code Map 12 | 13 | #### Code Map Legend 14 | 15 | `` for a file name 16 | 17 | `/` for a folder 18 | 19 | `/` for a file within a folder 20 | 21 | ### `/src/index.ts` 22 | 23 | This file is the main entry point for the bot. It defines how the bot starts. 24 | 25 | ### `/src/commands/` 26 | 27 | This directory contains the command features of the bot. During startup, each `.ts` file is loaded as a [`CommandType`](./src/types.ts). Commands are registered with Discord, globally in production, and only within a configured developer guild in development mode. 28 | 29 | See [FEATURES.md](FEATURES.md) for a breakdown of what each command does. 30 | 31 | ### `/src/events/` 32 | 33 | This directory contains event features. Unlike a command feature, events do not necessarily need a user to trigger them. Like the `/src/commands/` directory, it is scanned at startup, and each `.ts` file is loaded as an [`EventType`](./src/types.ts). Each event is then registered on the appropriate listener. 34 | 35 | ### `/src/helpers/` 36 | 37 | This directory is for modules that contain frequently used helper functions. 38 | 39 | ### `/src/config.ts` 40 | 41 | This file loads configuration values from `config.yaml` and defines a logger object for application-wide usage. 42 | 43 | ### `/src/types.ts` 44 | 45 | This file contains essential type definitions for the project. 46 | -------------------------------------------------------------------------------- /src/commands/say.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import { 3 | type CacheType, 4 | inlineCode, 5 | SlashCommandBuilder, 6 | SlashCommandChannelOption, 7 | SlashCommandStringOption, 8 | TextChannel, 9 | ChatInputCommandInteraction, 10 | PermissionFlagsBits, 11 | } from "discord.js"; 12 | import {handleEmbedResponse} from "@/helpers"; 13 | 14 | const sayModule: CommandType = { 15 | data: new SlashCommandBuilder() 16 | .setName("say") 17 | .setDescription("Announce a message in a channel") 18 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) 19 | .addChannelOption((option: SlashCommandChannelOption) => 20 | option 21 | .setName("destination") 22 | .setDescription("Select a channel") 23 | .setRequired(true) 24 | .addChannelTypes(0) 25 | ) 26 | .addStringOption((opt: SlashCommandStringOption) => 27 | opt 28 | .setName("message") 29 | .setDescription("The text that you would like to announce") 30 | .setRequired(true) 31 | ), 32 | execute: async (interaction: ChatInputCommandInteraction) => { 33 | try { 34 | const channelID = interaction.options.getChannel( 35 | "destination" 36 | ) as TextChannel; 37 | 38 | const message = interaction.options 39 | .getString("message", true) 40 | .replaceAll("\\n", "\n"); 41 | 42 | if (!channelID) { 43 | return await handleEmbedResponse(interaction, true, { 44 | message: `Please select a channel to announce the message in.`, 45 | }); 46 | } 47 | 48 | channelID?.send({content: message}); 49 | 50 | return await handleEmbedResponse(interaction, false, { 51 | message: `I have announced your message in ${inlineCode( 52 | channelID?.name 53 | )}.`, 54 | ephemeral: false, 55 | }); 56 | } catch (error) { 57 | logger.error(`Say command failed: ${error}`); 58 | } 59 | }, 60 | }; 61 | 62 | export {sayModule as command}; 63 | -------------------------------------------------------------------------------- /src/commands/help.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import {createEmbed} from "@/helpers"; 3 | import { 4 | ChatInputCommandInteraction, 5 | SlashCommandBuilder, 6 | type CacheType, 7 | Colors, 8 | PermissionsBitField, 9 | type PermissionResolvable, 10 | type RESTPostAPIChatInputApplicationCommandsJSONBody, 11 | } from "discord.js"; 12 | import {commandArr} from "."; 13 | 14 | const helpModule: CommandType = { 15 | data: new SlashCommandBuilder() 16 | .setName("help") 17 | .setDescription("Help command"), 18 | execute: async (interaction: ChatInputCommandInteraction) => { 19 | try { 20 | const memberPermissions = interaction.member 21 | ?.permissions as PermissionsBitField; 22 | const generalCommands: string[] = []; 23 | const staffCommands: string[] = []; 24 | 25 | const formatCommand = ( 26 | command: RESTPostAPIChatInputApplicationCommandsJSONBody 27 | ) => `**/${command.name}** - ${command.description}.`; 28 | 29 | commandArr.forEach((command) => { 30 | if (command.name !== "help") { 31 | if (command.default_member_permissions === undefined) { 32 | generalCommands.push(formatCommand(command)); 33 | } else if ( 34 | memberPermissions.has( 35 | command.default_member_permissions as PermissionResolvable 36 | ) 37 | ) { 38 | staffCommands.push(formatCommand(command)); 39 | } 40 | } 41 | }); 42 | 43 | let helpMessage = generalCommands.join("\n"); 44 | if (staffCommands.length > 0) { 45 | helpMessage += `\n\n ${"=".repeat(30)}\n 46 | **Staff commands that you have access to:**\n 47 | ${staffCommands.join("\n")}`; 48 | } 49 | 50 | await interaction.reply({ 51 | embeds: [createEmbed("Help", helpMessage, Colors.Blue)], 52 | ephemeral: true, 53 | }); 54 | } catch (error) { 55 | logger.error(`Help command failed: ${error}`); 56 | } 57 | }, 58 | }; 59 | 60 | export {helpModule as command}; 61 | -------------------------------------------------------------------------------- /docs/FEATURES.md: -------------------------------------------------------------------------------- 1 | # Commands and Features 2 | 3 | This file contains information about the various features available on our bot. 4 | 5 | ## Art 6 | 7 | The `art` command allows users to send pre-created ASCII art. Think of it like a huge sticker. 8 | 9 | ## Course 10 | 11 | The `course` command fetches and shows the details (name, description, etc.) of a course. 12 | 13 | ## Edit 14 | 15 | The `edit` command allows staff members to edit the messages sent by the bot using the [`say`](#say) or [`prompt`](#prompt) commands. 16 | 17 | ## Equation 18 | 19 | The `equation` command allows users to render a LaTeX math equation into an image. 20 | 21 | ## Free 22 | 23 | A joke command, paired with [`jail`](#jail), that allows you to "release" a user from jail. 24 | 25 | ## Jail 26 | 27 | A joke command, paired with [`free`](#free), that allows you to mock a user by "putting them behind bars". 28 | 29 | ## Ping 30 | 31 | The `ping` command is a utility feature that measures the latency or response time of the bot. It's a handy way to check if the bot is actively online and its uptime. 32 | 33 | ## Prompt 34 | 35 | The `prompt` command allows staff members to have the bot ask questions in the specified channel. This is a great way to encourage discussions among community members or gather their opinions on a specific question/topic. 36 | 37 | ## Purge 38 | 39 | The `purge` command allows designated users to have the bot mass-delete messages from a channel. We use this command to clean up after "spam attacks". 40 | 41 | ## Say 42 | 43 | The `say` command functions as a message relay tool, allowing designated users to share information, or make announcements. When users trigger the say command followed by a text input, the bot will post the provided message in the specified channel. It's particularly useful in cases where staff members want to share a message on behalf of the CSS team. 44 | 45 | ## Whereis 46 | 47 | The `whereis` command allows students to find the location of a building on campus by getting a campus map with the building highlighted. 48 | 49 | ## Year 50 | 51 | The `year` command enables users to assign themselves a role that indicates their current academic year. 52 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from "fs"; 2 | import path from "path"; 3 | import {Config, logger} from "@/config"; 4 | import { 5 | Collection, 6 | REST, 7 | Routes, 8 | type RESTPostAPIChatInputApplicationCommandsJSONBody, 9 | } from "discord.js"; 10 | 11 | export const commandArr: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; 12 | 13 | export default async (client: ClientType) => { 14 | logger.debug("Loading commands..."); 15 | client.commands = new Collection(); 16 | 17 | // all command files except index.ts 18 | const commandFiles: string[] = (await fs.readdir(__dirname)) 19 | .filter((file: string) => file.endsWith(".ts") || file.endsWith(".js")) 20 | .filter( 21 | (file: string) => 22 | file !== "index.ts" && 23 | file !== "index.js" && 24 | (Config.features as any)[file.slice(0, -3)] 25 | ); 26 | 27 | // command loader 28 | for (const file of commandFiles) { 29 | const filePath = path.join(__dirname, file); 30 | const {command} = await import(filePath.slice(0, -3)); 31 | 32 | logger.debug(`Load command file ${filePath}`); 33 | logger.debug({command}); 34 | 35 | // load into commands map 36 | client.commands.set(command.data.name, command as CommandType); 37 | } 38 | 39 | // register slash commands 40 | for (const command of client.commands.values()) { 41 | commandArr.push(command.data.toJSON()); 42 | } 43 | 44 | const rest = new REST({ 45 | version: Config.discord.api_version, 46 | }).setToken(Config.discord.api_token); 47 | 48 | // if in production mode, register globally, can take up to an hour to show up 49 | // else register in development guild 50 | if (Config.environment === "production") { 51 | logger.debug("Registering commands globally..."); 52 | await rest.put(Routes.applicationCommands(Config.discord.client_id), { 53 | body: commandArr, 54 | }); 55 | } else { 56 | logger.debug("Registering commands in development guild..."); 57 | await rest.put( 58 | Routes.applicationGuildCommands( 59 | Config.discord.client_id, 60 | Config.discord.guild_id 61 | ), 62 | { 63 | body: commandArr, 64 | } 65 | ); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /src/commands/purge.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import {handleEmbedResponse} from "@/helpers"; 3 | import { 4 | type CacheType, 5 | TextChannel, 6 | ThreadChannel, 7 | inlineCode, 8 | SlashCommandBuilder, 9 | SlashCommandIntegerOption, 10 | ChatInputCommandInteraction, 11 | PermissionFlagsBits, 12 | } from "discord.js"; 13 | 14 | const purgeModule: CommandType = { 15 | data: new SlashCommandBuilder() 16 | .setName("purge") 17 | .setDescription("Purges the last N messages where 1 <= n <= 99") 18 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) 19 | .addIntegerOption((option: SlashCommandIntegerOption) => 20 | option 21 | .setName("n") 22 | .setDescription("Number of messages to purge") 23 | .setRequired(true) 24 | ), 25 | execute: async (interaction: ChatInputCommandInteraction) => { 26 | try { 27 | const amount = interaction.options.getInteger("n"); 28 | logger.debug(`Purge was called with ${amount}`); 29 | if (!amount || amount < 1 || amount > 99) { 30 | return await handleEmbedResponse(interaction, true, { 31 | message: "`n` must be 1 <= n <= 99", 32 | }); 33 | } 34 | 35 | const channel = interaction.channel as TextChannel | ThreadChannel; 36 | const messages = await channel.messages.fetch({limit: amount}); 37 | if (messages.size === 0) { 38 | return await handleEmbedResponse(interaction, true, { 39 | message: "There are no messages to delete.", 40 | }); 41 | } else if (messages.size < amount) { 42 | return await handleEmbedResponse(interaction, true, { 43 | message: `There are only ${inlineCode( 44 | messages.size.toString() 45 | )} messages in this channel.`, 46 | }); 47 | } 48 | 49 | const deleted = await channel.bulkDelete(messages, true); 50 | 51 | return await handleEmbedResponse(interaction, false, { 52 | message: `Deleted ${ 53 | amount === 1 ? "`1` message" : `\`${deleted.size}\` messages` 54 | }.`, 55 | }); 56 | } catch (err) { 57 | console.error(err); 58 | } 59 | }, 60 | }; 61 | 62 | export {purgeModule as command}; 63 | -------------------------------------------------------------------------------- /src/events/interactionCreate.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import { 3 | ApplicationCommandOptionType, 4 | AutocompleteInteraction, 5 | type CacheType, 6 | ChatInputCommandInteraction, 7 | Events, 8 | type Interaction, 9 | } from "discord.js"; 10 | 11 | module.exports = { 12 | name: Events.InteractionCreate, 13 | async execute(client: ClientType, interaction: Interaction) { 14 | if (interaction.isChatInputCommand()) { 15 | HandleCommandInteraction(client, interaction); 16 | } else if (interaction.isAutocomplete()) { 17 | HandleAutoComplete(client, interaction); 18 | } 19 | }, 20 | }; 21 | 22 | const HandleCommandInteraction = async ( 23 | client: ClientType, 24 | interaction: ChatInputCommandInteraction 25 | ) => { 26 | const command = client.commands.get(interaction.commandName); 27 | if (!command) { 28 | logger.error(`Command ${interaction.commandName} not found!`); 29 | return false; 30 | } 31 | 32 | try { 33 | logger.info( 34 | `${interaction.user.displayName} (${interaction.user.username}) ran: /${ 35 | interaction.commandName 36 | }${interaction.options.data.map((option) => { 37 | if (option.type === ApplicationCommandOptionType.Subcommand) { 38 | return ` ${option.name} ${option.options?.map((o) => o.value)}`; 39 | } else if ( 40 | option.type === ApplicationCommandOptionType.SubcommandGroup 41 | ) { 42 | return ` ${option.name} ${option.options?.map((o) => o.value)}`; 43 | } 44 | return ` ${option.value}`; 45 | })}` 46 | ); 47 | 48 | await command.execute(interaction); 49 | } catch (error) { 50 | logger.error(error); 51 | return interaction.reply({ 52 | content: "There was an error while executing this command!", 53 | ephemeral: true, 54 | }); 55 | } 56 | }; 57 | 58 | const HandleAutoComplete = async ( 59 | client: ClientType, 60 | interaction: AutocompleteInteraction 61 | ) => { 62 | const command = client.commands.get(interaction.commandName); 63 | if (!command?.autoComplete) return; 64 | 65 | try { 66 | await command.autoComplete(interaction); 67 | } catch (error) { 68 | logger.error("Autocomplete Error:", error); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /src/commands/prompt.ts: -------------------------------------------------------------------------------- 1 | import { 2 | inlineCode, 3 | SlashCommandBuilder, 4 | SlashCommandChannelOption, 5 | SlashCommandStringOption, 6 | type CacheType, 7 | TextChannel, 8 | ChatInputCommandInteraction, 9 | PermissionFlagsBits, 10 | } from "discord.js"; 11 | import {handleEmbedResponse} from "@/helpers"; 12 | import {logger} from "@/config"; 13 | 14 | const promptModule: CommandType = { 15 | data: new SlashCommandBuilder() 16 | .setName("prompt") 17 | .setDescription("Ask a question to the community") 18 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) 19 | .addChannelOption((option: SlashCommandChannelOption) => 20 | option 21 | .setName("destination") 22 | .setDescription("Select a channel") 23 | .setRequired(true) 24 | .addChannelTypes(0) 25 | ) 26 | .addStringOption((opt: SlashCommandStringOption) => 27 | opt 28 | .setName("question") 29 | .setDescription("What is the question?") 30 | .setRequired(true) 31 | ) 32 | .addBooleanOption((opt) => 33 | opt 34 | .setName("thread") 35 | .setDescription("Create a thread for the prompt?") 36 | .setRequired(false) 37 | ), 38 | execute: async (interaction: ChatInputCommandInteraction) => { 39 | try { 40 | const isThread = interaction.options.getBoolean("thread") ?? true; 41 | const channelID = interaction.options.getChannel( 42 | "destination", 43 | true 44 | ) as TextChannel; 45 | 46 | if (!channelID) { 47 | return await handleEmbedResponse(interaction, true, { 48 | message: `Please select a channel to ask the question in.`, 49 | }); 50 | } 51 | 52 | const question = interaction.options.getString("question")!; 53 | const promptMsg = "## :loudspeaker: Community Prompt\n" + question; 54 | const promptMessage = await channelID.send(promptMsg); 55 | 56 | if (isThread) { 57 | await promptMessage.startThread({ 58 | name: question, 59 | autoArchiveDuration: 10080, 60 | }); 61 | } 62 | 63 | return await handleEmbedResponse(interaction, false, { 64 | message: `Asked ${inlineCode(question)} in ${channelID}`, 65 | ephemeral: false, 66 | }); 67 | } catch (error) { 68 | logger.error(`Prompt command failed: ${error}`); 69 | } 70 | }, 71 | }; 72 | 73 | export {promptModule as command}; 74 | -------------------------------------------------------------------------------- /src/commands/equation.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import { 3 | ActionRowBuilder, 4 | ButtonBuilder, 5 | ButtonInteraction, 6 | ButtonStyle, 7 | type CacheType, 8 | ChatInputCommandInteraction, 9 | ComponentType, 10 | SlashCommandBuilder, 11 | SlashCommandStringOption, 12 | } from "discord.js"; 13 | import {renderEquation, sanitizeEquation} from "../helpers/LatexHelpers"; 14 | 15 | const equationModule: CommandType = { 16 | data: new SlashCommandBuilder() 17 | .setName("equation") 18 | .setDescription("Render a LaTeX equation") 19 | .addStringOption((opt: SlashCommandStringOption) => 20 | opt 21 | .setName("equation") 22 | .setDescription("The equation to render") 23 | .setRequired(true) 24 | ), 25 | execute: async (interaction: ChatInputCommandInteraction) => { 26 | try { 27 | const message = interaction.options.getString("equation")!; 28 | const cleanedMessage = sanitizeEquation(message); 29 | const img = await renderEquation(cleanedMessage, interaction); 30 | 31 | const deleteBtn = new ButtonBuilder() 32 | .setCustomId("delete") 33 | .setEmoji("🗑️") 34 | .setStyle(ButtonStyle.Secondary); 35 | 36 | const row = new ActionRowBuilder().addComponents( 37 | deleteBtn 38 | ); 39 | 40 | const response = await interaction.editReply({ 41 | files: [img], 42 | components: [row], 43 | }); 44 | 45 | const buttonFilter = (i: ButtonInteraction) => { 46 | if (i.user.id !== interaction.user.id) { 47 | i.reply({ 48 | content: "You are not allowed to interact with this message!", 49 | ephemeral: true, 50 | }); 51 | return false; 52 | } 53 | return true; 54 | }; 55 | 56 | try { 57 | const componentInteraction = await response.awaitMessageComponent({ 58 | filter: buttonFilter, 59 | componentType: ComponentType.Button, 60 | time: 60000, 61 | dispose: true, 62 | }); 63 | 64 | if (componentInteraction.customId === "delete") await response.delete(); 65 | } catch (e) { 66 | await interaction.editReply({ 67 | components: [], 68 | }); 69 | } 70 | } catch (error: any) { 71 | // Don't log if the message is not being found due to being deleted 72 | if (error.code === 10008) return; 73 | logger.error(`Equation command failed: ${error}`); 74 | } 75 | }, 76 | }; 77 | 78 | export {equationModule as command}; 79 | -------------------------------------------------------------------------------- /src/commands/optin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SlashCommandBuilder, 3 | GuildMember, 4 | AutocompleteInteraction, 5 | ChatInputCommandInteraction, 6 | type CacheType, 7 | } from "discord.js"; 8 | import {Config} from "@/config"; 9 | 10 | // The roles that are eligible for opt-in 11 | const availableRoles = [{name: "Event Ping", value: "event_ping"}]; 12 | 13 | const optInModule: CommandType = { 14 | data: new SlashCommandBuilder() 15 | .setName("optin") 16 | .setDescription("Opt in to a role") 17 | .addStringOption((option) => 18 | option 19 | .setName("role") 20 | .setDescription("Select the role you want to opt in to") 21 | .setAutocomplete(true) 22 | .setRequired(true) 23 | ), 24 | autoComplete: async (interaction: AutocompleteInteraction) => { 25 | const focusedOption = interaction.options.getFocused(); 26 | const member = interaction.member as GuildMember; 27 | 28 | // Filter roles based on what the user does not have 29 | const memberRoles = member.roles.cache.map((role) => role.id); 30 | const filteredRoles = availableRoles.filter((role) => { 31 | const roleId = (Config.roles.other as any)[role.value]; 32 | return roleId && !memberRoles.includes(roleId); 33 | }); 34 | 35 | const filtered = filteredRoles.filter((role) => 36 | role.name.toLowerCase().includes(focusedOption.toLowerCase()) 37 | ); 38 | 39 | await interaction.respond( 40 | filtered.map((role) => ({name: role.name, value: role.value})) 41 | ); 42 | }, 43 | execute: async (interaction: ChatInputCommandInteraction) => { 44 | const member = interaction.member as GuildMember; 45 | const selectedRole = interaction.options.getString("role")!; 46 | 47 | let roleId: string | undefined; 48 | 49 | if (selectedRole === "event_ping") { 50 | roleId = Config.roles.other.event_ping; 51 | } 52 | 53 | if (!roleId) { 54 | await interaction.reply({ 55 | content: "The selected role is not available for opt-in.", 56 | ephemeral: true, 57 | }); 58 | return; 59 | } 60 | 61 | try { 62 | await member.roles.add(roleId); 63 | await interaction.reply({ 64 | content: `You have successfully opted in to the ${selectedRole.replace("_", " ")} role.`, 65 | ephemeral: true, 66 | }); 67 | } catch (error) { 68 | console.error(`Error adding role to ${member.user.tag}:`, error); 69 | await interaction.reply({ 70 | content: "An error occurred while trying to opt you in to the role.", 71 | ephemeral: true, 72 | }); 73 | } 74 | }, 75 | }; 76 | 77 | export {optInModule as command}; 78 | -------------------------------------------------------------------------------- /src/commands/optout.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SlashCommandBuilder, 3 | GuildMember, 4 | AutocompleteInteraction, 5 | ChatInputCommandInteraction, 6 | type CacheType, 7 | } from "discord.js"; 8 | import {Config} from "@/config"; 9 | 10 | // The roles that are eligible for opt-out 11 | const availableRoles = [{name: "Event Ping", value: "event_ping"}]; 12 | 13 | const optOutModule: CommandType = { 14 | data: new SlashCommandBuilder() 15 | .setName("optout") 16 | .setDescription("Opt out from a role") 17 | .addStringOption((option) => 18 | option 19 | .setName("role") 20 | .setDescription("Select the role you want to opt out from") 21 | .setAutocomplete(true) 22 | .setRequired(true) 23 | ), 24 | autoComplete: async (interaction: AutocompleteInteraction) => { 25 | const focusedOption = interaction.options.getFocused(); 26 | const member = interaction.member as GuildMember; 27 | 28 | // Filter roles based on what the user has 29 | const memberRoles = member.roles.cache.map((role) => role.id); 30 | const filteredRoles = availableRoles.filter((role) => { 31 | const roleId = (Config.roles.other as any)[role.value]; 32 | return roleId && memberRoles.includes(roleId); 33 | }); 34 | 35 | const filtered = filteredRoles.filter((role) => 36 | role.name.toLowerCase().includes(focusedOption.toLowerCase()) 37 | ); 38 | 39 | await interaction.respond( 40 | filtered.map((role) => ({name: role.name, value: role.value})) 41 | ); 42 | }, 43 | execute: async (interaction: ChatInputCommandInteraction) => { 44 | const member = interaction.member as GuildMember; 45 | const selectedRole = interaction.options.getString("role")!; 46 | 47 | let roleId: string | undefined; 48 | 49 | if (selectedRole === "welcome_week") { 50 | roleId = Config.roles.other.event_ping; 51 | } 52 | 53 | if (!roleId) { 54 | await interaction.reply({ 55 | content: "The selected role is not available for opt-out.", 56 | ephemeral: true, 57 | }); 58 | return; 59 | } 60 | 61 | try { 62 | await member.roles.remove(roleId); 63 | await interaction.reply({ 64 | content: `You have successfully opted out of the ${selectedRole.replace("_", " ")} role.`, 65 | ephemeral: true, 66 | }); 67 | } catch (error) { 68 | console.error(`Error removing role from ${member.user.tag}:`, error); 69 | await interaction.reply({ 70 | content: "An error occurred while trying to opt you out from the role.", 71 | ephemeral: true, 72 | }); 73 | } 74 | }, 75 | }; 76 | 77 | export {optOutModule as command}; 78 | -------------------------------------------------------------------------------- /src/commands/ping.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import { 3 | ChatInputCommandInteraction, 4 | SlashCommandBuilder, 5 | type CacheType, 6 | EmbedBuilder, 7 | Colors, 8 | } from "discord.js"; 9 | 10 | const getDiscordApiPing = async () => { 11 | const start = Date.now(); 12 | await fetch( 13 | `https://discord.com/api/v${process.env.DISCORD_API_VERSION}/gateway` 14 | ); 15 | return Date.now() - start; 16 | }; 17 | 18 | const pongModule: CommandType = { 19 | data: new SlashCommandBuilder() 20 | .setName("ping") 21 | .setDescription("Pings the bot to check its latency and uptime"), 22 | execute: async (interaction: ChatInputCommandInteraction) => { 23 | try { 24 | const uptime = interaction.client.uptime as number; 25 | 26 | const days = Math.floor(uptime / 86400000); 27 | const hours = Math.floor(uptime / 3600000) % 24; 28 | const minutes = Math.floor(uptime / 60000) % 60; 29 | const seconds = Math.floor(uptime / 1000) % 60; 30 | const milliseconds = uptime % 1000; 31 | 32 | const timeComponents: string[] = []; 33 | if (days) timeComponents.push(`${days}d`); 34 | if (hours) timeComponents.push(`${hours}h`); 35 | if (minutes) timeComponents.push(`${minutes}m`); 36 | if (seconds) timeComponents.push(`${seconds}s`); 37 | if (milliseconds) timeComponents.push(`${milliseconds}ms`); 38 | 39 | const pingEmbed = new EmbedBuilder() 40 | .setColor(Colors.Blue) 41 | .setTitle("Pinging..."); 42 | 43 | const interactionStartTime = interaction.createdTimestamp; 44 | const preResponseTime = Date.now(); 45 | 46 | // Send the initial response and wait for a reply 47 | const response = await interaction.reply({ 48 | embeds: [pingEmbed], 49 | fetchReply: true, 50 | }); 51 | 52 | const postResponseTime = Date.now(); 53 | const discordApiPing = await getDiscordApiPing(); 54 | const apiOffset = 55 | discordApiPing - (preResponseTime - interactionStartTime); 56 | 57 | pingEmbed.setTitle("Pong").addFields( 58 | { 59 | name: ":signal_strength: API Latency", 60 | value: `${preResponseTime - interactionStartTime + apiOffset}ms`, 61 | }, 62 | { 63 | name: ":signal_strength: Round Trip", 64 | value: `${postResponseTime - interactionStartTime + apiOffset}ms`, 65 | }, 66 | { 67 | name: ":arrow_up: Bot Uptime", 68 | value: timeComponents.join(", "), 69 | } 70 | ); 71 | 72 | return await response.edit({ 73 | embeds: [pingEmbed], 74 | }); 75 | } catch (error) { 76 | logger.error(error); 77 | } 78 | }, 79 | }; 80 | 81 | export {pongModule as command}; 82 | -------------------------------------------------------------------------------- /src/commands/year.ts: -------------------------------------------------------------------------------- 1 | import {Config, logger} from "@/config"; 2 | import {handleEmbedResponse} from "@/helpers"; 3 | import { 4 | SlashCommandBuilder, 5 | SlashCommandStringOption, 6 | type CacheType, 7 | GuildMemberRoleManager, 8 | ChatInputCommandInteraction, 9 | } from "discord.js"; 10 | 11 | const rolesMap = new Map(Object.entries(Config.roles.years)); 12 | const choices: {name: string; value: string}[] = Array.from(rolesMap).map( 13 | ([key]) => ({ 14 | name: key, 15 | value: key, 16 | }) 17 | ); 18 | 19 | const yearModule: CommandType = { 20 | data: new SlashCommandBuilder() 21 | .setName("year") 22 | .setDescription("Assign yourself a year role") 23 | .addStringOption((option: SlashCommandStringOption) => 24 | option 25 | .setName("year") 26 | .setDescription("Your year") 27 | .setRequired(true) 28 | .addChoices(...choices) 29 | ), 30 | execute: async (interaction: ChatInputCommandInteraction) => { 31 | try { 32 | const year = interaction.options.getString("year"); 33 | const member = interaction.member; 34 | const memberRoles = member?.roles as GuildMemberRoleManager; 35 | const guild = interaction.guild; 36 | const guildRoles = guild?.roles.cache; 37 | 38 | const assignRole = async (roleID: string) => { 39 | const selectedRole = guildRoles?.get(roleID); 40 | if (selectedRole) { 41 | const hasRole = memberRoles?.cache.some((role) => role.id === roleID); 42 | if (hasRole) { 43 | // If the user already has the role, remove it. 44 | await memberRoles?.remove(roleID); 45 | return await handleEmbedResponse(interaction, false, { 46 | message: `Removed the \`\`${selectedRole.name}\`\` role.`, 47 | ephemeral: false, 48 | }); 49 | } else { 50 | // Check if the user has any roles in the rolesMap, if so remove it them first. 51 | const rolesMapValues = Array.from(rolesMap.values()); 52 | const hasAnyRole = memberRoles?.cache.some((role) => { 53 | return rolesMapValues.includes(role.id); 54 | }); 55 | if (hasAnyRole) { 56 | await memberRoles?.remove(rolesMapValues); 57 | } 58 | await memberRoles?.add(roleID); 59 | return await handleEmbedResponse(interaction, false, { 60 | message: `Added the \`\`${selectedRole.name}\`\` role.`, 61 | ephemeral: false, 62 | }); 63 | } 64 | } 65 | }; 66 | 67 | const selectedRoleID = rolesMap.get(year || ""); 68 | if (selectedRoleID) { 69 | return assignRole(selectedRoleID); 70 | } 71 | } catch (err) { 72 | logger.error({err}); 73 | } 74 | }, 75 | }; 76 | 77 | export {yearModule as command}; 78 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Fork and Pull Request Proccess 4 | 5 | **1. Create a fork of the project** 6 | 7 | **2. Make your changes** 8 | 9 | - You can add yourself to the contributors list in [README.md](../README.md), if you desire. 10 | 11 | **3. Create a pull request** 12 | 13 | - When creating a pull request, please make sure that: 14 | 15 | - Your code works 16 | - You remove all excessive debug helpers 17 | - You follow this project's naming conventions 18 | - You follow this project's [structure](ARCHITECTURE.md) 19 | - You comment your code to explain how it works 20 | - You make your code easily readable and modifiable 21 | - You add to the documentation if needed 22 | 23 | - It's also imperative that you: 24 | - Never commit `config.yaml` or any other configuration to source control. `config.example.yaml` is the only configuration-like file that should be committed, as it is a template for other people to get up and running quickly 25 | - Don't add dependencies that are not able to be used our current [license (MIT)](../license) 26 | - Avoid adding dependencies for simple tasks - there's no need to include [`string-reverse`](https://www.npmjs.com/package/string-reverse), just write the code yourself. 27 | 28 | ## Commit Message Conventions 29 | 30 | Good commit message hygiene is extremely important. We follow the structure of 31 | 32 | - First line has 50 charecters. It describes what you added or changed 33 | - Second line is empty 34 | - Third line starts the paragraph section 35 | - Paragraph section has 72 charecters per line and explains in detail what you did and why you did it 36 | - Each section can use markdown formatting 37 | 38 | ## Branch Naming Conventions 39 | 40 | We like to follow the branch naming convention of `name-feature`. For example if Ryan Prairie is working on a feature called purge, the branch would look like `ryanp-purge`. 41 | 42 | ## Code of Conduct 43 | 44 | We want to foster an open and welcoming environment. We will answer any questions and issues. As this is a harassment-free environment, not being open to people of any kind will result in a ban. 45 | 46 | ## Starting an Issue 47 | 48 | Need help or want to bring something to our attention? Create an issue. There are multiple kinds but the 3 kinds that we support are bug report, code maintenance, and feature request. 49 | 50 | ### Labels 51 | 52 | We use labels to denote what is going on an issue. You can search by issues or use them as a quick guide to an issue. 53 | 54 | some important labels are 55 | 56 | - `bug` when something isn't working 57 | - `dependencies` to update the dependencies 58 | - `enhancement` for adding a new feature or functionality 59 | - `help wanted` for asking for help 60 | - `question` for asking a question 61 | - `security` for an issue with security 62 | 63 | ## Security 64 | 65 | We obviously want to know about security issues with our software. If there is a security vulnerability with our software or systems, please contact us before you create a public issue. You can contact us at [css@uwindsor.ca](mailto://css@uwindsor.ca) 66 | -------------------------------------------------------------------------------- /src/helpers/buildings.ts: -------------------------------------------------------------------------------- 1 | import Fuse from "fuse.js"; 2 | 3 | export const fuseOptions = { 4 | shouldSort: true, 5 | threshold: 0.1, 6 | distance: 100, 7 | isCaseSensitive: false, 8 | keys: ["name"], 9 | }; 10 | 11 | export const FindBuildingByCode = (buildingCode: string): string => { 12 | for (let i = 0; i < buildings.length; i++) { 13 | if (buildings[i]?.code === buildingCode) return buildings[i]?.name || ""; 14 | } 15 | return ""; 16 | }; 17 | 18 | export const ListAllBuildings = (): {codes: string; names: string} => { 19 | let codes: string = "", 20 | names: string = ""; 21 | for (let i = 0; i < buildings.length; i++) { 22 | codes += `${buildings[i]?.code ?? ""}\n`; 23 | names += `${buildings[i]?.name ?? ""}\n`; 24 | } 25 | 26 | return { 27 | codes, 28 | names, 29 | }; 30 | }; 31 | 32 | export const FindBuildingByName = (pattern: string) => { 33 | const fuse = new Fuse(buildings, fuseOptions); 34 | 35 | let res = fuse.search(pattern); 36 | 37 | return res; 38 | }; 39 | 40 | export const buildings: buildingType[] = [ 41 | { 42 | code: "AC", 43 | name: "Assumption Chapel", 44 | }, 45 | { 46 | code: "BB", 47 | name: "Biology Building", 48 | }, 49 | { 50 | code: "CE", 51 | name: "Centre for Engineering Innovation", 52 | }, 53 | { 54 | code: "CH", 55 | name: "Cartier Hall", 56 | }, 57 | { 58 | code: "CN", 59 | name: "Chrysler Hall North", 60 | }, 61 | { 62 | code: "CS", 63 | name: "Chrysler Hall South", 64 | }, 65 | { 66 | code: "DB", 67 | name: "Drama Building", 68 | }, 69 | { 70 | code: "DH", 71 | name: "Dillon Hall", 72 | }, 73 | { 74 | code: "ED", 75 | name: "Neal Education Building", 76 | }, 77 | { 78 | code: "EH", 79 | name: "Essex Hall", 80 | }, 81 | { 82 | code: "ER", 83 | name: "Erie Hall", 84 | }, 85 | { 86 | code: "JC", 87 | name: "Jackman Dramatic Art Centre", 88 | }, 89 | { 90 | code: "LB", 91 | name: "Ianni Law Building", 92 | }, 93 | { 94 | code: "LL", 95 | name: "Leddy Library", 96 | }, 97 | { 98 | code: "LT", 99 | name: "Lambton Tower", 100 | }, 101 | { 102 | code: "MB", 103 | name: "O'Neil Medical Education Centre", 104 | }, 105 | { 106 | code: "MC", 107 | name: "Macdonald Hall", 108 | }, 109 | { 110 | code: "MH", 111 | name: "Memorial Hall", 112 | }, 113 | { 114 | code: "MU", 115 | name: "Music Building", 116 | }, 117 | { 118 | code: "OB", 119 | name: "Odette Building", 120 | }, 121 | { 122 | code: "PA", 123 | name: "Parking Around Campus", 124 | }, 125 | { 126 | code: "TC", 127 | name: "Toldo Health Education Centre", 128 | }, 129 | { 130 | code: "UC", 131 | name: "C.A.W. Student Centre", 132 | }, 133 | { 134 | code: "VH", 135 | name: "Vanier Hall", 136 | }, 137 | { 138 | code: "WC", 139 | name: "Welcome Centre", 140 | }, 141 | { 142 | code: "WL", 143 | name: "West Library", 144 | }, 145 | ]; 146 | -------------------------------------------------------------------------------- /src/helpers/LatexHelpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AttachmentBuilder, 3 | type CacheType, 4 | CommandInteraction, 5 | } from "discord.js"; 6 | import {Resvg} from "@resvg/resvg-js"; 7 | import mjAPI from "mathjax-node"; 8 | 9 | // Sanitize function optimized using a single regex replace 10 | export const sanitizeEquation = (message: string): string => { 11 | const resCommands: [RegExp, string][] = [ 12 | [/\text{/g, `\\backslash text~{`], 13 | [/\$/g, `\\$`], 14 | [/"/g, '\\"'], 15 | [/\\union/g, "\\cup"], 16 | ]; 17 | 18 | return resCommands.reduce( 19 | (acc, [searchValue, replaceValue]) => 20 | acc.replace(searchValue, replaceValue), 21 | message 22 | ); 23 | }; 24 | 25 | // Initialize MathJax only once globally 26 | mjAPI.config({MathJax: {SVG: {font: "TeX"}}}); 27 | mjAPI.start(); 28 | 29 | // Helper function to convert svg to img buffer using resvg-js 30 | const svgToImgBuffer = async (svg: string): Promise => { 31 | try { 32 | const padding = 200; 33 | const equationSVGWithPadding = svg.replace( 34 | /viewBox\s*=\s*"([^"]*)"/, 35 | (_: any, viewBox: string) => { 36 | const [x, y, width, height] = viewBox.split(/\s*,*\s+/).map(Number); 37 | return `viewBox="${x! - padding} ${y! - padding} ${width! + padding * 2} ${height! + padding * 2}"`; 38 | } 39 | ); 40 | 41 | const resvg = new Resvg(equationSVGWithPadding, { 42 | background: "white", 43 | fitTo: {mode: "zoom", value: 2.5}, 44 | }); 45 | 46 | return resvg.render().asPng(); 47 | } catch (error) { 48 | throw new Error(`Error in svgToImgBuffer: ${error}`); 49 | } 50 | }; 51 | 52 | export const renderEquation = async ( 53 | cleanedMessage: string, 54 | interaction: CommandInteraction 55 | ): Promise => { 56 | try { 57 | await interaction.reply({content: "Generating equation..."}); 58 | 59 | return new Promise((resolve, reject) => { 60 | mjAPI.typeset( 61 | {math: cleanedMessage, format: "inline-TeX", svg: true}, 62 | async (data: any) => { 63 | if (data.errors) { 64 | await interaction.editReply({ 65 | content: `Sorry, there was an error: ${data.errors}`, 66 | }); 67 | return reject(new Error(`MathJax Error: ${data.errors}`)); 68 | } 69 | 70 | try { 71 | const equationSVG = data.svg.replace(/"currentColor"/g, '"black"'); 72 | const buffer = await svgToImgBuffer(equationSVG); 73 | const attachment = new AttachmentBuilder(buffer, { 74 | name: `equation-${interaction.user?.username}-${Date.now()}.png`, 75 | }); 76 | 77 | await interaction.editReply({content: "", files: [attachment]}); 78 | resolve(attachment); 79 | } catch (error) { 80 | await interaction.editReply({ 81 | content: `Error generating image: ${(error as Error).message}`, 82 | }); 83 | reject(error); 84 | } 85 | } 86 | ); 87 | }); 88 | } catch (error) { 89 | throw new Error(`EquationRender error: ${(error as Error).message}`); 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/commands/google.ts: -------------------------------------------------------------------------------- 1 | import {logger, Config} from "@/config"; 2 | 3 | import { 4 | type CacheType, 5 | SlashCommandBuilder, 6 | ChatInputCommandInteraction, 7 | EmbedBuilder, 8 | Colors, 9 | } from "discord.js"; 10 | 11 | // Interfaces for getting specific entries. 12 | interface SearchResult { 13 | title: string; 14 | link: string; 15 | } 16 | 17 | interface Results { 18 | items: SearchResult[]; 19 | } 20 | 21 | const googleModule: CommandType = { 22 | data: new SlashCommandBuilder() 23 | .setName("google") 24 | .setDescription("Google something and return the top 10 results.") 25 | .addStringOption((option) => 26 | option 27 | .setName("query") 28 | .setDescription("What you want to Google.") 29 | .setRequired(true) 30 | .setMaxLength(120) 31 | ) 32 | .addUserOption((option) => 33 | option 34 | .setName("to") 35 | .setDescription("The user you want to direct this to.") 36 | ), 37 | execute: async (interaction: ChatInputCommandInteraction) => { 38 | // Had to include the empty string so I can check the length. 39 | try { 40 | const query = interaction.options.getString("query"); 41 | const toWhom = interaction.options.getUser("to"); 42 | const embed = new EmbedBuilder() 43 | .setColor(Colors.Blue) 44 | .setAuthor({ 45 | name: `Requested by ${interaction.user.nickname}`, 46 | iconURL: `${interaction.user?.avatarURL()}`, 47 | }) 48 | .setTimestamp(); 49 | 50 | if (!Config.google.search_key || !Config.google.search_id) { 51 | logger.info("No key or search engine ID."); 52 | return; 53 | } 54 | 55 | const search_key = Config.google.search_key; 56 | const search_id = Config.google.search_id; 57 | const url = `https://www.googleapis.com/customsearch/v1?key=${search_key}&cx=${search_id}&q=${query}`; 58 | 59 | let response: string = ""; 60 | 61 | const res = await fetch(url, { 62 | method: "GET", 63 | headers: { 64 | Accept: "*/*", 65 | ContentType: "application/json", 66 | }, 67 | }); 68 | 69 | if (!res.ok) { 70 | logger.info("No response from Google"); 71 | return interaction.reply({ 72 | content: "Google was unable to return any results.", 73 | ephemeral: true, 74 | }); 75 | } 76 | 77 | const data: Results = (await res.json()) as Results; 78 | 79 | if (data.items.length == 0) { 80 | response = "No results."; 81 | } else { 82 | response = data.items 83 | .map( 84 | ({title, link}: SearchResult, i: number) => 85 | `${i + 1}. [${title}](${link})` 86 | ) 87 | .join("\n"); 88 | } 89 | 90 | embed.setDescription(`**Query: ${query}**\n${response}`); 91 | 92 | interaction.reply({ 93 | content: toWhom?.toString() ?? "", 94 | embeds: [embed], 95 | }); 96 | } catch (error: any) { 97 | logger.info(error); 98 | } 99 | }, 100 | }; 101 | 102 | export {googleModule as command}; 103 | -------------------------------------------------------------------------------- /src/helpers/ASCIIArts.ts: -------------------------------------------------------------------------------- 1 | export const ASCIIArts: ASCIIArt = { 2 | goat: { 3 | art: ` 4 | ,--._,--. 5 | ,' ,' ,-\`. 6 | (\`-.__ / ,' / 7 | \`. \`--' \\__,--'-. 8 | \`--/ ,-. ______/ 9 | (o-. ,o- / 10 | \`. ; \\ <------ %s 11 | |: \\ 12 | ,'\` , \\ 13 | (o o , --' : 14 | \\--','. ; 15 | \`;; : / 16 | ;' ; ,' ,' 17 | ,',' : ' 18 | \\ \\ : 19 | \` 20 | `, 21 | defaultString: "Baaaaa", 22 | }, 23 | train: { 24 | art: ` 25 | (@@) ( ) (@) ( ) @@ () @ O @ O @ 26 | ( ) 27 | (@@@@) %s 28 | ( ) 29 | (@@@) 30 | ==== ________ ___________ 31 | _D _| |_______/ \\__I_I_____===__|_________| 32 | |(_)--- | H\\________/ | | =|___ ___| _________________ 33 | / | | H | | | | ||_| |_|| _| \_____A 34 | | | | H |__--------------------| [___] | =| | 35 | | ________|___H__/__|_____/[][]~\\_______| | -| | 36 | |/ | |-----------I_____I [][] [] D |=======|____|________________________|_ 37 | __/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__|__________________________|_ 38 | |/-=|___|=O=====O=====O=====O |_____/~\\___/ |_D__D__D_| |_D__D__D_| 39 | \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ \\_/ \\_/ \\_/ \\_/ 40 | `, 41 | defaultString: "Choo Choo", 42 | }, 43 | dog: { 44 | art: ` 45 | ┈┈╱▏┈┈┈┈┈╱▔▔▔▔╲┈ 46 | ┈┈▏▏┈┈┈┈┈▏╲▕▋▕▋▏ 47 | ┈┈╲╲┈┈┈┈┈▏┈▏┈▔▔▔▆ ------- %s 48 | ┈┈┈╲▔▔▔▔▔╲╱┈╰┳┳┳╯ 49 | ╱╲╱╲▏┈┈┈┈┈┈▕▔╰━╯ 50 | ▔╲╲╱╱▔╱▔▔╲╲╲╲┈┈┈ 51 | ┈┈╲╱╲╱┈┈┈┈╲╲▂╲▂┈ 52 | ┈┈┈┈┈┈┈┈┈┈┈╲╱╲╱┈ 53 | `, 54 | defaultString: "Woof Woof", 55 | }, 56 | css: { 57 | art: ` 58 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 59 | %%#,,,,,,,,,,,,,,,,,,,,.....................%%% 60 | %%#,,,,,,,,,,,,,%,,,,,,......%%.............%%% 61 | %%#,,,,,,,,,,,%%%%%,,,,.....*%%%............%%% 62 | %%#,,,,,,,,,,,,,%,,,,,,.....................%%% 63 | %%#,,,,,,,,,,,,,,,,,,,,.....................%%% 64 | %%#,,,,,,,,,@,@,,@*@,,@.@..@.@..@.@.........%%% 65 | %%#,,,,,,,,,@@,,((((@,,@..@((((..@@.........%%% 66 | %%#,,,,,,,,,@@,@,,,,@,,@..@....@.@@.........%%% 67 | %%%%%%%%%%,,,,,@,,@,,,,.....@..@.....%%%%%%%%%% 68 | %%%%%%%%%%,,@@,,,,@,,@,..@..@....@@..%%%%%%%%%% 69 | %%%%%%%%%%..@@....@..@.,,@,,@,,,,@@,,%%%%%%%%%% 70 | %%%%%%%%%%.....@..@....,,,,,@,,@,,,,,%%%%%%%%%% 71 | %%..........@@.@....@..@,,@,,,,@,@@,,,,,,,,,/%% 72 | %%%.........@@..@@@@@..@,,@@@@@,,@@,,,,,,,,,%%% 73 | %%%.........@.@..@.@..@,@,,@,@,,@,@,,,,,,,,,%%% 74 | %%%....................,,,,,,,,,,,,,,,,,,,,,%%% 75 | %%%%..................,,,,,,,,,,,,,,,,,,,%%%% 76 | %%%%%...............%,,,,,,,,,,,,,,,%%%%% 77 | %%%%%..........%*%#%,,,,,,,,,,%%%%% 78 | %%%%%%.......,,,,,,,,%%%%%% 79 | %%%%%%%%.,,%%%%%%%% 80 | %%%%%%% 81 | 82 | %s 83 | `, 84 | defaultString: "I love CSS", 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /src/commands/notify.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@/config"; 2 | 3 | import { 4 | CacheType, 5 | SlashCommandBuilder, 6 | GuildMember, 7 | ChatInputCommandInteraction, 8 | Role, 9 | } from "discord.js"; 10 | 11 | const notifyModule: CommandType = { 12 | data: new SlashCommandBuilder() 13 | .setName("notify") 14 | .setDescription("Get or remove course notifications") 15 | .addSubcommand((subcommand) => 16 | subcommand 17 | .setName("add") 18 | .setDescription("Get a course notification role.") 19 | .addStringOption((option) => 20 | option 21 | .setName("course") 22 | .setDescription("Course code: e.g. Comp1000") 23 | .setMinLength(8) 24 | .setMaxLength(8) 25 | .setRequired(true) 26 | ) 27 | ) 28 | .addSubcommand((subcommand) => 29 | subcommand 30 | .setName("remove") 31 | .setDescription("Remove a course notification role") 32 | .addStringOption((option) => 33 | option 34 | .setName("course") 35 | .setDescription("Course code: e.g. Comp1000") 36 | .setMinLength(8) 37 | .setMaxLength(8) 38 | .setRequired(true) 39 | ) 40 | ), 41 | execute: async (interaction: ChatInputCommandInteraction) => { 42 | if (!interaction.member || !(interaction.member instanceof GuildMember)) { 43 | return; 44 | } 45 | 46 | const subCommand = interaction.options.getSubcommand(); 47 | const student = interaction.member as GuildMember; 48 | const courseCode = interaction.options.getString("course"); 49 | 50 | if (!courseCode) { 51 | await interaction.reply({ 52 | content: "Course code is missing.", 53 | ephemeral: true, 54 | }); 55 | return; 56 | } 57 | 58 | let courseSubject = courseCode.slice(0, 4); 59 | courseSubject = 60 | courseSubject.charAt(0).toUpperCase() + 61 | courseSubject.toLowerCase().slice(1); 62 | const courseNumber = courseCode.slice(4); 63 | 64 | try { 65 | if (!interaction.guild) { 66 | throw new Error("Guild not found."); 67 | } 68 | 69 | const courseRole: Role | undefined = interaction.guild.roles.cache.find( 70 | (role) => role.name === `${courseSubject}-${courseNumber}` 71 | ); 72 | 73 | if (!courseRole) { 74 | throw new Error(`Role not found: ${courseSubject}-${courseNumber}`); 75 | } 76 | 77 | if (subCommand === "add") { 78 | await student.roles.add(courseRole); 79 | await interaction.reply({ 80 | content: `You are now notified for **${courseSubject.toUpperCase()}-${courseNumber}** updates.`, 81 | ephemeral: true, 82 | }); 83 | } else if (subCommand === "remove") { 84 | await student.roles.remove(courseRole); 85 | await interaction.reply({ 86 | content: `You are no longer notified for **${courseSubject.toUpperCase()}-${courseNumber}** updates.`, 87 | ephemeral: true, 88 | }); 89 | } 90 | } catch (error: any) { 91 | logger.info(`Error handling course role: ${error.message}`); 92 | await interaction.reply({ 93 | content: "An error occurred or the course code is invalid.", 94 | ephemeral: true, 95 | }); 96 | } 97 | }, 98 | }; 99 | 100 | export { notifyModule as command }; 101 | -------------------------------------------------------------------------------- /src/helpers/seed.ts: -------------------------------------------------------------------------------- 1 | import {Config, logger, prisma} from "@/config"; 2 | import fs from "fs"; 3 | 4 | interface Course { 5 | code: string; 6 | name: string; 7 | description: string; 8 | notes: string; 9 | lectureHours: number; 10 | labHours: number; 11 | prerequisites: string[]; 12 | corequisites: string[]; 13 | antirequisites: string[]; 14 | } 15 | 16 | export async function seedDatabase() { 17 | if (!Config.seed) { 18 | logger.info("Seed is disabled. Skipping seeding..."); 19 | return; 20 | } else { 21 | logger.info("Seeding database..."); 22 | } 23 | 24 | // Check if the courses.json file exists 25 | if (!fs.existsSync("data/courses.json")) { 26 | logger.info("data/courses.json does not exist. Skipping seeding..."); 27 | return; 28 | } 29 | 30 | const courses = JSON.parse( 31 | fs.readFileSync("data/courses.json", "utf8") 32 | ) as Course[]; 33 | 34 | let count = 0; 35 | for (const course of courses) { 36 | const cleanedCode = course.code.trim().toLowerCase(); 37 | const courseCodeRegex = /[a-z]{4}[0-9]{4}/; 38 | 39 | if (!courseCodeRegex.test(cleanedCode) || cleanedCode.length > 8) { 40 | logger.debug(`Invalid course code: ${course.code}`); 41 | continue; 42 | } 43 | 44 | const cleanedName = course.name.trim(); 45 | const cleanedDescription = course.description.replace(/\n/g, "").trim(); 46 | const cleanedNotes = course.notes.trim(); 47 | 48 | const cleanRequirements = (requirements: string) => { 49 | return requirements.replace(/[\(\)]/g, "").trim(); 50 | }; 51 | 52 | const prerequisites = course.prerequisites.map((prerequisite) => { 53 | return { 54 | requirement: cleanRequirements(prerequisite), 55 | }; 56 | }); 57 | const corequisites = course.corequisites.map((corequisite) => { 58 | return { 59 | requirement: cleanRequirements(corequisite), 60 | }; 61 | }); 62 | const antirequisites = course.antirequisites.map((antirequisite) => { 63 | return { 64 | requirement: cleanRequirements(antirequisite), 65 | }; 66 | }); 67 | 68 | await prisma.course 69 | .upsert({ 70 | where: { 71 | code: cleanedCode, 72 | }, 73 | update: { 74 | code: cleanedCode, 75 | name: cleanedName, 76 | description: cleanedDescription, 77 | notes: cleanedNotes === "" ? null : cleanedNotes, 78 | lectureHours: course.lectureHours, 79 | labHours: course.labHours, 80 | }, 81 | create: { 82 | code: cleanedCode, 83 | name: cleanedName, 84 | description: cleanedDescription, 85 | notes: cleanedNotes === "" ? null : cleanedNotes, 86 | lectureHours: course.lectureHours, 87 | labHours: course.labHours, 88 | prerequisites: { 89 | create: prerequisites, 90 | }, 91 | corequisites: { 92 | create: corequisites, 93 | }, 94 | antirequisites: { 95 | create: antirequisites, 96 | }, 97 | }, 98 | }) 99 | .then(() => { 100 | logger.debug(`Created course ${cleanedCode}`); 101 | count++; 102 | }) 103 | .catch((e) => { 104 | logger.debug(`Failed to create course ${cleanedCode}`); 105 | console.error(e); 106 | }); 107 | } 108 | if (count === 0) { 109 | logger.info("No courses created."); 110 | } else { 111 | logger.info(`Upserted ${count} courses.`); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/commands/course.ts: -------------------------------------------------------------------------------- 1 | import {prisma} from "@/config"; 2 | import { 3 | type CacheType, 4 | SlashCommandBuilder, 5 | ChatInputCommandInteraction, 6 | SlashCommandStringOption, 7 | AutocompleteInteraction, 8 | } from "discord.js"; 9 | import {handleEmbedResponse} from "@/helpers"; 10 | import type {Course} from "@prisma/client"; 11 | 12 | const courseModule: CommandType = { 13 | data: new SlashCommandBuilder() 14 | .setName("course") 15 | .setDescription("Get information about a course.") 16 | .addStringOption((option: SlashCommandStringOption) => 17 | option 18 | .setName("code") 19 | .setDescription("The course code to get information about.") 20 | .setRequired(true) 21 | .setMaxLength(8) 22 | .setMinLength(8) 23 | .setAutocomplete(true) 24 | ), 25 | execute: async (interaction: ChatInputCommandInteraction) => { 26 | const code: string = interaction.options.getString("code", true); 27 | 28 | const courseCodeRegex = /^[a-zA-Z]{4}[0-9]{4}$/; 29 | if (!courseCodeRegex.test(code) || code.length !== 8) { 30 | return await handleEmbedResponse(interaction, true, { 31 | message: "Course code should be in the format of ABCD1234.", 32 | }); 33 | } 34 | 35 | const course = await prisma.course.findFirst({ 36 | where: { 37 | code: code.toLowerCase(), 38 | }, 39 | include: { 40 | prerequisites: true, 41 | antirequisites: true, 42 | corequisites: true, 43 | }, 44 | }); 45 | 46 | if (!course || course === null) { 47 | return await handleEmbedResponse(interaction, true, { 48 | message: "Course not found, please try another code.", 49 | }); 50 | } 51 | 52 | let embed = { 53 | title: `${course.code.toUpperCase()} - ${course.name}`, 54 | description: `**Description:** ${course.description}`, 55 | color: 0x0099ff, 56 | }; 57 | 58 | if (course.lectureHours !== null) { 59 | embed.description += `\n\n**Lecture Hours:** ${course.lectureHours}`; 60 | } 61 | 62 | if (course.labHours !== null) { 63 | embed.description += `\n**Lab Hours:** ${course.labHours}`; 64 | } 65 | 66 | if (course.notes) { 67 | embed.description += `\n**Notes:** ${course.notes}`; 68 | } 69 | 70 | if (course.prerequisites.length > 0) { 71 | embed.description += `\n**Prerequisites:** ${course.prerequisites 72 | .map((prerequisite) => prerequisite.requirement) 73 | .join(", ")}`; 74 | } 75 | 76 | if (course.corequisites.length > 0) { 77 | embed.description += `\n**Corequisites:** ${course.corequisites 78 | .map((corequisite) => corequisite.requirement) 79 | .join(", ")}`; 80 | } 81 | 82 | if (course.antirequisites.length > 0) { 83 | embed.description += `\n**Antirequisites:** ${course.antirequisites 84 | .map((antirequisite) => antirequisite.requirement) 85 | .join(", ")}`; 86 | } 87 | 88 | await interaction.reply({ 89 | embeds: [embed], 90 | }); 91 | }, 92 | autoComplete: async (interaction: AutocompleteInteraction) => { 93 | let searchString = interaction.options 94 | .getString("code", true) 95 | .toLowerCase(); 96 | let res: Course[]; 97 | if (searchString.length == 0) { 98 | res = await prisma.course.findMany({ 99 | take: 25, 100 | }); 101 | } else { 102 | res = await prisma.course.findMany({ 103 | where: { 104 | code: { 105 | contains: searchString, 106 | }, 107 | }, 108 | take: 25, 109 | }); 110 | } 111 | interaction.respond( 112 | res.map((course) => ({ 113 | name: course.code.toUpperCase(), 114 | value: course.code, 115 | })) 116 | ); 117 | }, 118 | }; 119 | 120 | export {courseModule as command}; 121 | -------------------------------------------------------------------------------- /src/commands/edit.ts: -------------------------------------------------------------------------------- 1 | import {logger} from "@/config"; 2 | import {handleEmbedResponse} from "@/helpers"; 3 | import { 4 | type CacheType, 5 | ChatInputCommandInteraction, 6 | MessageType, 7 | PermissionFlagsBits, 8 | SlashCommandBuilder, 9 | TextChannel, 10 | } from "discord.js"; 11 | 12 | const editModule: CommandType = { 13 | data: new SlashCommandBuilder() 14 | .setName("edit") 15 | .setDescription( 16 | "Edit a message sent by the bot using the say or promote command" 17 | ) 18 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) 19 | .addStringOption((option) => { 20 | option 21 | .setName("message-link") 22 | .setDescription("The link the message to edit") 23 | .setRequired(true); 24 | return option; 25 | }) 26 | .addStringOption((option) => { 27 | option 28 | .setName("new-message") 29 | .setDescription("The new message to replace the old one") 30 | .setRequired(true); 31 | return option; 32 | }), 33 | execute: async (interaction: ChatInputCommandInteraction) => { 34 | try { 35 | const messageLink = interaction.options.getString("message-link", true); 36 | let newMessage = interaction.options 37 | .getString("new-message", true) 38 | .replaceAll("\\n", "\n"); 39 | 40 | const messageLinkRegex = 41 | /https:\/\/discord.com\/channels\/(\d+)\/(\d+)\/(\d+)/; 42 | const messageLinkMatch = messageLink.match(messageLinkRegex); 43 | 44 | if (!messageLinkMatch) { 45 | return await handleEmbedResponse(interaction, true, { 46 | message: `Invalid message link, please try again`, 47 | ephemeral: true, 48 | }); 49 | } 50 | 51 | const [link, guild, channel, messageID] = messageLinkMatch; 52 | 53 | const channelToEdit: TextChannel | undefined = 54 | interaction.guild?.channels.cache.get(channel!) as TextChannel; 55 | 56 | if (!channelToEdit) { 57 | return await handleEmbedResponse(interaction, true, { 58 | message: `Channel not found, please try again.`, 59 | ephemeral: true, 60 | }); 61 | } 62 | 63 | const messageToEdit = await channelToEdit.messages 64 | .fetch(messageID!) 65 | .then((message) => message) 66 | .catch(() => null); 67 | 68 | if (!messageToEdit) { 69 | return await handleEmbedResponse(interaction, true, { 70 | message: `Message not found, please try again.`, 71 | ephemeral: true, 72 | }); 73 | } 74 | 75 | if (messageToEdit.author.id !== interaction.client.user?.id) { 76 | return await handleEmbedResponse(interaction, true, { 77 | message: `You can only edit messages sent by me.`, 78 | ephemeral: true, 79 | }); 80 | } 81 | 82 | if (messageToEdit.type === MessageType.ChatInputCommand) { 83 | return await handleEmbedResponse(interaction, true, { 84 | message: `You can't edit command responses.`, 85 | ephemeral: true, 86 | }); 87 | } 88 | 89 | if ( 90 | messageToEdit.embeds.length > 0 || 91 | messageToEdit.attachments.size > 0 92 | ) { 93 | return await handleEmbedResponse(interaction, true, { 94 | message: `You can only edit messages with text.`, 95 | ephemeral: true, 96 | }); 97 | } 98 | 99 | const oldMessage = messageToEdit.content; 100 | 101 | //check if message is a community prompt 102 | if (oldMessage.split("\n")[0]?.toLowerCase().includes("community prompt")) 103 | newMessage = `${oldMessage.split("\n")[0]}\n${newMessage}`; 104 | 105 | await messageToEdit.edit(newMessage); 106 | 107 | return await handleEmbedResponse(interaction, false, { 108 | message: `I have edited the message from 109 | 110 | ${oldMessage} 111 | 112 | to 113 | 114 | ${newMessage}`, 115 | ephemeral: false, 116 | }); 117 | } catch (error) { 118 | logger.error(`Edit command failed: ${error}`); 119 | } 120 | }, 121 | }; 122 | 123 | export {editModule as command}; 124 | -------------------------------------------------------------------------------- /src/commands/whereis.ts: -------------------------------------------------------------------------------- 1 | import {Config, logger} from "@/config"; 2 | import Fuse from "fuse.js"; 3 | import { 4 | type CacheType, 5 | SlashCommandBuilder, 6 | SlashCommandStringOption, 7 | EmbedBuilder, 8 | AutocompleteInteraction, 9 | ChatInputCommandInteraction, 10 | } from "discord.js"; 11 | import { 12 | FindBuildingByCode, 13 | FindBuildingByName, 14 | ListAllBuildings, 15 | buildings, 16 | fuseOptions, 17 | } from "../helpers/buildings"; 18 | 19 | const whereIsModule: CommandType = { 20 | data: new SlashCommandBuilder() 21 | .setName("whereis") 22 | .setDescription("Show the location of a building on campus") 23 | .addSubcommand((subcommand) => 24 | subcommand.setName("list").setDescription("List all buildings") 25 | ) 26 | .addSubcommand((subcommand) => 27 | subcommand 28 | .setName("search") 29 | .setDescription("Search for a building") 30 | .addStringOption((opt: SlashCommandStringOption) => 31 | opt 32 | .setName("building") 33 | .setDescription("Building name or code") 34 | .setRequired(true) 35 | .setAutocomplete(true) 36 | ) 37 | ), 38 | autoComplete: async (interaction: AutocompleteInteraction) => { 39 | const argValue = interaction.options.getString("building", true); 40 | const fuse = new Fuse(buildings, fuseOptions); 41 | let filtered = buildings.map((building) => building.name); 42 | 43 | // Limit the number of results to 25 to prevent Discord API errors 44 | if (filtered.length > 25) filtered = filtered.slice(0, 25); 45 | 46 | if (argValue.length > 0) 47 | filtered = fuse.search(argValue).map((result) => result.item.name); 48 | 49 | await interaction.respond( 50 | filtered.map((choice) => ({name: choice, value: choice})) 51 | ); 52 | }, 53 | execute: async (interaction: ChatInputCommandInteraction) => { 54 | try { 55 | const subcommand = interaction.options.getSubcommand(); 56 | const imageDirectoryURL = Config.image_directory_url; 57 | 58 | if (subcommand === "list") { 59 | const buildingsList = ListAllBuildings(); 60 | const embed = new EmbedBuilder() 61 | .setTitle("Building List") 62 | .addFields( 63 | {name: "Code", value: buildingsList.codes, inline: true}, 64 | {name: "Full Names", value: buildingsList.names, inline: true} 65 | ); 66 | 67 | return await interaction.reply({embeds: [embed]}); 68 | } else if (subcommand === "search") { 69 | const args = interaction.options.getString("building", true); 70 | const buildingCode = args.toUpperCase(); 71 | 72 | // Check if the "args" string is a building's code 73 | const buildingFound = FindBuildingByCode(buildingCode); 74 | 75 | if (buildingFound.length !== 0) { 76 | const embed = new EmbedBuilder() 77 | .setTitle("Building Search") 78 | .setDescription(`${buildingFound} (#${buildingCode}) `) 79 | .setImage(`${imageDirectoryURL}/${buildingCode}.png`); 80 | 81 | return await interaction.reply({embeds: [embed]}); 82 | } else { 83 | // If the argument matches a building name 84 | let resArr = FindBuildingByName(args); 85 | if (resArr.length > 0) { 86 | let bestRes = resArr[0]; 87 | 88 | const embed = new EmbedBuilder() 89 | .setTitle("Building Search") 90 | .setDescription(`${bestRes?.item.name} (${bestRes?.item.code}) `) 91 | .setImage(`${imageDirectoryURL}/${bestRes?.item.code}.png`); 92 | 93 | return await interaction.reply({embeds: [embed]}); 94 | } else { 95 | return interaction.reply({ 96 | content: "Building could not be found.", 97 | }); 98 | } 99 | } 100 | } else { 101 | return interaction.reply({ 102 | content: "Invalid subcommand.", 103 | }); 104 | } 105 | } catch (error) { 106 | logger.error(`Whereis command failed: ${error}`); 107 | } 108 | }, 109 | }; 110 | 111 | export {whereIsModule as command}; 112 | -------------------------------------------------------------------------------- /src/commands/timeout.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@/config"; 2 | import { handleEmbedResponse } from "@/helpers"; 3 | import { 4 | type CacheType, 5 | ChatInputCommandInteraction, 6 | ButtonInteraction, 7 | SlashCommandBuilder, 8 | PermissionFlagsBits, 9 | ButtonBuilder, 10 | ButtonStyle, 11 | ActionRowBuilder, 12 | ComponentType, 13 | TextChannel, 14 | } from "discord.js"; 15 | 16 | const TIMEOUT_OPTIONS = [ 17 | { label: "10m", minutes: 10 }, 18 | { label: "1h", minutes: 60 }, 19 | { label: "3h", minutes: 180 }, 20 | { label: "6h", minutes: 360 }, 21 | { label: "1d", minutes: 1440 }, 22 | { label: "2d", minutes: 2880 }, 23 | { label: "1w", minutes: 10080 }, 24 | ]; 25 | 26 | export function buildButtonRow(buttons: ButtonBuilder[]) { 27 | return new ActionRowBuilder().addComponents(buttons); 28 | } 29 | 30 | const timeoutUserModule: CommandType = { 31 | data: new SlashCommandBuilder() 32 | .setName("timeout") 33 | .setDescription("Timeout a user for a certain duration") 34 | .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) 35 | .addUserOption((option) => 36 | option.setName("user").setDescription("The user to timeout").setRequired(true) 37 | ) 38 | .addStringOption((option) => 39 | option 40 | .setName("duration") 41 | .setDescription("Duration of the timeout") 42 | .setRequired(true) 43 | .addChoices(...TIMEOUT_OPTIONS.map((o) => ({ name: o.label, value: o.minutes.toString() }))) 44 | ) 45 | .addStringOption((option) => 46 | option.setName("reason").setDescription("Reason for the timeout").setRequired(false) 47 | ), 48 | execute: async (interaction: ChatInputCommandInteraction) => { 49 | try { 50 | const user = interaction.options.getUser("user", true); 51 | const guildMember = await interaction.guild?.members.fetch(user.id); 52 | const channel = interaction.channel as TextChannel; 53 | 54 | if (!channel || !channel.isTextBased()) { 55 | return handleEmbedResponse(interaction, true, { 56 | message: "This command must be used in a text channel.", 57 | ephemeral: true, 58 | }); 59 | } 60 | 61 | if (!guildMember) { 62 | return handleEmbedResponse(interaction, true, { 63 | message: "User not found in this server.", 64 | ephemeral: true, 65 | }); 66 | } 67 | 68 | const durationMinutes = parseInt(interaction.options.getString("duration", true)); 69 | const reason = interaction.options.getString("reason") || "No reason provided"; 70 | const durationMs = durationMinutes * 60 * 1000; 71 | 72 | const confirmRow = buildButtonRow([ 73 | new ButtonBuilder() 74 | .setCustomId(`confirmTimeout_${user.id}`) 75 | .setLabel(`Timeout ${user.tag} for ${durationMinutes}m`) 76 | .setStyle(ButtonStyle.Danger), 77 | new ButtonBuilder() 78 | .setCustomId(`cancelTimeout_${user.id}`) 79 | .setLabel("Cancel") 80 | .setStyle(ButtonStyle.Secondary), 81 | ]); 82 | 83 | await interaction.reply({ 84 | content: `⚠️ You are about to timeout <@${user.id}> for **${durationMinutes} minute(s)**. Reason: ${reason}`, 85 | components: [confirmRow], 86 | ephemeral: true, 87 | }); 88 | 89 | const collector = channel.createMessageComponentCollector({ 90 | componentType: ComponentType.Button, 91 | time: 30000, 92 | filter: (i) => i.user.id === interaction.user.id, 93 | }); 94 | 95 | collector?.on("collect", async (btn: ButtonInteraction) => { 96 | if (!guildMember) return; 97 | 98 | if (btn.customId === `confirmTimeout_${user.id}`) { 99 | await guildMember.timeout(durationMs, reason).catch((err) => { 100 | logger.error(`Failed to timeout ${user.id}: ${err}`); 101 | return handleEmbedResponse(interaction, true, { 102 | message: "Failed to timeout the user.", 103 | ephemeral: true, 104 | }); 105 | }); 106 | 107 | await btn.update({ 108 | content: `✅ <@${user.id}> has been timed out for **${durationMinutes} minute(s)**.`, 109 | components: [], 110 | }); 111 | } else if (btn.customId === `cancelTimeout_${user.id}`) { 112 | await btn.update({ 113 | content: "⛔ Timeout cancelled.", 114 | components: [], 115 | }); 116 | } 117 | 118 | collector.stop(); 119 | }); 120 | } catch (error) { 121 | logger.error(`Timeout command failed: ${error}`); 122 | await handleEmbedResponse(interaction, true, { 123 | message: "An error occurred while trying to timeout the user.", 124 | ephemeral: true, 125 | }); 126 | } 127 | }, 128 | }; 129 | 130 | export { timeoutUserModule as command }; 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UWindsor CSS Discord Bot 2 | 3 | A multi-purpose Discord bot built for the University of Windsor CS program's Discord server. The bot enhances the server experience with a range of features, including the ability to locate campus buildings, generate LaTeX equations, provide frequently requested links, assign academic year roles to students, assist staff in message management tasks, offer fun community commands, and much more! 4 | 5 | ## Getting Started 6 | 7 | _**Warning**: This bot was designed to work on a Unix-based OS. We strongly recommend installing with docker._ 8 | 9 | 10 | ### Bot Config (`config.json`) 11 | 12 | To get the bot up and running, you need to create a `config.json` which contains the necessary configuration for the bot to function. 13 | 14 | To set up the `config.json` file, you can copy the provided `config.example.json` file: 15 | 16 | ```sh 17 | cp config.example.json config.json 18 | ``` 19 | 20 | #### Root Configuration 21 | 22 | - `environment` 23 | - **Type**: `development` | `production` 24 | - **Description**: Specifies the environment in which the application is running. 25 | 26 | - `debug` 27 | - **Type**: `boolean` 28 | - **Description**: Determines whether the application should run in debug mode. If set to `true`, additional debug information may be logged. 29 | 30 | - `seed` 31 | - **Type**: `boolean` 32 | - **Description**: Specifies whether the database should be seeded with initial sample data. 33 | 34 | - `image_directory_url` 35 | - **Type**: `string` 36 | - **Description**: URL where images related to buildings are hosted. 37 | 38 | --- 39 | 40 | #### Discord Server Configuration 41 | Configuration for Discord API integration. 42 | 43 | - `discord.api_version` 44 | - **Type**: `string` 45 | - **Description**: Specifies the Discord API version used by the application. 46 | 47 | - `discord.api_token` 48 | - **Type**: `string` 49 | - **Description**: Token used to authenticate with the Discord API. This field must be populated with a valid token for proper functionality. 50 | 51 | - `discord.client_id` 52 | - **Type**: `string` 53 | - **Description**: Client ID for the Discord application. 54 | 55 | - `discord.guild_id` 56 | - **Type**: `string` 57 | - **Description**: Guild ID (Server ID) where the bot will operate. 58 | 59 | - `discord.status` 60 | - **Type**: `string` 61 | - **Description**: Status message displayed for the bot on Discord. 62 | 63 | --- 64 | 65 | #### Google Search Configuration 66 | Configuration for Google Search API. 67 | 68 | - `google.search_key` 69 | - **Type**: `string` 70 | - **Description**: API key used to authenticate with the Google Search API. 71 | 72 | - `google.search_id` 73 | - **Type**: `string` 74 | - **Description**: Search engine ID used in conjunction with the Google Search API. 75 | 76 | --- 77 | 78 | #### Features Configuration 79 | 80 | - `features` 81 | - **Type**: `object` 82 | - **Description**: A map of bot features, where you can enable (`true`) or disable (`false`) specific features by setting their values accordingly. 83 | 84 | --- 85 | 86 | #### Roles Configuration 87 | Defines roles that can be assigned to users. 88 | 89 | - `roles.years` 90 | - **Type**: `object` 91 | - **Description**: The role IDs for each academic year. 92 | 93 | - `roles.other` 94 | - **Type**: `object` 95 | - **Description**: The role IDs for other miscellaneous roles. 96 | 97 | --- 98 | 99 | #### Pin Feature Configuration 100 | Controls pinning behavior in channels. 101 | 102 | - `pin.enabled` 103 | - **Type**: `boolean` 104 | - **Description**: Determines if the pinning feature is enabled. 105 | 106 | - `pin.count` 107 | - **Type**: `number` 108 | - **Description**: Specifies how many pin reactions are required to pin a message. 109 | 110 | - `pin.general_count` 111 | - **Type**: `number` 112 | - **Description**: Specifies how many pin reactions are required to pin a message in the general channel. 113 | 114 | - `pin.categories` 115 | - **Type**: `array of strings` 116 | - **Description**: List of categories where pinning is enabled. 117 | 118 | ## Build and Run with Docker Compose 119 | 120 | Docker Compose simplifies the process of setting up the bot by creating and managing containers for both the Node.js application and the accompanying PostgreSQL database. It utilizes the variables from the `.env` file for configuration. 121 | 122 | 1. **Build or rebuild the service:** 123 | ```sh 124 | docker compose build 125 | ``` 126 | 127 | 2. **Create and start containers:** 128 | ```sh 129 | docker compose up 130 | ``` 131 | 132 | ### Stopping the Bot 133 | 134 | If you would like to halt the bot's operation, you can stop the container with: 135 | ```sh 136 | docker compose down 137 | ``` 138 | 139 | ## Further Documentation 140 | 141 | - [FEATURES.md](docs/FEATURES.md) - a description of the bot's features 142 | - [ARCHITECTURE.md](docs/ARCHITECTURE.md) - a high level view of bot architecture 143 | - [CONTRIBUTING.md](docs/CONTRIBUTING.md) - things to keep in mind while contributing 144 | 145 | ## Contribution 146 | 147 | **By contributing to this software in any way, you agree to the terms laid out in [CONTRIBUTING.md](docs/CONTRIBUTING.md)** 148 | 149 | A huge thank you to all our contributors, who put lots of time 🕜 and care ❤️ into making this bot what it is. 150 | 151 | Feel free to contribute, suggest new features, or report any issues. 152 | -------------------------------------------------------------------------------- /src/commands/link.ts: -------------------------------------------------------------------------------- 1 | import {logger, prisma} from "@/config"; 2 | import { 3 | ChatInputCommandInteraction, 4 | SlashCommandBuilder, 5 | SlashCommandStringOption, 6 | type CacheType, 7 | AutocompleteInteraction, 8 | inlineCode, 9 | Colors, 10 | EmbedBuilder, 11 | ButtonBuilder, 12 | ButtonStyle, 13 | ActionRowBuilder, 14 | ComponentType, 15 | ButtonInteraction, 16 | } from "discord.js"; 17 | import type {Link} from "@prisma/client"; 18 | import {handleEmbedResponse, standardizeLinkName} from "@/helpers"; 19 | 20 | const linkModule: CommandType = { 21 | data: new SlashCommandBuilder() 22 | .setName("link") 23 | .setDescription("Get a link") 24 | .addSubcommand((subcommand) => 25 | subcommand 26 | .setName("get") 27 | .setDescription("Get a link") 28 | .addStringOption((option: SlashCommandStringOption) => 29 | option 30 | .setName("link") 31 | .setDescription("Select a link to get") 32 | .setRequired(true) 33 | .setMaxLength(20) 34 | .setMinLength(3) 35 | .setAutocomplete(true) 36 | ) 37 | ) 38 | .addSubcommand((subcommand) => 39 | subcommand 40 | .setName("list") 41 | .setDescription("List all links") 42 | .addIntegerOption((option) => 43 | option 44 | .setName("page") 45 | .setDescription("Which page of links would you like to see?") 46 | .setMinValue(1) 47 | .setMaxValue(100) 48 | .setRequired(false) 49 | ) 50 | ), 51 | execute: async (interaction: ChatInputCommandInteraction) => { 52 | try { 53 | const subCommand = interaction.options.getSubcommand(true); 54 | 55 | if (subCommand === "get") { 56 | const choice = interaction.options.getString("link", true); 57 | const id = standardizeLinkName(choice); 58 | const res = await prisma.link.findFirst({ 59 | where: { 60 | id: { 61 | contains: id, 62 | }, 63 | }, 64 | }); 65 | if (res) { 66 | await interaction.reply({ 67 | content: res.url, 68 | }); 69 | } else { 70 | return await handleEmbedResponse(interaction, true, { 71 | message: `I couldn't find a link with the name ${inlineCode( 72 | choice 73 | )}. Please try again.`, 74 | }); 75 | } 76 | } else if (subCommand === "list") { 77 | const n = await prisma.link.count(); 78 | if (n == 0) 79 | return await handleEmbedResponse(interaction, true, { 80 | message: "There are no links yet.", 81 | }); 82 | 83 | const linksPerPage = 5; 84 | const pages = Math.ceil(n / linksPerPage); 85 | let page = interaction.options.getInteger("page") ?? 1; 86 | 87 | if (page < 1 || page > pages) 88 | return await handleEmbedResponse(interaction, true, { 89 | message: `Invalid page number. Please enter a number between 1 and ${pages}.`, 90 | }); 91 | 92 | let pageContent = await prisma.link.findMany({ 93 | skip: (page - 1) * linksPerPage, 94 | take: linksPerPage, 95 | }); 96 | 97 | const backId = "back"; 98 | const forwardId = "forward"; 99 | const backButton = new ButtonBuilder({ 100 | style: ButtonStyle.Secondary, 101 | label: "Back", 102 | emoji: "⬅️", 103 | customId: backId, 104 | }); 105 | const forwardButton = new ButtonBuilder({ 106 | style: ButtonStyle.Secondary, 107 | label: "Forward", 108 | emoji: "➡️", 109 | customId: forwardId, 110 | }); 111 | 112 | const handleButtonsUpdate = () => { 113 | backButton.setDisabled(page === 1); 114 | forwardButton.setDisabled(page === pages); 115 | }; 116 | 117 | const linkListEmbed = () => { 118 | return new EmbedBuilder() 119 | .setTitle(":link: Links List") 120 | .setTimestamp() 121 | .addFields( 122 | pageContent.map((link, i) => ({ 123 | name: `${(page - 1) * linksPerPage + i + 1}. ${link.name}`, 124 | value: `► [Link](${link.url})\n**description**: ${link.description}\n‎ `, 125 | })) 126 | ) 127 | .setColor(Colors.Blue) 128 | .setFooter({ 129 | text: `Page ${page} of ${pages}`, 130 | }); 131 | }; 132 | 133 | const row = new ActionRowBuilder().setComponents([ 134 | backButton, 135 | forwardButton, 136 | ]); 137 | 138 | handleButtonsUpdate(); 139 | const response = await interaction.reply({ 140 | embeds: [linkListEmbed()], 141 | components: [row], 142 | }); 143 | 144 | const buttonFilter = (i: ButtonInteraction) => { 145 | if (i.user.id !== interaction.user.id) { 146 | i.reply({ 147 | content: "You are not allowed to interact with this message!", 148 | ephemeral: true, 149 | }); 150 | return false; 151 | } 152 | return true; 153 | }; 154 | 155 | const collector = response.createMessageComponentCollector({ 156 | filter: buttonFilter, 157 | componentType: ComponentType.Button, 158 | time: 120000, 159 | }); 160 | collector.on("collect", async (i) => { 161 | i.customId === backId ? (page -= 1) : (page += 1); 162 | pageContent = await prisma.link.findMany({ 163 | skip: (page - 1) * linksPerPage, 164 | take: linksPerPage, 165 | }); 166 | handleButtonsUpdate(); 167 | await i.update({ 168 | embeds: [linkListEmbed()], 169 | components: [row], 170 | }); 171 | }); 172 | 173 | collector.once("end", async () => { 174 | try { 175 | await response.edit({ 176 | embeds: [linkListEmbed()], 177 | components: [], 178 | }); 179 | } catch (error) {} 180 | }); 181 | } 182 | } catch (error) { 183 | logger.error(`Link command failed: ${error}`); 184 | } 185 | }, 186 | autoComplete: async (interaction: AutocompleteInteraction) => { 187 | let searchString = 188 | interaction.options.getString("link", true).toLowerCase() ?? ""; 189 | let res: Link[]; 190 | if (searchString.length == 0) { 191 | res = await prisma.link.findMany({ 192 | take: 25, 193 | }); 194 | } else { 195 | res = await prisma.link.findMany({ 196 | where: { 197 | name: { 198 | contains: searchString, 199 | }, 200 | }, 201 | take: 25, 202 | }); 203 | } 204 | interaction.respond( 205 | res.map((link) => ({ 206 | name: link.name, 207 | value: link.name, 208 | })) 209 | ); 210 | }, 211 | }; 212 | 213 | export {linkModule as command}; 214 | -------------------------------------------------------------------------------- /src/commands/linkAdmin.ts: -------------------------------------------------------------------------------- 1 | import {logger, prisma} from "@/config"; 2 | import { 3 | inlineCode, 4 | SlashCommandBuilder, 5 | SlashCommandStringOption, 6 | type CacheType, 7 | AutocompleteInteraction, 8 | ChatInputCommandInteraction, 9 | ActionRowBuilder, 10 | PermissionFlagsBits, 11 | ButtonBuilder, 12 | ButtonStyle, 13 | Colors, 14 | } from "discord.js"; 15 | import type {Link} from "@prisma/client"; 16 | import {createEmbed, handleEmbedResponse, standardizeLinkName} from "@/helpers"; 17 | 18 | const linkAdminModule: CommandType = { 19 | data: new SlashCommandBuilder() 20 | .setName("link-admin") 21 | .setDescription("Manage the links") 22 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) 23 | .addSubcommand((subcommand) => 24 | subcommand 25 | .setName("create") 26 | .setDescription("Add a new link") 27 | .addStringOption((option) => 28 | option 29 | .setName("name") 30 | .setDescription("What is the name of the link?") 31 | .setMaxLength(20) 32 | .setMinLength(3) 33 | .setRequired(true) 34 | ) 35 | .addStringOption((option) => 36 | option 37 | .setName("description") 38 | .setDescription("What's this link for?") 39 | .setMaxLength(100) 40 | .setMinLength(3) 41 | .setRequired(true) 42 | ) 43 | .addStringOption((option) => 44 | option 45 | .setName("url") 46 | .setDescription("What is the url of the link?") 47 | .setRequired(true) 48 | ) 49 | ) 50 | .addSubcommand((subcommand) => 51 | subcommand 52 | .setName("delete") 53 | .setDescription("Delete a link") 54 | .addStringOption((option: SlashCommandStringOption) => 55 | option 56 | .setName("link") 57 | .setDescription("Select a link to delete") 58 | .setRequired(true) 59 | .setAutocomplete(true) 60 | ) 61 | ), 62 | execute: async (interaction: ChatInputCommandInteraction) => { 63 | try { 64 | if (!interaction.isCommand()) return; 65 | const subcommand = interaction.options.getSubcommand(); 66 | if (subcommand === "create") { 67 | const name = interaction.options.getString("name", true); 68 | const description = interaction.options.getString("description", true); 69 | const url = interaction.options.getString("url", true); 70 | 71 | const asciiRegex = new RegExp(/^[\x00-\x7F]*$/); 72 | if (!asciiRegex.test(name)) { 73 | return await handleEmbedResponse(interaction, true, { 74 | message: `**${name}** is not a valid name, please use only ASCII characters.`, 75 | }); 76 | } 77 | 78 | const id = standardizeLinkName(name); 79 | 80 | //check if the link already exists in the database 81 | const link = await prisma.link.findUnique({ 82 | where: { 83 | id, 84 | }, 85 | }); 86 | 87 | // check if the link already exists 88 | if (link !== null) { 89 | return await handleEmbedResponse(interaction, true, { 90 | message: `${inlineCode( 91 | name 92 | )} already exists, please try another name.`, 93 | }); 94 | } 95 | 96 | //URL validation 97 | const urlRegex = new RegExp( 98 | /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/ 99 | ); 100 | if (!urlRegex.test(url)) { 101 | return await handleEmbedResponse(interaction, true, { 102 | message: `${inlineCode( 103 | url 104 | )} is not a valid URL, please make sure it starts with http:// or https:// and ends with a domain.`, 105 | }); 106 | } 107 | 108 | //create the link 109 | const createdLink = await prisma.link.create({ 110 | data: { 111 | id, 112 | name, 113 | description, 114 | url, 115 | authorID: interaction.user.id, 116 | authorUsername: interaction.user.username, 117 | authorDisplayName: interaction.user.displayName, 118 | }, 119 | }); 120 | 121 | //check if the link was created successfully 122 | if (createdLink === undefined) { 123 | return await handleEmbedResponse(interaction, true, { 124 | message: `**${name}** could not be created, please try again.`, 125 | }); 126 | } 127 | 128 | //send the response 129 | return await handleEmbedResponse(interaction, false, { 130 | message: `Link ${inlineCode(name)} created successfully.`, 131 | ephemeral: false, 132 | }); 133 | } else if (subcommand === "delete") { 134 | const searchString = interaction.options.getString("link", true); 135 | const id = standardizeLinkName(searchString); 136 | 137 | //Get the link 138 | const link = await prisma.link.findUnique({ 139 | where: { 140 | id, 141 | }, 142 | }); 143 | 144 | //check if the link exists 145 | if (link === undefined || link === null) { 146 | return await handleEmbedResponse(interaction, true, { 147 | message: "I couldn't find that link, please try another one.", 148 | }); 149 | } 150 | 151 | const deleteBtn = new ButtonBuilder() 152 | .setCustomId("delete") 153 | .setLabel("Delete") 154 | .setStyle(ButtonStyle.Danger); 155 | 156 | const cancelBtn = new ButtonBuilder() 157 | .setCustomId("cancel") 158 | .setLabel("Cancel") 159 | .setStyle(ButtonStyle.Secondary); 160 | 161 | const response = await interaction.reply({ 162 | embeds: [ 163 | createEmbed( 164 | ":bangbang: Confirm Deletion", 165 | `Are you sure you want to delete the following link?\n 166 | **name:** ${link.name}\n 167 | **description:** ${link.description}\n 168 | **url:** ${inlineCode(link.url)}\n 169 | `, 170 | Colors.Red 171 | ), 172 | ], 173 | components: [ 174 | new ActionRowBuilder().addComponents( 175 | cancelBtn, 176 | deleteBtn 177 | ), 178 | ], 179 | }); 180 | 181 | const buttonFilter = (i: any) => { 182 | if (i.user.id !== interaction.user.id) { 183 | i.reply({ 184 | content: "You are not allowed to interact with this message!", 185 | ephemeral: true, 186 | }); 187 | return false; 188 | } 189 | return true; 190 | }; 191 | 192 | try { 193 | const confirmation = await response.awaitMessageComponent({ 194 | filter: buttonFilter, 195 | time: 30000, 196 | }); 197 | 198 | if (confirmation.customId === "delete") { 199 | await prisma.link.delete({ 200 | where: { 201 | id, 202 | }, 203 | }); 204 | await confirmation.update({ 205 | embeds: [ 206 | createEmbed( 207 | ":white_check_mark: Link Deleted", 208 | `Link **${searchString}** was deleted successfully.`, 209 | Colors.Green 210 | ), 211 | ], 212 | components: [], 213 | }); 214 | } else if (confirmation.customId === "cancel") { 215 | await confirmation.update({ 216 | embeds: [ 217 | createEmbed( 218 | "Deletion Cancelled", 219 | `Link **${searchString}** was not deleted.`, 220 | Colors.Grey 221 | ), 222 | ], 223 | components: [], 224 | }); 225 | } 226 | } catch (e) { 227 | await interaction.editReply({ 228 | embeds: [ 229 | createEmbed( 230 | ":x: Deletion Cancelled", 231 | "Confirmation not received within 30 seconds, cancelling.", 232 | Colors.Red 233 | ), 234 | ], 235 | components: [], 236 | }); 237 | } 238 | } 239 | } catch (error: any) { 240 | // Don't log if the message is not being found due to being deleted 241 | if (error.code === 10008) return; 242 | logger.error(`Link command failed: ${error}`); 243 | } 244 | }, 245 | autoComplete: async (interaction: AutocompleteInteraction) => { 246 | const subcommand = interaction.options.getSubcommand(); 247 | if (subcommand === "delete") { 248 | const searchString = 249 | interaction.options.getString("link", true).toLowerCase() ?? ""; 250 | 251 | let res: Link[]; 252 | if (searchString.length == 0) { 253 | res = await prisma.link.findMany(); 254 | } else { 255 | res = await prisma.link.findMany({ 256 | where: { 257 | name: { 258 | contains: searchString, 259 | }, 260 | }, 261 | }); 262 | } 263 | interaction.respond( 264 | res.map((link) => ({ 265 | name: link.name, 266 | value: link.name, 267 | })) 268 | ); 269 | } 270 | }, 271 | }; 272 | 273 | export {linkAdminModule as command}; 274 | -------------------------------------------------------------------------------- /src/commands/deleteMsgs.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@/config"; 2 | import { handleEmbedResponse } from "@/helpers"; 3 | import { 4 | type CacheType, 5 | ChatInputCommandInteraction, 6 | ActionRowBuilder, 7 | StringSelectMenuBuilder, 8 | ButtonBuilder, 9 | ButtonStyle, 10 | PermissionFlagsBits, 11 | SlashCommandBuilder, 12 | TextChannel, 13 | User, 14 | Message, 15 | } from "discord.js"; 16 | 17 | const TIMEOUT_DEFAULT_HOURS = 24; 18 | 19 | function buildSelectMenu(messages: Message[]) { 20 | return new StringSelectMenuBuilder() 21 | .setCustomId("selectMessages") 22 | .setPlaceholder("Select messages to delete") 23 | .setMaxValues(messages.length) 24 | .addOptions( 25 | messages.map((msg, idx) => ({ 26 | label: msg.content?.slice(0, 80) || "[Attachment/Embed]", 27 | value: msg.id, 28 | description: `Sent: ${msg.createdAt.toLocaleString()}; Channel: ${(msg.channel as TextChannel).name}`, 29 | })) 30 | ); 31 | } 32 | 33 | function buildButtonRow(buttons: ButtonBuilder[]) { 34 | return new ActionRowBuilder().addComponents(buttons); 35 | } 36 | 37 | function buildSelectMenuRow(selectMenu: StringSelectMenuBuilder) { 38 | return new ActionRowBuilder().addComponents(selectMenu); 39 | } 40 | 41 | async function getChannelMessages(channel: TextChannel, limit: number): Promise { 42 | const fetchedMessages: Message[] = []; 43 | let lastId: string | undefined; 44 | 45 | while (fetchedMessages.length < limit) { 46 | const options: { limit: number; before?: string } = { 47 | limit: Math.min(100, limit - fetchedMessages.length), 48 | }; 49 | if (lastId) { 50 | options.before = lastId; 51 | } 52 | 53 | const messages = await channel.messages.fetch(options); 54 | if (messages.size === 0) { 55 | break; 56 | } 57 | 58 | fetchedMessages.push(...messages.values()); 59 | lastId = messages.last()?.id; 60 | } 61 | 62 | return fetchedMessages; 63 | }; 64 | 65 | const createChannelMessagesMap = (messages: Message[]): Map => { 66 | const channelMessagesMap = new Map(); 67 | messages.forEach((msg) => { 68 | const channelId = msg.channel.id; 69 | if (!channelMessagesMap.has(channelId)) { 70 | channelMessagesMap.set(channelId, []); 71 | } 72 | channelMessagesMap.get(channelId)!.push(msg); 73 | }); 74 | return channelMessagesMap; 75 | }; 76 | 77 | const deleteMessagesFromMap = async (map: Map, interaction: ChatInputCommandInteraction) => { 78 | for (const [channelId, msgs] of map.entries()) { 79 | const ch = interaction.guild?.channels.cache.get(channelId) as TextChannel; 80 | if (ch) { 81 | await ch.bulkDelete(msgs.map(item => item.id)); 82 | } 83 | } 84 | } 85 | 86 | const deleteMsgsModule: CommandType = { 87 | data: new SlashCommandBuilder() 88 | .setName("delete-msgs") 89 | .setDescription("Select recent messages from a user to delete") 90 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) 91 | .addUserOption((option) => 92 | option.setName("user-id").setDescription("User whose messages to delete").setRequired(true) 93 | ) 94 | .addChannelOption((option) => 95 | option.setName("channel-id").setDescription("Channel to scan (optional)").setRequired(false) 96 | ) 97 | .addIntegerOption((option) => 98 | option.setName("limit").setDescription("Max messages to display (up to 25)").setRequired(false) 99 | ) 100 | .addStringOption((option) => 101 | option 102 | .setName("range") 103 | .setDescription("Fetch messages from a certain period") 104 | .setRequired(false) 105 | .addChoices( 106 | { name: "1 hour", value: "1" }, 107 | { name: "3 hours", value: "3" }, 108 | { name: "6 hours", value: "6" }, 109 | { name: "12 hours", value: "12" }, 110 | { name: "1S day", value: "24" }, 111 | { name: "2 days", value: "48" }, 112 | { name: "1 week", value: "168" } 113 | ) 114 | ), 115 | execute: async (interaction: ChatInputCommandInteraction) => { 116 | try { 117 | const memberUser = interaction.options.getUser("user-id", true) as User; 118 | const userId = memberUser.id; 119 | 120 | let textChannels: TextChannel[] = [] 121 | 122 | const channel = 123 | (interaction.options.getChannel("channel-id") as TextChannel) || 124 | (interaction.channel as TextChannel); 125 | 126 | const channelFromOption = interaction.options.getChannel("channel-id"); 127 | 128 | if (channelFromOption) { 129 | textChannels = [channelFromOption as TextChannel]; 130 | } else { 131 | const channels = await interaction.guild?.channels?.fetch(); 132 | textChannels = channels!.filter((ch) => ch!.isTextBased()).map((ch) => ch as TextChannel); 133 | } 134 | 135 | if (!textChannels?.length) { 136 | return await handleEmbedResponse(interaction, true, { 137 | message: "Invalid or missing channel.", 138 | ephemeral: true, 139 | }); 140 | } 141 | 142 | const limit = Math.min(interaction.options.getInteger("limit") || 10, 25); 143 | const hours = parseInt(interaction.options.getString("range") || TIMEOUT_DEFAULT_HOURS.toString()); 144 | const cutoff = Date.now() - hours * 60 * 60 * 1000; 145 | 146 | let messages: Message[] = []; 147 | 148 | for (const ch of textChannels) { 149 | const msgs = await getChannelMessages(ch, 100); 150 | messages = messages.concat(msgs); 151 | } 152 | 153 | let userMessages = messages 154 | .filter((m) => m.author.id === userId && m.createdTimestamp >= cutoff) 155 | .sort((a, b) => b.createdTimestamp - a.createdTimestamp) 156 | .slice(0, limit); 157 | 158 | if (userMessages.length === 0) { 159 | return await handleEmbedResponse(interaction, true, { 160 | message: `No messages from <@${userId}> in the last ${hours} hour(s).`, 161 | ephemeral: true, 162 | }); 163 | } 164 | 165 | const selectRow = buildSelectMenuRow(buildSelectMenu(userMessages)); 166 | const buttonRow = buildButtonRow([ 167 | new ButtonBuilder().setCustomId("selectAll").setLabel("Select All").setStyle(ButtonStyle.Primary) 168 | ]); 169 | 170 | await interaction.reply({ 171 | content: "Select messages to delete:", 172 | components: [selectRow, buttonRow], 173 | ephemeral: true, 174 | }); 175 | 176 | let selectedMessages: typeof userMessages = []; 177 | 178 | const collector = channel.createMessageComponentCollector({ 179 | time: 60000, 180 | filter: (i) => i.user.id === interaction.user.id, 181 | }); 182 | 183 | collector.on("collect", async (i) => { 184 | if (i.isButton() && i.customId === "selectAll") { 185 | selectedMessages = userMessages; 186 | await i.update({ 187 | content: `You selected **all** messages from <@${userId}>:\n\n` + 188 | selectedMessages.map((m, idx) => `**${idx + 1}.** ${m.content?.slice(0, 100) || "[Attachment]"}`).join("\n"), 189 | components: [ 190 | buildButtonRow([ 191 | new ButtonBuilder().setCustomId("confirmDelete").setLabel("Confirm Delete").setStyle(ButtonStyle.Danger), 192 | new ButtonBuilder().setCustomId("cancelDelete").setLabel("Cancel").setStyle(ButtonStyle.Secondary), 193 | ]), 194 | ], 195 | }); 196 | return; 197 | } 198 | 199 | if (i.isStringSelectMenu() && i.customId === "selectMessages") { 200 | const selectedIds = i.values; 201 | selectedMessages = userMessages.filter((m) => selectedIds.includes(m.id)); 202 | 203 | await i.update({ 204 | content: `You selected **${selectedMessages.length}** message(s) from <@${userId}>:\n\n` + 205 | selectedMessages.map((m, idx) => `**${idx + 1}.** ${m.content?.slice(0, 100) || "[Attachment]"}`).join("\n"), 206 | components: [ 207 | buildButtonRow([ 208 | new ButtonBuilder().setCustomId("confirmDelete").setLabel("Confirm Delete").setStyle(ButtonStyle.Danger), 209 | new ButtonBuilder().setCustomId("cancelDelete").setLabel("Cancel").setStyle(ButtonStyle.Secondary), 210 | ]), 211 | ], 212 | }); 213 | return; 214 | } 215 | 216 | if (i.isButton() && (i.customId === "confirmDelete" || i.customId === "cancelDelete")) { 217 | await i.deferUpdate(); 218 | if (i.customId === "confirmDelete") { 219 | const map = createChannelMessagesMap(selectedMessages); 220 | await deleteMessagesFromMap(map, interaction); 221 | await i.editReply({ content: `🗑️ Deleted **${selectedMessages.length}** message(s) from <@${userId}>.`, components: [] }); 222 | } else { 223 | await i.editReply({ content: "❌ Deletion cancelled.", components: [] }); 224 | } 225 | collector.stop(); 226 | return; 227 | } 228 | }); 229 | } catch (err) { 230 | logger.error(`Select messages command failed: ${err}`); 231 | await handleEmbedResponse(interaction, true, { 232 | message: "Failed to fetch or delete messages.", 233 | ephemeral: true, 234 | }); 235 | } 236 | }, 237 | }; 238 | 239 | export { deleteMsgsModule as command }; 240 | -------------------------------------------------------------------------------- /src/commands/minigame.ts: -------------------------------------------------------------------------------- 1 | import {logger, Config} from "@/config"; 2 | import {readFile} from "fs"; 3 | import { 4 | EmbedBuilder, 5 | ActionRowBuilder, 6 | ButtonBuilder, 7 | } from "@discordjs/builders"; 8 | 9 | import { 10 | CacheType, 11 | SlashCommandBuilder, 12 | GuildMember, 13 | ChatInputCommandInteraction, 14 | Message, 15 | Colors, 16 | ComponentType, 17 | Collection, 18 | } from "discord.js"; 19 | 20 | // game config 21 | const WORDBOMB_TURN_TIME = 10_000; // time each player gets in their turn 22 | const JOIN_TIME = 8_000; // amt of time for people to join 23 | const REQUIRED_PLAYERS = 2; // (CHANGE IN PRODUCTION) required players in order for the game to start and continue 24 | let WORD_LIST: string[] = []; 25 | // hello 26 | // hi 🙂 27 | 28 | function InitWordList(): Promise { 29 | return new Promise((resolve, reject) => { 30 | readFile("data/wordlist.txt", (error, text) => { 31 | if (error) { 32 | logger.debug(error); 33 | reject(error); 34 | return; 35 | } 36 | const words = text 37 | .toString() 38 | .split("\n") 39 | .map((dWord) => dWord.trim()); 40 | resolve(words); 41 | }); 42 | }); 43 | } 44 | 45 | function getSubstring(words: string[]): string { 46 | const eligibleWords = words.filter((word) => word.length >= 3); 47 | const word = eligibleWords[Math.floor(Math.random() * eligibleWords.length)]; 48 | const startIndex = Math.floor(Math.random() * (word.length - 2)); 49 | 50 | return word.substring(startIndex, startIndex + 3); 51 | } 52 | 53 | function validateWord(word: string): Promise { 54 | return new Promise((resolve, reject) => { 55 | if (WORD_LIST.length === 0) { 56 | logger.debug("Word list is empty"); 57 | reject("Word list is empty"); 58 | return; 59 | } 60 | let low: number = 0; 61 | let high: number = WORD_LIST.length - 1; 62 | while (low <= high) { 63 | let mid: number = Math.floor((low + high) / 2); 64 | let currentWord = WORD_LIST[mid]; 65 | if (currentWord === word) { 66 | logger.info("found word: " + currentWord); 67 | resolve(true); 68 | return; 69 | } 70 | 71 | if (currentWord < word) { 72 | low = mid + 1; 73 | } else { 74 | high = mid - 1; 75 | } 76 | } 77 | 78 | resolve(false); 79 | }); 80 | } 81 | 82 | const minigameModule: CommandType = { 83 | data: new SlashCommandBuilder() 84 | .setName("minigame") 85 | .setDescription("Play a minigame in the channel") 86 | .addSubcommand((game: any) => 87 | game 88 | .setName("wordbomb") 89 | .setDescription("Find as much words as possible before time is up!") 90 | ), 91 | execute: async (interaction: ChatInputCommandInteraction) => { 92 | if (interaction.channel?.id !== Config.discord.bot_channel) { 93 | return await interaction.reply({ 94 | content: `You can only use this command in <#${Config.discord.bot_channel}>`, 95 | ephemeral: true, 96 | }); 97 | } 98 | 99 | const subcommand = interaction.options.getSubcommand(); 100 | const {channel} = interaction; 101 | 102 | if (subcommand === "wordbomb") { 103 | InitWordList().then((words) => { 104 | logger.info(`Word list initialized with ${words.length} words`); 105 | WORD_LIST = words; 106 | }); 107 | 108 | type Player = { 109 | Member: GuildMember; 110 | Chances: number; // # number of chances until eliminated 111 | CorrectGuess: Boolean; 112 | Score: number; 113 | }; 114 | 115 | let players: Array = []; 116 | let joinEmbed = new EmbedBuilder() 117 | .setColor(Colors.Blue) 118 | .setTitle(`${"Word Bomb! 💣"}`) 119 | .setDescription( 120 | `Click to join the Word-Bomb game! \n\n**${players.length}/${REQUIRED_PLAYERS} players required to start!**` 121 | ) 122 | .addFields({ 123 | name: "Rules", 124 | value: 125 | "1. Guess a word with a substring\n2. Wait for your turn to be announced\n3. You get a strike if you fail to guess a word or run out of time.\n4. You cannot use a word already used\n5. 2 strikes and you're out!", 126 | inline: false, 127 | }); 128 | 129 | let joinGame = await interaction.reply({ 130 | embeds: [joinEmbed], 131 | fetchReply: true, 132 | }); 133 | 134 | const collector = interaction.channel.createMessageComponentCollector({ 135 | componentType: ComponentType.Button, 136 | //filter: (i) => i.user, 137 | time: JOIN_TIME, 138 | }); 139 | 140 | collector.on("collect", async (i: ChatInputCommandInteraction) => { 141 | if (players.find((p) => p.Member.id === i.member.id)) { 142 | return await i.reply({ 143 | content: "You are already in the game!", 144 | ephemeral: true, 145 | }); 146 | } 147 | 148 | players.push({ 149 | Member: i.member as GuildMember, 150 | Chances: 2, 151 | CorrectGuess: false, 152 | Score: 0, 153 | } as Player); 154 | 155 | await i.reply({content: "You have joined the game!", ephemeral: true}); 156 | 157 | if (players.length > 1) { 158 | joinEmbed.setFields({ 159 | name: "Rules", 160 | value: 161 | "1. Guess a word with a substring\n2. Wait for your turn to be announced\n3. You get a strike if you fail to guess a word or run out of time.\n4. You cannot use a word already used\n5. 2 strikes and you're out!", 162 | inline: false, 163 | }); 164 | joinEmbed.setFields({ 165 | name: "Players", 166 | value: `${players.map((player) => player.Member).join("\n")}`, 167 | }); 168 | } else { 169 | joinEmbed.addFields({ 170 | name: "Players", 171 | value: `${players.map((player) => player.Member).join("\n")}`, 172 | inline: false, 173 | }); 174 | } 175 | 176 | joinEmbed.setDescription( 177 | `Click to join the Word-Bomb game! \n\n**${players.length}/${REQUIRED_PLAYERS} players required to start!**` 178 | ); 179 | 180 | joinGame.edit({embeds: [joinEmbed]}); 181 | }); 182 | 183 | const joinButton = new ButtonBuilder() 184 | .setCustomId("join-minigame") 185 | .setLabel("Join") 186 | .setStyle(3); 187 | 188 | const row = new ActionRowBuilder().addComponents( 189 | joinButton 190 | ); 191 | 192 | await interaction.editReply({embeds: [joinEmbed], components: [row]}); 193 | 194 | let resolveTurn: any; 195 | let currentPlayer: Player; 196 | 197 | async function wait(ms: number): Promise { 198 | return new Promise((resolve) => { 199 | resolveTurn = resolve; 200 | setTimeout(resolve, ms); 201 | }); 202 | } 203 | 204 | setTimeout(async () => { 205 | if (players.length < REQUIRED_PLAYERS) { 206 | interaction.channel.send( 207 | `Atleast ${REQUIRED_PLAYERS} people are required for this game 😔` 208 | ); 209 | return; 210 | } 211 | 212 | let usedWords: string[] = []; 213 | 214 | // This is to keep track of scores since original player list is updated. 215 | let scoreboard: Player[] = players; 216 | 217 | while (players.length >= REQUIRED_PLAYERS) { 218 | for (let i in players) { 219 | currentPlayer = players[i]; 220 | const subString: string = getSubstring(WORD_LIST); 221 | channel.send( 222 | `**It's ${currentPlayer.Member}'s turn!**\n\n**Substring: ${subString}**` 223 | ); 224 | let guessedCorrectly: Boolean = false; 225 | 226 | const filter = (msg: Message) => 227 | msg.member.id === currentPlayer.Member.id; 228 | const collector = channel.createMessageCollector({ 229 | filter, 230 | time: WORDBOMB_TURN_TIME, 231 | }); 232 | 233 | collector.on("collect", async (msg: Message) => { 234 | let wordIsValid: Boolean = await validateWord( 235 | msg.content.toLowerCase() 236 | ); 237 | if (wordIsValid && msg.content.includes(subString)) { 238 | if (msg.content.toLowerCase() in usedWords) { 239 | channel.send( 240 | "Can't use a word that has already been used, -1 Chance." 241 | ); 242 | wordIsValid = false; 243 | } else { 244 | usedWords.push(msg.content.toLowerCase()); 245 | scoreboard[i].Score += 1; 246 | currentPlayer.Score += 1; 247 | collector.stop(); 248 | resolveTurn(); 249 | guessedCorrectly = true; 250 | channel.send( 251 | `${currentPlayer.Member} **Correct! 👍**, your current score is **${currentPlayer.Score}**` 252 | ); 253 | } 254 | } else { 255 | guessedCorrectly = false; 256 | } 257 | }); 258 | 259 | await wait(WORDBOMB_TURN_TIME); 260 | if (!guessedCorrectly) { 261 | currentPlayer.Chances -= 1; 262 | if (currentPlayer.Chances <= 0) { 263 | const index = players.findIndex( 264 | (p) => p.Member.id === currentPlayer.Member.id 265 | ); 266 | 267 | scoreboard[index].Score = currentPlayer.Score; 268 | 269 | if (index > -1) { 270 | players.splice(index, 1); 271 | } else { 272 | logger.info("could not find player"); 273 | } 274 | 275 | channel.send( 276 | `**No more chances left, you have been Eliminated ❌**` 277 | ); 278 | } else { 279 | channel.send(`Times up! 😕\n-1 Chance 👎`); 280 | } 281 | } 282 | } 283 | } 284 | 285 | if (players.length > 0) { 286 | scoreboard.sort((a, b) => b.Score - a.Score); 287 | interaction.channel.send( 288 | `The Winner is: ${ 289 | players[0].Member.user 290 | } 🥳🏆\n\n**Scoreboard:** ${scoreboard 291 | .map((scores) => `${scores.Member}: ${scores.Score}`) 292 | .join("\n")}` 293 | ); 294 | } else { 295 | interaction.channel.send(`No one Won 😞`); 296 | } 297 | }, JOIN_TIME); 298 | } else { 299 | await interaction.editReply({content: "Invalid game"}); 300 | } 301 | }, 302 | }; 303 | 304 | export {minigameModule as command}; 305 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | '@prisma/client': 12 | specifier: ^5.19.1 13 | version: 5.19.1(prisma@5.19.1) 14 | '@resvg/resvg-js': 15 | specifier: ^2.6.2 16 | version: 2.6.2 17 | discord.js: 18 | specifier: ^14.16.1 19 | version: 14.16.1 20 | fuse.js: 21 | specifier: ^7.0.0 22 | version: 7.0.0 23 | mathjax-node: 24 | specifier: ^2.1.1 25 | version: 2.1.1 26 | pino: 27 | specifier: ^9.4.0 28 | version: 9.4.0 29 | pino-pretty: 30 | specifier: ^11.2.2 31 | version: 11.2.2 32 | pino-roll: 33 | specifier: 1.3.0 34 | version: 1.3.0 35 | prisma: 36 | specifier: ^5.19.1 37 | version: 5.19.1 38 | devDependencies: 39 | '@types/mathjax-node': 40 | specifier: ^2.1.0 41 | version: 2.1.0 42 | prettier: 43 | specifier: ^3.3.3 44 | version: 3.3.3 45 | tsx: 46 | specifier: ^4.19.0 47 | version: 4.19.0 48 | typescript: 49 | specifier: ^5.5.4 50 | version: 5.5.4 51 | 52 | packages: 53 | 54 | '@discordjs/builders@1.9.0': 55 | resolution: {integrity: sha512-0zx8DePNVvQibh5ly5kCEei5wtPBIUbSoE9n+91Rlladz4tgtFbJ36PZMxxZrTEOQ7AHMZ/b0crT/0fCy6FTKg==} 56 | engines: {node: '>=18'} 57 | 58 | '@discordjs/collection@1.5.3': 59 | resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} 60 | engines: {node: '>=16.11.0'} 61 | 62 | '@discordjs/collection@2.1.1': 63 | resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} 64 | engines: {node: '>=18'} 65 | 66 | '@discordjs/formatters@0.5.0': 67 | resolution: {integrity: sha512-98b3i+Y19RFq1Xke4NkVY46x8KjJQjldHUuEbCqMvp1F5Iq9HgnGpu91jOi/Ufazhty32eRsKnnzS8n4c+L93g==} 68 | engines: {node: '>=18'} 69 | 70 | '@discordjs/rest@2.4.0': 71 | resolution: {integrity: sha512-Xb2irDqNcq+O8F0/k/NaDp7+t091p+acb51iA4bCKfIn+WFWd6HrNvcsSbMMxIR9NjcMZS6NReTKygqiQN+ntw==} 72 | engines: {node: '>=18'} 73 | 74 | '@discordjs/util@1.1.1': 75 | resolution: {integrity: sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==} 76 | engines: {node: '>=18'} 77 | 78 | '@discordjs/ws@1.1.1': 79 | resolution: {integrity: sha512-PZ+vLpxGCRtmr2RMkqh8Zp+BenUaJqlS6xhgWKEZcgC/vfHLEzpHtKkB0sl3nZWpwtcKk6YWy+pU3okL2I97FA==} 80 | engines: {node: '>=16.11.0'} 81 | 82 | '@esbuild/aix-ppc64@0.23.1': 83 | resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} 84 | engines: {node: '>=18'} 85 | cpu: [ppc64] 86 | os: [aix] 87 | 88 | '@esbuild/android-arm64@0.23.1': 89 | resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} 90 | engines: {node: '>=18'} 91 | cpu: [arm64] 92 | os: [android] 93 | 94 | '@esbuild/android-arm@0.23.1': 95 | resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} 96 | engines: {node: '>=18'} 97 | cpu: [arm] 98 | os: [android] 99 | 100 | '@esbuild/android-x64@0.23.1': 101 | resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} 102 | engines: {node: '>=18'} 103 | cpu: [x64] 104 | os: [android] 105 | 106 | '@esbuild/darwin-arm64@0.23.1': 107 | resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} 108 | engines: {node: '>=18'} 109 | cpu: [arm64] 110 | os: [darwin] 111 | 112 | '@esbuild/darwin-x64@0.23.1': 113 | resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} 114 | engines: {node: '>=18'} 115 | cpu: [x64] 116 | os: [darwin] 117 | 118 | '@esbuild/freebsd-arm64@0.23.1': 119 | resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} 120 | engines: {node: '>=18'} 121 | cpu: [arm64] 122 | os: [freebsd] 123 | 124 | '@esbuild/freebsd-x64@0.23.1': 125 | resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} 126 | engines: {node: '>=18'} 127 | cpu: [x64] 128 | os: [freebsd] 129 | 130 | '@esbuild/linux-arm64@0.23.1': 131 | resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} 132 | engines: {node: '>=18'} 133 | cpu: [arm64] 134 | os: [linux] 135 | 136 | '@esbuild/linux-arm@0.23.1': 137 | resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} 138 | engines: {node: '>=18'} 139 | cpu: [arm] 140 | os: [linux] 141 | 142 | '@esbuild/linux-ia32@0.23.1': 143 | resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} 144 | engines: {node: '>=18'} 145 | cpu: [ia32] 146 | os: [linux] 147 | 148 | '@esbuild/linux-loong64@0.23.1': 149 | resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} 150 | engines: {node: '>=18'} 151 | cpu: [loong64] 152 | os: [linux] 153 | 154 | '@esbuild/linux-mips64el@0.23.1': 155 | resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} 156 | engines: {node: '>=18'} 157 | cpu: [mips64el] 158 | os: [linux] 159 | 160 | '@esbuild/linux-ppc64@0.23.1': 161 | resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} 162 | engines: {node: '>=18'} 163 | cpu: [ppc64] 164 | os: [linux] 165 | 166 | '@esbuild/linux-riscv64@0.23.1': 167 | resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} 168 | engines: {node: '>=18'} 169 | cpu: [riscv64] 170 | os: [linux] 171 | 172 | '@esbuild/linux-s390x@0.23.1': 173 | resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} 174 | engines: {node: '>=18'} 175 | cpu: [s390x] 176 | os: [linux] 177 | 178 | '@esbuild/linux-x64@0.23.1': 179 | resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} 180 | engines: {node: '>=18'} 181 | cpu: [x64] 182 | os: [linux] 183 | 184 | '@esbuild/netbsd-x64@0.23.1': 185 | resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} 186 | engines: {node: '>=18'} 187 | cpu: [x64] 188 | os: [netbsd] 189 | 190 | '@esbuild/openbsd-arm64@0.23.1': 191 | resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} 192 | engines: {node: '>=18'} 193 | cpu: [arm64] 194 | os: [openbsd] 195 | 196 | '@esbuild/openbsd-x64@0.23.1': 197 | resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} 198 | engines: {node: '>=18'} 199 | cpu: [x64] 200 | os: [openbsd] 201 | 202 | '@esbuild/sunos-x64@0.23.1': 203 | resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} 204 | engines: {node: '>=18'} 205 | cpu: [x64] 206 | os: [sunos] 207 | 208 | '@esbuild/win32-arm64@0.23.1': 209 | resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} 210 | engines: {node: '>=18'} 211 | cpu: [arm64] 212 | os: [win32] 213 | 214 | '@esbuild/win32-ia32@0.23.1': 215 | resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} 216 | engines: {node: '>=18'} 217 | cpu: [ia32] 218 | os: [win32] 219 | 220 | '@esbuild/win32-x64@0.23.1': 221 | resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} 222 | engines: {node: '>=18'} 223 | cpu: [x64] 224 | os: [win32] 225 | 226 | '@prisma/client@5.19.1': 227 | resolution: {integrity: sha512-x30GFguInsgt+4z5I4WbkZP2CGpotJMUXy+Gl/aaUjHn2o1DnLYNTA+q9XdYmAQZM8fIIkvUiA2NpgosM3fneg==} 228 | engines: {node: '>=16.13'} 229 | peerDependencies: 230 | prisma: '*' 231 | peerDependenciesMeta: 232 | prisma: 233 | optional: true 234 | 235 | '@prisma/debug@5.19.1': 236 | resolution: {integrity: sha512-lAG6A6QnG2AskAukIEucYJZxxcSqKsMK74ZFVfCTOM/7UiyJQi48v6TQ47d6qKG3LbMslqOvnTX25dj/qvclGg==} 237 | 238 | '@prisma/engines-version@5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3': 239 | resolution: {integrity: sha512-xR6rt+z5LnNqTP5BBc+8+ySgf4WNMimOKXRn6xfNRDSpHvbOEmd7+qAOmzCrddEc4Cp8nFC0txU14dstjH7FXA==} 240 | 241 | '@prisma/engines@5.19.1': 242 | resolution: {integrity: sha512-kR/PoxZDrfUmbbXqqb8SlBBgCjvGaJYMCOe189PEYzq9rKqitQ2fvT/VJ8PDSe8tTNxhc2KzsCfCAL+Iwm/7Cg==} 243 | 244 | '@prisma/fetch-engine@5.19.1': 245 | resolution: {integrity: sha512-pCq74rtlOVJfn4pLmdJj+eI4P7w2dugOnnTXpRilP/6n5b2aZiA4ulJlE0ddCbTPkfHmOL9BfaRgA8o+1rfdHw==} 246 | 247 | '@prisma/get-platform@5.19.1': 248 | resolution: {integrity: sha512-sCeoJ+7yt0UjnR+AXZL7vXlg5eNxaFOwC23h0KvW1YIXUoa7+W2ZcAUhoEQBmJTW4GrFqCuZ8YSP0mkDa4k3Zg==} 249 | 250 | '@resvg/resvg-js-android-arm-eabi@2.6.2': 251 | resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} 252 | engines: {node: '>= 10'} 253 | cpu: [arm] 254 | os: [android] 255 | 256 | '@resvg/resvg-js-android-arm64@2.6.2': 257 | resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} 258 | engines: {node: '>= 10'} 259 | cpu: [arm64] 260 | os: [android] 261 | 262 | '@resvg/resvg-js-darwin-arm64@2.6.2': 263 | resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} 264 | engines: {node: '>= 10'} 265 | cpu: [arm64] 266 | os: [darwin] 267 | 268 | '@resvg/resvg-js-darwin-x64@2.6.2': 269 | resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} 270 | engines: {node: '>= 10'} 271 | cpu: [x64] 272 | os: [darwin] 273 | 274 | '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': 275 | resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} 276 | engines: {node: '>= 10'} 277 | cpu: [arm] 278 | os: [linux] 279 | 280 | '@resvg/resvg-js-linux-arm64-gnu@2.6.2': 281 | resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} 282 | engines: {node: '>= 10'} 283 | cpu: [arm64] 284 | os: [linux] 285 | 286 | '@resvg/resvg-js-linux-arm64-musl@2.6.2': 287 | resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} 288 | engines: {node: '>= 10'} 289 | cpu: [arm64] 290 | os: [linux] 291 | 292 | '@resvg/resvg-js-linux-x64-gnu@2.6.2': 293 | resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} 294 | engines: {node: '>= 10'} 295 | cpu: [x64] 296 | os: [linux] 297 | 298 | '@resvg/resvg-js-linux-x64-musl@2.6.2': 299 | resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} 300 | engines: {node: '>= 10'} 301 | cpu: [x64] 302 | os: [linux] 303 | 304 | '@resvg/resvg-js-win32-arm64-msvc@2.6.2': 305 | resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} 306 | engines: {node: '>= 10'} 307 | cpu: [arm64] 308 | os: [win32] 309 | 310 | '@resvg/resvg-js-win32-ia32-msvc@2.6.2': 311 | resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} 312 | engines: {node: '>= 10'} 313 | cpu: [ia32] 314 | os: [win32] 315 | 316 | '@resvg/resvg-js-win32-x64-msvc@2.6.2': 317 | resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} 318 | engines: {node: '>= 10'} 319 | cpu: [x64] 320 | os: [win32] 321 | 322 | '@resvg/resvg-js@2.6.2': 323 | resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} 324 | engines: {node: '>= 10'} 325 | 326 | '@sapphire/async-queue@1.5.3': 327 | resolution: {integrity: sha512-x7zadcfJGxFka1Q3f8gCts1F0xMwCKbZweM85xECGI0hBTeIZJGGCrHgLggihBoprlQ/hBmDR5LKfIPqnmHM3w==} 328 | engines: {node: '>=v14.0.0', npm: '>=7.0.0'} 329 | 330 | '@sapphire/shapeshift@4.0.0': 331 | resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} 332 | engines: {node: '>=v16'} 333 | 334 | '@sapphire/snowflake@3.5.3': 335 | resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} 336 | engines: {node: '>=v14.0.0', npm: '>=7.0.0'} 337 | 338 | '@types/mathjax-node@2.1.0': 339 | resolution: {integrity: sha512-s1i5U1yVQBINaWuIa/950yxLRvF1DdaB+P4+hm+CjchbkjoufBnTQC91Rw7qFN2Z9UYvJQ4GPHdHlCcgiNx9oA==} 340 | 341 | '@types/mathjax@0.0.40': 342 | resolution: {integrity: sha512-rHusx08LCg92WJxrsM3SPjvLTSvK5C+gealtSuhKbEOcUZfWlwigaFoPLf6Dfxhg4oryN5qP9Sj7zOQ4HYXINw==} 343 | 344 | '@types/node@22.5.4': 345 | resolution: {integrity: sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==} 346 | 347 | '@types/ws@8.5.12': 348 | resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==} 349 | 350 | '@vladfrangu/async_event_emitter@2.4.6': 351 | resolution: {integrity: sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==} 352 | engines: {node: '>=v14.0.0', npm: '>=7.0.0'} 353 | 354 | abab@2.0.6: 355 | resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} 356 | deprecated: Use your platform's native atob() and btoa() methods instead 357 | 358 | abort-controller@3.0.0: 359 | resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} 360 | engines: {node: '>=6.5'} 361 | 362 | acorn-globals@4.3.4: 363 | resolution: {integrity: sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==} 364 | 365 | acorn-walk@6.2.0: 366 | resolution: {integrity: sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==} 367 | engines: {node: '>=0.4.0'} 368 | 369 | acorn@5.7.4: 370 | resolution: {integrity: sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==} 371 | engines: {node: '>=0.4.0'} 372 | hasBin: true 373 | 374 | acorn@6.4.2: 375 | resolution: {integrity: sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==} 376 | engines: {node: '>=0.4.0'} 377 | hasBin: true 378 | 379 | ajv@6.12.6: 380 | resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} 381 | 382 | array-equal@1.0.2: 383 | resolution: {integrity: sha512-gUHx76KtnhEgB3HOuFYiCm3FIdEs6ocM2asHvNTkfu/Y09qQVrrVVaOKENmS2KkSaGoxgXNqC+ZVtR/n0MOkSA==} 384 | 385 | asn1@0.2.6: 386 | resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} 387 | 388 | assert-plus@1.0.0: 389 | resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} 390 | engines: {node: '>=0.8'} 391 | 392 | async-limiter@1.0.1: 393 | resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} 394 | 395 | asynckit@0.4.0: 396 | resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} 397 | 398 | atomic-sleep@1.0.0: 399 | resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} 400 | engines: {node: '>=8.0.0'} 401 | 402 | aws-sign2@0.7.0: 403 | resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} 404 | 405 | aws4@1.13.2: 406 | resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} 407 | 408 | base64-js@1.5.1: 409 | resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} 410 | 411 | bcrypt-pbkdf@1.0.2: 412 | resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} 413 | 414 | browser-process-hrtime@1.0.0: 415 | resolution: {integrity: sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==} 416 | 417 | buffer@6.0.3: 418 | resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} 419 | 420 | caseless@0.12.0: 421 | resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} 422 | 423 | colorette@2.0.20: 424 | resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} 425 | 426 | combined-stream@1.0.8: 427 | resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} 428 | engines: {node: '>= 0.8'} 429 | 430 | core-util-is@1.0.2: 431 | resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} 432 | 433 | cssom@0.3.8: 434 | resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} 435 | 436 | cssstyle@1.4.0: 437 | resolution: {integrity: sha512-GBrLZYZ4X4x6/QEoBnIrqb8B/f5l4+8me2dkom/j1Gtbxy0kBv6OGzKuAsGM75bkGwGAFkt56Iwg28S3XTZgSA==} 438 | 439 | dashdash@1.14.1: 440 | resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} 441 | engines: {node: '>=0.10'} 442 | 443 | data-urls@1.1.0: 444 | resolution: {integrity: sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==} 445 | 446 | dateformat@4.6.3: 447 | resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} 448 | 449 | deep-is@0.1.4: 450 | resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} 451 | 452 | delayed-stream@1.0.0: 453 | resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} 454 | engines: {node: '>=0.4.0'} 455 | 456 | discord-api-types@0.37.83: 457 | resolution: {integrity: sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==} 458 | 459 | discord-api-types@0.37.97: 460 | resolution: {integrity: sha512-No1BXPcVkyVD4ZVmbNgDKaBoqgeQ+FJpzZ8wqHkfmBnTZig1FcH3iPPersiK1TUIAzgClh2IvOuVUYfcWLQAOA==} 461 | 462 | discord.js@14.16.1: 463 | resolution: {integrity: sha512-/diX4shp3q1F3EySGQbQl10el+KIpffLSOJdtM35gGV7zw2ED7rKbASKJT7UIR9L/lTd0KtNenZ/h739TN7diA==} 464 | engines: {node: '>=18'} 465 | 466 | domexception@1.0.1: 467 | resolution: {integrity: sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==} 468 | deprecated: Use your platform's native DOMException instead 469 | 470 | ecc-jsbn@0.1.2: 471 | resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} 472 | 473 | end-of-stream@1.4.4: 474 | resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} 475 | 476 | esbuild@0.23.1: 477 | resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} 478 | engines: {node: '>=18'} 479 | hasBin: true 480 | 481 | escodegen@1.14.3: 482 | resolution: {integrity: sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==} 483 | engines: {node: '>=4.0'} 484 | hasBin: true 485 | 486 | esprima@4.0.1: 487 | resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} 488 | engines: {node: '>=4'} 489 | hasBin: true 490 | 491 | estraverse@4.3.0: 492 | resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} 493 | engines: {node: '>=4.0'} 494 | 495 | esutils@2.0.3: 496 | resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} 497 | engines: {node: '>=0.10.0'} 498 | 499 | event-target-shim@5.0.1: 500 | resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} 501 | engines: {node: '>=6'} 502 | 503 | events@3.3.0: 504 | resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} 505 | engines: {node: '>=0.8.x'} 506 | 507 | extend@3.0.2: 508 | resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} 509 | 510 | extsprintf@1.3.0: 511 | resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} 512 | engines: {'0': node >=0.6.0} 513 | 514 | fast-copy@3.0.2: 515 | resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} 516 | 517 | fast-deep-equal@3.1.3: 518 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 519 | 520 | fast-json-stable-stringify@2.1.0: 521 | resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} 522 | 523 | fast-levenshtein@2.0.6: 524 | resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} 525 | 526 | fast-redact@3.5.0: 527 | resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} 528 | engines: {node: '>=6'} 529 | 530 | fast-safe-stringify@2.1.1: 531 | resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} 532 | 533 | forever-agent@0.6.1: 534 | resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} 535 | 536 | form-data@2.3.3: 537 | resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} 538 | engines: {node: '>= 0.12'} 539 | 540 | fsevents@2.3.3: 541 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 542 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 543 | os: [darwin] 544 | 545 | fuse.js@7.0.0: 546 | resolution: {integrity: sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==} 547 | engines: {node: '>=10'} 548 | 549 | get-tsconfig@4.8.0: 550 | resolution: {integrity: sha512-Pgba6TExTZ0FJAn1qkJAjIeKoDJ3CsI2ChuLohJnZl/tTU8MVrq3b+2t5UOPfRa4RMsorClBjJALkJUMjG1PAw==} 551 | 552 | getpass@0.1.7: 553 | resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} 554 | 555 | har-schema@2.0.0: 556 | resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} 557 | engines: {node: '>=4'} 558 | 559 | har-validator@5.1.5: 560 | resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} 561 | engines: {node: '>=6'} 562 | deprecated: this library is no longer supported 563 | 564 | help-me@5.0.0: 565 | resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} 566 | 567 | html-encoding-sniffer@1.0.2: 568 | resolution: {integrity: sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==} 569 | 570 | http-signature@1.2.0: 571 | resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} 572 | engines: {node: '>=0.8', npm: '>=1.3.7'} 573 | 574 | iconv-lite@0.4.24: 575 | resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} 576 | engines: {node: '>=0.10.0'} 577 | 578 | ieee754@1.2.1: 579 | resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 580 | 581 | is-fullwidth-code-point@2.0.0: 582 | resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} 583 | engines: {node: '>=4'} 584 | 585 | is-typedarray@1.0.0: 586 | resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} 587 | 588 | isstream@0.1.2: 589 | resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} 590 | 591 | joycon@3.1.1: 592 | resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} 593 | engines: {node: '>=10'} 594 | 595 | jsbn@0.1.1: 596 | resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} 597 | 598 | jsdom@11.12.0: 599 | resolution: {integrity: sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw==} 600 | 601 | json-schema-traverse@0.4.1: 602 | resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} 603 | 604 | json-schema@0.4.0: 605 | resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} 606 | 607 | json-stringify-safe@5.0.1: 608 | resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} 609 | 610 | jsprim@1.4.2: 611 | resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} 612 | engines: {node: '>=0.6.0'} 613 | 614 | left-pad@1.3.0: 615 | resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} 616 | deprecated: use String.prototype.padStart() 617 | 618 | levn@0.3.0: 619 | resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==} 620 | engines: {node: '>= 0.8.0'} 621 | 622 | lodash.snakecase@4.1.1: 623 | resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} 624 | 625 | lodash.sortby@4.7.0: 626 | resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} 627 | 628 | lodash@4.17.21: 629 | resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} 630 | 631 | magic-bytes.js@1.10.0: 632 | resolution: {integrity: sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==} 633 | 634 | mathjax-node@2.1.1: 635 | resolution: {integrity: sha512-i29tvqD8yHPB2WhrGV5rvliYnKwTT8a/TO8SCnuYtatpSHxLGy3aF7lDTVLD6B1bfuVMTFB6McZu2TBxk0XGeg==} 636 | engines: {node: '>=6.0.0'} 637 | 638 | mathjax@2.7.9: 639 | resolution: {integrity: sha512-NOGEDTIM9+MrsqnjPEjVGNx4q0GQxqm61yQwSK+/5S59i26wId5IC5gNu9/bu8+CCVl5p9G2IHcAl/wJa+5+BQ==} 640 | 641 | mime-db@1.52.0: 642 | resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} 643 | engines: {node: '>= 0.6'} 644 | 645 | mime-types@2.1.35: 646 | resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} 647 | engines: {node: '>= 0.6'} 648 | 649 | minimist@1.2.8: 650 | resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} 651 | 652 | nwsapi@2.2.12: 653 | resolution: {integrity: sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==} 654 | 655 | oauth-sign@0.9.0: 656 | resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} 657 | 658 | on-exit-leak-free@2.1.2: 659 | resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} 660 | engines: {node: '>=14.0.0'} 661 | 662 | once@1.4.0: 663 | resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} 664 | 665 | optionator@0.8.3: 666 | resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} 667 | engines: {node: '>= 0.8.0'} 668 | 669 | parse5@4.0.0: 670 | resolution: {integrity: sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==} 671 | 672 | performance-now@2.1.0: 673 | resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} 674 | 675 | pino-abstract-transport@1.2.0: 676 | resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==} 677 | 678 | pino-pretty@11.2.2: 679 | resolution: {integrity: sha512-2FnyGir8nAJAqD3srROdrF1J5BIcMT4nwj7hHSc60El6Uxlym00UbCCd8pYIterstVBFlMyF1yFV8XdGIPbj4A==} 680 | hasBin: true 681 | 682 | pino-roll@1.3.0: 683 | resolution: {integrity: sha512-bEjnbuSNjHY44LJH9MNqnrLnLWwWlDrK5AE9WMDR1bhQYiikzPgIla1TQ75+J0cx6Im2CYe5kMKRJzbRGVQjVQ==} 684 | 685 | pino-std-serializers@7.0.0: 686 | resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} 687 | 688 | pino@9.4.0: 689 | resolution: {integrity: sha512-nbkQb5+9YPhQRz/BeQmrWpEknAaqjpAqRK8NwJpmrX/JHu7JuZC5G1CeAwJDJfGes4h+YihC6in3Q2nGb+Y09w==} 690 | hasBin: true 691 | 692 | pn@1.1.0: 693 | resolution: {integrity: sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==} 694 | 695 | prelude-ls@1.1.2: 696 | resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} 697 | engines: {node: '>= 0.8.0'} 698 | 699 | prettier@3.3.3: 700 | resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} 701 | engines: {node: '>=14'} 702 | hasBin: true 703 | 704 | prisma@5.19.1: 705 | resolution: {integrity: sha512-c5K9MiDaa+VAAyh1OiYk76PXOme9s3E992D7kvvIOhCrNsBQfy2mP2QAQtX0WNj140IgG++12kwZpYB9iIydNQ==} 706 | engines: {node: '>=16.13'} 707 | hasBin: true 708 | 709 | process-warning@4.0.0: 710 | resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==} 711 | 712 | process@0.11.10: 713 | resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} 714 | engines: {node: '>= 0.6.0'} 715 | 716 | psl@1.9.0: 717 | resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} 718 | 719 | pump@3.0.0: 720 | resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} 721 | 722 | punycode@2.3.1: 723 | resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} 724 | engines: {node: '>=6'} 725 | 726 | qs@6.5.3: 727 | resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} 728 | engines: {node: '>=0.6'} 729 | 730 | quick-format-unescaped@4.0.4: 731 | resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} 732 | 733 | readable-stream@4.5.2: 734 | resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==} 735 | engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} 736 | 737 | real-require@0.2.0: 738 | resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} 739 | engines: {node: '>= 12.13.0'} 740 | 741 | request-promise-core@1.1.4: 742 | resolution: {integrity: sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==} 743 | engines: {node: '>=0.10.0'} 744 | peerDependencies: 745 | request: ^2.34 746 | 747 | request-promise-native@1.0.9: 748 | resolution: {integrity: sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==} 749 | engines: {node: '>=0.12.0'} 750 | deprecated: request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142 751 | peerDependencies: 752 | request: ^2.34 753 | 754 | request@2.88.2: 755 | resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} 756 | engines: {node: '>= 6'} 757 | deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 758 | 759 | resolve-pkg-maps@1.0.0: 760 | resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} 761 | 762 | safe-buffer@5.2.1: 763 | resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 764 | 765 | safe-stable-stringify@2.5.0: 766 | resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} 767 | engines: {node: '>=10'} 768 | 769 | safer-buffer@2.1.2: 770 | resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 771 | 772 | sax@1.4.1: 773 | resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} 774 | 775 | secure-json-parse@2.7.0: 776 | resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} 777 | 778 | sonic-boom@3.8.1: 779 | resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} 780 | 781 | sonic-boom@4.1.0: 782 | resolution: {integrity: sha512-NGipjjRicyJJ03rPiZCJYjwlsuP2d1/5QUviozRXC7S3WdVWNK5e3Ojieb9CCyfhq2UC+3+SRd9nG3I2lPRvUw==} 783 | 784 | source-map@0.6.1: 785 | resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} 786 | engines: {node: '>=0.10.0'} 787 | 788 | split2@4.2.0: 789 | resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} 790 | engines: {node: '>= 10.x'} 791 | 792 | sshpk@1.18.0: 793 | resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} 794 | engines: {node: '>=0.10.0'} 795 | hasBin: true 796 | 797 | stealthy-require@1.1.1: 798 | resolution: {integrity: sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==} 799 | engines: {node: '>=0.10.0'} 800 | 801 | string_decoder@1.3.0: 802 | resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 803 | 804 | strip-json-comments@3.1.1: 805 | resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} 806 | engines: {node: '>=8'} 807 | 808 | symbol-tree@3.2.4: 809 | resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} 810 | 811 | thread-stream@3.1.0: 812 | resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} 813 | 814 | tough-cookie@2.5.0: 815 | resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} 816 | engines: {node: '>=0.8'} 817 | 818 | tr46@1.0.1: 819 | resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} 820 | 821 | ts-mixer@6.0.4: 822 | resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} 823 | 824 | tslib@2.7.0: 825 | resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} 826 | 827 | tsx@4.19.0: 828 | resolution: {integrity: sha512-bV30kM7bsLZKZIOCHeMNVMJ32/LuJzLVajkQI/qf92J2Qr08ueLQvW00PUZGiuLPP760UINwupgUj8qrSCPUKg==} 829 | engines: {node: '>=18.0.0'} 830 | hasBin: true 831 | 832 | tunnel-agent@0.6.0: 833 | resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} 834 | 835 | tweetnacl@0.14.5: 836 | resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} 837 | 838 | type-check@0.3.2: 839 | resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} 840 | engines: {node: '>= 0.8.0'} 841 | 842 | typescript@5.5.4: 843 | resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} 844 | engines: {node: '>=14.17'} 845 | hasBin: true 846 | 847 | undici-types@6.19.8: 848 | resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} 849 | 850 | undici@6.19.8: 851 | resolution: {integrity: sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==} 852 | engines: {node: '>=18.17'} 853 | 854 | uri-js@4.4.1: 855 | resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 856 | 857 | uuid@3.4.0: 858 | resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} 859 | deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. 860 | hasBin: true 861 | 862 | verror@1.10.0: 863 | resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} 864 | engines: {'0': node >=0.6.0} 865 | 866 | w3c-hr-time@1.0.2: 867 | resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} 868 | deprecated: Use your platform's native performance.now() and performance.timeOrigin. 869 | 870 | webidl-conversions@4.0.2: 871 | resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} 872 | 873 | whatwg-encoding@1.0.5: 874 | resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==} 875 | 876 | whatwg-mimetype@2.3.0: 877 | resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==} 878 | 879 | whatwg-url@6.5.0: 880 | resolution: {integrity: sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==} 881 | 882 | whatwg-url@7.1.0: 883 | resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} 884 | 885 | word-wrap@1.2.5: 886 | resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} 887 | engines: {node: '>=0.10.0'} 888 | 889 | wrappy@1.0.2: 890 | resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 891 | 892 | ws@5.2.4: 893 | resolution: {integrity: sha512-fFCejsuC8f9kOSu9FYaOw8CdO68O3h5v0lg4p74o8JqWpwTf9tniOD+nOB78aWoVSS6WptVUmDrp/KPsMVBWFQ==} 894 | peerDependencies: 895 | bufferutil: ^4.0.1 896 | utf-8-validate: ^5.0.2 897 | peerDependenciesMeta: 898 | bufferutil: 899 | optional: true 900 | utf-8-validate: 901 | optional: true 902 | 903 | ws@8.18.0: 904 | resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} 905 | engines: {node: '>=10.0.0'} 906 | peerDependencies: 907 | bufferutil: ^4.0.1 908 | utf-8-validate: '>=5.0.2' 909 | peerDependenciesMeta: 910 | bufferutil: 911 | optional: true 912 | utf-8-validate: 913 | optional: true 914 | 915 | xml-name-validator@3.0.0: 916 | resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==} 917 | 918 | snapshots: 919 | 920 | '@discordjs/builders@1.9.0': 921 | dependencies: 922 | '@discordjs/formatters': 0.5.0 923 | '@discordjs/util': 1.1.1 924 | '@sapphire/shapeshift': 4.0.0 925 | discord-api-types: 0.37.97 926 | fast-deep-equal: 3.1.3 927 | ts-mixer: 6.0.4 928 | tslib: 2.7.0 929 | 930 | '@discordjs/collection@1.5.3': {} 931 | 932 | '@discordjs/collection@2.1.1': {} 933 | 934 | '@discordjs/formatters@0.5.0': 935 | dependencies: 936 | discord-api-types: 0.37.97 937 | 938 | '@discordjs/rest@2.4.0': 939 | dependencies: 940 | '@discordjs/collection': 2.1.1 941 | '@discordjs/util': 1.1.1 942 | '@sapphire/async-queue': 1.5.3 943 | '@sapphire/snowflake': 3.5.3 944 | '@vladfrangu/async_event_emitter': 2.4.6 945 | discord-api-types: 0.37.97 946 | magic-bytes.js: 1.10.0 947 | tslib: 2.7.0 948 | undici: 6.19.8 949 | 950 | '@discordjs/util@1.1.1': {} 951 | 952 | '@discordjs/ws@1.1.1': 953 | dependencies: 954 | '@discordjs/collection': 2.1.1 955 | '@discordjs/rest': 2.4.0 956 | '@discordjs/util': 1.1.1 957 | '@sapphire/async-queue': 1.5.3 958 | '@types/ws': 8.5.12 959 | '@vladfrangu/async_event_emitter': 2.4.6 960 | discord-api-types: 0.37.83 961 | tslib: 2.7.0 962 | ws: 8.18.0 963 | transitivePeerDependencies: 964 | - bufferutil 965 | - utf-8-validate 966 | 967 | '@esbuild/aix-ppc64@0.23.1': 968 | optional: true 969 | 970 | '@esbuild/android-arm64@0.23.1': 971 | optional: true 972 | 973 | '@esbuild/android-arm@0.23.1': 974 | optional: true 975 | 976 | '@esbuild/android-x64@0.23.1': 977 | optional: true 978 | 979 | '@esbuild/darwin-arm64@0.23.1': 980 | optional: true 981 | 982 | '@esbuild/darwin-x64@0.23.1': 983 | optional: true 984 | 985 | '@esbuild/freebsd-arm64@0.23.1': 986 | optional: true 987 | 988 | '@esbuild/freebsd-x64@0.23.1': 989 | optional: true 990 | 991 | '@esbuild/linux-arm64@0.23.1': 992 | optional: true 993 | 994 | '@esbuild/linux-arm@0.23.1': 995 | optional: true 996 | 997 | '@esbuild/linux-ia32@0.23.1': 998 | optional: true 999 | 1000 | '@esbuild/linux-loong64@0.23.1': 1001 | optional: true 1002 | 1003 | '@esbuild/linux-mips64el@0.23.1': 1004 | optional: true 1005 | 1006 | '@esbuild/linux-ppc64@0.23.1': 1007 | optional: true 1008 | 1009 | '@esbuild/linux-riscv64@0.23.1': 1010 | optional: true 1011 | 1012 | '@esbuild/linux-s390x@0.23.1': 1013 | optional: true 1014 | 1015 | '@esbuild/linux-x64@0.23.1': 1016 | optional: true 1017 | 1018 | '@esbuild/netbsd-x64@0.23.1': 1019 | optional: true 1020 | 1021 | '@esbuild/openbsd-arm64@0.23.1': 1022 | optional: true 1023 | 1024 | '@esbuild/openbsd-x64@0.23.1': 1025 | optional: true 1026 | 1027 | '@esbuild/sunos-x64@0.23.1': 1028 | optional: true 1029 | 1030 | '@esbuild/win32-arm64@0.23.1': 1031 | optional: true 1032 | 1033 | '@esbuild/win32-ia32@0.23.1': 1034 | optional: true 1035 | 1036 | '@esbuild/win32-x64@0.23.1': 1037 | optional: true 1038 | 1039 | '@prisma/client@5.19.1(prisma@5.19.1)': 1040 | optionalDependencies: 1041 | prisma: 5.19.1 1042 | 1043 | '@prisma/debug@5.19.1': {} 1044 | 1045 | '@prisma/engines-version@5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3': {} 1046 | 1047 | '@prisma/engines@5.19.1': 1048 | dependencies: 1049 | '@prisma/debug': 5.19.1 1050 | '@prisma/engines-version': 5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3 1051 | '@prisma/fetch-engine': 5.19.1 1052 | '@prisma/get-platform': 5.19.1 1053 | 1054 | '@prisma/fetch-engine@5.19.1': 1055 | dependencies: 1056 | '@prisma/debug': 5.19.1 1057 | '@prisma/engines-version': 5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3 1058 | '@prisma/get-platform': 5.19.1 1059 | 1060 | '@prisma/get-platform@5.19.1': 1061 | dependencies: 1062 | '@prisma/debug': 5.19.1 1063 | 1064 | '@resvg/resvg-js-android-arm-eabi@2.6.2': 1065 | optional: true 1066 | 1067 | '@resvg/resvg-js-android-arm64@2.6.2': 1068 | optional: true 1069 | 1070 | '@resvg/resvg-js-darwin-arm64@2.6.2': 1071 | optional: true 1072 | 1073 | '@resvg/resvg-js-darwin-x64@2.6.2': 1074 | optional: true 1075 | 1076 | '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': 1077 | optional: true 1078 | 1079 | '@resvg/resvg-js-linux-arm64-gnu@2.6.2': 1080 | optional: true 1081 | 1082 | '@resvg/resvg-js-linux-arm64-musl@2.6.2': 1083 | optional: true 1084 | 1085 | '@resvg/resvg-js-linux-x64-gnu@2.6.2': 1086 | optional: true 1087 | 1088 | '@resvg/resvg-js-linux-x64-musl@2.6.2': 1089 | optional: true 1090 | 1091 | '@resvg/resvg-js-win32-arm64-msvc@2.6.2': 1092 | optional: true 1093 | 1094 | '@resvg/resvg-js-win32-ia32-msvc@2.6.2': 1095 | optional: true 1096 | 1097 | '@resvg/resvg-js-win32-x64-msvc@2.6.2': 1098 | optional: true 1099 | 1100 | '@resvg/resvg-js@2.6.2': 1101 | optionalDependencies: 1102 | '@resvg/resvg-js-android-arm-eabi': 2.6.2 1103 | '@resvg/resvg-js-android-arm64': 2.6.2 1104 | '@resvg/resvg-js-darwin-arm64': 2.6.2 1105 | '@resvg/resvg-js-darwin-x64': 2.6.2 1106 | '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 1107 | '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 1108 | '@resvg/resvg-js-linux-arm64-musl': 2.6.2 1109 | '@resvg/resvg-js-linux-x64-gnu': 2.6.2 1110 | '@resvg/resvg-js-linux-x64-musl': 2.6.2 1111 | '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 1112 | '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 1113 | '@resvg/resvg-js-win32-x64-msvc': 2.6.2 1114 | 1115 | '@sapphire/async-queue@1.5.3': {} 1116 | 1117 | '@sapphire/shapeshift@4.0.0': 1118 | dependencies: 1119 | fast-deep-equal: 3.1.3 1120 | lodash: 4.17.21 1121 | 1122 | '@sapphire/snowflake@3.5.3': {} 1123 | 1124 | '@types/mathjax-node@2.1.0': 1125 | dependencies: 1126 | '@types/mathjax': 0.0.40 1127 | 1128 | '@types/mathjax@0.0.40': {} 1129 | 1130 | '@types/node@22.5.4': 1131 | dependencies: 1132 | undici-types: 6.19.8 1133 | 1134 | '@types/ws@8.5.12': 1135 | dependencies: 1136 | '@types/node': 22.5.4 1137 | 1138 | '@vladfrangu/async_event_emitter@2.4.6': {} 1139 | 1140 | abab@2.0.6: {} 1141 | 1142 | abort-controller@3.0.0: 1143 | dependencies: 1144 | event-target-shim: 5.0.1 1145 | 1146 | acorn-globals@4.3.4: 1147 | dependencies: 1148 | acorn: 6.4.2 1149 | acorn-walk: 6.2.0 1150 | 1151 | acorn-walk@6.2.0: {} 1152 | 1153 | acorn@5.7.4: {} 1154 | 1155 | acorn@6.4.2: {} 1156 | 1157 | ajv@6.12.6: 1158 | dependencies: 1159 | fast-deep-equal: 3.1.3 1160 | fast-json-stable-stringify: 2.1.0 1161 | json-schema-traverse: 0.4.1 1162 | uri-js: 4.4.1 1163 | 1164 | array-equal@1.0.2: {} 1165 | 1166 | asn1@0.2.6: 1167 | dependencies: 1168 | safer-buffer: 2.1.2 1169 | 1170 | assert-plus@1.0.0: {} 1171 | 1172 | async-limiter@1.0.1: {} 1173 | 1174 | asynckit@0.4.0: {} 1175 | 1176 | atomic-sleep@1.0.0: {} 1177 | 1178 | aws-sign2@0.7.0: {} 1179 | 1180 | aws4@1.13.2: {} 1181 | 1182 | base64-js@1.5.1: {} 1183 | 1184 | bcrypt-pbkdf@1.0.2: 1185 | dependencies: 1186 | tweetnacl: 0.14.5 1187 | 1188 | browser-process-hrtime@1.0.0: {} 1189 | 1190 | buffer@6.0.3: 1191 | dependencies: 1192 | base64-js: 1.5.1 1193 | ieee754: 1.2.1 1194 | 1195 | caseless@0.12.0: {} 1196 | 1197 | colorette@2.0.20: {} 1198 | 1199 | combined-stream@1.0.8: 1200 | dependencies: 1201 | delayed-stream: 1.0.0 1202 | 1203 | core-util-is@1.0.2: {} 1204 | 1205 | cssom@0.3.8: {} 1206 | 1207 | cssstyle@1.4.0: 1208 | dependencies: 1209 | cssom: 0.3.8 1210 | 1211 | dashdash@1.14.1: 1212 | dependencies: 1213 | assert-plus: 1.0.0 1214 | 1215 | data-urls@1.1.0: 1216 | dependencies: 1217 | abab: 2.0.6 1218 | whatwg-mimetype: 2.3.0 1219 | whatwg-url: 7.1.0 1220 | 1221 | dateformat@4.6.3: {} 1222 | 1223 | deep-is@0.1.4: {} 1224 | 1225 | delayed-stream@1.0.0: {} 1226 | 1227 | discord-api-types@0.37.83: {} 1228 | 1229 | discord-api-types@0.37.97: {} 1230 | 1231 | discord.js@14.16.1: 1232 | dependencies: 1233 | '@discordjs/builders': 1.9.0 1234 | '@discordjs/collection': 1.5.3 1235 | '@discordjs/formatters': 0.5.0 1236 | '@discordjs/rest': 2.4.0 1237 | '@discordjs/util': 1.1.1 1238 | '@discordjs/ws': 1.1.1 1239 | '@sapphire/snowflake': 3.5.3 1240 | discord-api-types: 0.37.97 1241 | fast-deep-equal: 3.1.3 1242 | lodash.snakecase: 4.1.1 1243 | tslib: 2.7.0 1244 | undici: 6.19.8 1245 | transitivePeerDependencies: 1246 | - bufferutil 1247 | - utf-8-validate 1248 | 1249 | domexception@1.0.1: 1250 | dependencies: 1251 | webidl-conversions: 4.0.2 1252 | 1253 | ecc-jsbn@0.1.2: 1254 | dependencies: 1255 | jsbn: 0.1.1 1256 | safer-buffer: 2.1.2 1257 | 1258 | end-of-stream@1.4.4: 1259 | dependencies: 1260 | once: 1.4.0 1261 | 1262 | esbuild@0.23.1: 1263 | optionalDependencies: 1264 | '@esbuild/aix-ppc64': 0.23.1 1265 | '@esbuild/android-arm': 0.23.1 1266 | '@esbuild/android-arm64': 0.23.1 1267 | '@esbuild/android-x64': 0.23.1 1268 | '@esbuild/darwin-arm64': 0.23.1 1269 | '@esbuild/darwin-x64': 0.23.1 1270 | '@esbuild/freebsd-arm64': 0.23.1 1271 | '@esbuild/freebsd-x64': 0.23.1 1272 | '@esbuild/linux-arm': 0.23.1 1273 | '@esbuild/linux-arm64': 0.23.1 1274 | '@esbuild/linux-ia32': 0.23.1 1275 | '@esbuild/linux-loong64': 0.23.1 1276 | '@esbuild/linux-mips64el': 0.23.1 1277 | '@esbuild/linux-ppc64': 0.23.1 1278 | '@esbuild/linux-riscv64': 0.23.1 1279 | '@esbuild/linux-s390x': 0.23.1 1280 | '@esbuild/linux-x64': 0.23.1 1281 | '@esbuild/netbsd-x64': 0.23.1 1282 | '@esbuild/openbsd-arm64': 0.23.1 1283 | '@esbuild/openbsd-x64': 0.23.1 1284 | '@esbuild/sunos-x64': 0.23.1 1285 | '@esbuild/win32-arm64': 0.23.1 1286 | '@esbuild/win32-ia32': 0.23.1 1287 | '@esbuild/win32-x64': 0.23.1 1288 | 1289 | escodegen@1.14.3: 1290 | dependencies: 1291 | esprima: 4.0.1 1292 | estraverse: 4.3.0 1293 | esutils: 2.0.3 1294 | optionator: 0.8.3 1295 | optionalDependencies: 1296 | source-map: 0.6.1 1297 | 1298 | esprima@4.0.1: {} 1299 | 1300 | estraverse@4.3.0: {} 1301 | 1302 | esutils@2.0.3: {} 1303 | 1304 | event-target-shim@5.0.1: {} 1305 | 1306 | events@3.3.0: {} 1307 | 1308 | extend@3.0.2: {} 1309 | 1310 | extsprintf@1.3.0: {} 1311 | 1312 | fast-copy@3.0.2: {} 1313 | 1314 | fast-deep-equal@3.1.3: {} 1315 | 1316 | fast-json-stable-stringify@2.1.0: {} 1317 | 1318 | fast-levenshtein@2.0.6: {} 1319 | 1320 | fast-redact@3.5.0: {} 1321 | 1322 | fast-safe-stringify@2.1.1: {} 1323 | 1324 | forever-agent@0.6.1: {} 1325 | 1326 | form-data@2.3.3: 1327 | dependencies: 1328 | asynckit: 0.4.0 1329 | combined-stream: 1.0.8 1330 | mime-types: 2.1.35 1331 | 1332 | fsevents@2.3.3: 1333 | optional: true 1334 | 1335 | fuse.js@7.0.0: {} 1336 | 1337 | get-tsconfig@4.8.0: 1338 | dependencies: 1339 | resolve-pkg-maps: 1.0.0 1340 | 1341 | getpass@0.1.7: 1342 | dependencies: 1343 | assert-plus: 1.0.0 1344 | 1345 | har-schema@2.0.0: {} 1346 | 1347 | har-validator@5.1.5: 1348 | dependencies: 1349 | ajv: 6.12.6 1350 | har-schema: 2.0.0 1351 | 1352 | help-me@5.0.0: {} 1353 | 1354 | html-encoding-sniffer@1.0.2: 1355 | dependencies: 1356 | whatwg-encoding: 1.0.5 1357 | 1358 | http-signature@1.2.0: 1359 | dependencies: 1360 | assert-plus: 1.0.0 1361 | jsprim: 1.4.2 1362 | sshpk: 1.18.0 1363 | 1364 | iconv-lite@0.4.24: 1365 | dependencies: 1366 | safer-buffer: 2.1.2 1367 | 1368 | ieee754@1.2.1: {} 1369 | 1370 | is-fullwidth-code-point@2.0.0: {} 1371 | 1372 | is-typedarray@1.0.0: {} 1373 | 1374 | isstream@0.1.2: {} 1375 | 1376 | joycon@3.1.1: {} 1377 | 1378 | jsbn@0.1.1: {} 1379 | 1380 | jsdom@11.12.0: 1381 | dependencies: 1382 | abab: 2.0.6 1383 | acorn: 5.7.4 1384 | acorn-globals: 4.3.4 1385 | array-equal: 1.0.2 1386 | cssom: 0.3.8 1387 | cssstyle: 1.4.0 1388 | data-urls: 1.1.0 1389 | domexception: 1.0.1 1390 | escodegen: 1.14.3 1391 | html-encoding-sniffer: 1.0.2 1392 | left-pad: 1.3.0 1393 | nwsapi: 2.2.12 1394 | parse5: 4.0.0 1395 | pn: 1.1.0 1396 | request: 2.88.2 1397 | request-promise-native: 1.0.9(request@2.88.2) 1398 | sax: 1.4.1 1399 | symbol-tree: 3.2.4 1400 | tough-cookie: 2.5.0 1401 | w3c-hr-time: 1.0.2 1402 | webidl-conversions: 4.0.2 1403 | whatwg-encoding: 1.0.5 1404 | whatwg-mimetype: 2.3.0 1405 | whatwg-url: 6.5.0 1406 | ws: 5.2.4 1407 | xml-name-validator: 3.0.0 1408 | transitivePeerDependencies: 1409 | - bufferutil 1410 | - utf-8-validate 1411 | 1412 | json-schema-traverse@0.4.1: {} 1413 | 1414 | json-schema@0.4.0: {} 1415 | 1416 | json-stringify-safe@5.0.1: {} 1417 | 1418 | jsprim@1.4.2: 1419 | dependencies: 1420 | assert-plus: 1.0.0 1421 | extsprintf: 1.3.0 1422 | json-schema: 0.4.0 1423 | verror: 1.10.0 1424 | 1425 | left-pad@1.3.0: {} 1426 | 1427 | levn@0.3.0: 1428 | dependencies: 1429 | prelude-ls: 1.1.2 1430 | type-check: 0.3.2 1431 | 1432 | lodash.snakecase@4.1.1: {} 1433 | 1434 | lodash.sortby@4.7.0: {} 1435 | 1436 | lodash@4.17.21: {} 1437 | 1438 | magic-bytes.js@1.10.0: {} 1439 | 1440 | mathjax-node@2.1.1: 1441 | dependencies: 1442 | is-fullwidth-code-point: 2.0.0 1443 | jsdom: 11.12.0 1444 | mathjax: 2.7.9 1445 | transitivePeerDependencies: 1446 | - bufferutil 1447 | - utf-8-validate 1448 | 1449 | mathjax@2.7.9: {} 1450 | 1451 | mime-db@1.52.0: {} 1452 | 1453 | mime-types@2.1.35: 1454 | dependencies: 1455 | mime-db: 1.52.0 1456 | 1457 | minimist@1.2.8: {} 1458 | 1459 | nwsapi@2.2.12: {} 1460 | 1461 | oauth-sign@0.9.0: {} 1462 | 1463 | on-exit-leak-free@2.1.2: {} 1464 | 1465 | once@1.4.0: 1466 | dependencies: 1467 | wrappy: 1.0.2 1468 | 1469 | optionator@0.8.3: 1470 | dependencies: 1471 | deep-is: 0.1.4 1472 | fast-levenshtein: 2.0.6 1473 | levn: 0.3.0 1474 | prelude-ls: 1.1.2 1475 | type-check: 0.3.2 1476 | word-wrap: 1.2.5 1477 | 1478 | parse5@4.0.0: {} 1479 | 1480 | performance-now@2.1.0: {} 1481 | 1482 | pino-abstract-transport@1.2.0: 1483 | dependencies: 1484 | readable-stream: 4.5.2 1485 | split2: 4.2.0 1486 | 1487 | pino-pretty@11.2.2: 1488 | dependencies: 1489 | colorette: 2.0.20 1490 | dateformat: 4.6.3 1491 | fast-copy: 3.0.2 1492 | fast-safe-stringify: 2.1.1 1493 | help-me: 5.0.0 1494 | joycon: 3.1.1 1495 | minimist: 1.2.8 1496 | on-exit-leak-free: 2.1.2 1497 | pino-abstract-transport: 1.2.0 1498 | pump: 3.0.0 1499 | readable-stream: 4.5.2 1500 | secure-json-parse: 2.7.0 1501 | sonic-boom: 4.1.0 1502 | strip-json-comments: 3.1.1 1503 | 1504 | pino-roll@1.3.0: 1505 | dependencies: 1506 | sonic-boom: 3.8.1 1507 | 1508 | pino-std-serializers@7.0.0: {} 1509 | 1510 | pino@9.4.0: 1511 | dependencies: 1512 | atomic-sleep: 1.0.0 1513 | fast-redact: 3.5.0 1514 | on-exit-leak-free: 2.1.2 1515 | pino-abstract-transport: 1.2.0 1516 | pino-std-serializers: 7.0.0 1517 | process-warning: 4.0.0 1518 | quick-format-unescaped: 4.0.4 1519 | real-require: 0.2.0 1520 | safe-stable-stringify: 2.5.0 1521 | sonic-boom: 4.1.0 1522 | thread-stream: 3.1.0 1523 | 1524 | pn@1.1.0: {} 1525 | 1526 | prelude-ls@1.1.2: {} 1527 | 1528 | prettier@3.3.3: {} 1529 | 1530 | prisma@5.19.1: 1531 | dependencies: 1532 | '@prisma/engines': 5.19.1 1533 | optionalDependencies: 1534 | fsevents: 2.3.3 1535 | 1536 | process-warning@4.0.0: {} 1537 | 1538 | process@0.11.10: {} 1539 | 1540 | psl@1.9.0: {} 1541 | 1542 | pump@3.0.0: 1543 | dependencies: 1544 | end-of-stream: 1.4.4 1545 | once: 1.4.0 1546 | 1547 | punycode@2.3.1: {} 1548 | 1549 | qs@6.5.3: {} 1550 | 1551 | quick-format-unescaped@4.0.4: {} 1552 | 1553 | readable-stream@4.5.2: 1554 | dependencies: 1555 | abort-controller: 3.0.0 1556 | buffer: 6.0.3 1557 | events: 3.3.0 1558 | process: 0.11.10 1559 | string_decoder: 1.3.0 1560 | 1561 | real-require@0.2.0: {} 1562 | 1563 | request-promise-core@1.1.4(request@2.88.2): 1564 | dependencies: 1565 | lodash: 4.17.21 1566 | request: 2.88.2 1567 | 1568 | request-promise-native@1.0.9(request@2.88.2): 1569 | dependencies: 1570 | request: 2.88.2 1571 | request-promise-core: 1.1.4(request@2.88.2) 1572 | stealthy-require: 1.1.1 1573 | tough-cookie: 2.5.0 1574 | 1575 | request@2.88.2: 1576 | dependencies: 1577 | aws-sign2: 0.7.0 1578 | aws4: 1.13.2 1579 | caseless: 0.12.0 1580 | combined-stream: 1.0.8 1581 | extend: 3.0.2 1582 | forever-agent: 0.6.1 1583 | form-data: 2.3.3 1584 | har-validator: 5.1.5 1585 | http-signature: 1.2.0 1586 | is-typedarray: 1.0.0 1587 | isstream: 0.1.2 1588 | json-stringify-safe: 5.0.1 1589 | mime-types: 2.1.35 1590 | oauth-sign: 0.9.0 1591 | performance-now: 2.1.0 1592 | qs: 6.5.3 1593 | safe-buffer: 5.2.1 1594 | tough-cookie: 2.5.0 1595 | tunnel-agent: 0.6.0 1596 | uuid: 3.4.0 1597 | 1598 | resolve-pkg-maps@1.0.0: {} 1599 | 1600 | safe-buffer@5.2.1: {} 1601 | 1602 | safe-stable-stringify@2.5.0: {} 1603 | 1604 | safer-buffer@2.1.2: {} 1605 | 1606 | sax@1.4.1: {} 1607 | 1608 | secure-json-parse@2.7.0: {} 1609 | 1610 | sonic-boom@3.8.1: 1611 | dependencies: 1612 | atomic-sleep: 1.0.0 1613 | 1614 | sonic-boom@4.1.0: 1615 | dependencies: 1616 | atomic-sleep: 1.0.0 1617 | 1618 | source-map@0.6.1: 1619 | optional: true 1620 | 1621 | split2@4.2.0: {} 1622 | 1623 | sshpk@1.18.0: 1624 | dependencies: 1625 | asn1: 0.2.6 1626 | assert-plus: 1.0.0 1627 | bcrypt-pbkdf: 1.0.2 1628 | dashdash: 1.14.1 1629 | ecc-jsbn: 0.1.2 1630 | getpass: 0.1.7 1631 | jsbn: 0.1.1 1632 | safer-buffer: 2.1.2 1633 | tweetnacl: 0.14.5 1634 | 1635 | stealthy-require@1.1.1: {} 1636 | 1637 | string_decoder@1.3.0: 1638 | dependencies: 1639 | safe-buffer: 5.2.1 1640 | 1641 | strip-json-comments@3.1.1: {} 1642 | 1643 | symbol-tree@3.2.4: {} 1644 | 1645 | thread-stream@3.1.0: 1646 | dependencies: 1647 | real-require: 0.2.0 1648 | 1649 | tough-cookie@2.5.0: 1650 | dependencies: 1651 | psl: 1.9.0 1652 | punycode: 2.3.1 1653 | 1654 | tr46@1.0.1: 1655 | dependencies: 1656 | punycode: 2.3.1 1657 | 1658 | ts-mixer@6.0.4: {} 1659 | 1660 | tslib@2.7.0: {} 1661 | 1662 | tsx@4.19.0: 1663 | dependencies: 1664 | esbuild: 0.23.1 1665 | get-tsconfig: 4.8.0 1666 | optionalDependencies: 1667 | fsevents: 2.3.3 1668 | 1669 | tunnel-agent@0.6.0: 1670 | dependencies: 1671 | safe-buffer: 5.2.1 1672 | 1673 | tweetnacl@0.14.5: {} 1674 | 1675 | type-check@0.3.2: 1676 | dependencies: 1677 | prelude-ls: 1.1.2 1678 | 1679 | typescript@5.5.4: {} 1680 | 1681 | undici-types@6.19.8: {} 1682 | 1683 | undici@6.19.8: {} 1684 | 1685 | uri-js@4.4.1: 1686 | dependencies: 1687 | punycode: 2.3.1 1688 | 1689 | uuid@3.4.0: {} 1690 | 1691 | verror@1.10.0: 1692 | dependencies: 1693 | assert-plus: 1.0.0 1694 | core-util-is: 1.0.2 1695 | extsprintf: 1.3.0 1696 | 1697 | w3c-hr-time@1.0.2: 1698 | dependencies: 1699 | browser-process-hrtime: 1.0.0 1700 | 1701 | webidl-conversions@4.0.2: {} 1702 | 1703 | whatwg-encoding@1.0.5: 1704 | dependencies: 1705 | iconv-lite: 0.4.24 1706 | 1707 | whatwg-mimetype@2.3.0: {} 1708 | 1709 | whatwg-url@6.5.0: 1710 | dependencies: 1711 | lodash.sortby: 4.7.0 1712 | tr46: 1.0.1 1713 | webidl-conversions: 4.0.2 1714 | 1715 | whatwg-url@7.1.0: 1716 | dependencies: 1717 | lodash.sortby: 4.7.0 1718 | tr46: 1.0.1 1719 | webidl-conversions: 4.0.2 1720 | 1721 | word-wrap@1.2.5: {} 1722 | 1723 | wrappy@1.0.2: {} 1724 | 1725 | ws@5.2.4: 1726 | dependencies: 1727 | async-limiter: 1.0.1 1728 | 1729 | ws@8.18.0: {} 1730 | 1731 | xml-name-validator@3.0.0: {} 1732 | --------------------------------------------------------------------------------