├── .gitmodules ├── .nvmrc ├── .npmrc ├── web ├── staff-document │ └── .gitignore ├── fonts │ └── Ginto-Nord-700.woff ├── appeals │ ├── alert.html │ ├── form.html │ └── main.css ├── forbidden.html ├── logout.html ├── unauthorized.html ├── too_many_requests.html ├── bad_request.html ├── not_found.html ├── internal_server_error.html └── static │ ├── statuses │ ├── main.css │ ├── internal_server_error.svg │ ├── unauthorized.svg │ ├── forbidden.svg │ ├── too_many_requests.svg │ └── bad_request.svg │ └── robots.txt ├── .gitattributes ├── .dockerignore ├── .gitignore ├── eslint.config.mjs ├── src ├── constants │ ├── subservers │ │ ├── overrides │ │ │ ├── .gitignore │ │ │ └── index.ts │ │ ├── dev.ts │ │ ├── assets.ts │ │ ├── dev2.ts │ │ ├── minecraft.ts │ │ ├── index.ts │ │ └── staff.ts │ ├── discord.ts │ ├── emojis.ts │ ├── aboutContent │ │ ├── index.ts │ │ ├── section3AboutModeration.ts │ │ ├── section2ServerRules.ts │ │ ├── section4HowToMakeABlurpleImage.ts │ │ ├── section1ProjectBlurple.ts │ │ ├── section6FrequentlyAskedQuestions.ts │ │ └── section5RoleDescriptions.ts │ ├── zeppelinCases.ts │ └── policies.ts ├── utils │ ├── logger │ │ ├── staff.ts │ │ ├── database.ts │ │ ├── main.ts │ │ ├── discord.ts │ │ └── index.ts │ ├── text.ts │ ├── oauth.ts │ ├── time.ts │ ├── webtokens.ts │ ├── header.ts │ └── mail.ts ├── commands │ ├── chatInput │ │ ├── channels │ │ │ ├── index.ts │ │ │ ├── lock.ts │ │ │ └── unlock.ts │ │ ├── ping.ts │ │ ├── duty.ts │ │ ├── bean.ts │ │ ├── strip.ts │ │ ├── auth.ts │ │ ├── index.ts │ │ ├── join.ts │ │ └── forcejoin.ts │ ├── mention │ │ ├── checkDupes.ts │ │ ├── ping.ts │ │ ├── index.ts │ │ ├── recreateAbout.ts │ │ ├── policyCheck.ts │ │ └── eval.ts │ ├── menu │ │ ├── removeBlurple.ts │ │ ├── index.ts │ │ └── manageUserRestrictions.ts │ ├── applicationCommands.ts │ └── applicationCommands.test.ts ├── handlers │ ├── appeals │ │ ├── finalResolutionColors.ts │ │ ├── logging.ts │ │ ├── emails.ts │ │ ├── index.ts │ │ └── messageEntry.ts │ ├── restrictions │ │ ├── reactions.ts │ │ ├── nickname.ts │ │ └── index.ts │ ├── dutyPing.ts │ ├── serverEnforcements │ │ ├── index.ts │ │ ├── policyStatus.ts │ │ └── subserverAccess │ │ │ ├── calculator.ts │ │ │ └── refresh.ts │ ├── interactions │ │ ├── chatInputCommands.ts │ │ ├── menuCommands.ts │ │ ├── modals.ts │ │ ├── autocompletes.ts │ │ ├── index.ts │ │ └── components.ts │ ├── web │ │ ├── staffPortal │ │ │ ├── staffDocumentCloner.ts │ │ │ └── index.ts │ │ └── index.ts │ ├── mentionCommands.ts │ └── zeppelinCases.ts ├── database │ ├── models │ │ ├── UserStrip.ts │ │ ├── OAuthTokens.ts │ │ ├── SubserverAccessOverride.ts │ │ ├── ZeppelinCase.ts │ │ └── Appeal.ts │ └── index.ts ├── index.ts └── config.ts ├── tsconfig.json ├── .github ├── renovate.json ├── workflows │ ├── docker-compose-test.yml │ ├── label-sync.yml │ ├── testing.yml │ ├── linting.yml │ └── docker-image.yml └── labels.yml ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE ├── example.env ├── README.md ├── package.json └── docker-compose.yml /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.21.1 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /web/staff-document/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=crlf 2 | /web/fonts/* -text 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /build 2 | /database 3 | /logs 4 | /node_modules -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /database 3 | /logs 4 | /node_modules 5 | /.env -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | export { default } from "eslint-config-promise"; 2 | -------------------------------------------------------------------------------- /src/constants/subservers/overrides/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !index.ts 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /web/fonts/Ginto-Nord-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-blurple/blurple-hammer/HEAD/web/fonts/Ginto-Nord-700.woff -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ "./src" ], 3 | "extends": ["@tsconfig/node22", "@tsconfig/strictest"], 4 | "compilerOptions": { 5 | "outDir": "./build", 6 | "experimentalDecorators": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>promise/renovate-config", 4 | "github>promise/renovate-config:force-node-version(22)", 5 | "github>promise/renovate-config:force-mongo-version(4)" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/constants/discord.ts: -------------------------------------------------------------------------------- 1 | export const messageFetchLimit = 100; 2 | export const embedsPerMessage = 10; 3 | export const messagesPerBulkDeletion = 100; 4 | export const bulkDeleteDelay = 2000; 5 | export const charactersPerMessage = 2000; 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "aaron-bond.better-comments", 4 | "mikestead.dotenv", 5 | "dbaeumer.vscode-eslint", 6 | "christian-kohler.npm-intellisense", 7 | "meganrogge.template-string-converter" 8 | ] 9 | } -------------------------------------------------------------------------------- /src/utils/logger/staff.ts: -------------------------------------------------------------------------------- 1 | import { createLogger } from "winston"; 2 | import { createFileTransports, globalFormat } from "."; 3 | 4 | const staffLogger = createLogger({ 5 | format: globalFormat, 6 | transports: createFileTransports("staff", ["info", "debug"]), 7 | }); 8 | 9 | export default staffLogger; 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 3 | "editor.formatOnPaste": true, 4 | "editor.formatOnSave": true, 5 | "files.trimFinalNewlines": true, 6 | "files.trimTrailingWhitespace": true, 7 | "eslint.format.enable": true, 8 | "typescript.tsdk": "node_modules/typescript/lib" 9 | } -------------------------------------------------------------------------------- /src/commands/chatInput/channels/index.ts: -------------------------------------------------------------------------------- 1 | import type { FirstLevelChatInputCommand } from ".."; 2 | import lock from "./lock"; 3 | import unlock from "./unlock"; 4 | 5 | export default { 6 | name: "channel", 7 | description: "Manage channels", 8 | subcommands: [lock, unlock], 9 | } as FirstLevelChatInputCommand; 10 | -------------------------------------------------------------------------------- /src/utils/text.ts: -------------------------------------------------------------------------------- 1 | export const trail = "…"; 2 | export function fitText(string: string, length: number, includeTrail = true): string { 3 | if (string.length <= length) return string; 4 | if (includeTrail) return `${string.slice(0, length - trail.length).trimEnd()}${trail}`; 5 | return string.slice(0, length); 6 | } 7 | 8 | export const zeroWidthSpace = "\u200B"; 9 | -------------------------------------------------------------------------------- /src/handlers/appeals/finalResolutionColors.ts: -------------------------------------------------------------------------------- 1 | import { Colors } from "discord.js"; 2 | import type { AppealAction } from "../../database/models/Appeal"; 3 | 4 | export default { 5 | blocked: Colors.DarkRed, 6 | invalid: Colors.Fuchsia, 7 | none: Colors.LightGrey, 8 | reduction: Colors.Orange, 9 | removal: Colors.Green, 10 | } satisfies Record; 11 | -------------------------------------------------------------------------------- /src/utils/logger/database.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, transports } from "winston"; 2 | import { createFileTransports, globalFormat } from "."; 3 | 4 | const databaseLogger = createLogger({ 5 | format: globalFormat, 6 | transports: [ 7 | ...createFileTransports("database", ["debug"]), 8 | new transports.Console({ level: "info" }), 9 | ], 10 | }); 11 | 12 | export default databaseLogger; 13 | -------------------------------------------------------------------------------- /src/utils/logger/main.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, transports } from "winston"; 2 | import { createFileTransports, globalFormat } from "."; 3 | 4 | const mainLogger = createLogger({ 5 | format: globalFormat, 6 | transports: [ 7 | ...createFileTransports("main", ["info", "http", "debug"]), 8 | new transports.Console({ level: "info" }), 9 | ], 10 | }); 11 | 12 | export default mainLogger; 13 | -------------------------------------------------------------------------------- /src/utils/logger/discord.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, transports } from "winston"; 2 | import { createFileTransports, globalFormat } from "."; 3 | 4 | const discordLogger = createLogger({ 5 | format: globalFormat, 6 | transports: [ 7 | ...createFileTransports("discord", ["info", "debug"]), 8 | new transports.Console({ level: "info" }), 9 | ], 10 | }); 11 | 12 | export default discordLogger; 13 | -------------------------------------------------------------------------------- /web/appeals/alert.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Project Blurple | Appeals 8 | 9 | 10 | 11 |
12 |

${TITLE}

13 |

${MESSAGE}

14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/constants/subservers/overrides/index.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from "fs"; 2 | import type { Subserver } from ".."; 3 | 4 | const files = readdirSync(__dirname).filter(file => file !== "index.ts" && file !== ".gitignore"); 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-require-imports -- we need this for it to be synchronous 7 | const overrides = files.map(file => require(`./${file}`).default as Subserver).filter(Boolean); 8 | 9 | export default overrides; 10 | -------------------------------------------------------------------------------- /src/handlers/restrictions/reactions.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from "discord.js"; 2 | import config from "../../config"; 3 | 4 | export default function handleReactionsRestriction(client: Client): void { 5 | client.on("messageReactionAdd", (reaction, user) => void (async () => { 6 | const member = await reaction.message.guild?.members.fetch({ user: user.id, force: false }).catch(() => null); 7 | if (member?.roles.cache.has(config.roles.restrictions.reactions)) await reaction.users.remove(user.id); 8 | })()); 9 | } 10 | -------------------------------------------------------------------------------- /web/forbidden.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Project Blurple | Forbidden 8 | 9 | 10 | 11 |
12 | Forbidden 13 |

Forbidden

14 |

You don't have access to this page.

15 |
16 | 17 | -------------------------------------------------------------------------------- /src/database/models/UserStrip.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentType } from "@typegoose/typegoose"; 2 | import type { Snowflake } from "discord.js"; 3 | import { getModelForClass, prop, PropType } from "@typegoose/typegoose"; 4 | 5 | export class UserStripSchema { 6 | @prop({ type: [String], required: true }, PropType.ARRAY) roleIds!: Snowflake[]; 7 | @prop({ type: String, required: true }) userId!: Snowflake; 8 | } 9 | 10 | export type UserStripDocument = DocumentType; 11 | 12 | export const UserStrip = getModelForClass(UserStripSchema); 13 | -------------------------------------------------------------------------------- /web/logout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Project Blurple | Logged out 8 | 9 | 10 | 11 |
12 | Error 13 |

Logged out

14 |

You're now logged out. You can close this window, or log back in.

15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | name: Docker Compose 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | name: Test Build 14 | runs-on: self-hosted 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 19 | 20 | - name: Touch .env file 21 | run: echo -e "APPEALS_PORT=1234\nSTAFF_PORTAL_PORT=2345" > .env 22 | 23 | - name: Test docker compose build 24 | run: docker compose build 25 | -------------------------------------------------------------------------------- /src/database/models/OAuthTokens.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentType } from "@typegoose/typegoose"; 2 | import type { Snowflake } from "discord.js"; 3 | import { getModelForClass, prop } from "@typegoose/typegoose"; 4 | 5 | export class OAuthTokensSchema { 6 | @prop({ type: String, required: true }) accessToken!: string; 7 | @prop({ type: String, required: true }) refreshToken!: string; 8 | @prop({ type: String, required: true }) userId!: Snowflake; 9 | } 10 | 11 | export type OAuthTokensDocument = DocumentType; 12 | 13 | export const OAuthTokens = getModelForClass(OAuthTokensSchema); 14 | -------------------------------------------------------------------------------- /web/unauthorized.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Project Blurple | Unauthorized 8 | 9 | 10 | 11 |
12 | Unauthorized 13 |

Unauthorized

14 |

You need to authenticate yourself before accessing this page. Go to /auth

15 |
16 | 17 | -------------------------------------------------------------------------------- /src/commands/chatInput/ping.ts: -------------------------------------------------------------------------------- 1 | import type{ FirstLevelChatInputCommand } from "."; 2 | import { msToHumanShortTime } from "../../utils/time"; 3 | 4 | export default { 5 | name: "ping", 6 | description: "Ping the bot", 7 | public: true, 8 | async execute(interaction) { 9 | const now = Date.now(); 10 | await interaction.deferReply(); 11 | return void interaction.editReply(`🏓 Server latency is \`${Date.now() - now}ms\`, shard latency is \`${Math.ceil(interaction.guild.shard.ping)}ms\` and my uptime is \`${msToHumanShortTime(interaction.client.uptime)}\`.`); 12 | }, 13 | } as FirstLevelChatInputCommand; 14 | -------------------------------------------------------------------------------- /web/too_many_requests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Project Blurple | Too many requests 8 | 9 | 10 | 11 |
12 | Too many requests 13 |

Too many requests

14 |

You made too many requests within a short timespan. Please try again later.

15 |
16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/label-sync.yml: -------------------------------------------------------------------------------- 1 | name: Label sync 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - './github/labels.yml' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | label-sync: 13 | name: Sync labels with labels.yml 14 | runs-on: self-hosted 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 19 | 20 | - name: Label sync 21 | uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5 22 | with: 23 | github-token: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /src/database/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { inspect } from "util"; 3 | import config from "../config"; 4 | import databaseLogger from "../utils/logger/database"; 5 | 6 | mongoose.set("debug", (collectionName, method, query: string, doc: string) => databaseLogger.debug(JSON.stringify({ collectionName, method, query, doc }))); 7 | 8 | const connection = mongoose.connect(config.databaseUri); 9 | 10 | connection 11 | .then(() => databaseLogger.info("Connected to database")) 12 | .catch((err: unknown) => databaseLogger.error(`Error when connecting to database: ${inspect(err)}`)); 13 | 14 | export default connection; 15 | -------------------------------------------------------------------------------- /web/bad_request.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Project Blurple | Bad Request 8 | 9 | 10 | 11 |
12 | Bad Request 13 |

Bad request

14 |

Please contact BlurpleMail or promise@projectblurple.com if the issue persists.

15 |
16 | 17 | -------------------------------------------------------------------------------- /src/commands/mention/checkDupes.ts: -------------------------------------------------------------------------------- 1 | import type { MentionCommand } from "."; 2 | import Emojis from "../../constants/emojis"; 3 | 4 | export default { 5 | names: ["filterdupes", "dupes"], 6 | testArgs(args) { return args.length >= 1; }, 7 | execute(_, reply, args) { 8 | const filtered = args.filter((value, index, self) => self.indexOf(value) === index); 9 | return void reply(`${Emojis.Sparkle} Filtered ${args.length - filtered.length} duplicate${args.length - filtered.length === 1 ? "" : "s"} out of ${args.length} total argument${args.length === 1 ? "" : "s"}: \n> \`\`\`fix\n${filtered.join(" ")}\`\`\``); 10 | }, 11 | } as MentionCommand; 12 | -------------------------------------------------------------------------------- /src/commands/mention/ping.ts: -------------------------------------------------------------------------------- 1 | import type{ MentionCommand } from "."; 2 | import Emojis from "../../constants/emojis"; 3 | import { msToHumanShortTime } from "../../utils/time"; 4 | 5 | export default { 6 | names: ["ping", "pong", ""], 7 | testArgs(args) { return args.length === 0; }, 8 | async execute(message, reply) { 9 | const now = Date.now(); 10 | const botMessage = await reply("〽️ Pinging..."); 11 | return void botMessage.edit(`${Emojis.Sparkle} Server latency is \`${Date.now() - now}ms\`, shard latency is \`${Math.ceil(message.guild.shard.ping)}ms\` and my uptime is \`${msToHumanShortTime(message.client.uptime)}\`.`); 12 | }, 13 | } as MentionCommand; 14 | -------------------------------------------------------------------------------- /src/handlers/dutyPing.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from "discord.js"; 2 | import config from "../config"; 3 | import Emojis from "../constants/emojis"; 4 | 5 | export default function handleDutyPing(client: Client): void { 6 | const role = client.guilds.cache.get(config.mainGuildId)?.roles.cache.get(config.roles.staff.duty); 7 | client.on("messageCreate", message => { 8 | if (role && message.mentions.roles.has(role.id)) { 9 | void role.setMentionable(false, `Role was pinged in channel ${message.channelId} by ${message.author.tag}`); 10 | void message.react(Emojis.WeeWoo); 11 | setTimeout(() => void role.setMentionable(true), 1000 * 60 * 2); 12 | } 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /web/not_found.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Project Blurple | Not found 8 | 9 | 10 | 11 |
12 | Not found 13 |

Not found

14 |

We could not find what you were looking for. Please contact BlurpleMail or promise@projectblurple.com if you feel this is a fault on our side.

15 |
16 | 17 | -------------------------------------------------------------------------------- /web/internal_server_error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Project Blurple | Error 8 | 9 | 10 | 11 |
12 | Error 13 |

Error

14 |

An unknown error occurred. We're not able to disclose the error. Please contact BlurpleMail or promise@projectblurple.com if the issue persists.

15 |
16 | 17 | -------------------------------------------------------------------------------- /src/utils/oauth.ts: -------------------------------------------------------------------------------- 1 | import OAuth2 from "discord-oauth2"; 2 | import { createLogger } from "winston"; 3 | import config from "../config"; 4 | import { createFileTransports, globalFormat } from "./logger"; 5 | 6 | const oauth = new OAuth2({ 7 | clientId: config.client.id, 8 | clientSecret: config.client.secret, 9 | credentials: Buffer.from(`${config.client.id}:${config.client.secret}`).toString("base64"), 10 | }); 11 | 12 | const oauthLogger = createLogger({ format: globalFormat, transports: createFileTransports("discord-oauth2", ["debug", "warn"]) }); 13 | oauth.on("debug", message => oauthLogger.debug(message)); 14 | oauth.on("warn", message => oauthLogger.warn(message)); 15 | 16 | export default oauth; 17 | -------------------------------------------------------------------------------- /src/utils/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { format } from "winston"; 2 | import DailyRotateFile from "winston-daily-rotate-file"; 3 | 4 | export const globalFormat = format.combine( 5 | format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), 6 | format.align(), 7 | format.printf(({ level, timestamp, message }) => `${String(timestamp)} ${level}: ${String(message)}`), 8 | ); 9 | 10 | export function createFileTransports(name: string, levels: string[]): DailyRotateFile[] { 11 | return levels.map(level => new DailyRotateFile({ 12 | filename: `logs/${name}-${level}.%DATE%`, 13 | level, 14 | maxSize: "25m", 15 | maxFiles: "14d", 16 | zippedArchive: true, 17 | extension: ".log", 18 | })); 19 | } 20 | -------------------------------------------------------------------------------- /src/database/models/SubserverAccessOverride.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentType } from "@typegoose/typegoose"; 2 | import type { Snowflake } from "discord.js"; 3 | import { getModelForClass, prop } from "@typegoose/typegoose"; 4 | 5 | export class SubserverAccessOverrideSchema { 6 | @prop({ type: String, required: true }) issuerId!: Snowflake; 7 | @prop({ type: String, required: true }) reason!: string; 8 | @prop({ type: String, required: true }) subserverId!: Snowflake; 9 | @prop({ type: String, required: true }) userId!: Snowflake; 10 | } 11 | 12 | export type SubserverAccessOverrideDocument = DocumentType; 13 | 14 | export const SubserverAccessOverride = getModelForClass(SubserverAccessOverrideSchema); 15 | -------------------------------------------------------------------------------- /src/commands/menu/removeBlurple.ts: -------------------------------------------------------------------------------- 1 | import type { MenuCommand } from "."; 2 | import config from "../../config"; 3 | import Emojis from "../../constants/emojis"; 4 | 5 | export default { 6 | name: "Remove Blurple User", 7 | type: "user", 8 | execute(interaction, target) { 9 | if (target.roles.cache.has(config.roles.blurpleUser)) { 10 | void target.roles.remove(config.roles.blurpleUser); 11 | return void interaction.reply({ content: `${Emojis.TickYes} ${target.user.tag} no longer has the Blurple User role.`, ephemeral: true }); 12 | } 13 | 14 | return void interaction.reply({ content: `${Emojis.TickNo} ${target.user.tag} does not have the Blurple User role.`, ephemeral: true }); 15 | }, 16 | } as MenuCommand; 17 | -------------------------------------------------------------------------------- /src/utils/time.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/a/19700358 2 | // eslint-disable-next-line import/prefer-default-export -- multiple exports can be defined in this file 3 | export function msToHumanShortTime(ms: number): string { 4 | const days = Math.floor(ms / 86400000); 5 | const daysMs = ms % 86400000; 6 | const hours = Math.floor(daysMs / 3600000); 7 | const hoursMs = daysMs % 3600000; 8 | const minutes = Math.floor(hoursMs / 60000); 9 | const minutesMs = hoursMs % 60000; 10 | const seconds = Math.floor(minutesMs / 1000); 11 | 12 | let str = ""; 13 | if (days) str += `${days}d`; 14 | if (hours) str += `${hours}h`; 15 | if (minutes) str += `${minutes}m`; 16 | if (seconds) str += `${seconds}s`; 17 | return str || "0s"; 18 | } 19 | -------------------------------------------------------------------------------- /src/handlers/serverEnforcements/index.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from "discord.js"; 2 | import { SubserverAccessOverride } from "../../database/models/SubserverAccessOverride"; 3 | import { refreshAllSubserverAccess, refreshSubserverAccess } from "./subserverAccess/refresh"; 4 | 5 | export default function handleServerPolicies(client: Client): void { 6 | refreshAllSubserverAccess(client); 7 | 8 | client.on("guildMemberAdd", member => void refreshSubserverAccess(member.id, client)); 9 | client.on("guildMemberUpdate", member => void refreshSubserverAccess(member.id, client)); 10 | client.on("guildMemberRemove", member => void (async () => { 11 | await SubserverAccessOverride.deleteMany({ userId: member.id, guildId: member.guild.id }); 12 | void refreshSubserverAccess(member.id, client); 13 | })()); 14 | } 15 | -------------------------------------------------------------------------------- /web/static/statuses/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | --not-quite-black: rgb(35, 39, 42); 3 | --blurple: rgb(88, 101, 242); 4 | --full-white: rgb(255, 255, 255); 5 | } 6 | 7 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;500&display=swap'); 8 | 9 | body { 10 | font-family: 'Montserrat', sans-serif; 11 | background-color: var(--not-quite-black); 12 | height: 100vh; 13 | margin: 0; 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | text-align: center; 18 | } 19 | 20 | img { 21 | width: min(100%, 1000px); 22 | } 23 | 24 | h1 { 25 | color: var(--full-white); 26 | font-weight: 500; 27 | font-size: 3rem; 28 | } 29 | 30 | p { 31 | color: var(--full-white); 32 | font-weight: 300; 33 | font-size: 1.5rem; 34 | } 35 | 36 | a { 37 | color: var(--blurple); 38 | } -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | jest: 13 | name: Jest 14 | runs-on: self-hosted 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 19 | 20 | - name: Set up pnpm 21 | uses: pnpm/action-setup@v2 22 | with: 23 | run_install: false 24 | 25 | - name: Set up node 26 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 27 | with: 28 | node-version-file: ".nvmrc" 29 | cache: "pnpm" 30 | 31 | - name: Install dependencies 32 | run: pnpm install --frozen-lockfile 33 | 34 | - name: Run Jest 35 | run: pnpm test 36 | -------------------------------------------------------------------------------- /src/handlers/appeals/logging.ts: -------------------------------------------------------------------------------- 1 | import type { Client, TextChannel } from "discord.js"; 2 | import type { AppealDocument } from "../../database/models/Appeal"; 3 | import config from "../../config"; 4 | import generateAppealMessage from "./messageEntry"; 5 | 6 | export default function addLogToAppeal(appeal: AppealDocument, message: string, client: Client, updateMessage = true): void { 7 | appeal.logs.push({ message, timestamp: new Date() }); 8 | 9 | void (async () => { 10 | const appealChannel = client.channels.cache.get(config.channels.appeals) as TextChannel; 11 | const appealMessage = await appealChannel.messages.fetch(appeal.messageId).catch(() => null); 12 | 13 | if (updateMessage) void appealMessage?.edit(generateAppealMessage(appeal, client)); 14 | void appealMessage?.thread?.send(`**LOG:** ${message}`); 15 | })(); 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/webtokens.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import config from "../config"; 3 | 4 | export function sign(payload: object, expiresIn = "7 days"): Promise { 5 | return new Promise((resolve, reject) => { 6 | jwt.sign(payload, config.client.secret, { expiresIn: String(expiresIn) }, (err, token) => { 7 | if (err) return reject(err); 8 | resolve(token!); 9 | }); 10 | }); 11 | } 12 | 13 | export function verify(token: string): Promise { 14 | return new Promise(resolve => { 15 | jwt.verify(token, config.client.secret, {}, (err, decoded) => resolve(err ? false : Boolean(decoded))); 16 | }); 17 | } 18 | 19 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters 20 | export function decode(token: string): T { 21 | return jwt.decode(token, {}) as T; 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | eslint: 13 | name: ESLint 14 | runs-on: self-hosted 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 19 | 20 | - name: Set up pnpm 21 | uses: pnpm/action-setup@v2 22 | with: 23 | run_install: false 24 | 25 | - name: Set up node 26 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 27 | with: 28 | node-version-file: ".nvmrc" 29 | cache: "pnpm" 30 | 31 | - name: Install dependencies 32 | run: pnpm install --frozen-lockfile 33 | 34 | - name: Run ESLint 35 | run: pnpm lint 36 | -------------------------------------------------------------------------------- /src/handlers/interactions/chatInputCommands.ts: -------------------------------------------------------------------------------- 1 | import type{ ChatInputCommandInteraction } from "discord.js"; 2 | import { allChatInputCommands } from "../../commands/chatInput"; 3 | 4 | export default function chatInputCommandHandler(interaction: ChatInputCommandInteraction<"cached">): void { 5 | const hierarchy = [interaction.commandName, interaction.options.getSubcommandGroup(false), interaction.options.getSubcommand(false)] as const; 6 | let command = allChatInputCommands.find(({ name }) => name === hierarchy[0]); 7 | if (command && hierarchy[1] && "subcommands" in command) command = command.subcommands.find(({ name }) => name === hierarchy[1]); 8 | if (command && hierarchy[2] && "subcommands" in command) command = command.subcommands.find(({ name }) => name === hierarchy[2]); 9 | 10 | if (command && "execute" in command) return void command.execute(interaction); 11 | } 12 | -------------------------------------------------------------------------------- /src/commands/chatInput/duty.ts: -------------------------------------------------------------------------------- 1 | import type { FirstLevelChatInputCommand } from "."; 2 | import config from "../../config"; 3 | import Emojis from "../../constants/emojis"; 4 | 5 | export default { 6 | name: "duty", 7 | description: "Toggle the duty role", 8 | async execute(interaction) { 9 | const role = interaction.guild.roles.cache.get(config.roles.staff.duty)!; 10 | if (interaction.member.roles.cache.has(role.id)) { 11 | await interaction.member.roles.remove(role, "User toggled duty role"); 12 | return void interaction.reply({ content: `${Emojis.TickYes} You no longer have the duty role.`, ephemeral: true }); 13 | } 14 | 15 | await interaction.member.roles.add(role, "User toggled duty role"); 16 | return void interaction.reply({ content: `${Emojis.TickYes} You now have the duty role.`, ephemeral: true }); 17 | }, 18 | } as FirstLevelChatInputCommand; 19 | -------------------------------------------------------------------------------- /src/handlers/interactions/menuCommands.ts: -------------------------------------------------------------------------------- 1 | import type{ ContextMenuCommandInteraction } from "discord.js"; 2 | import { allMenuCommands } from "../../commands/menu"; 3 | 4 | export default async function menuCommandHandler(interaction: ContextMenuCommandInteraction<"cached">): Promise { 5 | const command = allMenuCommands.find(({ name }) => name === interaction.commandName); 6 | if (interaction.isMessageContextMenuCommand() && command?.type === "message") { 7 | const target = await interaction.channel?.messages.fetch(interaction.targetId).catch(() => null); 8 | if (target) return command.execute(interaction, target); 9 | } else if (interaction.isUserContextMenuCommand() && command?.type === "user") { 10 | const target = await interaction.guild.members.fetch(interaction.targetId).catch(() => null); 11 | if (target) return command.execute(interaction, target); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/handlers/restrictions/nickname.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from "discord.js"; 2 | import { AuditLogEvent } from "discord.js"; 3 | import config from "../../config"; 4 | 5 | export default function handleNicknameRestriction(client: Client): void { 6 | client.on("guildMemberUpdate", (old, member) => void (async () => { 7 | if (old.nickname !== member.nickname && member.roles.cache.has(config.roles.restrictions.nick)) { 8 | const audits = await member.guild.fetchAuditLogs({ type: AuditLogEvent.MemberUpdate }); 9 | const entry = audits.entries.sort((a, b) => b.createdTimestamp - a.createdTimestamp).find(({ target, changes }) => target?.id === member.id && changes.some(change => change.key === "nick")); 10 | 11 | if (entry?.executor?.id === member.id) await member.setNickname(old.nickname, "User is restricted from changing their nickname"); 12 | } 13 | })()); 14 | } 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine@sha256:0340fa682d72068edf603c305bfbc10e23219fb0e40df58d9ea4d6f33a9798bf AS base 2 | RUN apk --no-cache add g++ gcc make python3 3 | 4 | WORKDIR /app 5 | ENV IS_DOCKER=true 6 | 7 | 8 | # install prod dependencies 9 | 10 | FROM base AS deps 11 | RUN corepack enable pnpm 12 | 13 | COPY package.json ./ 14 | COPY pnpm-lock.yaml ./ 15 | 16 | RUN pnpm install --frozen-lockfile --prod 17 | 18 | 19 | # install all dependencies and build typescript 20 | 21 | FROM deps AS ts-builder 22 | RUN pnpm install --frozen-lockfile 23 | 24 | COPY tsconfig.json ./ 25 | COPY ./src ./src 26 | RUN pnpm run build 27 | 28 | 29 | # production image 30 | 31 | FROM base 32 | 33 | COPY .env* ./ 34 | COPY --from=deps /app/node_modules ./node_modules 35 | COPY --from=ts-builder /app/build ./build 36 | COPY ./web ./web 37 | COPY package.json ./ 38 | 39 | ENV NODE_ENV=production 40 | ENTRYPOINT [ "npm", "run" ] 41 | CMD [ "start" ] 42 | -------------------------------------------------------------------------------- /src/commands/mention/index.ts: -------------------------------------------------------------------------------- 1 | import type{ Awaitable, Message, MessageEditOptions, MessageReplyOptions } from "discord.js"; 2 | import { readdirSync } from "fs"; 3 | 4 | export interface MentionCommand { 5 | execute(message: Message, reply: (content: MessageEditOptions & MessageReplyOptions | string) => Promise, args: string[]): Awaitable; 6 | names: [string, ...string[]]; 7 | ownerOnly?: true; 8 | testArgs(args: string[]): boolean; 9 | } 10 | 11 | export const quickResponses: Array<[ 12 | triggers: [string, ...string[]], 13 | response: string, 14 | ]> = []; 15 | 16 | export const allMentionCommands = readdirSync(__dirname) 17 | .filter(file => !file.includes("index") && (file.endsWith(".js") || file.endsWith(".ts"))) 18 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-require-imports -- we need this for it to be synchronous 19 | .map(file => require(`./${file}`).default as MentionCommand); 20 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | ghcr: 9 | name: ${{ github.ref == 'refs/heads/main' && 'Build and Push' || 'Test Build' }} 10 | runs-on: self-hosted 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 15 | 16 | - name: Login to ghcr.io 17 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 18 | with: 19 | registry: ghcr.io 20 | username: ${{ github.actor }} 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - name: ${{ github.ref == 'refs/heads/main' && 'Build and Push' || 'Test Build' }} 24 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 25 | with: 26 | push: ${{ github.ref == 'refs/heads/main' }} 27 | tags: ghcr.io/project-blurple/blurple-hammer:latest 28 | -------------------------------------------------------------------------------- /src/commands/chatInput/bean.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from "discord.js"; 2 | import type { FirstLevelChatInputCommand } from "."; 3 | import Emojis from "../../constants/emojis"; 4 | 5 | export default { 6 | name: "bean", 7 | description: "Bean a user", 8 | options: [ 9 | { 10 | type: ApplicationCommandOptionType.User, 11 | name: "user", 12 | description: "The user to bean", 13 | required: true, 14 | }, 15 | { 16 | type: ApplicationCommandOptionType.String, 17 | name: "message", 18 | description: "The message to bean with", 19 | required: true, 20 | }, 21 | ], 22 | execute(interaction) { 23 | const user = interaction.options.getUser("user", true); 24 | const message = interaction.options.getString("message", true); 25 | return void interaction.reply({ content: `${user.toString()}: You have been beaned for ${message} ${Emojis.Sparkle}` }); 26 | }, 27 | } as FirstLevelChatInputCommand; 28 | -------------------------------------------------------------------------------- /src/constants/emojis.ts: -------------------------------------------------------------------------------- 1 | enum Emojis { 2 | Anger = "<:blurpleanger:708662727140311041>", 3 | Blank = "<:blank:840901752642338837>", 4 | Blurple = "<:blurple:972447932462813274>", 5 | Blurple2021 = "<:blurple2021:442603700960362497>", 6 | DarkBlurple = "<:darkblurple:972447932404084816>", 7 | DarkBlurple2021 = "<:darkblurple2021:442603701325266955>", 8 | Hammer = "<:hammer:840901015317250058>", 9 | Loading = "", 10 | Love = "", 11 | Sparkle = "<:sparkle:840901008204234772>", 12 | Star = "<:star:840901011274989588>", 13 | Tada = "<:tada:840901013727871006>", 14 | ThumbsDown = "<:thumbsdown:840901014550085652>", 15 | ThumbsUp = "<:thumbsup:840901010216976396>", 16 | TickNo = "<:tickno:840901009010458645>", 17 | TickYes = "<:tickyes:840901012441006100>", 18 | Wave = "<:wave:840901016220336149>", 19 | WeeWoo = "", 20 | White = "<:white:442603701648228352>", 21 | } 22 | 23 | export default Emojis; 24 | -------------------------------------------------------------------------------- /src/constants/aboutContent/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIEmbed, MessageCreateOptions } from "discord.js"; 2 | import config from "../../config"; 3 | import section1ProjectBlurple from "./section1ProjectBlurple"; 4 | import section2ServerRules from "./section2ServerRules"; 5 | import section3AboutModeration from "./section3AboutModeration"; 6 | import section4HowToMakeABlurpleImage from "./section4HowToMakeABlurpleImage"; 7 | import section5RoleDescriptions from "./section5RoleDescriptions"; 8 | import section6FrequentlyAskedQuestions from "./section6FrequentlyAskedQuestions"; 9 | 10 | export interface AboutSection { 11 | components?: MessageCreateOptions["components"] | undefined; 12 | embed: APIEmbed; 13 | title: string; 14 | } 15 | 16 | export const modmailMention = `<@${config.bots.modmail}>`; 17 | 18 | export default [ 19 | section1ProjectBlurple, 20 | section2ServerRules, 21 | section3AboutModeration, 22 | section4HowToMakeABlurpleImage, 23 | section5RoleDescriptions, 24 | section6FrequentlyAskedQuestions, 25 | ] as AboutSection[]; 26 | -------------------------------------------------------------------------------- /src/constants/subservers/dev.ts: -------------------------------------------------------------------------------- 1 | import type { Subserver } from "."; 2 | import { SubserverAccess } from "."; 3 | import config from "../../config"; 4 | 5 | export enum Roles { 6 | Administrators = "559351138034647070", 7 | Developer = "559815628953878551", 8 | Leadership = "1072131732817596509", 9 | OverrideRole = "837388484565925931", 10 | } 11 | 12 | const devSubserver: Subserver = { 13 | id: "559341262302347314", 14 | name: "Blurple Application Development", 15 | acronym: "BAD", 16 | staffAccess: { 17 | [config.roles.staff.administrators]: { 18 | access: SubserverAccess.Allowed, 19 | roles: [Roles.Administrators], 20 | }, 21 | [config.roles.staff.leadership]: { 22 | access: SubserverAccess.Allowed, 23 | roles: [Roles.Leadership], 24 | }, 25 | [config.roles.staff.teams.developer]: { 26 | access: SubserverAccess.Allowed, 27 | roles: [Roles.Developer], 28 | }, 29 | }, 30 | userOverrideNoticeRoleId: Roles.OverrideRole, 31 | }; 32 | 33 | export default devSubserver; 34 | -------------------------------------------------------------------------------- /src/constants/subservers/assets.ts: -------------------------------------------------------------------------------- 1 | import type { Subserver } from "."; 2 | import { SubserverAccess } from "."; 3 | import config from "../../config"; 4 | 5 | export enum Roles { 6 | Administrator = "708630517528002581", 7 | Designer = "799262919111344128", 8 | Leadership = "559336076456755200", 9 | OverrideRole = "1072130561184911481", 10 | } 11 | 12 | const assetsSubserver: Subserver = { 13 | id: "540758383582511115", 14 | name: "Blurple Asset Resource Facility", 15 | acronym: "BARF", 16 | 17 | staffAccess: { 18 | [config.roles.staff.administrators]: { 19 | access: SubserverAccess.Allowed, 20 | roles: [Roles.Administrator], 21 | }, 22 | [config.roles.staff.leadership]: { 23 | access: SubserverAccess.Allowed, 24 | roles: [Roles.Leadership], 25 | }, 26 | [config.roles.staff.teams.designer]: { 27 | access: SubserverAccess.Allowed, 28 | roles: [Roles.Designer], 29 | }, 30 | }, 31 | userOverrideNoticeRoleId: Roles.OverrideRole, 32 | }; 33 | 34 | export default assetsSubserver; 35 | -------------------------------------------------------------------------------- /src/constants/subservers/dev2.ts: -------------------------------------------------------------------------------- 1 | import type { Subserver } from "."; 2 | import { SubserverAccess } from "."; 3 | import config from "../../config"; 4 | 5 | export enum Roles { 6 | Administrators = "803646089696903209", 7 | BlurpleStaff = "803646824938340443", 8 | Leadership = "803646635225645076", 9 | OverrideRole = "1072136613330702378", 10 | } 11 | 12 | const dev2Subserver: Subserver = { 13 | id: "803645810549981244", 14 | name: "Blurple Innovative Development Environmental", 15 | acronym: "BIDE", 16 | staffAccess: { 17 | [config.roles.staff.administrators]: { 18 | access: SubserverAccess.Allowed, 19 | roles: [Roles.Administrators], 20 | }, 21 | [config.roles.staff.leadership]: { 22 | access: SubserverAccess.Allowed, 23 | roles: [Roles.Leadership], 24 | }, 25 | [config.roles.staff.all]: { 26 | access: SubserverAccess.Allowed, 27 | roles: [Roles.BlurpleStaff], 28 | }, 29 | }, 30 | userOverrideNoticeRoleId: Roles.OverrideRole, 31 | }; 32 | 33 | export default dev2Subserver; 34 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # GitHub 2 | 3 | - name: "bug" 4 | color: "D73A4A" 5 | description: "Something isn't working" 6 | 7 | - name: "duplicate" 8 | color: "CFD3D7" 9 | description: "This issue or pull request already exists" 10 | 11 | - name: "enhancement" 12 | color: "A2EEEF" 13 | description: "New feature or request" 14 | 15 | - name: "good first issue" 16 | color: "7057FF" 17 | description: "Good for newcomers" 18 | 19 | - name: "help wanted" 20 | color: "008672" 21 | description: "Extra attention is needed" 22 | 23 | - name: "invalid" 24 | color: "E4E669" 25 | description: "This doesn't seem right" 26 | 27 | - name: "repository" 28 | color: "C2E0C6" 29 | description: "Improves or adds to the repository" 30 | 31 | 32 | # Renovate 33 | 34 | - name: "dependencies" 35 | color: "0366D6" 36 | description: "Updates one or more dependencies" 37 | 38 | - name: "auto-merge" 39 | color: "10A631" 40 | description: "Bot auto-merges" 41 | 42 | - name: "stop-updating" 43 | color: "B60205" 44 | description: "Bot stops updating" -------------------------------------------------------------------------------- /src/constants/subservers/minecraft.ts: -------------------------------------------------------------------------------- 1 | import type { Subserver } from "."; 2 | import { SubserverAccess } from "."; 3 | import config from "../../config"; 4 | 5 | export enum Roles { 6 | Administrators = "804843371037982720", 7 | BlurpleStaff = "1072258066575265812", 8 | MinecraftManagement = "804874122520952852", 9 | MinecraftStaff = "804874182198165515", 10 | OverrideRole = "1072257810693373984", 11 | } 12 | 13 | const minecraftSubserver: Subserver = { 14 | id: "804843010479095818", 15 | name: "Blurple Assistive Minecraft Facility", 16 | acronym: "BAMF", 17 | staffAccess: { 18 | [config.roles.staff.administrators]: { 19 | access: SubserverAccess.Allowed, 20 | roles: [Roles.Administrators], 21 | }, 22 | [config.roles.staff.all]: { 23 | access: SubserverAccess.Allowed, 24 | roles: [Roles.BlurpleStaff], 25 | }, 26 | [config.roles.staff.teams.minecraft]: { 27 | access: SubserverAccess.Forced, 28 | roles: [Roles.MinecraftStaff], 29 | }, 30 | }, 31 | userOverrideNoticeRoleId: Roles.OverrideRole, 32 | }; 33 | 34 | export default minecraftSubserver; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Project Blurple 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/commands/menu/index.ts: -------------------------------------------------------------------------------- 1 | import type{ Awaitable, GuildMember, Message, MessageContextMenuCommandInteraction, UserContextMenuCommandInteraction } from "discord.js"; 2 | import { readdirSync } from "fs"; 3 | 4 | interface BaseMenuCommand { 5 | name: string; 6 | public?: true; 7 | } 8 | 9 | export interface UserMenuCommand extends BaseMenuCommand { 10 | execute(interaction: UserContextMenuCommandInteraction<"cached">, target: GuildMember): Awaitable; 11 | type: "user"; 12 | } 13 | 14 | export interface MessageMenuCommand extends BaseMenuCommand { 15 | execute(interaction: MessageContextMenuCommandInteraction<"cached">, target: Message): Awaitable; 16 | type: "message"; 17 | } 18 | 19 | export type MenuCommand = MessageMenuCommand | UserMenuCommand; 20 | 21 | export const allMenuCommands = readdirSync(__dirname) 22 | .filter(file => !file.includes("index") && (file.endsWith(".js") || file.endsWith(".ts"))) 23 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-require-imports -- we need this for it to be synchronous 24 | .map(file => require(`./${file}`).default as MenuCommand); 25 | -------------------------------------------------------------------------------- /src/constants/subservers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Snowflake } from "discord.js"; 2 | import { readdirSync } from "fs"; 3 | 4 | export enum SubserverAccess { Denied, Allowed, Forced } 5 | 6 | export interface Subserver { 7 | acronym: string; 8 | id: Snowflake; 9 | name: string; 10 | 11 | // snowflake can either be role id or user id 12 | staffAccess: Record; 16 | 17 | userOverrideNoticeRoleId?: Snowflake; 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-require-imports -- we can't import this in the file because it's a circular dependency 21 | const subservers = [...require("./overrides").default as Subserver[]]; 22 | 23 | if (!subservers.length) { 24 | readdirSync(__dirname) 25 | .filter(file => !file.startsWith("index") && file !== "overrides") 26 | .forEach(file => { 27 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-require-imports -- we need this for it to be synchronous 28 | const subserver = require(`./${file}`).default as Subserver; 29 | subservers.push(subserver); 30 | }); 31 | } 32 | 33 | export default subservers; 34 | -------------------------------------------------------------------------------- /src/constants/aboutContent/section3AboutModeration.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import type { AboutSection } from "."; 3 | 4 | export default { 5 | title: "About Moderation", 6 | embed: { 7 | // turn on word wrapping lol 8 | description: dedent` 9 | It is our responsibility to ensure that all content being posted in this server is appropriate for the entire community, especially when the server is open to all users. Therefore, we have to mind the presence of underage members and tackle inappropriate content with extreme sensitivity. 10 | 11 | While we believe that everyone has the right to free speech, we also have the right to implement and enforce rules for everyone's safety. If you are the kind of person to express offensive messages, sorry but this is not the right place for you. 12 | 13 | We will not deny the fact that our rule listing is not perfect, but one thing you should take note is that our rules are not, in any way, written by lawyers. If you are the kind of person to exploit and abuse every loophole you can find, there is this thing called common sense. Use it. 14 | 15 | If you want to appeal your punishment, you can do so here: [appeals.projectblurple.com](https://appeals.projectblurple.com) 16 | `, 17 | }, 18 | } satisfies AboutSection; 19 | -------------------------------------------------------------------------------- /src/constants/aboutContent/section2ServerRules.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import type { AboutSection } from "."; 3 | import { modmailMention } from "."; 4 | 5 | export default { 6 | title: "Server Rules", 7 | embed: { 8 | description: dedent` 9 | *Although we're an open community, we do have a few rules that we expect everyone to follow. If you're unsure about something, please ask a moderator through ${modmailMention}.* 10 | 11 | 1. We expect you to follow Discord's [Terms of Service](https://discord.com/terms) and [Guidelines](https://discord.com/guidelines), any violation of these will be forwarded to Discord's Trust and Safety team. 12 | 2. This is an English only server, this is purely because we're unable to moderate other languages. 13 | 3. Advertising is not allowed, we are not the place for this. Some channels might except this rule. 14 | 4. We do not tolerate any form of harassment or hate speech, this includes but is not limited to: racism, sexism, homophobia, transphobia, ableism, ageism, and any other form of discrimination. 15 | 16 | *Moderators can take appropriate action to you, regardless if it's a server rule or not. If you feel like your punishment was unfair, you can appeal here: [appeals.projectblurple.com](https://appeals.projectblurple.com)* 17 | `, 18 | }, 19 | } satisfies AboutSection; 20 | -------------------------------------------------------------------------------- /src/handlers/interactions/modals.ts: -------------------------------------------------------------------------------- 1 | import type { ActionRowData, Awaitable, ModalSubmitInteraction, TextInputComponentData } from "discord.js"; 2 | import { ComponentType } from "discord.js"; 3 | 4 | export type Modal = (interaction: ModalSubmitInteraction<"cached">) => Awaitable; 5 | 6 | export const modals = new Map(); 7 | 8 | export default function modalHandler(interaction: ModalSubmitInteraction<"cached">): void { 9 | const modal = modals.get(interaction.customId); 10 | if (modal) void modal(interaction); 11 | modals.delete(interaction.customId); 12 | } 13 | 14 | export function getModalTextInput(actionRows: ModalSubmitInteraction["components"], customId: string): null | string { 15 | const actionRow = actionRows.find(row => row.components.some(component => component.customId === customId)); 16 | if (!actionRow) return null; 17 | 18 | const textInput = actionRow.components.find(component => component.customId === customId); 19 | if (!textInput) return null; 20 | 21 | return textInput.value; 22 | } 23 | 24 | export function createModalTextInput(options: Omit): ActionRowData { 25 | return { 26 | type: ComponentType.ActionRow, 27 | components: [ 28 | { 29 | type: ComponentType.TextInput, 30 | ...options, 31 | }, 32 | ], 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | BOT_ID= 2 | BOT_SECRET= 3 | BOT_TOKEN= 4 | 5 | DATABASE_URI= 6 | 7 | OWNER_ID= 8 | GUILD_ID= 9 | OTHER_GUILD_IDS= 10 | 11 | CHANNEL_ABOUT= 12 | CHANNEL_APPEALS= 13 | CHANNELS_PUBLIC= 14 | CHANNELS_BLURPLEFIER= 15 | CHANNEL_ZEPPELIN_CASES= 16 | 17 | ROLE_ADMINISTRATORS= 18 | ROLE_LEADERSHIP= 19 | ROLE_STAFF= 20 | ROLE_TEAM_MODERATION= 21 | ROLE_TEAM_DEVELOPER= 22 | ROLE_TEAM_DESIGNER= 23 | ROLE_TEAM_EVENTS= 24 | ROLE_TEAM_MINECRAFT= 25 | ROLE_STAFF_ON_DUTY= 26 | ROLE_RESTRICTION_EMBED= 27 | ROLE_RESTRICTION_REACTIONS= 28 | ROLE_RESTRICTION_BOTS= 29 | ROLE_RESTRICTION_VAD= 30 | ROLE_RESTRICTION_NICK= 31 | ROLE_RESTRICTION_SOUNDBOARD= 32 | ROLE_PARTNERS= 33 | ROLE_MEGA_DONATORS= 34 | ROLE_DONATORS= 35 | ROLE_RETIRED_STAFF= 36 | ROLE_BLURPLE_SERVER_REPRESENTATIVE= 37 | ROLE_BLURPLE_USER= 38 | ROLE_PAINTERS= 39 | ROLE_ARTISTS= 40 | ROLE_ADVENTURERS= 41 | ROLE_ARCHIVE_ACCESS= 42 | ROLE_CANVAS_PING= 43 | ROLE_EVENTS_PING= 44 | 45 | BOT_MODMAIL= 46 | BOT_ZEPPELIN= 47 | 48 | APPEALS_PORT= 49 | APPEALS_URL= 50 | APPEALS_NUMBER_OF_PROXIES=0 51 | 52 | STAFF_PORTAL_PORT= 53 | STAFF_PORTAL_URL= 54 | STAFF_PORTAL_NUMBER_OF_PROXIES=0 55 | 56 | SMTP_HOST= 57 | SMTP_PORT= 58 | SMTP_SECURE= 59 | SMTP_USERNAME= 60 | SMTP_PASSWORD= 61 | SMTP_DISPLAY_NAME= 62 | SMTP_EMAIL_ADDRESS= 63 | SMTP_REPLY_TO_EMAIL_ADDRESS= 64 | 65 | SUBSERVERS_NO_DESTRUCTIVE_ACTIONS= 66 | 67 | STAFF_DOCUMENT_CLONING_TOKEN= 68 | -------------------------------------------------------------------------------- /src/database/models/ZeppelinCase.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import type { DocumentType } from "@typegoose/typegoose"; 3 | import type { Snowflake } from "discord.js"; 4 | import { getModelForClass, prop, PropType } from "@typegoose/typegoose"; 5 | import type { ZeppelinCaseType } from "../../constants/zeppelinCases"; 6 | 7 | export class ZeppelinCaseNotesSchema { 8 | @prop({ type: String, default: null }) body!: null | string; 9 | @prop({ type: Date, required: true }) createdAt!: Date; 10 | @prop({ type: String, required: true }) moderatorTag!: string; 11 | } 12 | 13 | export class ZeppelinCaseSchema { 14 | @prop({ type: Number, required: true }) caseNumber!: number; 15 | @prop({ type: Date, required: true }) createdAt!: Date; 16 | @prop({ type: Boolean, default: false }) hidden!: boolean; 17 | @prop({ type: String, required: true }) logMessageId!: Snowflake; 18 | @prop({ type: String, required: true }) moderatorId!: Snowflake; 19 | @prop({ type: [ZeppelinCaseNotesSchema], default: [] }, PropType.ARRAY) notes!: ZeppelinCaseNotesSchema[]; 20 | @prop({ type: String, default: null }) ppId!: null | Snowflake; 21 | @prop({ type: Number, required: true }) type!: ZeppelinCaseType; 22 | @prop({ type: String, required: true }) userId!: Snowflake; 23 | } 24 | 25 | export type ZeppelinCaseDocument = DocumentType; 26 | 27 | export const ZeppelinCase = getModelForClass(ZeppelinCaseSchema); 28 | -------------------------------------------------------------------------------- /src/handlers/interactions/autocompletes.ts: -------------------------------------------------------------------------------- 1 | import type{ ApplicationCommandOptionChoiceData, AutocompleteInteraction, Awaitable } from "discord.js"; 2 | import { allChatInputCommands } from "../../commands/chatInput"; 3 | 4 | export type Autocomplete = (query: QueryType, interaction: AutocompleteInteraction<"cached">) => Awaitable>>; 5 | 6 | export default async function autocompleteHandler(interaction: AutocompleteInteraction<"cached">): Promise { 7 | const hierarchy = [interaction.commandName, interaction.options.getSubcommandGroup(false), interaction.options.getSubcommand(false)] as const; 8 | let command = allChatInputCommands.find(({ name }) => name === hierarchy[0]); 9 | if (command && hierarchy[1] && "subcommands" in command) command = command.subcommands.find(({ name }) => name === hierarchy[1]); 10 | if (command && hierarchy[2] && "subcommands" in command) command = command.subcommands.find(({ name }) => name === hierarchy[2]); 11 | 12 | if (command && "options" in command) { 13 | const { name, value } = interaction.options.getFocused(true); 14 | const option = command.options.find(({ name: optionName }) => optionName === name); 15 | if (option && "autocomplete" in option) { 16 | const choices = await option.autocomplete(value as never, interaction); 17 | return interaction.respond(choices); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Docker test](https://img.shields.io/github/actions/workflow/status/project-blurple/blurple-hammer/docker-compose-test.yml)](https://github.com/project-blurple/blurple-hammer/actions/workflows/docker-compose-test.yml) 2 | [![Linting](https://img.shields.io/github/actions/workflow/status/project-blurple/blurple-hammer/linting.yml?label=quality)](https://github.com/project-blurple/blurple-hammer/actions/workflows/linting.yml) 3 | [![Testing](https://img.shields.io/github/actions/workflow/status/project-blurple/blurple-hammer/testing.yml?label=test)](https://github.com/project-blurple/blurple-hammer/actions/workflows/testing.yml) 4 | [![DeepScan grade](https://deepscan.io/api/teams/16173/projects/22743/branches/674974/badge/grade.svg)](https://deepscan.io/dashboard#view=project&tid=16173&pid=22743&bid=674974) 5 | [![discord.js version](https://img.shields.io/github/package-json/dependency-version/project-blurple/blurple-hammer/discord.js)](https://www.npmjs.com/package/discord.js) 6 | [![GitHub Issues](https://img.shields.io/github/issues-raw/project-blurple/blurple-hammer.svg)](https://github.com/project-blurple/blurple-hammer/issues) 7 | [![GitHub Pull Requests](https://img.shields.io/github/issues-pr-raw/project-blurple/blurple-hammer.svg)](https://github.com/project-blurple/blurple-hammer/pulls) 8 | 9 | # Blurple Hammer 10 | 11 | This repository is still work-in-progress and is not ready for production use. Documentation is also limited until the project is finalized for 2023. 12 | -------------------------------------------------------------------------------- /src/handlers/serverEnforcements/policyStatus.ts: -------------------------------------------------------------------------------- 1 | import type { Guild } from "discord.js"; 2 | import type { Policy } from "../../constants/policies"; 3 | import config from "../../config"; 4 | import { policies, PolicyStatus, ServerType } from "../../constants/policies"; 5 | import subservers from "../../constants/subservers"; 6 | 7 | export default async function getPolicyStatus(guild: Guild): Promise> { 8 | const me = await guild.members.fetchMe(); 9 | const serverType = getServerType(guild); 10 | 11 | return Object.fromEntries( 12 | await Promise.all( 13 | Object.entries(policies).map(async ([policy, { appliesTo, check }]) => { 14 | if (!appliesTo.includes(serverType)) return [Number(policy) as Policy, { status: PolicyStatus.NonApplicable }]; 15 | const [status, message] = await check({ guild, me }); 16 | return [Number(policy) as Policy, { status, message }]; 17 | }), 18 | ), 19 | ) as Record; 20 | } 21 | 22 | export function getServerType(guild: Guild): ServerType { 23 | let serverType = ServerType.Unknown; 24 | if (guild.id === config.mainGuildId) serverType = ServerType.Main; 25 | else if (subservers.some(subserver => subserver.id === guild.id)) serverType = ServerType.Subserver; 26 | else if (config.otherGuildIds.includes(guild.id)) serverType = ServerType.Other; 27 | return serverType; 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/chatInput/strip.ts: -------------------------------------------------------------------------------- 1 | import type { FirstLevelChatInputCommand } from "."; 2 | import config from "../../config"; 3 | import Emojis from "../../constants/emojis"; 4 | import { UserStrip } from "../../database/models/UserStrip"; 5 | 6 | export default { 7 | name: "strip", 8 | description: "Strip yourself (staff-only command)", 9 | public: true, 10 | async execute(interaction) { 11 | const strip = await UserStrip.findOne({ userId: interaction.user.id }); 12 | if (strip) { 13 | await interaction.member.roles.add(strip.roleIds, "User unstripped"); 14 | await strip.deleteOne(); 15 | return void interaction.reply({ content: `${Emojis.ThumbsUp} You have been unstripped.`, ephemeral: true }); 16 | } 17 | 18 | const isStaff = interaction.member.roles.cache.find(role => role.id === config.roles.staff.all); 19 | if (!isStaff) return void interaction.reply({ content: `${Emojis.ThumbsDown} You cannot strip yourself.`, ephemeral: true }); 20 | 21 | const me = await interaction.guild.members.fetchMe({ force: false, cache: true }); 22 | const roleIds = interaction.member.roles.cache.filter(role => role.id !== interaction.guild.roles.everyone.id && !role.managed && me.roles.highest.position > role.position).map(role => role.id); 23 | 24 | await UserStrip.create({ userId: interaction.user.id, roleIds }); 25 | await interaction.member.roles.remove(roleIds, "User stripped"); 26 | return void interaction.reply({ content: `${Emojis.ThumbsUp} You have been stripped.`, ephemeral: true }); 27 | }, 28 | } as FirstLevelChatInputCommand; 29 | -------------------------------------------------------------------------------- /src/constants/aboutContent/section4HowToMakeABlurpleImage.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import type { AboutSection } from "."; 3 | import Emojis from "../emojis"; 4 | 5 | export default { 6 | title: "How to make a Blurple image", 7 | embed: { 8 | description: dedent` 9 | There's a lot of ways to make a Blurple image. Here are some of them: 10 | 11 | 1. We have a "Blurplefier" bot that can automatically transform your user profile picture to Blurple colors. Type out the command, download the image, and change your avatar in User Settings. (this is unfortunately not available outside of the event) 12 | 2. For web-based image transformations, head to our official website: [projectblurple.com/paint](https://projectblurple.com/paint) 13 | 3. If you prefer to design your own logo, the color codes you need to know are as follows: 14 | `, 15 | fields: [ 16 | { 17 | name: "Colors", 18 | value: dedent` 19 | > ${Emojis.Blurple} **Blurple** - HEX: #5865F2 or RGB: (88, 101, 242) 20 | > ${Emojis.DarkBlurple} **Dark Blurple** - HEX: #454FBF or RGB: (69, 79, 191) 21 | > ${Emojis.White} **White** - HEX: #FFFFFF or RGB: (255, 255, 255) 22 | `, 23 | }, 24 | { 25 | name: "Legacy colors (pre-2021)", 26 | value: dedent` 27 | > ${Emojis.Blurple2021} **Legacy Blurple** - HEX: #7289DA or RGB: (114, 137, 218) 28 | > ${Emojis.DarkBlurple2021} **Legacy Dark Blurple** - HEX: #4E5D94 or RGB: (78, 93, 148) 29 | `, 30 | }, 31 | ], 32 | }, 33 | } satisfies AboutSection; 34 | -------------------------------------------------------------------------------- /src/constants/aboutContent/section1ProjectBlurple.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import type { AboutSection } from "."; 3 | import { zeroWidthSpace } from "../../utils/text"; 4 | 5 | export default { 6 | title: "Project Blurple", 7 | embed: { 8 | fields: [ 9 | { 10 | name: "What is Project Blurple?", 11 | value: "Project Blurple is a community-run initiative to celebrate Discord's anniversary (May 13th) every year.", 12 | }, 13 | { 14 | name: "Why the name \"Blurple\"?", 15 | value: "Blurple is a combination of blue and purple colors. It is the primary color used by Discord on their brand properties. Reference: [discord.com/branding](https://discord.com/branding)", 16 | }, 17 | { 18 | name: "Links", 19 | value: dedent` 20 | **Website:** [projectblurple.com](https://projectblurple.com) 21 | **Twitter:** [@BlurpleProject](https://twitter.com/BlurpleProject) 22 | **GitHub:** [@project-blurple](https://github.com/project-blurple) 23 | `, 24 | inline: true, 25 | }, 26 | { 27 | name: zeroWidthSpace, 28 | value: dedent` 29 | **Server Invite:** https://discord.gg/blurple 30 | **Blob Server 1:** https://discord.gg/9hPpBEY 31 | **Blob Server 2:** https://discord.gg/AqggbcT 32 | `, 33 | inline: true, 34 | }, 35 | { 36 | name: "How can I get involved during the event?", 37 | value: "You can read more about how you can get involved by checking out the !", 38 | }, 39 | ], 40 | }, 41 | } satisfies AboutSection; 42 | -------------------------------------------------------------------------------- /src/commands/chatInput/auth.ts: -------------------------------------------------------------------------------- 1 | import type { FirstLevelChatInputCommand } from "."; 2 | import config from "../../config"; 3 | import Emojis from "../../constants/emojis"; 4 | import { OAuthTokens } from "../../database/models/OAuthTokens"; 5 | import { refreshSubserverAccess } from "../../handlers/serverEnforcements/subserverAccess/refresh"; 6 | import oauth from "../../utils/oauth"; 7 | 8 | export default { 9 | name: "auth", 10 | description: "Authenticate yourself to the staff portal to gain access", 11 | async execute(interaction) { 12 | const tokens = await OAuthTokens.findOne({ userId: interaction.user.id }); 13 | if (!tokens) return void interaction.reply({ content: `${Emojis.Sparkle} Authenticate yourself [here](${new URL("/login", config.staffPortal!.url).href}).`, ephemeral: true }); 14 | 15 | return void oauth.tokenRequest({ 16 | refreshToken: tokens.refreshToken, 17 | grantType: "refresh_token", 18 | scope: ["identify", "guilds.join"], 19 | }) 20 | .then(({ access_token: accessToken, refresh_token: refreshToken }) => { 21 | tokens.accessToken = accessToken; 22 | tokens.refreshToken = refreshToken; 23 | void tokens.save().then(() => refreshSubserverAccess(interaction.user.id, interaction.client)); 24 | 25 | return void interaction.reply({ content: `${Emojis.TickYes} Your authentication works!`, ephemeral: true }); 26 | }) 27 | .catch(() => { 28 | void tokens.deleteOne(); 29 | return void interaction.reply({ content: `${Emojis.TickNo} Your authentication is no longer working, please authenticate yourself [here](${new URL("/login", config.staffPortal!.url).href}).`, ephemeral: true }); 30 | }); 31 | }, 32 | } as FirstLevelChatInputCommand; 33 | -------------------------------------------------------------------------------- /src/constants/zeppelinCases.ts: -------------------------------------------------------------------------------- 1 | import { Colors } from "discord.js"; 2 | 3 | export const enum ZeppelinCaseType { Ban = 1, Unban, Note, Warn, Kick, Mute, Unmute, Deleted, Softban } 4 | 5 | export const zeppelinCaseTypes: Record = { 11 | [ZeppelinCaseType.Ban]: { 12 | name: "Ban", 13 | color: Colors.DarkRed, 14 | hideForUser: false, 15 | appealable: true, 16 | }, 17 | [ZeppelinCaseType.Unban]: { 18 | name: "Unban", 19 | color: Colors.Blue, 20 | hideForUser: false, 21 | appealable: false, 22 | }, 23 | [ZeppelinCaseType.Note]: { 24 | name: "Note", 25 | color: Colors.LightGrey, 26 | hideForUser: true, 27 | appealable: false, 28 | }, 29 | [ZeppelinCaseType.Warn]: { 30 | name: "Warn", 31 | color: Colors.Yellow, 32 | hideForUser: false, 33 | appealable: true, 34 | }, 35 | [ZeppelinCaseType.Kick]: { 36 | name: "Kick", 37 | color: Colors.DarkOrange, 38 | hideForUser: false, 39 | appealable: true, 40 | }, 41 | [ZeppelinCaseType.Mute]: { 42 | name: "Mute", 43 | color: Colors.Orange, 44 | hideForUser: false, 45 | appealable: true, 46 | }, 47 | [ZeppelinCaseType.Unmute]: { 48 | name: "Unmute", 49 | color: Colors.Blue, 50 | hideForUser: false, 51 | appealable: false, 52 | }, 53 | [ZeppelinCaseType.Deleted]: { 54 | name: "Deleted", 55 | color: Colors.LightGrey, 56 | hideForUser: true, 57 | appealable: false, 58 | }, 59 | [ZeppelinCaseType.Softban]: { 60 | name: "Softban", 61 | color: Colors.DarkRed, 62 | hideForUser: false, 63 | appealable: true, 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/constants/aboutContent/section6FrequentlyAskedQuestions.ts: -------------------------------------------------------------------------------- 1 | import type { AboutSection } from "."; 2 | import { modmailMention } from "."; 3 | 4 | export default { 5 | title: "Frequently Asked Questions", 6 | embed: { 7 | fields: Object.entries({ 8 | "What does being a donator give me?": 9 | "Donators have their own lounge chat where they can hang out with other donators. They also get recognition for their donation.", 10 | "Why is there a year next to people's roles?": 11 | "If a user has a role with a year next to it, they gained that role during Discord's anniversary on that particular year. Some roles are also from other events, like april fools.", 12 | "Are you recruiting for staff?": 13 | "We currently do not accept applications. We will reach out if we are recruiting staff.", 14 | "Will this server shut down after May 13th?": 15 | "No, this server and the emoji servers will remain! We host the main event annually across the duration of approximately a week, and when the main event is over, we plan for next year. We also host some smaller events in between.", 16 | "Is this server an official Discord server?": 17 | "No, we are in no way affiliated, endorsed, verified nor partnered with Discord.", 18 | "I have a feedback or complaint!": 19 | `Feel free to forward any suggestions or complaints to the team through ${modmailMention}! We want everyone to have the best experience they can, so please do not hesitate to forward any concerns.`, 20 | "Why can't I upload files?": 21 | "We've denied some permissions during the off-season to prevent spam. Some roles are exempt from this.", 22 | }).map(([name, value]) => ({ name, value })), 23 | }, 24 | } satisfies AboutSection; 25 | -------------------------------------------------------------------------------- /web/appeals/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Project Blurple | Appeals 8 | 9 | 10 | 11 |
12 |
13 | 14 |

Project Blurple Appeals

15 | 16 |
17 |
18 |
19 |
20 | 21 |
22 | 28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 |
36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /src/utils/header.ts: -------------------------------------------------------------------------------- 1 | import { Colors } from "discord.js"; 2 | import { join } from "path"; 3 | import svg2img from "svg2img"; 4 | import TextToSVG from "text-to-svg"; 5 | import { promisify } from "util"; 6 | 7 | const fontPromise = promisify(TextToSVG.load)(join(__dirname, "../../web/fonts/Ginto-Nord-700.woff")); 8 | 9 | const width = 1800; 10 | const height = 300; 11 | const widthPadding = 100; 12 | const heightPadding = 50; 13 | const roundedCorners = height / 2.25; 14 | 15 | const svgTemplate = async (text: string, backgroundColor: number) => ` 16 | 17 | 18 | ${await generateTextPath(text)} 19 | 20 | `; 21 | 22 | async function generateTextPath(text: string): Promise { 23 | const font = (await fontPromise)!; 24 | 25 | let fontSize = height; 26 | let [textHeight, textWidth, textY] = [Infinity, Infinity, Infinity]; 27 | while (textHeight > height - heightPadding || textWidth > width - widthPadding) { 28 | fontSize -= 1; 29 | const metrics = font.getMetrics(text, { fontSize }); 30 | [textHeight, textWidth, textY] = [metrics.height, metrics.width, metrics.y]; 31 | } 32 | 33 | return font.getPath(text, { 34 | fontSize, 35 | /* eslint-disable id-length */ 36 | x: (width - textWidth) / 2, 37 | y: (height - textHeight) / 2 - textY, 38 | /* eslint-enable id-length */ 39 | attributes: { fill: `#${Colors.White.toString(16)}` }, 40 | }); 41 | } 42 | 43 | export default async function generateHeader(text: string, backgroundColor: number = Colors.Blurple): Promise { 44 | return new Promise(resolve => { 45 | void svgTemplate(text, backgroundColor).then(svg => { 46 | svg2img(svg, (error: Error | null, buffer: Buffer | null) => { 47 | if (error) throw error; 48 | if (buffer) resolve(buffer); 49 | }); 50 | }); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /src/handlers/appeals/emails.ts: -------------------------------------------------------------------------------- 1 | import type { AppealAction, AppealDocument } from "../../database/models/Appeal"; 2 | 3 | export const appealEmailHeader = (appeal: AppealDocument): string => `Hello ${appeal.user.tag},`; 4 | export const appealEmailFooter = [ 5 | "If you have any questions, please contact us via BlurpleMail or send an email to promise@projectblurple.com. Please do not reply to this email as it is not monitored.", 6 | "Also, if you have feedback on the appeal process, please let us know!", 7 | "", 8 | "Best regards,", 9 | "The Project Blurple Team", 10 | ].join("\n"); 11 | 12 | export const appealEmailConfirmationOnReceive = (appeal: AppealDocument): string => [ 13 | appealEmailHeader(appeal), 14 | "", 15 | `This is just an automated message letting you know that we have received your ${appeal.type} appeal, and a staff member will be looking at it as soon as possible.`, 16 | "Please note that this email does not mean your appeal has been accepted or denied, it is just a confirmation that we have received it.", 17 | "", 18 | appealEmailFooter, 19 | ].join("\n"); 20 | 21 | export const appealEmailActionTemplates: Record string> = { 22 | removal: appeal => [ 23 | appealEmailHeader(appeal), 24 | "We have reviewed your appeal and have decided to remove your punishment.", 25 | appealEmailFooter, 26 | ].join("\n\n"), 27 | 28 | reduction: appeal => [ 29 | appealEmailHeader(appeal), 30 | "We have reviewed your appeal and have decided to reduce your punishment.", 31 | appealEmailFooter, 32 | ].join("\n\n"), 33 | 34 | none: appeal => [ 35 | appealEmailHeader(appeal), 36 | "We have reviewed your appeal and have decided to not take any action. Your punishment will remain in place.", 37 | appealEmailFooter, 38 | ].join("\n\n"), 39 | 40 | /* eslint-disable @typescript-eslint/no-unused-vars -- invalid and appeals should be empty to not send an email to the user */ 41 | invalid: _appeal => "", 42 | blocked: _appeal => "", 43 | }; 44 | -------------------------------------------------------------------------------- /src/handlers/appeals/index.ts: -------------------------------------------------------------------------------- 1 | import type { Client, Snowflake, TextChannel } from "discord.js"; 2 | import { ThreadAutoArchiveDuration } from "discord.js"; 3 | import { inspect } from "util"; 4 | import type { AppealType } from "../../database/models/Appeal"; 5 | import config from "../../config"; 6 | import { Appeal } from "../../database/models/Appeal"; 7 | import mainLogger from "../../utils/logger/main"; 8 | import { sendMail } from "../../utils/mail"; 9 | import registerAppealButtons from "./buttons"; 10 | import { appealEmailConfirmationOnReceive } from "./emails"; 11 | import generateAppealMessage from "./messageEntry"; 12 | 13 | export default function handleAppeals(client: Client): void { 14 | void Appeal.find().then(appeals => appeals.forEach(appeal => { 15 | registerAppealButtons(appeal, client); 16 | })); 17 | 18 | // the appeals handler doesn't really need the client, but it's here for consistency between the handlers 19 | void client.channels.fetch(config.channels.appeals).catch((err: unknown) => { 20 | mainLogger.warn(`Appeals channel not found, error: ${inspect(err)}`); 21 | }); 22 | } 23 | 24 | export function createNewAppeal(user: { avatarUrl: string; email: string; id: Snowflake; tag: string }, userAppeal: { caseId: null | string; type: AppealType; userReason: string; userStatement: string }, client: Client): void { 25 | void Appeal.countDocuments().then(async count => { 26 | const appeal = new Appeal({ appealId: count + 1, user, ...userAppeal }); 27 | 28 | void sendMail([user.tag, user.email], `Appeal #${appeal.appealId} has been received`, appealEmailConfirmationOnReceive(appeal)); 29 | 30 | const appealChannel = client.channels.cache.get(config.channels.appeals) as TextChannel; 31 | const message = await appealChannel.send(generateAppealMessage(appeal, client)); 32 | 33 | appeal.messageId = message.id; 34 | void appeal.save(); 35 | 36 | void message.startThread({ name: `#${appeal.appealId}, ${appeal.user.tag}`, autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/handlers/restrictions/index.ts: -------------------------------------------------------------------------------- 1 | import type { Client, Snowflake } from "discord.js"; 2 | import config from "../../config"; 3 | import handleNicknameRestriction from "./nickname"; 4 | import handleReactionsRestriction from "./reactions"; 5 | 6 | export default function handleRestrictions(client: Client): void { 7 | handleNicknameRestriction(client); 8 | handleReactionsRestriction(client); 9 | } 10 | 11 | export const restrictions: Array<{ 12 | description: string; 13 | descriptionAllowed: string; 14 | descriptionDisallowed: string; 15 | name: string; 16 | roleId: Snowflake; 17 | }> = [ 18 | { 19 | name: "embed", 20 | description: "Disallow the use of embeds", 21 | descriptionAllowed: "Can use embeds", 22 | descriptionDisallowed: "Cannot use embeds", 23 | roleId: config.roles.restrictions.nick, 24 | }, 25 | { 26 | name: "reactions", 27 | description: "Disallow adding reactions", 28 | descriptionAllowed: "Can add reactions", 29 | descriptionDisallowed: "Cannot add reactions", 30 | roleId: config.roles.restrictions.reactions, 31 | }, 32 | { 33 | name: "bots", 34 | description: "Disallow using bot commands", 35 | descriptionAllowed: "Can use bot commands", 36 | descriptionDisallowed: "Cannot use bot commands", 37 | roleId: config.roles.restrictions.bots, 38 | }, 39 | { 40 | name: "vad", 41 | description: "Disallow voice activity detection", 42 | descriptionAllowed: "Can use voice activity detection", 43 | descriptionDisallowed: "Cannot use voice activity detection", 44 | roleId: config.roles.restrictions.vad, 45 | }, 46 | { 47 | name: "nick", 48 | description: "Disallow nickname change", 49 | descriptionAllowed: "Can change nickname", 50 | descriptionDisallowed: "Cannot change nickname", 51 | roleId: config.roles.restrictions.nick, 52 | }, 53 | { 54 | name: "soundboard", 55 | description: "Disallow soundboard", 56 | descriptionAllowed: "Can use soundboard", 57 | descriptionDisallowed: "Cannot use soundboard", 58 | roleId: config.roles.restrictions.soundboard, 59 | }, 60 | ]; 61 | -------------------------------------------------------------------------------- /src/constants/subservers/staff.ts: -------------------------------------------------------------------------------- 1 | import type { Subserver } from "."; 2 | import { SubserverAccess } from "."; 3 | import config from "../../config"; 4 | import { Roles as MinecraftRoles } from "./minecraft"; 5 | 6 | export enum Roles { 7 | Administrator = "573193684296925204", 8 | BlurpleStaff = "1228258710233546793", 9 | Designer = "573355860584038400", 10 | Developer = "573223306627514386", 11 | Events = "972864361293029406", 12 | GiveawayManagement = "972803140296511488", 13 | Leadership = "573176977045979147", 14 | Minecraft = "701872110754070590", 15 | MinecraftManagement = "573177129693609984", 16 | Moderator = "573176977683644450", 17 | Modmails = "1236769849426968689", 18 | PartnershipManagement = "840532265167486978", 19 | } 20 | 21 | const staffSubserver: Subserver = { 22 | id: "573169434227900417", 23 | name: "Blurple Analogous Staff Environment", 24 | acronym: "BASE", 25 | staffAccess: { 26 | [config.roles.staff.administrators]: { 27 | access: SubserverAccess.Forced, 28 | roles: [Roles.Administrator], 29 | }, 30 | [config.roles.staff.leadership]: { 31 | access: SubserverAccess.Forced, 32 | roles: [Roles.Leadership], 33 | }, 34 | [config.roles.staff.all]: { 35 | access: SubserverAccess.Allowed, 36 | roles: [Roles.BlurpleStaff], 37 | }, 38 | [config.roles.staff.teams.moderation]: { access: SubserverAccess.Forced, roles: [Roles.Moderator] }, 39 | [config.roles.staff.teams.developer]: { roles: [Roles.Developer] }, 40 | [config.roles.staff.teams.designer]: { roles: [Roles.Designer] }, 41 | [config.roles.staff.teams.events]: { roles: [Roles.Events] }, 42 | [config.roles.staff.teams.giveaways]: { access: SubserverAccess.Forced, roles: [Roles.GiveawayManagement] }, 43 | [config.roles.staff.teams.minecraft]: { access: SubserverAccess.Forced, roles: [Roles.Minecraft] }, 44 | [config.roles.staff.teams.modmails]: { access: SubserverAccess.Forced, roles: [Roles.Modmails] }, 45 | [config.roles.staff.teams.partnerships]: { access: SubserverAccess.Forced, roles: [Roles.PartnershipManagement] }, 46 | [MinecraftRoles.MinecraftManagement]: { roles: [Roles.MinecraftManagement] }, 47 | }, 48 | }; 49 | 50 | export default staffSubserver; 51 | -------------------------------------------------------------------------------- /src/database/models/Appeal.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import type { DocumentType } from "@typegoose/typegoose"; 3 | import type { Snowflake } from "discord.js"; 4 | import { getModelForClass, prop, PropType } from "@typegoose/typegoose"; 5 | 6 | export type AppealType = "ban" | "kick" | "mute" | "warning"; 7 | export type AppealAction = "blocked" | "invalid" | "none" | "reduction" | "removal"; 8 | 9 | class UserSchema { 10 | @prop({ type: String, required: true }) avatarUrl!: string; 11 | @prop({ type: String, required: true }) email!: string; 12 | @prop({ type: String, required: true }) id!: Snowflake; 13 | @prop({ type: String, required: true }) tag!: string; 14 | } 15 | 16 | class FinalResolutionSchema { 17 | @prop({ type: String, required: true }) action!: AppealAction; 18 | @prop({ type: String, required: true }) reason!: string; 19 | @prop({ type: String, required: true }) staffId!: Snowflake; 20 | @prop({ type: String, default: null }) statement!: null | string; 21 | @prop({ type: Date, default: Date.now }) timestamp!: Date; 22 | } 23 | 24 | class LogEntrySchema { 25 | @prop({ type: String, required: true }) message!: string; 26 | @prop({ type: Date, default: Date.now }) timestamp!: Date; 27 | } 28 | 29 | export class AppealSchema { 30 | @prop({ type: Number, required: true }) appealId!: number; 31 | @prop({ type: String, default: null }) caseId!: null | string; 32 | 33 | @prop({ type: Date, default: Date.now }) createdAt!: Date; 34 | @prop({ type: FinalResolutionSchema, default: null }) finalResolution!: FinalResolutionSchema | null; 35 | 36 | @prop({ type: [LogEntrySchema], default: [] }, PropType.ARRAY) logs!: LogEntrySchema[]; 37 | @prop({ type: String, required: true }) messageId!: Snowflake; 38 | @prop({ type: String, default: null }) staffAssigneeId!: null | Snowflake; 39 | 40 | @prop({ type: String, required: true }) type!: AppealType; 41 | @prop({ type: UserSchema, required: true }) user!: UserSchema; 42 | 43 | @prop({ type: String, required: true }) userReason!: string; 44 | @prop({ type: String, required: true }) userStatement!: string; 45 | } 46 | 47 | export type AppealDocument = DocumentType; 48 | 49 | export const Appeal = getModelForClass(AppealSchema); 50 | -------------------------------------------------------------------------------- /src/handlers/web/staffPortal/staffDocumentCloner.ts: -------------------------------------------------------------------------------- 1 | import decompress from "decompress"; 2 | import { createWriteStream } from "fs"; 3 | import { move } from "fs-extra"; 4 | import { mkdir, readdir, rm } from "fs/promises"; 5 | import { tmpdir } from "os"; 6 | import { join } from "path"; 7 | import superfetch from "superagent"; 8 | import config from "../../../config"; 9 | import mainLogger from "../../../utils/logger/main"; 10 | 11 | export const staffDocumentFolder = join(__dirname, "../../../staff-document"); 12 | 13 | const tempDirectory = join(tmpdir(), "staff-document-cloner"); 14 | const githubToken = config.staffDocumentCloningToken!; 15 | 16 | // delete the /web/staff-document folder, then re-download it from github and unzip it 17 | export default async function cloneStaffDocument(): Promise { 18 | // make an empty folder if the token is not set 19 | if (!githubToken) return void mkdir(staffDocumentFolder, { recursive: true }); 20 | await downloadStaffDocumentZip(); 21 | await unpackZipAndMoveFolder(); 22 | mainLogger.info("Staff document has been successfully re-cloned"); 23 | } 24 | 25 | async function downloadStaffDocumentZip(): Promise { 26 | await rm(tempDirectory, { force: true, recursive: true }); 27 | await mkdir(tempDirectory, { recursive: true }); 28 | 29 | return new Promise(resolve => { 30 | const stream = createWriteStream(join(tempDirectory, "build.zip")); 31 | superfetch 32 | .get("https://api.github.com/repos/project-blurple/staff-document/zipball/build") 33 | .set("User-Agent", "Blurple Hammer") 34 | .set("Accept", "application/vnd.github+json") 35 | .set("Authorization", `Bearer ${githubToken}`) 36 | .set("X-GitHub-Api-Version", "2022-11-28") 37 | .redirects(1) 38 | .pipe(stream) 39 | .on("close", () => resolve(stream.close())); 40 | }); 41 | } 42 | 43 | async function unpackZipAndMoveFolder(): Promise { 44 | await decompress(join(tempDirectory, "build.zip"), join(tempDirectory, "buildBundle")); 45 | const [folder] = await readdir(join(tempDirectory, "buildBundle")) as [string]; 46 | await rm(staffDocumentFolder, { force: true, recursive: true }); 47 | await move(join(tempDirectory, "buildBundle", folder), staffDocumentFolder); 48 | } 49 | 50 | void cloneStaffDocument(); 51 | -------------------------------------------------------------------------------- /src/handlers/interactions/index.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from "discord.js"; 2 | import { ApplicationCommandOptionType } from "discord.js"; 3 | import getAllApplicationCommands from "../../commands/applicationCommands"; 4 | import config from "../../config"; 5 | import mainLogger from "../../utils/logger/main"; 6 | import autocompleteHandler from "./autocompletes"; 7 | import chatInputCommandHandler from "./chatInputCommands"; 8 | import componentHandler from "./components"; 9 | import menuCommandHandler from "./menuCommands"; 10 | import modalHandler from "./modals"; 11 | 12 | export const commandMentions: Record = {}; 13 | 14 | export default function handleInteractions(client: Client): void { 15 | client.on("interactionCreate", interaction => { 16 | if (!interaction.inCachedGuild()) return void mainLogger.warn(`Received interaction ${interaction.id} (guild ${interaction.guildId ?? "n/a"}, channel ${interaction.channelId ?? "n/a"}, user ${interaction.user.id}) from uncached guild.`); 17 | if (interaction.isModalSubmit()) return modalHandler(interaction); 18 | if (interaction.isMessageComponent()) return componentHandler(interaction); 19 | if (interaction.isChatInputCommand()) return chatInputCommandHandler(interaction); 20 | if (interaction.isContextMenuCommand()) return void menuCommandHandler(interaction); 21 | if (interaction.isAutocomplete()) return void autocompleteHandler(interaction); 22 | }); 23 | 24 | mainLogger.info("Interaction command listener registered."); 25 | 26 | void client.guilds.cache.get(config.mainGuildId)!.commands.set(getAllApplicationCommands()).then(commands => { 27 | mainLogger.info("Application commands registered."); 28 | 29 | // register command mentions 30 | commands.forEach(command => { 31 | commandMentions[command.name] = ``; 32 | command.options.filter(option => option.type === ApplicationCommandOptionType.SubcommandGroup || option.type === ApplicationCommandOptionType.Subcommand).forEach(subcommand => { 33 | commandMentions[`${command.name} ${subcommand.name}`] = ``; 34 | if (subcommand.type === ApplicationCommandOptionType.SubcommandGroup && "options" in subcommand) { 35 | subcommand.options.forEach(subsubcommand => { 36 | commandMentions[`${command.name} ${subcommand.name} ${subsubcommand.name}`] = ``; 37 | }); 38 | } 39 | }); 40 | }); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blurple-hammer", 3 | "main": "build", 4 | "license": "MIT", 5 | "scripts": { 6 | "build": "tsc", 7 | "build:watch": "tsc -w", 8 | "docker": "npm run docker:build && npm run docker:up", 9 | "docker:build": "docker-compose --project-directory . build", 10 | "docker:down": "docker-compose --project-directory . down", 11 | "docker:logs": "docker-compose --project-directory . logs --tail=500 -f", 12 | "docker:start": "npm run docker:up", 13 | "docker:stop": "npm run docker:down", 14 | "docker:up": "docker-compose --project-directory . up -d", 15 | "lint": "eslint .", 16 | "lint:fix": "eslint . --fix", 17 | "start": "node .", 18 | "test": "jest" 19 | }, 20 | "dependencies": { 21 | "@sapphire/type": "2.6.0", 22 | "@typegoose/typegoose": "12.20.0", 23 | "bufferutil": "4.0.9", 24 | "cookie-parser": "1.4.7", 25 | "decompress": "4.2.1", 26 | "dedent": "1.7.1", 27 | "discord-oauth2": "2.12.1", 28 | "discord.js": "14.16.3", 29 | "dotenv": "17.2.3", 30 | "express": "5.1.0", 31 | "express-rate-limit": "8.2.1", 32 | "fs-extra": "11.3.3", 33 | "helmet": "8.1.0", 34 | "jsonwebtoken": "9.0.2", 35 | "match-sorter": "8.2.0", 36 | "mongoose": "8.20.4", 37 | "morgan": "1.10.1", 38 | "nodemailer": "7.0.11", 39 | "superagent": "10.2.3", 40 | "svg-png-converter": "0.0.9", 41 | "svg2img": "1.0.0-beta.2", 42 | "text-to-svg": "3.1.5", 43 | "winston": "3.19.0", 44 | "winston-daily-rotate-file": "5.0.0", 45 | "zlib-sync": "0.1.10" 46 | }, 47 | "devDependencies": { 48 | "@tsconfig/node22": "22.0.5", 49 | "@tsconfig/strictest": "2.0.8", 50 | "@types/cookie-parser": "1.4.10", 51 | "@types/decompress": "4.2.7", 52 | "@types/express": "5.0.6", 53 | "@types/fs-extra": "11.0.4", 54 | "@types/jest": "30.0.0", 55 | "@types/jsonwebtoken": "9.0.7", 56 | "@types/morgan": "1.9.10", 57 | "@types/node": "22.19.3", 58 | "@types/nodemailer": "7.0.4", 59 | "@types/superagent": "8.1.9", 60 | "@types/text-to-svg": "3.1.4", 61 | "eslint": "9.39.2", 62 | "eslint-config-promise": "github:promise/eslint-config", 63 | "jest": "30.2.0", 64 | "ts-jest": "29.4.6", 65 | "typescript": "5.9.3" 66 | }, 67 | "eslintConfig": { 68 | "extends": "promise" 69 | }, 70 | "jest": { 71 | "preset": "ts-jest", 72 | "testEnvironment": "node", 73 | "testPathIgnorePatterns": [ 74 | "/build/", 75 | "/node_modules/" 76 | ] 77 | }, 78 | "packageManager": "pnpm@10.26.0" 79 | } 80 | -------------------------------------------------------------------------------- /src/commands/menu/manageUserRestrictions.ts: -------------------------------------------------------------------------------- 1 | import type { ButtonComponentData, GuildMember, InteractionReplyOptions, InteractionUpdateOptions } from "discord.js"; 2 | import { ButtonStyle, ComponentType } from "discord.js"; 3 | import type { MenuCommand } from "."; 4 | import Emojis from "../../constants/emojis"; 5 | import { buttonComponents } from "../../handlers/interactions/components"; 6 | import { restrictions } from "../../handlers/restrictions"; 7 | 8 | export default { 9 | name: "Manage user restrictions", 10 | type: "user", 11 | execute(interaction, target) { 12 | const restrictionStatusRecord: Record = Object.fromEntries(restrictions.map(restriction => [restriction.name, target.roles.cache.has(restriction.roleId)])); 13 | 14 | restrictions.forEach(({ name, roleId }) => { 15 | buttonComponents.set(`${interaction.id}:toggle-${name}`, { 16 | allowedUsers: [interaction.user.id], 17 | persistent: true, 18 | callback(button) { 19 | restrictionStatusRecord[name] = !(restrictionStatusRecord[name] ?? false); 20 | 21 | if (restrictionStatusRecord[name]) void target.roles.add(roleId); 22 | else void target.roles.remove(roleId); 23 | 24 | void button.update(generateMessage(target, restrictionStatusRecord, interaction.id)); 25 | }, 26 | }); 27 | }); 28 | 29 | return void interaction.reply({ ...generateMessage(target, restrictionStatusRecord, interaction.id), ephemeral: true }); 30 | }, 31 | } as MenuCommand; 32 | 33 | function generateMessage(member: GuildMember, restrictionStatusRecord: Record, uniqueIdentifier: string): InteractionReplyOptions & InteractionUpdateOptions { 34 | const buttons: ButtonComponentData[] = restrictions.map(({ name, descriptionAllowed, descriptionDisallowed }) => ({ 35 | type: ComponentType.Button, 36 | customId: `${uniqueIdentifier}:toggle-${name}`, 37 | style: restrictionStatusRecord[name] ? ButtonStyle.Danger : ButtonStyle.Success, 38 | label: restrictionStatusRecord[name] ? descriptionDisallowed : descriptionAllowed, 39 | })); 40 | 41 | // split buttons into groups of five. copilot made this code, i have no idea how it works 42 | const buttonGroups = buttons.reduce((acc, button, index) => { 43 | if (index % 5 === 0) acc.push([]); 44 | acc[acc.length - 1]!.push(button); 45 | return acc; 46 | }, []); 47 | 48 | return { 49 | content: `${Emojis.WeeWoo} Manage restrictions for user ${member.toString()}:`, 50 | components: buttonGroups.map(buttonGroup => ({ type: ComponentType.ActionRow, components: buttonGroup })), 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/mail.ts: -------------------------------------------------------------------------------- 1 | import type SMTPTransport from "nodemailer/lib/smtp-transport"; 2 | import nodemailer from "nodemailer"; 3 | import { inspect } from "util"; 4 | import { createLogger } from "winston"; 5 | import config from "../config"; 6 | import { createFileTransports, globalFormat } from "./logger"; 7 | 8 | const defaults: SMTPTransport.Options = { 9 | ...config.smtpSettings && { 10 | from: `"${config.smtpSettings.displayName}" <${config.smtpSettings.emailAddress}>`, 11 | ...config.smtpSettings.replyToEmailAddress && { replyTo: config.smtpSettings.replyToEmailAddress }, 12 | }, 13 | }; 14 | 15 | const promisedTransporter = (async () => { 16 | if (config.smtpSettings) { 17 | return nodemailer.createTransport({ 18 | host: config.smtpSettings.host, 19 | port: config.smtpSettings.port, 20 | secure: config.smtpSettings.secure, 21 | auth: { 22 | user: config.smtpSettings.username, 23 | pass: config.smtpSettings.password, 24 | }, 25 | }, defaults); 26 | } 27 | 28 | // create test account to preview emails in development 29 | const testAccount = await nodemailer.createTestAccount(); 30 | return nodemailer.createTransport({ 31 | host: testAccount.smtp.host, 32 | port: testAccount.smtp.port, 33 | secure: testAccount.smtp.secure, 34 | auth: { 35 | user: testAccount.user, 36 | pass: testAccount.pass, 37 | }, 38 | }, defaults); 39 | })(); 40 | 41 | export const mailLogger = createLogger({ format: globalFormat, transports: createFileTransports("mail", ["debug", "error"]) }); 42 | void promisedTransporter.then(tp => { 43 | tp.on("error", err => void mailLogger.error(`Mail transporter errored: ${inspect(err)}`)); 44 | tp.on("idle", () => void mailLogger.debug("Mail transporter is idle")); 45 | tp.on("token", token => void mailLogger.debug(`Mail transporter got new token: ${inspect(token)}`)); 46 | }); 47 | 48 | export async function sendMail(to: [name: string, email: string], subject: string, text: string): Promise { 49 | const transporter = await promisedTransporter; 50 | return new Promise(resolve => { 51 | transporter.sendMail({ 52 | to: `"${to[0]}" <${to[1]}>`, 53 | subject, 54 | text, 55 | }, (err, info) => { 56 | if (err) { 57 | mailLogger.warn(`Sending mail to ${to[1]} failed: ${inspect(err)}`); 58 | return resolve(false); 59 | } 60 | 61 | const preview = nodemailer.getTestMessageUrl(info); 62 | mailLogger.debug(`Sent mail to ${to[1]} (${preview ? `preview: ${preview}` : "config"}): ${inspect(info)}`); 63 | return resolve(true); 64 | }); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /src/handlers/serverEnforcements/subserverAccess/calculator.ts: -------------------------------------------------------------------------------- 1 | import type { Client, Snowflake } from "discord.js"; 2 | import type { Subserver } from "../../../constants/subservers"; 3 | import config from "../../../config"; 4 | import { SubserverAccess } from "../../../constants/subservers"; 5 | import { SubserverAccessOverride } from "../../../database/models/SubserverAccessOverride"; 6 | import { UserStrip } from "../../../database/models/UserStrip"; 7 | 8 | export default async function calculateAccess(userId: Snowflake, subserver: Subserver, client: Client): Promise<{ access: SubserverAccess; applicableRoles: Snowflake[]; prohibitedRoles: Snowflake[] }> { 9 | const member = await client.guilds.cache.get(config.mainGuildId)!.members.fetch({ user: userId, force: false }).catch(() => null); 10 | if (!member) return { access: SubserverAccess.Denied, applicableRoles: [], prohibitedRoles: [] }; 11 | 12 | const override = await SubserverAccessOverride.findOne({ userId, subserverId: subserver.id }); 13 | if (override) return { access: SubserverAccess.Allowed, applicableRoles: subserver.userOverrideNoticeRoleId ? [subserver.userOverrideNoticeRoleId] : [], prohibitedRoles: [] }; 14 | 15 | const members = client.guilds.cache.map(guild => guild.members.cache.get(userId)); 16 | 17 | const userStrip = await UserStrip.findOne({ userId }); 18 | const allUserOrRoleIdsApplicable = [ 19 | member.id, 20 | ...member.roles.cache.map(role => role.id), 21 | ...userStrip?.roleIds ?? [], 22 | ...members.flatMap(user => user?.roles.cache.map(role => role.id) ?? []), 23 | ].filter((id, index, array) => array.indexOf(id) === index); 24 | 25 | const allEntries = Object.entries(subserver.staffAccess); 26 | const applicableEntries = allEntries.filter(([userOrRoleId]) => allUserOrRoleIdsApplicable.includes(userOrRoleId)).map(([, access]) => access); 27 | const otherManagedEntries = allEntries.map(([, access]) => access).filter(access => !applicableEntries.some(entry => entry === access)); 28 | 29 | const access = applicableEntries.reduce((previous, { access: current = SubserverAccess.Denied }) => current > previous ? current : previous, SubserverAccess.Denied); 30 | const applicableRoles = applicableEntries.reduce((previous, { roles = [] }) => previous.concat(roles), []).filter((role, index, array) => array.indexOf(role) === index); 31 | const prohibitedRoles = otherManagedEntries.reduce((previous, { roles = [] }) => previous.concat(roles), []).filter((role, index, array) => array.indexOf(role) === index && !applicableRoles.includes(role)); 32 | if (subserver.userOverrideNoticeRoleId) prohibitedRoles.push(subserver.userOverrideNoticeRoleId); 33 | 34 | return { access, applicableRoles, prohibitedRoles }; 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/mention/recreateAbout.ts: -------------------------------------------------------------------------------- 1 | import type { Message, TextChannel } from "discord.js"; 2 | import { Colors } from "discord.js"; 3 | import type { MentionCommand } from "."; 4 | import type { AboutSection } from "../../constants/aboutContent"; 5 | import config from "../../config"; 6 | import aboutSections from "../../constants/aboutContent"; 7 | import Emojis from "../../constants/emojis"; 8 | import generateHeader from "../../utils/header"; 9 | import { zeroWidthSpace } from "../../utils/text"; 10 | import { msToHumanShortTime } from "../../utils/time"; 11 | 12 | export default { 13 | names: ["recreateabout"], 14 | ownerOnly: true, 15 | testArgs(args) { return args.length === 0; }, 16 | async execute(message, reply) { 17 | let botMessage: Message | null = null; 18 | 19 | const aboutChannel = message.client.channels.cache.get(config.channels.about) as TextChannel; 20 | if (aboutChannel.id !== message.channel.id) botMessage = await reply(`${Emojis.Loading} Recreating...`); 21 | 22 | const now = Date.now(); 23 | 24 | const oldMessages = await aboutChannel.messages.fetch({ limit: 100 }); 25 | if (oldMessages.every(oldMessage => oldMessage.createdTimestamp > now - 14 * 24 * 60 * 60 * 1000)) await aboutChannel.bulkDelete(oldMessages); 26 | else await Promise.all(oldMessages.map(oldMessage => oldMessage.delete())); 27 | 28 | const navigation: Record = {}; 29 | for (const section of aboutSections) { 30 | const header = await generateHeader(section.title); 31 | await aboutChannel.send({ files: [{ name: `${section.title.toLowerCase()}.png`, attachment: header }] }); 32 | const navigationMessage = await aboutChannel.send({ 33 | embeds: [ 34 | { 35 | color: Colors.Blurple, 36 | ...section.embed, 37 | }, 38 | ], 39 | ...section.components && { components: section.components }, 40 | }); 41 | await aboutChannel.send({ content: zeroWidthSpace }); 42 | navigation[section.title] = navigationMessage.url; 43 | } 44 | 45 | await aboutChannel.send({ files: [{ name: "navigation.png", attachment: await generateHeader("Navigation") }] }); 46 | await aboutChannel.send({ 47 | embeds: [ 48 | { 49 | color: Colors.Blurple, 50 | description: Object.entries(navigation) 51 | .map(([title, url]) => `• [${title}](${url})`) 52 | .join("\n"), 53 | }, 54 | ], 55 | }); 56 | 57 | const response = `${Emojis.Sparkle} Recreated in \`${msToHumanShortTime(Date.now() - now)}\`.`; 58 | if (botMessage) void botMessage.edit(response); 59 | else void message.channel.send(response).then(res => setTimeout(() => void res.delete(), 10 * 1000)); 60 | }, 61 | } as MentionCommand; 62 | -------------------------------------------------------------------------------- /src/constants/policies.ts: -------------------------------------------------------------------------------- 1 | import type { Awaitable, Guild, GuildMember } from "discord.js"; 2 | 3 | export enum Policy { 4 | BotRoleIsAdministrator, 5 | BotRoleIsHighestRole, 6 | EveryoneRoleNoPermissions, 7 | BotRolesIncludeBotTag, 8 | NotInUnknownServers, 9 | } 10 | 11 | export enum PolicyStatus { Compliant, NonCompliant, NonApplicable } 12 | export enum ServerType { Main, Subserver, Other, Unknown } 13 | 14 | export const policies: Record; 17 | description: string; 18 | }> = { 19 | [Policy.BotRoleIsAdministrator]: { 20 | description: "Bot role is Administrator", 21 | appliesTo: [ServerType.Main, ServerType.Subserver], 22 | check: ({ guild, me }) => { 23 | const botRole = guild.roles.botRoleFor(me); 24 | if (!botRole) return [PolicyStatus.NonCompliant, "No bot role is present."]; 25 | if (botRole.permissions.has("Administrator")) return [PolicyStatus.Compliant]; 26 | return [PolicyStatus.NonCompliant]; 27 | }, 28 | }, 29 | [Policy.BotRoleIsHighestRole]: { 30 | description: "Bot role is highest role", 31 | appliesTo: [ServerType.Subserver], 32 | check: ({ guild, me }) => { 33 | const botRole = guild.roles.botRoleFor(me); 34 | if (!botRole) return [PolicyStatus.NonCompliant, "No bot role is present."]; 35 | if (botRole.id === guild.roles.highest.id) return [PolicyStatus.Compliant]; 36 | return [PolicyStatus.NonCompliant]; 37 | }, 38 | }, 39 | [Policy.EveryoneRoleNoPermissions]: { 40 | description: "Everyone role has no permissions", 41 | appliesTo: [ServerType.Subserver], 42 | check: ({ guild }) => { 43 | const everyoneRole = guild.roles.everyone; 44 | if (everyoneRole.permissions.equals(0n)) return [PolicyStatus.Compliant]; 45 | return [PolicyStatus.NonCompliant]; 46 | }, 47 | }, 48 | [Policy.BotRolesIncludeBotTag]: { 49 | description: "Bot roles start with [BOT]", 50 | appliesTo: [ServerType.Main, ServerType.Subserver], 51 | check: ({ guild }) => { 52 | const botRoles = guild.roles.cache.filter(role => role.managed && role.name !== "Server Boosters"); 53 | const nonCompliantRoles = botRoles.filter(role => !role.name.startsWith("[BOT] ")); 54 | if (nonCompliantRoles.size === 0) return [PolicyStatus.Compliant]; 55 | return [PolicyStatus.NonCompliant, `The following roles are managed by bots but do not start with "[BOT] ": ${nonCompliantRoles.map(role => role.name).join(", ")}`]; 56 | }, 57 | }, 58 | [Policy.NotInUnknownServers]: { 59 | description: "Bot is not in unknown servers", 60 | appliesTo: [ServerType.Unknown], 61 | check: () => [PolicyStatus.NonCompliant], 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/handlers/mentionCommands.ts: -------------------------------------------------------------------------------- 1 | import type{ Client, Message, MessageEditOptions, MessageReplyOptions, Snowflake } from "discord.js"; 2 | import { escapeInlineCode, MessageType } from "discord.js"; 3 | import { allMentionCommands, quickResponses } from "../commands/mention"; 4 | import config from "../config"; 5 | import mainLogger from "../utils/logger/main"; 6 | import { fitText } from "../utils/text"; 7 | 8 | const replies = new Map(); 9 | 10 | export default function handleMentionCommands(client: Client): void { 11 | client 12 | .on("messageCreate", message => handleMessage(message)) 13 | .on("messageUpdate", (_, potentialPartialMessage) => void (async () => { 14 | if (replies.has(potentialPartialMessage.id)) handleMessage(potentialPartialMessage.partial ? await potentialPartialMessage.fetch() : potentialPartialMessage); 15 | })()); 16 | 17 | mainLogger.info("Mention command listener registered."); 18 | } 19 | 20 | function handleMessage(message: Message): void { 21 | if ( 22 | !message.inGuild() || 23 | message.author.bot || 24 | message.type !== MessageType.Default && message.type !== MessageType.Reply || 25 | !RegExp(`^<@!?${message.client.user.id}>`, "u").exec(message.content) 26 | ) return; 27 | 28 | const existingReply = replies.get(message.id); 29 | const args = message.content.split(" ").slice(1); 30 | const trigger = (args.shift() ?? "").toLowerCase(); 31 | 32 | const quickResponse = quickResponses.find(([triggers]) => triggers.includes(trigger)); 33 | if (quickResponse) return void reply(quickResponse[1], message, existingReply); 34 | 35 | const command = allMentionCommands.find(({ names }) => names.includes(trigger)); 36 | if (!command) return void reply(`❓ Command \`${escapeInlineCode(fitText(trigger, 20))}\` not found.`, message, existingReply); 37 | if (command.ownerOnly && message.author.id !== config.ownerId) return void reply("⛔ You don't have permission to do this.", message, existingReply); 38 | if (!command.testArgs(args)) return void reply("❓ Invalid arguments provided.", message, existingReply); 39 | 40 | return void command.execute(message, options => reply(options, message, existingReply), args); 41 | } 42 | 43 | async function reply(content: MessageEditOptions & MessageReplyOptions | string, message: Message, existingReply?: Message): Promise { 44 | const options: MessageEditOptions & MessageReplyOptions = { 45 | allowedMentions: { repliedUser: true }, 46 | components: [], 47 | embeds: [], 48 | files: [], 49 | ...typeof content === "string" ? { content } : content, 50 | }; 51 | 52 | if (existingReply) return existingReply.edit({ content: null, ...options }); 53 | 54 | const newReply = await message.reply(options); 55 | replies.set(message.id, newReply); 56 | return newReply; 57 | } 58 | -------------------------------------------------------------------------------- /src/commands/mention/policyCheck.ts: -------------------------------------------------------------------------------- 1 | import type { EmbedField } from "discord.js"; 2 | import { Colors } from "discord.js"; 3 | import type { MentionCommand } from "."; 4 | import type { Policy } from "../../constants/policies"; 5 | import Emojis from "../../constants/emojis"; 6 | import { policies, PolicyStatus, ServerType } from "../../constants/policies"; 7 | import getPolicyStatus, { getServerType } from "../../handlers/serverEnforcements/policyStatus"; 8 | 9 | export default { 10 | names: ["policycheck", "checkpolicy", "policystatys", "serverstatus"], 11 | ownerOnly: true, 12 | testArgs(args) { return args.length === 0 || args.length === 1; }, 13 | async execute(message, reply, [serverId = message.guildId]) { 14 | const guild = message.client.guilds.cache.get(serverId); 15 | if (!guild) return void reply(`${Emojis.Anger} I am not in a server with the ID \`${serverId}\`.`); 16 | 17 | const policyStatus = Object.entries(await getPolicyStatus(guild)); 18 | return void reply({ 19 | content: `${Emojis.Sparkle} This is the policy status for server \`${guild.name}\` (type: \`${{ 20 | [ServerType.Main]: "MAIN", 21 | [ServerType.Subserver]: "SUBSERVER", 22 | [ServerType.Other]: "OTHER", 23 | [ServerType.Unknown]: "UNKNOWN", 24 | }[getServerType(guild)]}\`):`, 25 | embeds: [ 26 | ...policyStatus.some(([, { status }]) => status === PolicyStatus.NonCompliant) ? 27 | [ 28 | { 29 | title: "Non-Compliant Policies", 30 | fields: policyStatus.filter(([, { status }]) => status === PolicyStatus.NonCompliant).map(([policy, { message: policyMessage }]) => ({ name: policies[Number(policy) as Policy].description, value: policyMessage ?? "*No more information provided.*", inline: false })), 31 | color: Colors.Red, 32 | }, 33 | ] : 34 | [], 35 | ...policyStatus.some(([, { status }]) => status === PolicyStatus.Compliant) ? 36 | [ 37 | { 38 | title: "Compliant Policies", 39 | fields: policyStatus.filter(([, { status }]) => status === PolicyStatus.Compliant).map(([policy, { message: policyMessage }]) => ({ name: policies[Number(policy) as Policy].description, value: policyMessage ?? "*No more information provided.*", inline: false })), 40 | color: Colors.Green, 41 | }, 42 | ] : 43 | [], 44 | ...policyStatus.filter(([, { status }]) => status !== PolicyStatus.NonApplicable).length === 0 ? 45 | [ 46 | { 47 | title: "No Policies", 48 | description: "*This server has no enforced policies, most likely because of its server type.*", 49 | color: Colors.LightGrey, 50 | }, 51 | ] : 52 | [], 53 | ], 54 | }); 55 | }, 56 | } as MentionCommand; 57 | -------------------------------------------------------------------------------- /src/commands/chatInput/index.ts: -------------------------------------------------------------------------------- 1 | import type{ ApplicationCommandAutocompleteNumericOptionData, ApplicationCommandAutocompleteStringOptionData, ApplicationCommandBooleanOptionData, ApplicationCommandChannelOptionData, ApplicationCommandMentionableOptionData, ApplicationCommandNonOptionsData, ApplicationCommandNumericOptionData, ApplicationCommandRoleOptionData, ApplicationCommandStringOptionData, ApplicationCommandUserOptionData, Awaitable, ChatInputCommandInteraction } from "discord.js"; 2 | import { readdirSync } from "fs"; 3 | import type{ Autocomplete } from "../../handlers/interactions/autocompletes"; 4 | 5 | export type FirstLevelChatInputCommand = { 6 | public?: true; 7 | } & (ChatInputCommandExecutable | ChatInputCommandGroup) & ChatInputCommandMeta; 8 | 9 | export type SecondLevelChatInputCommand = (ChatInputCommandExecutable | ChatInputCommandGroup) & ChatInputCommandMeta; 10 | 11 | export type ThirdLevelChatInputCommand = ChatInputCommandExecutable & ChatInputCommandMeta; 12 | 13 | export type ChatInputCommand = FirstLevelChatInputCommand | SecondLevelChatInputCommand | ThirdLevelChatInputCommand; 14 | 15 | export interface ChatInputCommandExecutable { 16 | execute(interaction: ChatInputCommandInteraction<"cached">): Awaitable; 17 | options?: [ChatInputCommandOptionData, ...ChatInputCommandOptionData[]]; 18 | } 19 | 20 | export interface ChatInputCommandGroup { 21 | subcommands: [NthLevelChatInputCommand, ...NthLevelChatInputCommand[]]; 22 | } 23 | 24 | export interface ChatInputCommandMeta { 25 | description: string; 26 | name: string; 27 | } 28 | 29 | export type ChatInputCommandOptionDataAutocomplete = 30 | | ({ autocomplete: Autocomplete } & Omit) 31 | | ({ autocomplete: Autocomplete } & Omit); 32 | 33 | export type ChatInputCommandOptionDataNoAutocomplete = 34 | | ApplicationCommandBooleanOptionData 35 | | ApplicationCommandChannelOptionData 36 | | ApplicationCommandMentionableOptionData 37 | | ApplicationCommandNonOptionsData 38 | | ApplicationCommandRoleOptionData 39 | | ApplicationCommandUserOptionData 40 | | Omit 41 | | Omit; 42 | 43 | export type ChatInputCommandOptionData = ChatInputCommandOptionDataAutocomplete | ChatInputCommandOptionDataNoAutocomplete; 44 | 45 | export const allChatInputCommands = readdirSync(__dirname) 46 | .filter(file => !file.includes("index")) 47 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-require-imports -- we need this for it to be synchronous 48 | .map(file => require(`./${file}`).default as FirstLevelChatInputCommand); 49 | -------------------------------------------------------------------------------- /src/handlers/web/index.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from "discord.js"; 2 | import type { Express } from "express"; 3 | import cookieParser from "cookie-parser"; 4 | import express from "express"; 5 | import expressRateLimit from "express-rate-limit"; 6 | import helmet, { contentSecurityPolicy } from "helmet"; 7 | import morgan from "morgan"; 8 | import { join } from "path"; 9 | import { createLogger } from "winston"; 10 | import config from "../../config"; 11 | import { createFileTransports, globalFormat } from "../../utils/logger"; 12 | import handleWebAppeals from "./appeals"; 13 | import handleWebStaffPortal from "./staffPortal"; 14 | 15 | export default function handleWeb(client: Client): void { 16 | if (config.appeals) handleWebAppeals(client, config.appeals); 17 | if (config.staffPortal) handleWebStaffPortal(client, config.staffPortal); 18 | } 19 | 20 | export const webFolderPath = join(__dirname, "../../../web"); 21 | 22 | export function createExpressApp(name: string, numberOfProxies = 0): [app: Express, listen: (port: number) => void] { 23 | const app = express(); 24 | app.set("trust proxy", numberOfProxies); 25 | 26 | // logging 27 | const logger = createLogger({ format: globalFormat, transports: createFileTransports(`express-${name}`, ["http"]) }); 28 | app.use(morgan(":remote-addr :method :url :status :res[content-length] - :response-time ms", { stream: { write: message => logger.http(`Received HTTP request: ${message.slice(0, -1)}`) } })); 29 | 30 | // security 31 | app.use(helmet({ 32 | // for docusaurus 33 | contentSecurityPolicy: { 34 | directives: { 35 | "img-src": [...Array.from(contentSecurityPolicy.getDefaultDirectives()["img-src"]!), "https://cdn.discordapp.com/"], 36 | "script-src": [...Array.from(contentSecurityPolicy.getDefaultDirectives()["script-src"]!), "'unsafe-inline'"], 37 | }, 38 | }, 39 | // for cloudflare assets, apparently they don't support COEP 40 | crossOriginEmbedderPolicy: false, 41 | })); 42 | app.use( 43 | expressRateLimit({ 44 | windowMs: 5 * 60 * 1000, 45 | max: 5000, 46 | legacyHeaders: true, 47 | standardHeaders: true, 48 | handler: (_, res) => res.status(429).sendFile(join(webFolderPath, "too_many_requests.html")), 49 | skipSuccessfulRequests: false, 50 | skipFailedRequests: true, 51 | }), 52 | expressRateLimit({ 53 | windowMs: 15 * 60 * 1000, 54 | max: 500, 55 | legacyHeaders: true, 56 | standardHeaders: true, 57 | handler: (_, res) => res.status(429).sendFile(join(webFolderPath, "too_many_requests.html")), 58 | skipSuccessfulRequests: true, 59 | skipFailedRequests: false, 60 | }), 61 | ); 62 | 63 | // miscellaneois 64 | app.use(cookieParser()); 65 | 66 | // static files 67 | app.use(express.static(join(webFolderPath, "static"))); 68 | 69 | return [ 70 | app, 71 | port => { 72 | app.all("*splat", (_, res) => res.status(404).sendFile(join(webFolderPath, "not_found.html"))); 73 | app.listen(port, () => logger.info(`Listening on port ${port}`)); 74 | }, 75 | ]; 76 | } 77 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Client, IntentsBitField, Options, Partials } from "discord.js"; 2 | import { inspect } from "util"; 3 | import config from "./config"; 4 | import connection from "./database"; 5 | import handleAppeals from "./handlers/appeals"; 6 | import handleDutyPing from "./handlers/dutyPing"; 7 | import handleInteractions from "./handlers/interactions"; 8 | import handleMentionCommands from "./handlers/mentionCommands"; 9 | import handleRestrictions from "./handlers/restrictions"; 10 | import handleServerEnforcements from "./handlers/serverEnforcements"; 11 | import handleWeb from "./handlers/web"; 12 | import handleZeppelinCases from "./handlers/zeppelinCases"; 13 | import discordLogger from "./utils/logger/discord"; 14 | import mainLogger from "./utils/logger/main"; 15 | 16 | const client = new Client({ 17 | allowedMentions: { parse: [], users: [], roles: [], repliedUser: true }, 18 | intents: [ 19 | IntentsBitField.Flags.GuildMembers, 20 | IntentsBitField.Flags.GuildMessages, 21 | IntentsBitField.Flags.Guilds, 22 | IntentsBitField.Flags.MessageContent, 23 | ], 24 | makeCache: Options.cacheEverything(), 25 | partials: [ 26 | Partials.Channel, 27 | Partials.GuildMember, 28 | Partials.GuildScheduledEvent, 29 | Partials.Message, 30 | Partials.Reaction, 31 | Partials.ThreadMember, 32 | Partials.User, 33 | ], 34 | presence: { status: "online" }, 35 | rest: { userAgentAppendix: "Blurple Hammer (projectblurple.com)" }, 36 | }); 37 | 38 | client.once("ready", trueClient => { 39 | mainLogger.info(`Ready as ${trueClient.user.tag}!`); 40 | 41 | handleAppeals(trueClient); 42 | handleDutyPing(trueClient); 43 | handleInteractions(trueClient); 44 | handleMentionCommands(trueClient); 45 | handleRestrictions(trueClient); 46 | handleServerEnforcements(trueClient); 47 | handleWeb(trueClient); 48 | handleZeppelinCases(trueClient); 49 | }); 50 | 51 | // discord debug logging 52 | client 53 | .on("cacheSweep", message => void discordLogger.debug(message)) 54 | .on("debug", info => void discordLogger.debug(info)) 55 | .on("error", error => void discordLogger.error(`Cluster errored. ${inspect(error)}`)) 56 | .on("rateLimit", rateLimitData => void discordLogger.warn(`Rate limit ${JSON.stringify(rateLimitData)}`)) 57 | .on("ready", () => void discordLogger.info("All shards have been connected.")) 58 | .on("shardDisconnect", (_, id) => void discordLogger.warn(`Shard ${id} disconnected.`)) 59 | .on("shardError", (error, id) => void discordLogger.error(`Shard ${id} errored. ${inspect(error)}`)) 60 | .on("shardReady", id => void discordLogger.info(`Shard ${id} is ready.`)) 61 | .on("shardReconnecting", id => void discordLogger.warn(`Shard ${id} is reconnecting.`)) 62 | .on("shardResume", (id, replayed) => void discordLogger.info(`Shard ${id} resumed. ${replayed} events replayed.`)) 63 | .on("warn", info => void discordLogger.warn(info)); 64 | 65 | // other debug logging 66 | process 67 | .on("uncaughtException", error => mainLogger.warn(`Uncaught exception: ${inspect(error)}`)) 68 | .on("unhandledRejection", error => mainLogger.warn(`Unhandled rejection: ${inspect(error)}`)); 69 | 70 | void connection.then(() => void client.login(config.client.token)); 71 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | bot: 5 | build: . 6 | restart: unless-stopped 7 | environment: 8 | - BOT_ID=$BOT_ID 9 | - BOT_SECRET=$BOT_SECRET 10 | - BOT_TOKEN=$BOT_TOKEN 11 | - DATABASE_URI=mongodb://db/hammer 12 | - OWNER_ID=$OWNER_ID 13 | - GUILD_ID=$GUILD_ID 14 | - OTHER_GUILD_IDS=$OTHER_GUILD_IDS 15 | - CHANNEL_ABOUT=$CHANNEL_ABOUT 16 | - CHANNEL_APPEALS=$CHANNEL_APPEALS 17 | - CHANNELS_PUBLIC=$CHANNELS_PUBLIC 18 | - CHANNELS_BLURPLEFIER=$CHANNELS_BLURPLEFIER 19 | - CHANNEL_ZEPPELIN_CASES=$CHANNEL_ZEPPELIN_CASES 20 | - ROLE_ADMINISTRATORS=$ROLE_ADMINISTRATORS 21 | - ROLE_TEAM_LEADERS=$ROLE_TEAM_LEADERS 22 | - ROLE_LEADERSHIP_STAFF=$ROLE_LEADERSHIP_STAFF 23 | - ROLE_MODERATION_STAFF=$ROLE_MODERATION_STAFF 24 | - ROLE_DEVELOPERS=$ROLE_DEVELOPERS 25 | - ROLE_MEDIAS=$ROLE_MEDIAS 26 | - ROLE_SUPPORT_STAFF=$ROLE_SUPPORT_STAFF 27 | - ROLE_EVENTS_TEAM=$ROLE_EVENTS_TEAM 28 | - ROLE_MINECRAFT_TEAM=$ROLE_MINECRAFT_TEAM 29 | - ROLE_STAFF_ON_DUTY=$ROLE_STAFF_ON_DUTY 30 | - ROLE_RESTRICTION_EMBED=$ROLE_RESTRICTION_EMBED 31 | - ROLE_RESTRICTION_REACTIONS=$ROLE_RESTRICTION_REACTIONS 32 | - ROLE_RESTRICTION_BOTS=$ROLE_RESTRICTION_BOTS 33 | - ROLE_RESTRICTION_VAD=$ROLE_RESTRICTION_VAD 34 | - ROLE_RESTRICTION_NICK=$ROLE_RESTRICTION_NICK 35 | - ROLE_PARTNERS=$ROLE_PARTNERS 36 | - ROLE_MEGA_DONATORS=$ROLE_MEGA_DONATORS 37 | - ROLE_DONATORS=$ROLE_DONATORS 38 | - ROLE_RETIRED_STAFF=$ROLE_RETIRED_STAFF 39 | - ROLE_BLURPLE_SERVER_REPRESENTATIVE=$ROLE_BLURPLE_SERVER_REPRESENTATIVE 40 | - ROLE_BLURPLE_USER=$ROLE_BLURPLE_USER 41 | - ROLE_PAINTERS=$ROLE_PAINTERS 42 | - ROLE_ARTISTS=$ROLE_ARTISTS 43 | - ROLE_ADVENTURERS=$ROLE_ADVENTURERS 44 | - ROLE_ARCHIVE_ACCESS=$ROLE_ARCHIVE_ACCESS 45 | - ROLE_EVENTS_PING=$ROLE_EVENTS_PING 46 | - BOT_MODMAIL=$BOT_MODMAIL 47 | - BOT_ZEPPELIN=$BOT_ZEPPELIN 48 | - APPEALS_PORT=$APPEALS_PORT 49 | - APPEALS_URL=$APPEALS_URL 50 | - APPEALS_NUMBER_OF_PROXIES=$APPEALS_NUMBER_OF_PROXIES 51 | - STAFF_PORTAL_PORT=$STAFF_PORTAL_PORT 52 | - STAFF_PORTAL_URL=$STAFF_PORTAL_URL 53 | - STAFF_PORTAL_NUMBER_OF_PROXIES=$STAFF_PORTAL_NUMBER_OF_PROXIES 54 | - SMTP_HOST=$SMTP_HOST 55 | - SMTP_PORT=$SMTP_PORT 56 | - SMTP_SECURE=$SMTP_SECURE 57 | - SMTP_USERNAME=$SMTP_USERNAME 58 | - SMTP_PASSWORD=$SMTP_PASSWORD 59 | - SMTP_DISPLAY_NAME=$SMTP_DISPLAY_NAME 60 | - SMTP_EMAIL_ADDRESS=$SMTP_EMAIL_ADDRESS 61 | - SMTP_REPLY_TO_EMAIL_ADDRESS=$SMTP_REPLY_TO_EMAIL_ADDRESS 62 | - SUBSERVERS_NO_DESTRUCTIVE_ACTIONS=$SUBSERVERS_NO_DESTRUCTIVE_ACTIONS 63 | - STAFF_DOCUMENT_CLONING_TOKEN=$STAFF_DOCUMENT_CLONING_TOKEN 64 | volumes: 65 | - ./logs:/app/logs 66 | depends_on: 67 | - db 68 | ports: 69 | - 127.0.0.1:$APPEALS_PORT:$APPEALS_PORT 70 | - 127.0.0.1:$STAFF_PORTAL_PORT:$STAFF_PORTAL_PORT 71 | db: 72 | image: mongo:4@sha256:52c42cbab240b3c5b1748582cc13ef46d521ddacae002bbbda645cebed270ec0 73 | restart: always 74 | volumes: 75 | - ./database:/data/db 76 | -------------------------------------------------------------------------------- /src/commands/applicationCommands.ts: -------------------------------------------------------------------------------- 1 | import type{ ApplicationCommandData, ApplicationCommandOptionData, ApplicationCommandSubCommandData, ApplicationCommandSubGroupData } from "discord.js"; 2 | import { ApplicationCommandOptionType, ApplicationCommandType } from "discord.js"; 3 | import type{ ChatInputCommand, ChatInputCommandExecutable, ChatInputCommandOptionData, ChatInputCommandOptionDataAutocomplete } from "./chatInput"; 4 | import { allChatInputCommands } from "./chatInput"; 5 | import { allMenuCommands } from "./menu"; 6 | 7 | export default function getAllApplicationCommands(): ApplicationCommandData[] { 8 | const applicationCommands: ApplicationCommandData[] = []; 9 | 10 | for (const command of allChatInputCommands) { 11 | applicationCommands.push({ 12 | name: command.name, 13 | description: command.description, 14 | type: ApplicationCommandType.ChatInput, 15 | ...chatInputIsExecutable(command) ? 16 | { ...command.options && { options: convertChatInputCommandOptionsToApplicationCommandOptions(command.options) } } : 17 | { 18 | options: command.subcommands.map(subcommand => ({ 19 | name: subcommand.name, 20 | description: subcommand.description, 21 | ...chatInputIsExecutable(subcommand) ? 22 | { 23 | type: ApplicationCommandOptionType.Subcommand, 24 | ...subcommand.options && { options: convertChatInputCommandOptionsToApplicationCommandOptions(subcommand.options) }, 25 | } : 26 | { 27 | type: ApplicationCommandOptionType.SubcommandGroup, 28 | options: subcommand.subcommands.map(subsubcommand => ({ 29 | name: subsubcommand.name, 30 | description: subsubcommand.description, 31 | type: ApplicationCommandOptionType.Subcommand, 32 | ...subsubcommand.options && { options: convertChatInputCommandOptionsToApplicationCommandOptions(subsubcommand.options) }, 33 | })), 34 | }, 35 | })), 36 | }, 37 | ...!command.public && { defaultMemberPermissions: 0n }, 38 | }); 39 | } 40 | 41 | for (const command of allMenuCommands) { 42 | applicationCommands.push({ 43 | name: command.name, 44 | type: command.type === "message" ? ApplicationCommandType.Message : ApplicationCommandType.User, 45 | ...!command.public && { defaultMemberPermissions: 0n }, 46 | }); 47 | } 48 | 49 | return applicationCommands; 50 | } 51 | 52 | function convertChatInputCommandOptionsToApplicationCommandOptions(chatInputCommandOptions: ChatInputCommandOptionData[]): Array> { 53 | return chatInputCommandOptions.map(option => { 54 | if (chatInputCommandOptionIsAutocomplete(option)) return { ...option, autocomplete: true }; 55 | return option; 56 | }); 57 | } 58 | 59 | function chatInputIsExecutable(chatInputCommand: ChatInputCommand): chatInputCommand is ChatInputCommandExecutable & typeof chatInputCommand { 60 | // it's basically the same so it doesn't really matter 61 | return "execute" in chatInputCommand; 62 | } 63 | 64 | function chatInputCommandOptionIsAutocomplete(option: ChatInputCommandOptionData): option is ChatInputCommandOptionDataAutocomplete { 65 | return "autocomplete" in option; 66 | } 67 | -------------------------------------------------------------------------------- /src/handlers/interactions/components.ts: -------------------------------------------------------------------------------- 1 | import type{ AnySelectMenuInteraction, Awaitable, ButtonInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, RoleSelectMenuInteraction, Snowflake, StringSelectMenuInteraction, UserSelectMenuInteraction } from "discord.js"; 2 | import { ComponentType } from "discord.js"; 3 | 4 | interface BaseComponent { 5 | allowedUsers: "all" | [Snowflake, ...Snowflake[]]; 6 | persistent?: true; 7 | } 8 | 9 | interface ButtonComponent extends BaseComponent { 10 | callback(interaction: ButtonInteraction<"cached">): Awaitable; 11 | } 12 | 13 | interface ChannelSelectMenuComponent extends BaseComponent { 14 | callback(interaction: ChannelSelectMenuInteraction<"cached">): Awaitable; 15 | selectType: "channel"; 16 | } 17 | 18 | interface MentionableSelectMenuComponent extends BaseComponent { 19 | callback(interaction: MentionableSelectMenuInteraction<"cached">): Awaitable; 20 | selectType: "mentionable"; 21 | } 22 | 23 | interface RoleSelectMenuComponent extends BaseComponent { 24 | callback(interaction: RoleSelectMenuInteraction<"cached">): Awaitable; 25 | selectType: "role"; 26 | } 27 | 28 | interface StringSelectMenuComponent extends BaseComponent { 29 | callback(interaction: StringSelectMenuInteraction<"cached">): Awaitable; 30 | selectType: "string"; 31 | } 32 | 33 | interface UserSelectMenuComponent extends BaseComponent { 34 | callback(interaction: UserSelectMenuInteraction<"cached">): Awaitable; 35 | selectType: "user"; 36 | } 37 | 38 | export const buttonComponents = new Map(); 39 | export const selectMenuComponents = new Map(); 40 | 41 | export default function componentHandler(interaction: AnySelectMenuInteraction<"cached"> | ButtonInteraction<"cached">): void { 42 | if (interaction.isButton()) { 43 | const component = buttonComponents.get(interaction.customId); 44 | if (component && (component.allowedUsers === "all" || component.allowedUsers.includes(interaction.user.id))) void component.callback(interaction); 45 | if (!component?.persistent) buttonComponents.delete(interaction.customId); 46 | } else if (interaction.isAnySelectMenu()) { 47 | const component = selectMenuComponents.get(interaction.customId); 48 | if (component && (component.allowedUsers === "all" || component.allowedUsers.includes(interaction.user.id)) && selectComponentMatchesInteractionType(interaction, component)) void component.callback(interaction as never); 49 | if (!component?.persistent) selectMenuComponents.delete(interaction.customId); 50 | } 51 | } 52 | 53 | const selectTypes: Record<(ChannelSelectMenuComponent | MentionableSelectMenuComponent | RoleSelectMenuComponent | StringSelectMenuComponent | UserSelectMenuComponent)["selectType"], ComponentType> = { 54 | channel: ComponentType.ChannelSelect, 55 | mentionable: ComponentType.MentionableSelect, 56 | role: ComponentType.RoleSelect, 57 | string: ComponentType.StringSelect, 58 | user: ComponentType.UserSelect, 59 | }; 60 | 61 | function selectComponentMatchesInteractionType(interaction: AnySelectMenuInteraction<"cached">, component: ChannelSelectMenuComponent | MentionableSelectMenuComponent | RoleSelectMenuComponent | StringSelectMenuComponent | UserSelectMenuComponent): boolean { 62 | return selectTypes[component.selectType] === interaction.componentType; 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/chatInput/join.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from "discord.js"; 2 | import { inspect } from "util"; 3 | import type { FirstLevelChatInputCommand } from "."; 4 | import Emojis from "../../constants/emojis"; 5 | import subservers, { SubserverAccess } from "../../constants/subservers"; 6 | import { OAuthTokens } from "../../database/models/OAuthTokens"; 7 | import { commandMentions } from "../../handlers/interactions"; 8 | import calculateAccess from "../../handlers/serverEnforcements/subserverAccess/calculator"; 9 | import mainLogger from "../../utils/logger/main"; 10 | import oauth from "../../utils/oauth"; 11 | 12 | export default { 13 | name: "join", 14 | description: "Join a subserver", 15 | options: [ 16 | { 17 | type: ApplicationCommandOptionType.String, 18 | name: "subserver", 19 | description: "The subserver to join", 20 | required: true, 21 | choices: subservers.map(server => ({ 22 | name: `${server.acronym.split("").join(".")}. (${server.name})`, 23 | value: server.id, 24 | })), 25 | }, 26 | ], 27 | async execute(interaction) { 28 | const tokens = await OAuthTokens.findOne({ userId: interaction.user.id }); 29 | if (!tokens) return void interaction.reply({ content: `${Emojis.TickNo} You're not authenticated. Run ${commandMentions["auth"]!}.`, ephemeral: true }); 30 | 31 | const subserver = subservers.find(server => server.id === interaction.options.getString("subserver", true))!; 32 | const server = interaction.client.guilds.cache.get(subserver.id)!; 33 | 34 | const member = await server.members.fetch(interaction.user.id).catch(() => null); 35 | if (member) return void interaction.reply({ content: `${Emojis.TickNo} You're already in this subserver!`, ephemeral: true }); 36 | 37 | return void oauth.tokenRequest({ 38 | refreshToken: tokens.refreshToken, 39 | grantType: "refresh_token", 40 | scope: ["identify", "guilds.join"], 41 | }) 42 | .then(async ({ access_token: accessToken, refresh_token: refreshToken }) => { 43 | tokens.accessToken = accessToken; 44 | tokens.refreshToken = refreshToken; 45 | await tokens.save(); 46 | 47 | const { access, applicableRoles: roles } = await calculateAccess(interaction.user.id, subserver, interaction.client); 48 | 49 | if (access < SubserverAccess.Allowed) return void interaction.reply({ content: `${Emojis.TickNo} You don't have access to this subserver.`, ephemeral: true }); 50 | 51 | return void oauth.addMember({ 52 | accessToken, 53 | guildId: subserver.id, 54 | userId: interaction.user.id, 55 | botToken: interaction.client.token, 56 | roles, 57 | }) 58 | .then(() => void interaction.reply({ content: `${Emojis.TickYes} Added you to the subserver **${subserver.name}**.` })) 59 | .catch((err: unknown) => { 60 | mainLogger.error(`Failed to add user ${interaction.user.id} to subserver ${subserver.name}: ${inspect(err)}`); 61 | return void interaction.reply({ content: `${Emojis.TickNo} An unknown error occurred when trying to add you to the subserver.`, ephemeral: true }); 62 | }); 63 | }) 64 | .catch(() => { 65 | void tokens.deleteOne(); 66 | return void interaction.reply({ content: `${Emojis.TickNo} Your authentication is not working, please re-authenticate yourself using ${commandMentions["auth"]!}`, ephemeral: true }); 67 | }); 68 | }, 69 | } as FirstLevelChatInputCommand; 70 | -------------------------------------------------------------------------------- /src/handlers/zeppelinCases.ts: -------------------------------------------------------------------------------- 1 | import type { Client, Message, PartialMessage } from "discord.js"; 2 | import { inspect } from "util"; 3 | import type { ZeppelinCaseType } from "../constants/zeppelinCases"; 4 | import type { ZeppelinCaseNotesSchema } from "../database/models/ZeppelinCase"; 5 | import config from "../config"; 6 | import { zeppelinCaseTypes } from "../constants/zeppelinCases"; 7 | import { ZeppelinCase } from "../database/models/ZeppelinCase"; 8 | import mainLogger from "../utils/logger/main"; 9 | 10 | export default function handleZeppelinCases(client: Client): void { 11 | client.on("messageCreate", message => checkMessage(message)); 12 | client.on("messageUpdate", (_, message) => checkMessage(message as never)); 13 | } 14 | 15 | function checkMessage(partialMessage: Message | PartialMessage): void { 16 | // eslint-disable-next-line complexity 17 | return void (async () => { 18 | if (partialMessage.channelId !== config.channels.zeppelinCaseLog) return; 19 | const message = partialMessage.partial ? await partialMessage.fetch() : partialMessage; 20 | if (message.webhookId && message.embeds[0]) { 21 | const [possibleCaseEmbed] = message.embeds; 22 | try { 23 | const [matchedCaseType, matchedCaseNumber, matchedCaseHidden] = possibleCaseEmbed.title?.match(/(\w+) - Case #(\d+)( \(hidden\))?/u)?.slice(1) ?? []; 24 | const [matchedDateFormatted] = possibleCaseEmbed.footer?.text.match(/Case created on (.+)/u)?.slice(1) ?? []; 25 | const [matchedUser] = possibleCaseEmbed.fields[0]?.value.match(/<@!(\d+)>/um)?.slice(1) ?? []; 26 | const [matchedModerator] = possibleCaseEmbed.fields[1]?.value.match(/<@!(\d+)>/um)?.slice(1) ?? []; 27 | const [matchedPpModerator] = possibleCaseEmbed.fields[1]?.value.match(/p\.p\. (.+)\n<@!(\d+)>/um)?.slice(2) ?? []; 28 | const matchedNotes = possibleCaseEmbed.fields.slice(2).filter(field => field.name.includes(" at ")); 29 | 30 | const caseType = Number(Object.entries(zeppelinCaseTypes).find(([, { name }]) => name.toLowerCase() === matchedCaseType!.toLowerCase())![0]) as ZeppelinCaseType; 31 | const caseNumber = Number(matchedCaseNumber); 32 | const caseHidden = Boolean(matchedCaseHidden); 33 | const dateFormatted = new Date(matchedDateFormatted!.replace(" at ", ", ")); 34 | const user = matchedUser!; 35 | const moderator = matchedModerator!; 36 | const actualModerator = matchedPpModerator; 37 | const notes = matchedNotes.map(note => ({ 38 | moderatorTag: note.name.split(" at ")[0]!, 39 | body: note.value.replace(/__\[(.+)\]__/gu, "").trim() || null, 40 | createdAt: new Date((/ at (.+):/u).exec(note.name)![1]!.replace(" at ", ", ")), 41 | })); 42 | 43 | const zeppelinCase = await ZeppelinCase.findOne({ caseNumber }) ?? new ZeppelinCase(); 44 | 45 | zeppelinCase.caseNumber = caseNumber; 46 | zeppelinCase.userId = user; 47 | zeppelinCase.moderatorId = moderator; 48 | zeppelinCase.ppId = actualModerator ?? null; 49 | zeppelinCase.type = caseType; 50 | zeppelinCase.createdAt = dateFormatted; 51 | zeppelinCase.hidden = caseHidden; 52 | zeppelinCase.logMessageId = message.id; 53 | zeppelinCase.notes = notes; 54 | 55 | await zeppelinCase.save(); 56 | mainLogger.debug(`Saved Zeppelin case ${caseNumber} from message ${message.url}`); 57 | } catch (err) { 58 | // not a zeppelin case 59 | mainLogger.debug(`Message ${message.url} is not a Zeppelin case: ${inspect(err)}`); 60 | } 61 | } 62 | })(); 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/applicationCommands.test.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandType } from "discord.js"; 2 | import getAllApplicationCommands from "./applicationCommands"; 3 | 4 | const nameRegex = /^[-_\p{Ll}\p{N}]{1,32}$/u; 5 | const nameRegexMenus = /^[-_\p{L}\p{N} ]{1,32}$/ui; 6 | const choiceRegex = /^.{1,100}$/u; 7 | const descriptionRegex = /^.{1,100}$/u; 8 | 9 | describe.each(getAllApplicationCommands().map(command => [command.name, command] as const))("command %s", (_1, command) => { 10 | if (command.type === ApplicationCommandType.ChatInput) { 11 | describe("chat input command", () => { 12 | it("should have a valid name", () => expect(command.name).toMatch(nameRegex)); 13 | 14 | it("should have a valid description", () => expect(command.description).toMatch(descriptionRegex)); 15 | 16 | if ("options" in command) { 17 | describe.each(command.options.map(option => [option.name, option] as const))("option %s", (_2, option) => { 18 | it("should have a valid name", () => expect(option.name).toMatch(nameRegex)); 19 | 20 | it("should have a valid description", () => expect(option.description).toMatch(descriptionRegex)); 21 | 22 | if ("choices" in option) { 23 | describe.each(option.choices.map(choice => [choice.name, choice] as const))("choice %s", (_, choice) => { 24 | it("should have a valid name", () => expect(choice.name).toMatch(choiceRegex)); 25 | 26 | if (typeof choice.value === "string") it("should have a valid description", () => expect(choice.value).toMatch(/^.{1,100}$/u)); 27 | }); 28 | } 29 | if ("options" in option) { 30 | describe.each(option.options.map(suboption => ["options" in suboption ? "subgroup" : "subcommand", suboption.name, suboption] as const))("%s %s", (_3, _4, suboption) => { 31 | it("should have a valid name", () => expect(suboption.name).toMatch(nameRegex)); 32 | 33 | it("should have a valid description", () => expect(suboption.description).toMatch(descriptionRegex)); 34 | 35 | if ("choices" in suboption) { 36 | describe.each(suboption.choices.map(choice => [choice.name, choice] as const))("choice %s", (_, choice) => { 37 | it("should have a valid name", () => expect(choice.name).toMatch(choiceRegex)); 38 | 39 | if (typeof choice.value === "string") it("should have a valid description", () => expect(choice.value).toMatch(/^.{1,100}$/u)); 40 | }); 41 | } 42 | if ("options" in suboption) { 43 | describe.each(suboption.options.map(subsuboption => [subsuboption.name, subsuboption] as const))("subcommand %s", (_5, subsuboption) => { 44 | it("should have a valid name", () => expect(subsuboption.name).toMatch(nameRegex)); 45 | 46 | it("should have a valid description", () => expect(subsuboption.description).toMatch(descriptionRegex)); 47 | 48 | if ("choices" in subsuboption) { 49 | describe.each(subsuboption.choices.map(choice => [choice.name, choice] as const))("choice %s", (_, choice) => { 50 | it("should have a valid name", () => expect(choice.name).toMatch(choiceRegex)); 51 | 52 | if (typeof choice.value === "string") it("should have a valid description", () => expect(choice.value).toMatch(/^.{1,100}$/u)); 53 | }); 54 | } 55 | }); 56 | } 57 | }); 58 | } 59 | }); 60 | } 61 | }); 62 | } else { 63 | describe("menu input command", () => { 64 | it("should have a valid name", () => expect(command.name).toMatch(nameRegexMenus)); 65 | }); 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /web/appeals/main.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is the CSS file for the appeals page. Since the page and server are both relatively simple, the CSS is also simple. We will not bother optimizing it. 3 | */ 4 | 5 | * { 6 | --not-quite-black: rgb(35, 39, 42); 7 | --blurple: rgb(88, 101, 242); 8 | --full-white: rgb(255, 255, 255); 9 | --dark-but-not-black: rgb(44, 47, 51); 10 | } 11 | 12 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;500&display=swap'); 13 | 14 | body { 15 | font-family: 'Montserrat', sans-serif; 16 | background-color: var(--not-quite-black); 17 | color: var(--full-white); 18 | margin: 0; 19 | padding: 0; 20 | } 21 | 22 | a { 23 | color: var(--blurple); 24 | } 25 | 26 | 27 | /********************/ 28 | /** the alert page **/ 29 | /********************/ 30 | 31 | body#alert { 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | text-align: center; 36 | height: 100vh; 37 | } 38 | 39 | body#alert > div { 40 | display: flex; 41 | flex-direction: column; 42 | gap: 1rem; 43 | width: 100%; 44 | max-width: 800px; 45 | padding: 1rem; 46 | background-color: var(--dark-but-not-black); 47 | border-radius: 0.5rem; 48 | } 49 | 50 | body#alert > div > h1 { 51 | font-size: 3rem; 52 | font-weight: 500; 53 | } 54 | 55 | body#alert > div > p { 56 | font-size: 1.5rem; 57 | font-weight: 300; 58 | } 59 | 60 | 61 | /*******************/ 62 | /** the form page **/ 63 | /*******************/ 64 | 65 | /** the header **/ 66 | 67 | body#form > .header { 68 | display: flex; 69 | width: 100vw; 70 | margin: 1rem 0; 71 | background-color: var(--dark-but-not-black); 72 | } 73 | 74 | body#form > .header > .contents { 75 | display: flex; 76 | margin: 0 auto; 77 | gap: 20px; 78 | padding: 1rem 0; 79 | } 80 | 81 | body#form > .header > .contents > h1 { 82 | font-size: 3rem; 83 | font-weight: 500; 84 | } 85 | 86 | body#form > .header > .contents > img, 87 | body#form > .header > .contents > a, 88 | body#form > .header > .contents > a > img { 89 | width: 5rem; 90 | height: 5rem; 91 | border-radius: 50%; 92 | margin: auto 0; 93 | } 94 | 95 | /** the form **/ 96 | 97 | body#form > .form { 98 | display: flex; 99 | justify-content: center; 100 | align-items: center; 101 | text-align: center; 102 | max-width: 800px; 103 | margin: 0 auto; 104 | } 105 | 106 | body#form > .form > form { 107 | display: flex; 108 | flex-direction: column; 109 | gap: 1rem; 110 | width: 100%; 111 | padding: 1rem; 112 | background-color: var(--dark-but-not-black); 113 | border-radius: 0.5rem 114 | } 115 | 116 | body#form > .form > form > label { 117 | font-size: 1.5rem; 118 | font-weight: 500; 119 | margin-top: 1rem; 120 | } 121 | 122 | body#form > .form > form > div.case-info > input, 123 | body#form > .form > form > div.case-info > select, 124 | body#form > .form > form > textarea { 125 | font-size: 1.5rem; 126 | font-weight: 300; 127 | padding: 0.5rem; 128 | border: none; 129 | border-radius: 0.5rem; 130 | background-color: var(--not-quite-black); 131 | color: var(--full-white); 132 | } 133 | 134 | body#form > .form > form > input[type="submit"] { 135 | font-size: 1.5rem; 136 | font-weight: 500; 137 | padding: 0.5rem; 138 | border: none; 139 | border-radius: 0.5rem; 140 | background-color: var(--blurple); 141 | color: var(--full-white); 142 | cursor: pointer; 143 | } 144 | 145 | body#form > .form > form > input[type="submit"]:focus, 146 | body#form > .form > form > input[type="submit"]:hover { 147 | background-color: var(--not-quite-black); 148 | color: var(--blurple); 149 | } 150 | 151 | body#form > .form > form > div.case-info { 152 | display: flex; 153 | gap: 1rem; 154 | width: 80%; 155 | margin: 0 auto; 156 | } 157 | 158 | body#form > .form > form > div.case-info > input { 159 | width: 100%; 160 | } 161 | 162 | body#form > .form > form > div.case-info > input:invalid { 163 | border: 1px solid red; 164 | } 165 | -------------------------------------------------------------------------------- /web/static/statuses/internal_server_error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/static/statuses/unauthorized.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/commands/chatInput/channels/lock.ts: -------------------------------------------------------------------------------- 1 | import type { ForumChannel, Snowflake, StageChannel, TextChannel, VoiceChannel } from "discord.js"; 2 | import { ApplicationCommandOptionType, ChannelType } from "discord.js"; 3 | import { inspect } from "util"; 4 | import type { SecondLevelChatInputCommand } from ".."; 5 | import config from "../../../config"; 6 | import Emojis from "../../../constants/emojis"; 7 | 8 | export default { 9 | name: "lock", 10 | description: "Lock a channel", 11 | options: [ 12 | { 13 | type: ApplicationCommandOptionType.Channel, 14 | name: "channel", 15 | description: "The channel to lock", 16 | channelTypes: [ 17 | ChannelType.GuildForum, 18 | ChannelType.GuildStageVoice, 19 | ChannelType.GuildText, 20 | ChannelType.GuildVoice, 21 | ], 22 | }, 23 | { 24 | type: ApplicationCommandOptionType.String, 25 | name: "channel_group", 26 | description: "The channel group to lock", 27 | choices: [ 28 | { name: "All public channels", value: "public" }, 29 | { name: "Blurplefier channels", value: "blurplefier" }, 30 | ], 31 | }, 32 | { 33 | type: ApplicationCommandOptionType.String, 34 | name: "reason", 35 | description: "The reason for locking the channel", 36 | }, 37 | ], 38 | async execute(interaction) { 39 | await interaction.deferReply({ ephemeral: true }); 40 | 41 | const channels: Snowflake[] = []; 42 | 43 | const singularChannel = interaction.options.getChannel("channel"); 44 | if (singularChannel) channels.push(singularChannel.id); 45 | 46 | const channelGroup = interaction.options.getString("channel_group") as "blurplefier" | "public" | undefined; 47 | if (channelGroup === "blurplefier") channels.push(...config.channels.blurplefierChannels); 48 | if (channelGroup === "public") channels.push(...config.channels.publicChannels); 49 | 50 | const reason = interaction.options.getString("reason"); 51 | 52 | const success: Snowflake[] = []; 53 | const errors: string[] = []; 54 | const interval = setInterval(() => { 55 | void interaction.editReply({ 56 | content: [ 57 | `${Emojis.Loading} Locking channels... (${success.length + errors.length}/${channels.length})`, 58 | `Locked: ${success.map(channelId => `<#${channelId}>`).join(", ")} | In queue: ${channels.slice(success.length + errors.length).map(channelId => `<#${channelId}>`) 59 | .join(", ")}`, 60 | errors.length && `Failed to lock channels:\n${errors.map(error => `• ${error}`).join("\n")}`, 61 | ].filter(Boolean).join("\n"), 62 | }); 63 | }, 1000); 64 | 65 | for (const channelId of channels) { 66 | const channel = interaction.guild.channels.cache.get(channelId); 67 | if (channel) { 68 | const result = await lockChannel(channel as never, reason); 69 | if (typeof result === "string") errors.push(`Failed to lock channel <#${channelId}>: ${result}`); 70 | else success.push(channelId); 71 | } else errors.push(`Channel ${channelId} not found.`); 72 | } 73 | 74 | clearInterval(interval); 75 | return void interaction.editReply({ 76 | content: [ 77 | `${Emojis.ThumbsUp} Channels are now locked: ${success.map(channelId => `<#${channelId}>`).join(", ") || "*None.*"}`, 78 | errors.length && errors.map(error => `• ${error}`).join("\n"), 79 | ].filter(Boolean).join("\n"), 80 | }); 81 | }, 82 | } as SecondLevelChatInputCommand; 83 | 84 | function lockChannel(channel: ForumChannel | StageChannel | TextChannel | VoiceChannel, reason: null | string): Promise { 85 | if (channel.permissionOverwrites.cache.find(overwrite => overwrite.id === channel.guild.roles.everyone.id)?.deny.has("SendMessages")) return Promise.resolve("Channel is already locked."); 86 | return channel.permissionOverwrites.edit(channel.guild.roles.everyone, { SendMessages: false, SendMessagesInThreads: false, CreatePublicThreads: false, CreatePrivateThreads: false, AddReactions: false }) 87 | .then(async () => { 88 | if ("send" in channel) await channel.send(`${Emojis.WeeWoo} ***This channel is now locked.*** ${reason ? `\n>>> *${reason}*` : ""}`); 89 | return true as const; 90 | }) 91 | .catch((err: unknown) => inspect(err).split("\n")[0]!.trim()); 92 | } 93 | -------------------------------------------------------------------------------- /src/commands/chatInput/channels/unlock.ts: -------------------------------------------------------------------------------- 1 | import type { ForumChannel, Snowflake, StageChannel, TextChannel, VoiceChannel } from "discord.js"; 2 | import { ApplicationCommandOptionType, ChannelType } from "discord.js"; 3 | import { inspect } from "util"; 4 | import type { SecondLevelChatInputCommand } from ".."; 5 | import config from "../../../config"; 6 | import Emojis from "../../../constants/emojis"; 7 | 8 | export default { 9 | name: "unlock", 10 | description: "Unlock a channel", 11 | options: [ 12 | { 13 | type: ApplicationCommandOptionType.Channel, 14 | name: "channel", 15 | description: "The channel to unlock", 16 | channelTypes: [ 17 | ChannelType.GuildForum, 18 | ChannelType.GuildStageVoice, 19 | ChannelType.GuildText, 20 | ChannelType.GuildVoice, 21 | ], 22 | }, 23 | { 24 | type: ApplicationCommandOptionType.String, 25 | name: "channel_group", 26 | description: "The channel group to unlock", 27 | choices: [ 28 | { name: "All public channels", value: "public" }, 29 | { name: "Blurplefier channels", value: "blurplefier" }, 30 | ], 31 | }, 32 | { 33 | type: ApplicationCommandOptionType.String, 34 | name: "reason", 35 | description: "The reason for unlocking the channel", 36 | }, 37 | ], 38 | async execute(interaction) { 39 | await interaction.deferReply({ ephemeral: true }); 40 | 41 | const channels: Snowflake[] = []; 42 | 43 | const singularChannel = interaction.options.getChannel("channel"); 44 | if (singularChannel) channels.push(singularChannel.id); 45 | 46 | const channelGroup = interaction.options.getString("channel_group") as "blurplefier" | "public" | undefined; 47 | if (channelGroup === "blurplefier") channels.push(...config.channels.blurplefierChannels); 48 | if (channelGroup === "public") channels.push(...config.channels.publicChannels); 49 | 50 | const reason = interaction.options.getString("reason"); 51 | 52 | const success: Snowflake[] = []; 53 | const errors: string[] = []; 54 | const interval = setInterval(() => { 55 | void interaction.editReply({ 56 | content: [ 57 | `${Emojis.Loading} Unlocking channels... (${success.length + errors.length}/${channels.length})`, 58 | `Unlocked: ${success.map(channelId => `<#${channelId}>`).join(", ")} | In queue: ${channels.slice(success.length + errors.length).map(channelId => `<#${channelId}>`) 59 | .join(", ")}`, 60 | errors.length && `Failed to unlock channels:\n${errors.map(error => `• ${error}`).join("\n")}`, 61 | ].filter(Boolean).join("\n"), 62 | }); 63 | }, 1000); 64 | 65 | for (const channelId of channels) { 66 | const channel = interaction.guild.channels.cache.get(channelId); 67 | if (channel) { 68 | const result = await unlockChannel(channel as never, reason); 69 | if (typeof result === "string") errors.push(`Failed to unlock channel <#${channelId}>: ${result}`); 70 | else success.push(channelId); 71 | } else errors.push(`Channel ${channelId} not found.`); 72 | } 73 | 74 | clearInterval(interval); 75 | return void interaction.editReply({ 76 | content: [ 77 | `${Emojis.ThumbsUp} Channels are now unlocked: ${success.map(channelId => `<#${channelId}>`).join(", ") || "*None.*"}`, 78 | errors.length && errors.map(error => `• ${error}`).join("\n"), 79 | ].filter(Boolean).join("\n"), 80 | }); 81 | }, 82 | } as SecondLevelChatInputCommand; 83 | 84 | function unlockChannel(channel: ForumChannel | StageChannel | TextChannel | VoiceChannel, reason: null | string): Promise { 85 | if (channel.permissionOverwrites.cache.find(overwrite => overwrite.id === channel.guild.roles.everyone.id)?.deny.has("SendMessages") === false) return Promise.resolve("Channel is already unlocked."); 86 | return channel.permissionOverwrites.edit(channel.guild.roles.everyone, { SendMessages: null, SendMessagesInThreads: null, CreatePublicThreads: null, CreatePrivateThreads: null, AddReactions: null }) 87 | .then(async () => { 88 | if ("send" in channel) await channel.send(`${Emojis.WeeWoo} ***This channel is now unlocked.*** ${reason ? `\n>>> *${reason}*` : ""}`); 89 | return true as const; 90 | }) 91 | .catch((err: unknown) => inspect(err).split("\n")[0]!.trim()); 92 | } 93 | -------------------------------------------------------------------------------- /web/static/statuses/forbidden.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | export default { 4 | client: { 5 | id: String(process.env["BOT_ID"]), 6 | secret: String(process.env["BOT_SECRET"]), 7 | token: String(process.env["BOT_TOKEN"]), 8 | }, 9 | 10 | databaseUri: String(process.env["DATABASE_URI"]), 11 | 12 | ownerId: String(process.env["OWNER_ID"]), 13 | mainGuildId: String(process.env["GUILD_ID"]), 14 | otherGuildIds: String(process.env["OTHER_GUILD_IDS"]).split(","), 15 | 16 | channels: { 17 | about: String(process.env["CHANNEL_ABOUT"]), 18 | appeals: String(process.env["CHANNEL_APPEALS"]), 19 | publicChannels: String(process.env["CHANNELS_PUBLIC"]).split(","), 20 | blurplefierChannels: String(process.env["CHANNELS_BLURPLEFIER"]).split(","), 21 | zeppelinCaseLog: String(process.env["CHANNEL_ZEPPELIN_CASES"]), 22 | }, 23 | 24 | roles: { 25 | staff: { 26 | administrators: String(process.env["ROLE_ADMINISTRATORS"]), 27 | leadership: String(process.env["ROLE_LEADERSHIP"]), 28 | all: String(process.env["ROLE_STAFF"]), 29 | teams: { 30 | moderation: String(process.env["ROLE_TEAM_MODERATION"]), 31 | developer: String(process.env["ROLE_TEAM_DEVELOPER"]), 32 | designer: String(process.env["ROLE_TEAM_DESIGNER"]), 33 | events: String(process.env["ROLE_TEAM_EVENTS"]), 34 | giveaways: String(process.env["ROLE_TEAM_GIVEAWAYS"]), 35 | minecraft: String(process.env["ROLE_TEAM_MINECRAFT"]), 36 | modmails: String(process.env["ROLE_TEAM_MODMAILS"]), 37 | partnerships: String(process.env["ROLE_TEAM_PARTNERSHIPS"]), 38 | }, 39 | duty: String(process.env["ROLE_STAFF_ON_DUTY"]), 40 | }, 41 | restrictions: { 42 | embed: String(process.env["ROLE_RESTRICTION_EMBED"]), 43 | reactions: String(process.env["ROLE_RESTRICTION_REACTIONS"]), 44 | bots: String(process.env["ROLE_RESTRICTION_BOTS"]), 45 | vad: String(process.env["ROLE_RESTRICTION_VAD"]), 46 | nick: String(process.env["ROLE_RESTRICTION_NICK"]), 47 | soundboard: String(process.env["ROLE_RESTRICTION_SOUNDBOARD"]), 48 | }, 49 | partners: String(process.env["ROLE_PARTNERS"]), 50 | megaDonators: String(process.env["ROLE_MEGA_DONATORS"]), 51 | donators: String(process.env["ROLE_DONATORS"]), 52 | retiredStaff: String(process.env["ROLE_RETIRED_STAFF"]), 53 | blurpleServerRepresentative: String(process.env["ROLE_BLURPLE_SERVER_REPRESENTATIVE"]), 54 | blurpleUser: String(process.env["ROLE_BLURPLE_USER"]), 55 | painters: String(process.env["ROLE_PAINTERS"]), 56 | artists: String(process.env["ROLE_ARTISTS"]), 57 | adventurers: String(process.env["ROLE_ADVENTURERS"]), 58 | miscellaneous: { 59 | archiveAccess: String(process.env["ROLE_ARCHIVE_ACCESS"]), 60 | canvasPing: String(process.env["ROLE_CANVAS_PING"]), 61 | eventsPing: String(process.env["ROLE_EVENTS_PING"]), 62 | }, 63 | }, 64 | 65 | bots: { 66 | modmail: String(process.env["BOT_MODMAIL"]), 67 | zeppelin: String(process.env["BOT_ZEPPELIN"]), 68 | }, 69 | 70 | appeals: process.env["APPEALS_PORT"] ? 71 | { 72 | port: Number(process.env["APPEALS_PORT"]), 73 | url: String(process.env["APPEALS_URL"]), 74 | numberOfProxies: Number(process.env["APPEALS_NUMBER_OF_PROXIES"]), 75 | } : 76 | null, 77 | 78 | staffPortal: process.env["STAFF_PORTAL_PORT"] ? 79 | { 80 | port: Number(process.env["STAFF_PORTAL_PORT"]), 81 | url: String(process.env["STAFF_PORTAL_URL"]), 82 | numberOfProxies: Number(process.env["STAFF_PORTAL_NUMBER_OF_PROXIES"]), 83 | } : 84 | null, 85 | 86 | smtpSettings: process.env["SMTP_HOST"] ? 87 | { 88 | host: String(process.env["SMTP_HOST"]), 89 | port: Number(process.env["SMTP_PORT"]), 90 | secure: String(process.env["SMTP_SECURE"]) === "true", 91 | username: String(process.env["SMTP_USERNAME"]), 92 | password: String(process.env["SMTP_PASSWORD"]), 93 | displayName: String(process.env["SMTP_DISPLAY_NAME"]), 94 | emailAddress: String(process.env["SMTP_EMAIL_ADDRESS"]), 95 | replyToEmailAddress: String(process.env["SMTP_REPLY_TO_EMAIL_ADDRESS"]), 96 | } : 97 | null, 98 | 99 | subservers: { 100 | noDestructiveActions: String(process.env["SUBSERVERS_NO_DESTRUCTIVE_ACTIONS"]) === "true", 101 | }, 102 | 103 | staffDocumentCloningToken: String(process.env["STAFF_DOCUMENT_CLONING_TOKEN"]) || null, 104 | } as const; 105 | -------------------------------------------------------------------------------- /src/commands/chatInput/forcejoin.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from "discord.js"; 2 | import { inspect } from "util"; 3 | import type { FirstLevelChatInputCommand } from "."; 4 | import config from "../../config"; 5 | import Emojis from "../../constants/emojis"; 6 | import subservers, { SubserverAccess } from "../../constants/subservers"; 7 | import { OAuthTokens } from "../../database/models/OAuthTokens"; 8 | import { SubserverAccessOverride } from "../../database/models/SubserverAccessOverride"; 9 | import { commandMentions } from "../../handlers/interactions"; 10 | import calculateAccess from "../../handlers/serverEnforcements/subserverAccess/calculator"; 11 | import mainLogger from "../../utils/logger/main"; 12 | import oauth from "../../utils/oauth"; 13 | 14 | export default { 15 | name: "forcejoin", 16 | description: "Force-join someone else in to a subserver", 17 | options: [ 18 | { 19 | type: ApplicationCommandOptionType.User, 20 | name: "user", 21 | description: "The user to force-join", 22 | required: true, 23 | }, 24 | { 25 | type: ApplicationCommandOptionType.String, 26 | name: "subserver", 27 | description: "The subserver to join", 28 | required: true, 29 | choices: subservers.map(server => ({ 30 | name: `${server.acronym.split("").join(".")}. (${server.name})`, 31 | value: server.id, 32 | })), 33 | }, 34 | { 35 | type: ApplicationCommandOptionType.String, 36 | name: "reason", 37 | description: "The reason for force-joining the user", 38 | required: true, 39 | }, 40 | ], 41 | async execute(interaction) { 42 | const user = interaction.options.getUser("user", true); 43 | 44 | const tokens = await OAuthTokens.findOne({ userId: user.id }); 45 | if (!tokens) return void interaction.reply({ content: `${Emojis.TickNo} The user is not authenticated. Make them run ${commandMentions["auth"]!} or [copy this link](${new URL("/login", config.staffPortal!.url).href}) and give it to them if they're not staff.`, ephemeral: true }); 46 | 47 | const subserver = subservers.find(server => server.id === interaction.options.getString("subserver", true))!; 48 | const server = interaction.client.guilds.cache.get(subserver.id)!; 49 | 50 | const { access } = await calculateAccess(interaction.user.id, subserver, interaction.client); 51 | if (access < SubserverAccess.Allowed) return void interaction.reply({ content: `${Emojis.TickNo} You don't have permission to force-join users in to this subserver.`, ephemeral: true }); 52 | 53 | const member = await server.members.fetch(user.id).catch(() => null); 54 | if (member) return void interaction.reply({ content: `${Emojis.TickNo} They're already in this subserver!`, ephemeral: true }); 55 | 56 | return void oauth.tokenRequest({ 57 | refreshToken: tokens.refreshToken, 58 | grantType: "refresh_token", 59 | scope: ["identify", "guilds.join"], 60 | }) 61 | .then(async ({ access_token: accessToken, refresh_token: refreshToken }) => { 62 | tokens.accessToken = accessToken; 63 | tokens.refreshToken = refreshToken; 64 | await tokens.save(); 65 | 66 | await SubserverAccessOverride.create({ 67 | userId: user.id, 68 | subserverId: subserver.id, 69 | issuerId: interaction.user.id, 70 | reason: interaction.options.getString("reason", true), 71 | }); 72 | 73 | return void oauth.addMember({ 74 | accessToken, 75 | guildId: subserver.id, 76 | userId: user.id, 77 | botToken: interaction.client.token, 78 | }) 79 | .then(() => void interaction.reply({ content: `${Emojis.TickYes} Force-joined user ${user.toString()} to the subserver **${subserver.name}**.` })) 80 | .catch((err: unknown) => { 81 | mainLogger.error(`Failed to force-add user ${interaction.user.id} to subserver ${subserver.name}: ${inspect(err)}`); 82 | return void interaction.reply({ content: `${Emojis.TickNo} An unknown error occurred when trying to force-join them to the subserver.`, ephemeral: true }); 83 | }); 84 | }) 85 | .catch(() => { 86 | void tokens.deleteOne(); 87 | return void interaction.reply({ content: `${Emojis.TickNo} Their authentication is not working, please have them re-authenticate themselves using ${commandMentions["auth"]!} or [copy this link](${new URL("/login", config.staffPortal!.url).href}) and give it to them if they're not staff.`, ephemeral: true }); 88 | }); 89 | }, 90 | } as FirstLevelChatInputCommand; 91 | -------------------------------------------------------------------------------- /src/handlers/web/staffPortal/index.ts: -------------------------------------------------------------------------------- 1 | import type { Client, Snowflake } from "discord.js"; 2 | import express from "express"; 3 | import { join } from "path"; 4 | import { createExpressApp, webFolderPath } from ".."; 5 | import config from "../../../config"; 6 | import { OAuthTokens } from "../../../database/models/OAuthTokens"; 7 | import oauth from "../../../utils/oauth"; 8 | import { decode, sign, verify } from "../../../utils/webtokens"; 9 | import { refreshSubserverAccess } from "../../serverEnforcements/subserverAccess/refresh"; 10 | import cloneStaffDocument, { staffDocumentFolder } from "./staffDocumentCloner"; 11 | 12 | export default function handleWebStaffPortal(client: Client, webConfig: Exclude): void { 13 | const [app, listen] = createExpressApp("staff-portal", webConfig.numberOfProxies); 14 | 15 | const redirectUri = new URL("/oauth-callback", webConfig.url).href; 16 | const authorizationLink = (state?: string) => oauth.generateAuthUrl({ 17 | prompt: "none", 18 | redirectUri, 19 | scope: ["identify", "guilds.join"], 20 | ...state && { state }, 21 | }); 22 | 23 | // create token 24 | app.get("/oauth-callback", (req, res) => { 25 | const code = typeof req.query["code"] === "string" ? req.query["code"] : null; 26 | const state = typeof req.query["code"] === "string" && req.query["code"] || "/"; 27 | if (!code) return res.redirect(authorizationLink(state)); 28 | 29 | void oauth.tokenRequest({ 30 | code, 31 | scope: ["identify", "guilds.join"], 32 | grantType: "authorization_code", 33 | redirectUri, 34 | }) 35 | .then(async ({ access_token: accessToken, refresh_token: refreshToken }) => { 36 | const { id } = await oauth.getUser(accessToken); 37 | 38 | const tokens = await OAuthTokens.findOne({ userId: id }) ?? new OAuthTokens({ userId: id }); 39 | tokens.accessToken = accessToken; 40 | tokens.refreshToken = refreshToken; 41 | await tokens.save(); 42 | 43 | res.cookie("token", await sign({ id })).redirect(state); 44 | void refreshSubserverAccess(id, client); 45 | }) 46 | .catch(() => res.redirect(authorizationLink(state))); 47 | }); 48 | 49 | // login and logout 50 | app.get("/login", (_, res) => res.clearCookie("token").redirect(authorizationLink("/"))); 51 | app.get("/logout", (_, res) => res.clearCookie("token").sendFile(join(webFolderPath, "logout.html"))); 52 | 53 | // refresh staff document 54 | app.post("/refresh-staff-document", (req, res) => { 55 | const { authorization } = req.headers; 56 | if (authorization !== config.staffDocumentCloningToken) return void res.status(401).send("Unauthorized"); 57 | 58 | return void cloneStaffDocument() 59 | .then(() => res.status(200).send("OK")) 60 | .catch(() => res.status(500).send("Internal Server Error")); 61 | }); 62 | 63 | // check if token is valid 64 | app.use((req, res, next) => { 65 | const { token } = req.cookies as { token?: string }; 66 | if (!token) return res.redirect(authorizationLink(req.path)); 67 | 68 | void verify(token).then(valid => { 69 | if (valid) return next(); 70 | res.redirect(authorizationLink(req.path)); 71 | }); 72 | }); 73 | 74 | // check if user is staff 75 | app.use((req, res, next) => { 76 | const { token } = req.cookies as { token: string }; 77 | 78 | const { id } = decode<{ id: Snowflake }>(token); 79 | void client.guilds.cache.get(config.mainGuildId)!.members.fetch({ user: id, force: false }).catch(() => null) 80 | .then(member => { 81 | if (member?.roles.cache.find(role => role.id === config.roles.staff.all)) return next(); 82 | return res.status(403).sendFile(join(webFolderPath, "forbidden.html")); 83 | }); 84 | }); 85 | 86 | // serve content 87 | app.get("/api/users/:userId.json", (req, res) => { 88 | client.users.fetch(req.params.userId, { cache: true, force: false }).then(user => { 89 | res.setHeader("Cache-Control", "public, max-age=0").send({ 90 | username: user.username, 91 | discriminator: user.discriminator, 92 | avatar: user.displayAvatarURL({ extension: "webp", size: 32 }), 93 | }); 94 | }) 95 | .catch(() => res.status(404).send({ error: "user not found" })); 96 | }); 97 | app.use(express.static(staffDocumentFolder)); 98 | app.get("*splat", (_, res) => res.status(404).sendFile(join(webFolderPath, "staff-document", "404.html"))); 99 | 100 | // start app 101 | listen(webConfig.port); 102 | } 103 | -------------------------------------------------------------------------------- /src/commands/mention/eval.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-inline-comments -- bug with @sapphire/type comments out inline code */ 2 | import type { MessageEditOptions, MessageReplyOptions } from "discord.js"; 3 | // import SapphireType from "@sapphire/type"; 4 | import { randomBytes } from "crypto"; 5 | import dedent from "dedent"; 6 | import { blockQuote, ButtonStyle, codeBlock, ComponentType, inlineCode } from "discord.js"; 7 | import { inspect } from "util"; 8 | import { isPromise } from "util/types"; 9 | import type{ MentionCommand } from "."; 10 | import config from "../../config"; 11 | import Emojis from "../../constants/emojis"; 12 | import { buttonComponents } from "../../handlers/interactions/components"; 13 | 14 | 15 | export default { 16 | names: ["eval", "evaluate", "run"], 17 | ownerOnly: true, 18 | testArgs(args) { return args.length > 0; }, 19 | // eslint-disable-next-line id-length, @typescript-eslint/no-unused-expressions, @stylistic/ts/brace-style -- the dollar sign is the message, we need this for context in the eval function 20 | execute($, reply, args) { $; 21 | try { 22 | // eslint-disable-next-line no-eval, @typescript-eslint/no-unsafe-assignment 23 | const evaluated = eval(args.join(" ")); 24 | if (isPromise(evaluated)) { 25 | const now = new Date(); 26 | const message = reply({ 27 | ...generateResponse(evaluated), 28 | content: `${Emojis.Loading} Running...`, 29 | }); 30 | return evaluated 31 | .then(async result => { 32 | const ms = new Date().getTime() - now.getTime(); 33 | void (await message).edit(generateFinalResponse(result, ms)); 34 | }) 35 | .catch(async (err: unknown) => { 36 | const ms = new Date().getTime() - now.getTime(); 37 | void (await message).edit(generateFinalResponse(err, ms, false)); 38 | }); 39 | } 40 | return reply(generateFinalResponse(evaluated)); 41 | } catch (err) { 42 | return reply(generateFinalResponse(err, -1, false)); 43 | } 44 | }, 45 | } as MentionCommand; 46 | 47 | function generateFinalResponse(result: unknown, ms = -1, success = true, fileUpload = false): MessageEditOptions & MessageReplyOptions { 48 | const identifier = randomBytes(16).toString("hex"); 49 | buttonComponents.set(`${identifier}:upload-to-file`, { 50 | allowedUsers: [config.ownerId], 51 | callback(button) { 52 | void button.update(generateFinalResponse(result, ms, success, true)); 53 | }, 54 | }); 55 | 56 | return { 57 | components: [], 58 | embeds: [], 59 | ...generateResponse(result, ms, success, !fileUpload), 60 | ...fileUpload ? 61 | { 62 | files: [ 63 | { 64 | name: "output.ts", 65 | attachment: Buffer.from(dedent/* // type: ${new SapphireType(result).toString()} */` 66 | // time: ${ms === -1 ? "n/a" : `${ms}ms`} 67 | // success: ${success ? "yes" : "no"}\n 68 | ` + inspect(result, { depth: Infinity, maxArrayLength: Infinity, maxStringLength: Infinity })), 69 | }, 70 | ], 71 | } : 72 | { 73 | components: [ 74 | { 75 | type: ComponentType.ActionRow, 76 | components: [ 77 | { 78 | type: ComponentType.Button, 79 | label: "Upload to file", 80 | style: ButtonStyle.Primary, 81 | customId: `${identifier}:upload-to-file`, 82 | }, 83 | ], 84 | }, 85 | ], 86 | }, 87 | }; 88 | } 89 | 90 | function generateResponse(result: unknown, ms = -1, success = true, includeResult = true, depth = 10, maxArrayLength = 100): MessageEditOptions & MessageReplyOptions { 91 | if (depth <= 0) return { content: `${Emojis.WeeWoo} Output is too big to display.` }; 92 | const output = inspect(result, { colors: true, depth, maxArrayLength }); 93 | // const type = new SapphireType(result).toString(); 94 | const content = `${success ? "✅ Evaluated successfully" : "❌ Javascript failed"}. ${ms === -1 ? "" : `(${inlineCode(`${ms}ms`)})`}\n${includeResult ? blockQuote(/* codeBlock("ts", ms === -1 ? type : `Promise<${type}>`) + */ codeBlock("ansi", success ? output : output.split("\n")[0]!)) : ""}`; 95 | 96 | // 1024 is not the actual limit but any bigger than 1k is really not ideal either way 97 | if (content.length > 1024) { 98 | if (!maxArrayLength) return generateResponse(result, ms, success, includeResult, depth - 1, maxArrayLength); 99 | return generateResponse(result, ms, success, includeResult, depth, maxArrayLength - 1); 100 | } 101 | 102 | return { content }; 103 | } 104 | -------------------------------------------------------------------------------- /web/static/statuses/too_many_requests.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/handlers/serverEnforcements/subserverAccess/refresh.ts: -------------------------------------------------------------------------------- 1 | import type { Client, Snowflake } from "discord.js"; 2 | import { inspect } from "util"; 3 | import type { OAuthTokensDocument } from "../../../database/models/OAuthTokens"; 4 | import config from "../../../config"; 5 | import subservers, { SubserverAccess } from "../../../constants/subservers"; 6 | import { OAuthTokens } from "../../../database/models/OAuthTokens"; 7 | import staffLogger from "../../../utils/logger/staff"; 8 | import oauth from "../../../utils/oauth"; 9 | import calculateAccess from "./calculator"; 10 | 11 | export async function refreshSubserverAccess(userId: Snowflake, client: Client): Promise { 12 | let tokens: false | null | OAuthTokensDocument = false; 13 | for (const subserver of subservers) { 14 | const server = client.guilds.cache.get(subserver.id); 15 | if (server) { 16 | const member = await server.members.fetch({ user: userId, force: false }).catch(() => null); 17 | const { access, applicableRoles, prohibitedRoles } = await calculateAccess(userId, subserver, client); 18 | 19 | // kick user for having no access 20 | if (access === SubserverAccess.Denied && member && !member.user.bot) { 21 | if (config.subservers.noDestructiveActions) staffLogger.warn(`Would have kicked user ${userId} from subserver ${subserver.name} due to no access`); 22 | else { 23 | await member.kick("User has no access to subserver") 24 | .then(() => staffLogger.info(`Kicked user ${userId} from subserver ${subserver.name} due to no access`)) 25 | .catch((err: unknown) => staffLogger.error(`Failed to kick user ${userId} from subserver ${subserver.name}: ${inspect(err)}`)); 26 | } 27 | // force-add user 28 | } else if (access === SubserverAccess.Forced && !member) { 29 | if (tokens === false) { 30 | tokens = await OAuthTokens.findOne({ userId }).then(tokenDoc => { 31 | if (!tokenDoc) return null; 32 | return oauth.tokenRequest({ 33 | refreshToken: tokenDoc.refreshToken, 34 | grantType: "refresh_token", 35 | scope: ["identify", "guilds.join"], 36 | }) 37 | .then(async ({ access_token: accessToken, refresh_token: refreshToken }) => { 38 | tokenDoc.accessToken = accessToken; 39 | tokenDoc.refreshToken = refreshToken; 40 | await tokenDoc.save(); 41 | return tokenDoc; 42 | }) 43 | .catch((err: unknown) => { 44 | staffLogger.error(`Failed to refresh access token for user ${userId}: ${inspect(err)}`); 45 | return null; 46 | }); 47 | }); 48 | } 49 | 50 | if (tokens) { 51 | if (config.subservers.noDestructiveActions) staffLogger.warn(`Would have added user ${userId} to subserver ${subserver.name} due to forced access`); 52 | else { 53 | await oauth.addMember({ 54 | accessToken: tokens.accessToken, 55 | botToken: client.token!, 56 | guildId: subserver.id, 57 | userId, 58 | roles: applicableRoles, 59 | }) 60 | .then(() => staffLogger.info(`Added user ${userId} to subserver ${subserver.name} due to forced access`)) 61 | .catch((err: unknown) => staffLogger.error(`Failed to add user ${userId} to subserver ${subserver.name}: ${inspect(err)}`)); 62 | } 63 | } else { 64 | staffLogger.error(`Failed to add user ${userId} to subserver ${subserver.name} because access token could not be refreshed`); 65 | } 66 | 67 | // apply roles to existing member 68 | } else if (member) { 69 | const rolesToAdd = applicableRoles.filter(role => !member.roles.cache.has(role)); 70 | if (rolesToAdd.length > 0) { 71 | await member.roles.add(rolesToAdd, "Role is forced (policy)"); 72 | staffLogger.debug(`Added roles ${rolesToAdd.join(", ")} to user ${userId} in subserver ${subserver.name} due to roles being forced`); 73 | } 74 | 75 | const rolesToRemove = prohibitedRoles.filter(role => member.roles.cache.has(role)); 76 | if (rolesToRemove.length > 0) { 77 | await member.roles.remove(rolesToRemove, "User is not allowed to have this role (policy)"); 78 | staffLogger.debug(`Removed roles ${rolesToRemove.join(", ")} from user ${userId} in subserver ${subserver.name} due to roles being prohibited`); 79 | } 80 | } 81 | } 82 | } 83 | staffLogger.debug(`Refreshed subserver access for user ${userId}`); 84 | } 85 | 86 | export function refreshAllSubserverAccess(client: Client): void { 87 | void Promise.all(client.guilds.cache.map(guild => guild.members.fetch())).then(async memberChunks => { 88 | const members = memberChunks.reduce((a, b) => [...a, ...b.map(member => member.id).filter(id => !a.includes(id))], []); 89 | for (const member of members) await refreshSubserverAccess(member, client); 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /src/constants/aboutContent/section5RoleDescriptions.ts: -------------------------------------------------------------------------------- 1 | import type { ButtonComponentData, MessageCreateOptions, Snowflake } from "discord.js"; 2 | import { ButtonStyle, ComponentType } from "discord.js"; 3 | import type { AboutSection } from "."; 4 | import config from "../../config"; 5 | import { buttonComponents } from "../../handlers/interactions/components"; 6 | import Emojis from "../emojis"; 7 | 8 | export default { 9 | title: "Role Descriptions", 10 | embed: { 11 | fields: [ 12 | { 13 | name: "Staff Roles", 14 | value: formatRoleList({ 15 | [config.roles.staff.leadership]: "Responsible for the management of the community behind the scenes.", 16 | [config.roles.staff.teams.moderation]: "Responsible for the moderation of all channels and enforce our server rules. They are also responsible for handling all server submissions.", 17 | [config.roles.staff.teams.developer]: "Responsible for the development of our bots and websites.", 18 | [config.roles.staff.teams.designer]: "Responsible for the creation of various designs used in Project Blurple.", 19 | [config.roles.staff.teams.events]: "Responsible for the community events.", 20 | [config.roles.staff.teams.giveaways]: "Responsible for the giveaway handling and creation.", 21 | [config.roles.staff.teams.minecraft]: "Responsible for the Minecraft server.", 22 | [config.roles.staff.teams.modmails]: "Responsible for the Modmails (1st line of support).", 23 | [config.roles.staff.teams.partnerships]: "Responsible for the partnerships.", 24 | }), 25 | }, 26 | { 27 | name: "Colored Roles", 28 | value: formatRoleList({ 29 | [config.roles.partners]: "Server owners that have partnered up with Project Blurple.", 30 | [config.roles.megaDonators]: "Users who donated an enormous value to be given to Blurple users.", 31 | [config.roles.donators]: "Given to users who kindly donated prizes during the event.", 32 | [config.roles.retiredStaff]: "Staff members who voluntarily resigned from the team.", 33 | [config.roles.blurpleServerRepresentative]: "Blurple server representatives who are celebrating with us and joined the server roster in blurple server list.", 34 | [config.roles.blurpleUser]: "Blurple users who are celebrating with us by setting their profile picture to a Blurple-colored picture.", 35 | [config.roles.painters]: "Blurple users who have collected paint and unlocked the mighty role.", 36 | [config.roles.artists]: "Blurple users who have shown their artistic skills in the Blurple Canvas.", 37 | [config.roles.adventurers]: "Blurple users who have or are currently participating in the Project Blurple Minecraft server.", 38 | }), 39 | }, 40 | { 41 | name: "Self-obtainable Roles", 42 | value: formatRoleList({ 43 | [config.roles.miscellaneous.archiveAccess]: "Has access to archived channels.", 44 | [config.roles.miscellaneous.canvasPing]: "Gets notified about Canvas updates.", 45 | [config.roles.miscellaneous.eventsPing]: "Gets notified whenever we have an event.", 46 | }), 47 | }, 48 | ], 49 | }, 50 | components: selfObtainableRoleButtons({ 51 | [config.roles.miscellaneous.archiveAccess]: "Archive Access", 52 | [config.roles.miscellaneous.canvasPing]: "Canvas Ping", 53 | [config.roles.miscellaneous.eventsPing]: "Events Ping", 54 | }), 55 | } satisfies AboutSection; 56 | 57 | function formatRoleList(roleDescriptions: Record) { 58 | return Object.entries(roleDescriptions) 59 | .map(([roleId, description]) => `- <@&${roleId}>: ${description}`) 60 | .join("\n"); 61 | } 62 | 63 | function selfObtainableRoleButtons(roles: Record): MessageCreateOptions["components"] { 64 | for (const roleId in roles) { 65 | buttonComponents.set(`self-assign-role:${roleId}`, { 66 | allowedUsers: "all", 67 | persistent: true, 68 | callback(button) { 69 | const hasRole = button.member.roles.cache.has(roleId); 70 | if (hasRole) { 71 | void button.member.roles.remove(roleId, "User self-removed role"); 72 | void button.reply({ content: `${Emojis.ThumbsUp} Removed the <@&${roleId}> role.`, ephemeral: true }); 73 | } else { 74 | void button.member.roles.add(roleId, "User self-assigned role"); 75 | void button.reply({ content: `${Emojis.ThumbsUp} Added the <@&${roleId}> role.`, ephemeral: true }); 76 | } 77 | }, 78 | }); 79 | } 80 | 81 | const buttons = Object.entries(roles).map(([roleId, roleName]) => ({ 82 | type: ComponentType.Button, 83 | label: `Toggle ${roleName}-role`, 84 | style: ButtonStyle.Secondary, 85 | customId: `self-assign-role:${roleId}`, 86 | })); 87 | 88 | // group buttons into groups of 5 89 | const groups: Array = []; 90 | for (let i = 0; i < buttons.length; i += 5) groups.push(buttons.slice(i, i + 5)); 91 | 92 | return groups.map(group => ({ type: ComponentType.ActionRow, components: group })); 93 | } 94 | -------------------------------------------------------------------------------- /src/handlers/appeals/messageEntry.ts: -------------------------------------------------------------------------------- 1 | import type { ButtonComponentData, Client, InteractionUpdateOptions, MessageCreateOptions } from "discord.js"; 2 | import { ButtonStyle, Colors, ComponentType, time } from "discord.js"; 3 | import type { AppealDocument } from "../../database/models/Appeal"; 4 | import { fitText } from "../../utils/text"; 5 | import registerAppealButtons from "./buttons"; 6 | import finalResolutionColors from "./finalResolutionColors"; 7 | 8 | export default function generateAppealMessage(appeal: AppealDocument, client: Client): InteractionUpdateOptions & MessageCreateOptions { 9 | registerAppealButtons(appeal, client); 10 | 11 | let color: number = Colors.White; 12 | if (appeal.staffAssigneeId) color = Colors.LightGrey; 13 | if (appeal.finalResolution) color = finalResolutionColors[appeal.finalResolution.action]; 14 | 15 | return { 16 | ...appeal.staffAssigneeId && { content: `*Assigned to <@${appeal.staffAssigneeId}>.*` }, 17 | ...appeal.finalResolution && { content: `Resolved by <@${appeal.finalResolution.staffId}> ${time(appeal.finalResolution.timestamp, "R")} with action **${appeal.finalResolution.action.toUpperCase()}**.` }, 18 | allowedMentions: { users: appeal.staffAssigneeId && !appeal.finalResolution ? [appeal.staffAssigneeId] : [] }, 19 | embeds: [ 20 | { 21 | author: { 22 | name: `${appeal.user.tag} (${appeal.user.id})`, 23 | // eslint-disable-next-line camelcase 24 | icon_url: appeal.user.avatarUrl, 25 | }, 26 | title: `Appeal #${appeal.appealId} - ${appeal.type.toUpperCase()} ${appeal.caseId ? `- Case #${appeal.caseId}` : ""}`, 27 | color, 28 | fields: [ 29 | { name: "User Statement", value: `${fitText(appeal.userStatement, 1024)}\n` }, 30 | { name: "Why should we appeal your punishment?", value: `${fitText(appeal.userReason, 1024)}\n` }, 31 | { name: "Appeal log", value: ((appeal.logs as typeof appeal["logs"] | undefined) ?? []).map(logEntry => `• ${time(logEntry.timestamp, "R")}: ${logEntry.message}`).join("\n") || "*No logs yet.*" }, 32 | ...appeal.finalResolution ? [{ name: "Final resolution", value: `<@${appeal.finalResolution.staffId}> resolved this ${time(appeal.finalResolution.timestamp, "R")} with action **${appeal.finalResolution.action.toUpperCase()}**.\n>>> ${appeal.finalResolution.reason}` }] : [], 33 | ], 34 | }, 35 | ], 36 | components: [ 37 | { 38 | type: ComponentType.ActionRow, 39 | components: [ 40 | ...(appeal.finalResolution ? 41 | [ 42 | { 43 | type: ComponentType.Button, 44 | customId: `appeal-${appeal.appealId}:viewFinalResolution`, 45 | style: ButtonStyle.Secondary as never, 46 | label: "View final resolution", 47 | }, 48 | ] : 49 | [ 50 | appeal.staffAssigneeId ? 51 | { 52 | type: ComponentType.Button, 53 | customId: `appeal-${appeal.appealId}:unassign`, 54 | style: ButtonStyle.Secondary as never, 55 | label: "Unassign", 56 | } : 57 | { 58 | type: ComponentType.Button, 59 | customId: `appeal-${appeal.appealId}:assign`, 60 | style: ButtonStyle.Primary as never, 61 | label: "Assign to me", 62 | }, 63 | { 64 | type: ComponentType.Button, 65 | customId: `appeal-${appeal.appealId}:createFinalResolution`, 66 | style: ButtonStyle.Success as never, 67 | label: "Create a final resolution", 68 | }, 69 | ] 70 | ) satisfies ButtonComponentData[], 71 | { 72 | type: ComponentType.Button, 73 | customId: `appeal-${appeal.appealId}:refresh`, 74 | style: ButtonStyle.Secondary, 75 | label: "Refresh", 76 | }, 77 | ], 78 | }, 79 | // if the statement or reason is longer than 1024 characters, add a button to view the full text 80 | ...Math.max(appeal.userStatement.length, appeal.userReason.length) > 1024 ? 81 | [ 82 | { 83 | type: ComponentType.ActionRow, 84 | components: [ 85 | ...(appeal.userStatement.length > 1024 ? 86 | [ 87 | { 88 | type: ComponentType.Button, 89 | customId: `appeal-${appeal.appealId}:viewFullStatement`, 90 | style: ButtonStyle.Secondary as never, 91 | label: "View full statement", 92 | }, 93 | ] : 94 | []) satisfies ButtonComponentData[], 95 | ...(appeal.userReason.length > 1024 ? 96 | [ 97 | { 98 | type: ComponentType.Button, 99 | customId: `appeal-${appeal.appealId}:viewFullReason`, 100 | style: ButtonStyle.Secondary as never, 101 | label: "View full reason", 102 | }, 103 | ] : 104 | []) satisfies ButtonComponentData[], 105 | ], 106 | }, 107 | ] : 108 | [], 109 | ], 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /web/static/statuses/bad_request.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/static/robots.txt: -------------------------------------------------------------------------------- 1 | # .,...,...... 2 | # .,,,*/////****,....... 3 | # .,**/((((((((((((((((/*,,*. 4 | # .**//((((((((((((((((((((//**,,,. 5 | # .,,**//(((((((((((((((((((((((((((/****,. 6 | # .,,,**/(((((((((((((((((((((((((((((((((((/,,,. 7 | # ,(///*/(((((((((((((((((((((((((((((((((((((((/,,,. 8 | # .,//////(((((((((((((((((((((((((((((((((((((((((((/*,,. 9 | # ,***///(((((((((((((((((((((((((((((((((((((((((((((((/**,. 10 | # .,**/(((((((((((((((((((((((((((((((((((((((((((((((((((((/*,,. 11 | # ..,*/((((((((((((((((((((((((((((((((((((((((((((///((((((((((/***. 12 | # ...*(((((((((((((((((((((((((((((((((((((((##%&&&%%#((((((((((((((///, 13 | # ...*((((((((((((((((((((((((((((((((((((#%&&@&%##%&&&#(((((((((((((/*,,. 14 | # .,*(#((((((((((((((((((((((((((((((((#%&@@@&%#(((#&&@%((((((((((/((((////*,. 15 | # .,,,/((((((((((((((((((((((((((((#%@@@@@@@@@@@@@@@&%#//(((((((((((((((/*,,. 16 | # .*/((((((((((((((((((((((((((##%@&&&%&@@@@@@&&&%%#((((((((//(((((((((((/***, 17 | # .,**/((((((((((((((((((((/(#&@&&#((/#%&@@@%#(((((///(((((((((/(((((((((((/*,. 18 | # .,,**//((((/(((((((((((((((#&@@@&%#(#%&@@@%(((//((##%%&&&@&&#(((((((((((((/**,. 19 | # ..,*/(((((((((((((((((((((%&@@@@@@@@@@@@%(##%&@@@@@@@@@@@@%(((((((((((((((((/,. 20 | # .,,*/((((((((((((((((((/((#&&@@@@@@@@@@@@@@@@@@@@@@@@@&%#(((((((((((((((((/**,. 21 | # .,**/((((((((((((((((((/(((#####%&&@@@@@@@&%&@@@@@@&#((((((/((((((((((((((/*,.. 22 | # ,////(((((((((((((((((((((((((((((##&@@@@@@@@@@&%(((((###((/((((((((((((((/***. 23 | # .,**/((((((((((((((((((((((((((///((%@@@@@@&%((((#%@@@@@%(((((((((((((((//*,,. 24 | # .,*/((((((((((((((((((((((((((//(#%@@@@&%(((#%&@@@@@@@%((//(////((((((((////,. 25 | # .,*(///(((((((((((((((((((((((((#&&&&%#(((#%&@@@@@@@@&#//((((####(((((((((//*,.. 26 | # .*(/*/((((((((((((((((((((((((((((#((((((#&&@@@@@@@@@&##(#%%&&@@@%((((((((((((/*,,*. 27 | # .,****/((((((((((((((((((((((((((((((((/((#%&@@&%(%&@@@@%#%@@@@@@@%#(((((((((((((/**, 28 | # .,*/#&&@%##((((((((((((((((((((((((((((((((((((((((#%@@@@@@@@@@@@&%#(((/(((((((((((/*,. 29 | # .,,.*/((%&@@@@%(((((((((((((((((((((((((((((((/(//((//#%&@@@@@@@@@&%#((((((((((((((((((/*,. 30 | # .,*(///(((##&@@@&%#(((((((((((((((((((((((((((((//((((//#&@@@@@@@&%#(((((((((((((((((((((/*,. 31 | # .***/(((((((((%@@@@&%((((((((//*/((((((((((((((((((((((((((#%&&&%#(((((((((((((((((((((((((/*,. 32 | # .,**//(((((((((((%@@@@@%##((((/*,,,*(((((((((((((((((((((((((((((((((((((((((((((((((((((((((//*,. 33 | # .,,,*///((((((((((((((#%&@@@@@&&#/*,..,***/(((((((((((((((((((((//((((((((((((((((((((((((((((((((////, 34 | # ..,*/#%&&%#(((((((((((((((#%&@@@%(/,. .,*/(((((((((((((((((((((((((((((((((((((((((((((((((((((//**,,. 35 | # .,,,*(##&@@@&%#(((((((((((((((((/*,,. .,*/((((((((((((((((((((((((((((((((((((((((((((((((((/*,,. 36 | # ..,,*/(((((#%&@@@%(((((((((((((//*,.. .,*/(((((((((((((((((((((((((((((((((((((((((((((//*,**, 37 | # ..,,,**/(((((((#%&@@@&%######(((//*,.. .,*/(((((((((((((((((((((((((((((((((((((((((///*,,.. 38 | # .,**///(((((((((((#&@@@@@@@@@@@%(#%(*. ,//*/(((((((((((((((((((((((((((((((((((((/**,,,. 39 | # ,/((#&&@&##(((((((((((#%%&@@@@@@%(/*. .*/((((((((((((((((((((((((((((((((((//****. 40 | # .,,**/(#%&@@@&##(((((((((((((((((//**. .,,,*///////////////////////((((///**,,.. 41 | # .,,.,*//((((((#&@@@@%((((((((((((///*,,,. ..,********,,************/*,,,,.. 42 | # .,,*(((((((((((((#%&@@@&%%##((((((/****,. .. .,*. 43 | # .,,,/%&@@@%((((((((((#%@@@@@@@@@&&#/*,. 44 | # ..,,**/(#%&@@@&%#(((((((((##%&&&@@&%%#/*, 45 | # ....,*/(((((##&@@@&&#((((((((((((((((((/**, 46 | # .**,*//((((((((#&@@@@&#(((((((((((((/**,,. 47 | # .**/(#%%#((((((((((#&@@@@@&&&&@@@&&&%(/*,,. 48 | # .,/(##%@@@@@&##(((((((%&@@@@@@@@@@@@&&#/*,. 49 | # ..,**/(((##%&@@@@%#(((((((##%%&&&&&%%##(/*. 50 | # .,*///((((((((##&@@@&&#(((((((((((((/*////, 51 | # ,/*,/(((((((((((%@@@@@%(((((((((((/**,. 52 | # ..,*//(((((((((#&@@@@&%##(((((//*,,. 53 | # .,,*/((((((((#%@@@@@@&&%#(/*,. 54 | # ..,***/(((((##%&@@@&%#((*. 55 | # .,**,**//((((((//(#* 56 | # .,...,,,,*,.. 57 | # .... 58 | 59 | User-Agent: * 60 | Disallow: / --------------------------------------------------------------------------------