├── resources └── .exists ├── .npmrc ├── .eslintignore ├── assets ├── images │ ├── f.png │ ├── 8-ball.png │ ├── doubt.jpg │ ├── coinflip.gif │ ├── steve_heads.png │ ├── steve_tails.png │ └── thisisfine.png └── Roboto-Regular.ttf ├── src ├── lib │ ├── types │ │ ├── AssignableRole.d.ts │ │ ├── Snippet.ts │ │ ├── QuestionTag.d.ts │ │ ├── PVQuestion.d.ts │ │ ├── Reminder.d.ts │ │ ├── SageData.d.ts │ │ ├── Course.d.ts │ │ ├── Poll.d.ts │ │ ├── InteractionType.ts │ │ ├── SageUser.d.ts │ │ ├── discord.js.d.ts │ │ ├── errors.ts │ │ └── Command.ts │ ├── enums.d.ts │ ├── utils │ │ └── interactionUtils.ts │ ├── permissions.ts │ └── arguments.ts ├── commands │ ├── fun │ │ ├── thisisfine.ts │ │ ├── catfacts.ts │ │ ├── quote.ts │ │ ├── doubt.ts │ │ ├── f.ts │ │ ├── define.ts │ │ ├── coinflip.ts │ │ ├── blindfoldedroosen.ts │ │ ├── submit.ts │ │ ├── 8ball.ts │ │ ├── latex.ts │ │ ├── diceroll.ts │ │ └── rockpaperscissors.ts │ ├── info │ │ ├── ping.ts │ │ ├── info.ts │ │ ├── commit.ts │ │ ├── feedback.ts │ │ ├── stats.ts │ │ ├── serverinfo.ts │ │ ├── discordstatus.ts │ │ ├── leaderboard.ts │ │ └── help.ts │ ├── admin │ │ ├── showcommands.ts │ │ ├── restart.ts │ │ ├── status.ts │ │ ├── count.ts │ │ ├── setassign.ts │ │ ├── activity.ts │ │ ├── enable.ts │ │ ├── resetlevels.ts │ │ ├── disable.ts │ │ ├── issue.ts │ │ ├── refresh.ts │ │ ├── announce.ts │ │ ├── edit.ts │ │ ├── addbutton.ts │ │ ├── prune.ts │ │ └── addcourse.ts │ ├── partial visibility question │ │ ├── archive.ts │ │ ├── reply.ts │ │ ├── anonymous.ts │ │ └── private.ts │ ├── configuration │ │ ├── togglelevelpings.ts │ │ └── togglepii.ts │ ├── staff │ │ ├── google.ts │ │ ├── roleinfo.ts │ │ ├── whois.ts │ │ ├── mute.ts │ │ ├── resetlevel.ts │ │ ├── blockpy.ts │ │ ├── addassignment.ts │ │ ├── warn.ts │ │ ├── lookup.ts │ │ └── sudoreply.ts │ ├── reminders │ │ ├── viewreminders.ts │ │ ├── cancelreminder.ts │ │ └── remind.ts │ ├── check.ts │ └── question tagging │ │ ├── tagquestion.ts │ │ └── question.ts ├── pieces │ ├── interactionHandler.ts │ ├── verification.ts │ ├── databaseSync.ts │ ├── logs │ │ ├── errorLog.ts │ │ └── modLog.ts │ ├── report.ts │ ├── memberHandler.ts │ ├── blacklist.ts │ └── tasks.ts └── sage.ts ├── .editorconfig ├── .github └── workflows │ └── lint.yml ├── autodoc ├── movemd.sh └── writecommands.ts ├── tsconfig.json ├── .vscode └── settings.json ├── LICENSE ├── config.example.ts ├── package.json ├── .gitignore ├── README.md ├── onboard ├── nudge.ts └── onboard.ts └── Jenkinsfile /resources/.exists: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /assets/images/f.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ud-cis-discord/Sage/HEAD/assets/images/f.png -------------------------------------------------------------------------------- /src/lib/types/AssignableRole.d.ts: -------------------------------------------------------------------------------- 1 | export interface AssignableRole { 2 | id: string; 3 | } 4 | -------------------------------------------------------------------------------- /assets/images/8-ball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ud-cis-discord/Sage/HEAD/assets/images/8-ball.png -------------------------------------------------------------------------------- /assets/images/doubt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ud-cis-discord/Sage/HEAD/assets/images/doubt.jpg -------------------------------------------------------------------------------- /src/lib/types/Snippet.ts: -------------------------------------------------------------------------------- 1 | export interface Snippet { 2 | name: string; 3 | content: string; 4 | } 5 | -------------------------------------------------------------------------------- /assets/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ud-cis-discord/Sage/HEAD/assets/Roboto-Regular.ttf -------------------------------------------------------------------------------- /assets/images/coinflip.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ud-cis-discord/Sage/HEAD/assets/images/coinflip.gif -------------------------------------------------------------------------------- /assets/images/steve_heads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ud-cis-discord/Sage/HEAD/assets/images/steve_heads.png -------------------------------------------------------------------------------- /assets/images/steve_tails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ud-cis-discord/Sage/HEAD/assets/images/steve_tails.png -------------------------------------------------------------------------------- /assets/images/thisisfine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ud-cis-discord/Sage/HEAD/assets/images/thisisfine.png -------------------------------------------------------------------------------- /src/lib/types/QuestionTag.d.ts: -------------------------------------------------------------------------------- 1 | export interface QuestionTag { 2 | link: string; 3 | course: string; 4 | assignment: string; 5 | header: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/types/PVQuestion.d.ts: -------------------------------------------------------------------------------- 1 | export interface PVQuestion { 2 | owner: string; 3 | questionId: string; 4 | messageLink: string; 5 | type: 'private' | 'anonymous'; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/types/Reminder.d.ts: -------------------------------------------------------------------------------- 1 | export interface Reminder { 2 | owner: string; 3 | expires: Date; 4 | content: string; 5 | repeat: null | 'daily' | 'weekly'; 6 | mode: 'public' | 'private'; 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | 8 | [*.{js,ts}] 9 | charset = utf-8 10 | indent_style = tab 11 | indent_size = 4 -------------------------------------------------------------------------------- /src/lib/types/SageData.d.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType } from 'discord.js'; 2 | 3 | export interface SageData { 4 | status: { 5 | type: ActivityType; 6 | name: string; 7 | }; 8 | commandSettings: Array<{ name: string, enabled: boolean }>; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/types/Course.d.ts: -------------------------------------------------------------------------------- 1 | export interface Course { 2 | name: string; 3 | channels: { 4 | category: string; 5 | general: string; 6 | staff: string; 7 | private: string; 8 | } 9 | roles: { 10 | student: string; 11 | staff: string; 12 | } 13 | assignments: Array; 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/types/Poll.d.ts: -------------------------------------------------------------------------------- 1 | export interface Poll { 2 | question: string; 3 | results: PollResult[] 4 | owner: string; 5 | expires: Date; 6 | message: string; 7 | channel: string; 8 | type: 'Single' | 'Multiple' 9 | } 10 | 11 | export interface PollResult { 12 | option: string; 13 | users: string[]; 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/enums.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-shadow */ 2 | export const enum Leaderboard { 3 | width = 750, 4 | margin = 5, 5 | userPillHeight = 50, 6 | userPillColor = '#555555', 7 | textColor = '#EEEEEE', 8 | firstColor = '#FFD700', 9 | secondColor = '#C0C0C0', 10 | thirdColor = '#CD7F32', 11 | font = '30px Roboto' 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/types/InteractionType.ts: -------------------------------------------------------------------------------- 1 | export enum SageInteractionType { 2 | POLL = 'P', 3 | RPS = 'RPS' 4 | } 5 | 6 | /** 7 | * {SageInteractionType} type - the type of interaction 8 | * { string} 9 | */ 10 | export interface SageComponentInteractionData { 11 | type: SageInteractionType, 12 | commandOwner: string, 13 | additionalData: string[] 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/types/SageUser.d.ts: -------------------------------------------------------------------------------- 1 | export interface SageUser { 2 | email: string; 3 | hash: string; 4 | discordId: string; 5 | pii: boolean; 6 | count: number; 7 | levelExp: number; 8 | curExp: number; 9 | level: number; 10 | levelPings: boolean; 11 | isVerified: boolean; 12 | isStaff: boolean; 13 | roles: Array; 14 | courses: Array 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/types/discord.js.d.ts: -------------------------------------------------------------------------------- 1 | import { Db } from 'mongodb'; 2 | import { Command } from '@lib/types/Command'; 3 | import { Octokit } from '@octokit/rest'; 4 | import { Collection } from 'discord.js'; 5 | 6 | declare module 'discord.js' { 7 | interface Client{ 8 | mongo: Db 9 | octokit: Octokit 10 | commands: Collection; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Lint 3 | on: [push, pull_request] 4 | jobs: 5 | lint: 6 | name: ESLint 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v2 11 | 12 | - name: Install Node v14 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 14 16 | 17 | - name: Install dependencies 18 | run: npm ci 19 | 20 | - name: Run ESLint 21 | run: npm test 22 | -------------------------------------------------------------------------------- /src/commands/fun/thisisfine.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | 4 | export default class extends Command { 5 | 6 | description = 'Everything is fine... probably.'; 7 | 8 | run(interaction: ChatInputCommandInteraction): Promise | void> { 9 | return interaction.reply({ files: [{ 10 | attachment: `${__dirname}../../../../../assets/images/thisisfine.png`, // aliases don't work for file uploads 11 | name: `this_is_fine.png` 12 | }] }); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /autodoc/movemd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DOC_DIR="ud-cis-discord.github.io" 3 | SAGE_DIR="SageV2" 4 | if [[ ! -d ../ud-cis-discord.github.io ]] 5 | then 6 | echo "docs repo doesn't exist where expected, exiting" 7 | exit 1 8 | fi 9 | 10 | if [[ ! -f ./Commands.md ]] || [[ ! -f ./'Staff Commands.md' ]] 11 | then 12 | echo "commands markdown files not created, exiting" 13 | exit 1 14 | fi 15 | 16 | cd ../$DOC_DIR 17 | git pull 18 | mv ../$SAGE_DIR/Commands.md ./pages/Commands.md 19 | mv ../$SAGE_DIR/'Staff Commands.md' ./pages/'Staff Commands.md' 20 | git commit -a -m 'jenkins pipeline automatic docs update' 21 | git push 22 | -------------------------------------------------------------------------------- /src/lib/types/errors.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommand, CommandInteraction } from 'discord.js'; 2 | 3 | export class DatabaseError extends Error { 4 | 5 | constructor(message: string) { 6 | super(message); 7 | this.name = 'Database Error'; 8 | } 9 | 10 | } 11 | 12 | export class CommandError extends Error { 13 | 14 | interaction?: CommandInteraction; 15 | command?: ApplicationCommand; 16 | 17 | constructor(error: Error, interaction: CommandInteraction) { 18 | super(); 19 | this.name = error.name; 20 | this.message = error.message; 21 | this.stack = error.stack; 22 | this.interaction = interaction; 23 | this.command = interaction?.command; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/info/ping.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | import prettyMilliseconds from 'pretty-ms'; 4 | 5 | export default class extends Command { 6 | 7 | description = 'Runs a connection test to Discord'; 8 | 9 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 10 | const msgTime = new Date().getTime(); 11 | await interaction.reply('Ping?'); 12 | interaction.editReply(`Pong! Round trip took ${prettyMilliseconds(msgTime - interaction.createdTimestamp)}, REST ping ${interaction.client.ws.ping}ms.`); 13 | return; 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/commands/fun/catfacts.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | import axios from 'axios'; 4 | 5 | export default class extends Command { // Made by matt nadar 6 | 7 | description = 'This command will give you a random cat fact'; 8 | 9 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 10 | const response = await axios.get('https://catfact.ninja/fact'); 11 | const { fact } = response.data; 12 | const responseEmbed = new EmbedBuilder() 13 | .setColor('Blue') 14 | .setTitle('A Cat Fact') 15 | .setFooter({ text: `${fact}` }); 16 | return interaction.reply({ embeds: [responseEmbed] }); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": ".", 6 | "esModuleInterop": true, 7 | "outDir": "./dist", 8 | "paths": { 9 | "@root/*": ["*"], 10 | "@lib/*": ["src/lib/*"], 11 | "@pieces/*": ["src/pieces/*"] 12 | }, 13 | "resolveJsonModule": true, 14 | "rootDir": "./", 15 | "skipLibCheck": true, 16 | "target": "ES2019", 17 | "types": [ 18 | "node", 19 | "discord.js" 20 | ], 21 | "module": "commonjs" 22 | }, 23 | "exclude": [ 24 | "**/node_modules", 25 | "**/node_modules/discord.js/typings/index.d.ts", // problematic in djs versions >13.1 26 | "**/dist", 27 | "config.example.ts" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/fun/quote.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | import axios from 'axios'; 4 | 5 | export default class extends Command { 6 | 7 | description = 'Get a quote from historical figures via ZenQuotes API at https://zenquotes.io/'; 8 | 9 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 10 | const response = await axios.get('https://zenquotes.io/api/random'); 11 | const quote = response.data[0]; 12 | const responseEmbed = new EmbedBuilder() 13 | .setColor('#3CD6A3') 14 | .setTitle(`${quote.a}:`) 15 | .setDescription(`"${quote.q}"`); 16 | return interaction.reply({ embeds: [responseEmbed] }); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/commands/admin/showcommands.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandPermissions, ChatInputCommandInteraction, Formatters, InteractionResponse } from 'discord.js'; 2 | import { BOTMASTER_PERMS } from '@lib/permissions'; 3 | import { Command } from '@lib/types/Command'; 4 | 5 | export default class extends Command { 6 | 7 | description = 'Show all commands, including disable commands.'; 8 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 9 | 10 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 11 | let commands = '+ Enabled\n- Disabled\n'; 12 | 13 | interaction.client.commands.forEach(command => { 14 | commands += `\n${command.enabled === false ? '-' : '+'} ${command.name}`; 15 | }); 16 | 17 | return interaction.reply(Formatters.codeBlock('diff', commands)); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/admin/restart.ts: -------------------------------------------------------------------------------- 1 | import { BOT } from '@root/config'; 2 | import { BOTMASTER_PERMS } from '@lib/permissions'; 3 | import { ActivityType, ApplicationCommandPermissions, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 4 | import { Command } from '@lib/types/Command'; 5 | 6 | export default class extends Command { 7 | 8 | description = `Sets ${BOT.NAME}'s activity to 'Playing Restart...' and ends the process.`; 9 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 10 | 11 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 12 | const bot = interaction.client; 13 | bot.user.setActivity(`Restarting...`, { type: ActivityType.Playing }); 14 | interaction.reply(`Restarting ${BOT.NAME}`) 15 | .then(() => { 16 | bot.destroy(); 17 | process.exit(0); 18 | }); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.detectIndentation": false, 3 | "editor.insertSpaces": false, 4 | "editor.tabSize": 4, 5 | "eslint.validate": ["typescript"], 6 | "typescript.tsdk": "node_modules\\typescript\\lib", 7 | "typescript.preferences.quoteStyle": "single", 8 | "cmake.configureOnOpen": false, 9 | "cSpell.words": [ 10 | "BLURPLE", 11 | "CISC", 12 | "Deleter", 13 | "GREYPLE", 14 | "QTAGS", 15 | "assignables", 16 | "createc", 17 | "createcourse", 18 | "deletec", 19 | "deletecourse", 20 | "discordstats", 21 | "discstatus", 22 | "errormsg", 23 | "removec", 24 | "removereminder", 25 | "shortlink", 26 | "showremind", 27 | "showreminders", 28 | "sreply", 29 | "sudoreply", 30 | "tagq", 31 | "timespan", 32 | "udel", 33 | "viewremind" 34 | ], 35 | "search.exclude": { 36 | "**/node_modules": false 37 | }, 38 | "discord.enabled": false 39 | } -------------------------------------------------------------------------------- /src/commands/fun/doubt.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, GuildMember, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | 4 | export default class extends Command { 5 | 6 | description = 'Press X to doubt.'; 7 | options: ApplicationCommandOptionData[] = [ 8 | { 9 | name: 'target', 10 | description: 'The user to doubt', 11 | type: ApplicationCommandOptionType.User, 12 | required: true 13 | } 14 | ] 15 | 16 | run(interaction: ChatInputCommandInteraction): Promise | void> { 17 | const target = interaction.options.getMember('target') as GuildMember; 18 | return interaction.reply({ files: [{ 19 | attachment: `${__dirname}../../../../../assets/images/doubt.jpg`, 20 | name: 'doubt.jpg' 21 | }], content: `${interaction.user.username} pressed X to doubt ${target.user.username}` }); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/partial visibility question/archive.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@lib/types/Command'; 2 | import { generateErrorEmbed } from '@lib/utils/generalUtils'; 3 | import { ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 4 | 5 | export default class extends Command { 6 | 7 | description = `Archive a private question thread.`; 8 | extendedHelp = `This command only works in private question threads.`; 9 | 10 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 11 | if (!interaction.channel.isThread()) { 12 | return interaction.reply({ embeds: [generateErrorEmbed('You must run this command in a private question thread.')], ephemeral: true }); 13 | } 14 | await interaction.reply(`Archiving thread...`); 15 | await interaction.channel.setArchived(true, `${interaction.user.username} archived the question.`); 16 | interaction.editReply(`Thread archived.`); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/pieces/interactionHandler.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction, Client, MessageComponentInteraction } from 'discord.js'; 2 | import { handleRpsOptionSelect } from '../commands/fun/rockpaperscissors'; 3 | import { handlePollOptionSelect } from '../commands/fun/poll'; 4 | import { SageInteractionType } from '@lib/types/InteractionType'; 5 | 6 | async function register(bot: Client): Promise { 7 | bot.on('interactionCreate', i => { 8 | if (i.isMessageComponent()) routeComponentInteraction(bot, i); 9 | }); 10 | } 11 | 12 | async function routeComponentInteraction(bot: Client, i: MessageComponentInteraction) { 13 | if (i.isButton()) handleBtnPress(bot, i); 14 | } 15 | 16 | export default register; 17 | function handleBtnPress(bot: Client, i: ButtonInteraction) { 18 | switch (i.customId.split('_')[0] as SageInteractionType) { 19 | case SageInteractionType.POLL: 20 | handlePollOptionSelect(bot, i); 21 | break; 22 | case SageInteractionType.RPS: 23 | handleRpsOptionSelect(i); 24 | break; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/fun/f.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, GuildMember, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | 4 | export default class extends Command { 5 | 6 | description = 'Press F to pay respects.'; 7 | options: ApplicationCommandOptionData[] = [ 8 | { 9 | name: 'target', 10 | description: 'The user to pay respects to', 11 | type: ApplicationCommandOptionType.User, 12 | required: false 13 | } 14 | ] 15 | 16 | run(interaction: ChatInputCommandInteraction): Promise | void> { 17 | const target = interaction.options.getMember('target') as GuildMember; 18 | const replyContent = `${interaction.user.username} paid their respects ${target ? `to ${target.user.username}` : ``}`; 19 | return interaction.reply({ files: [{ 20 | attachment: `${__dirname}../../../../../assets/images/f.png`, 21 | name: 'pay_respects.png' 22 | }], content: replyContent }); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/commands/fun/define.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | 4 | export default class extends Command { 5 | 6 | description = 'Find the definition of a word.'; 7 | options: ApplicationCommandOptionData[] = [ 8 | { 9 | name: 'word', 10 | description: 'The word to define', 11 | type: ApplicationCommandOptionType.String, 12 | required: true 13 | } 14 | ] 15 | 16 | run(interaction: ChatInputCommandInteraction): Promise | void> { 17 | const input = interaction.options.getString('word'); 18 | 19 | // Get the first word in the sentence and make it URL-friendly 20 | if (input.indexOf(' ') !== -1) { 21 | return interaction.reply({ content: 'You can only define one word at a time!', ephemeral: true }); 22 | } 23 | const word = encodeURIComponent(input.toLowerCase()); 24 | return interaction.reply(`https://www.merriam-webster.com/dictionary/${word}`); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/pieces/verification.ts: -------------------------------------------------------------------------------- 1 | import { Client, Guild, ModalSubmitInteraction } from 'discord.js'; 2 | import { SageUser } from '@lib/types/SageUser'; 3 | import { DB, GUILDS, ROLES } from '@root/config'; 4 | 5 | export async function verify(interaction: ModalSubmitInteraction, bot: Client, guild: Guild, entry: SageUser, givenHash: string): Promise { 6 | if (!entry.isVerified) { 7 | entry.isVerified = true; 8 | entry.discordId = interaction.user.id; 9 | entry.roles.push(ROLES.VERIFIED); 10 | 11 | bot.mongo.collection(DB.USERS).updateOne( 12 | { hash: givenHash }, 13 | { $set: { ...entry } }) 14 | .then(async () => { 15 | const member = guild.members.cache.get(interaction.user.id); 16 | if (member) { 17 | entry.roles.forEach(role => member.roles.add(role, `${member.user.username} (${member.id}) just verified.`)); 18 | return; 19 | } return; 20 | }); 21 | } 22 | } 23 | 24 | async function register(bot: Client): Promise { 25 | const guild = await bot.guilds.fetch(GUILDS.MAIN); 26 | guild.members.fetch(); 27 | } 28 | 29 | export default register; 30 | 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 Matt Nadar, Leo Chen, Simon Brugel, Blade Tyrrell, Josh Lyon, Ren Ross & Ben Segal 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/configuration/togglelevelpings.ts: -------------------------------------------------------------------------------- 1 | import { DB } from '@root/config'; 2 | import { SageUser } from '@lib/types/SageUser'; 3 | import { ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 4 | import { DatabaseError } from '@lib/types/errors'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = `Toggles whether or not you will receive notifications from Sage on a level up.`; 10 | 11 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 12 | const entry: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: interaction.user.id }); 13 | 14 | if (!entry) { 15 | throw new DatabaseError(`Member ${interaction.user.username} (${interaction.user.id}) not in database`); 16 | } 17 | 18 | entry.levelPings = !entry.levelPings; 19 | interaction.client.mongo.collection(DB.USERS).updateOne({ discordId: interaction.user.id }, { $set: { levelPings: entry.levelPings } }); 20 | return interaction.reply({ content: `You will${entry.levelPings ? ' now' : ' no longer'} receive notifications from Sage on a level up.`, ephemeral: true }); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/fun/coinflip.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eqeqeq */ 2 | import { ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 3 | import { Command } from '@lib/types/Command'; 4 | import { setTimeout } from 'timers'; 5 | 6 | const COIN_FLIP = ['You got: Heads!', 'You got: Tails!']; 7 | 8 | export default class extends Command { 9 | 10 | description = 'Have Sage flip a coin for you!'; 11 | 12 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 13 | await interaction.reply('Flipping...'); 14 | const result = COIN_FLIP[Math.floor(Math.random() * COIN_FLIP.length)]; 15 | 16 | setTimeout(() => { 17 | if (result == COIN_FLIP[0]) { 18 | interaction.editReply({ files: [{ 19 | attachment: `${__dirname}../../../../../assets/images/steve_heads.png`, // aliases don't work for file uploads 20 | name: `steve_heads.png` 21 | }] }); 22 | } else { 23 | interaction.editReply({ files: [{ 24 | attachment: `${__dirname}../../../../../assets/images/steve_tails.png`, // aliases don't work for file uploads 25 | name: `steve_tails.png` 26 | }] }); 27 | } 28 | return interaction.editReply(result); 29 | }, 3000); 30 | } 31 | 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/commands/fun/blindfoldedroosen.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | 4 | export default class extends Command { 5 | 6 | description = 'Challenge a blindfolded Prof. Roosen to a sword fight!'; 7 | extendedHelp = 'You\'ve been challenged to a sword fight. However, your opponent, Professor Roosen, has decided to wear a blindfold. Feeling lucky?'; 8 | 9 | run(interaction: ChatInputCommandInteraction): Promise | void> { 10 | // 5 is a random number I chose to be the blindfolded Roosen, no other meaning 11 | let responseEmbed: EmbedBuilder; 12 | if (Math.floor(Math.random() * 6) === 5) { 13 | responseEmbed = new EmbedBuilder() 14 | .setColor('#ff0000') 15 | .setTitle('Battle results') 16 | .setDescription('Ooooooooooooh... ouch! Blindfolded Roosen has killed you! You lose.'); 17 | } else { 18 | responseEmbed = new EmbedBuilder() 19 | .setColor('#00ff00') 20 | .setTitle('Battle results') 21 | .setDescription('You\'ve won the fight against blindfolded Roosen. You live another day!'); 22 | } 23 | return interaction.reply({ embeds: [responseEmbed] }); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/utils/interactionUtils.ts: -------------------------------------------------------------------------------- 1 | import { SageComponentInteractionData, SageInteractionType } from '@lib/types/InteractionType'; 2 | 3 | export function getDataFromCustomId(customId: string): SageComponentInteractionData { 4 | const [commandType, owner, ...extra] = customId.split('_'); 5 | if (!(commandType in SageInteractionType)) { 6 | throw 'Invalid type for component\'s customId!'; 7 | } 8 | 9 | return { 10 | commandOwner: owner, 11 | type: commandType as SageInteractionType, 12 | additionalData: extra 13 | }; 14 | } 15 | 16 | /** 17 | * Create a customId to use in the interaction handlers. It is up to the implementer to 18 | * make sure that additionalData is set and used correctly, as it is meant to be flexible. 19 | * @param {SageComponentInteractionData} data The data to build the customId with 20 | * @returns {string} the custom ID 21 | */ 22 | export function buildCustomId(data: SageComponentInteractionData): string { 23 | if (data.commandOwner.length !== 18) throw 'owner must be a 18 digit Discord ID'; 24 | const customId = `${data.type}_${data.commandOwner}_${data.additionalData.join('_')}`; 25 | if (customId.length > 100) { 26 | throw 'Custom ID must not exceed 100 characters. Shorten additional data field.'; 27 | } 28 | return customId; 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/admin/status.ts: -------------------------------------------------------------------------------- 1 | import { BOT } from '@root/config'; 2 | import { BOTMASTER_PERMS } from '@lib/permissions'; 3 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, ChatInputCommandInteraction, InteractionResponse, 4 | PresenceStatusData } from 'discord.js'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | const STATUSES = ['online', 'idle', 'dnd', 'invisible']; 8 | export default class extends Command { 9 | 10 | description = `Sets ${BOT.NAME}'s status.`; 11 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 12 | 13 | options: ApplicationCommandOptionData[] = [{ 14 | name: 'status', 15 | description: 'The status to give the bot (online, idle, dnd, invis).', 16 | type: ApplicationCommandOptionType.String, 17 | required: true, 18 | choices: STATUSES.map((status) => ({ 19 | name: status, 20 | value: status 21 | })) 22 | }] 23 | 24 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 25 | const status = interaction.options.getString('status') as PresenceStatusData; 26 | const bot = interaction.client; 27 | await bot.user.setStatus(status); 28 | 29 | return interaction.reply(`Set ${BOT.NAME}'s status to ${status}`); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/commands/info/info.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 2 | import { BOT, MAINTAINERS } from '@root/config'; 3 | import { Command } from '@lib/types/Command'; 4 | 5 | export default class extends Command { 6 | 7 | description = `Provides information about ${BOT.NAME}.`; 8 | 9 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 10 | const info 11 | = `Welcome to ${BOT.NAME}, a wonderful, magical bot that has been custom-coded to assist you while you use this Discord server! 12 | 13 | Some features of ${BOT.NAME} include: 14 | • :man_mage: Self-assignable roles 15 | • :ticket: Question tagging so you can easily find questions others have asked 16 | • :ninja: Private and anonymous questions 17 | • :fire: And many more! (use /help for the full list of commands) 18 | 19 | Our friend ${BOT.NAME} was originally created by Ben Segal, Josh Lyon, and Ren Ross and is actively maintained by ${MAINTAINERS}. 20 | 21 | Please let any of us know if you have any issues! We try to fix bugs as soon as possible and are still adding new features. 22 | 23 | If you're interested in how ${BOT.NAME} works, you can check the code out at .`; 24 | return interaction.reply(info); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/staff/google.ts: -------------------------------------------------------------------------------- 1 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 2 | import { Command } from '@lib/types/Command'; 3 | import { BOT } from '@root/config'; 4 | import { EmbedBuilder, ApplicationCommandPermissions, ApplicationCommandOptionData, ChatInputCommandInteraction, ApplicationCommandOptionType, 5 | InteractionResponse } from 'discord.js'; 6 | 7 | export default class extends Command { 8 | 9 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 10 | description = `Have ${BOT.NAME} google something for someone`; 11 | options: ApplicationCommandOptionData[] = [ 12 | { 13 | name: 'query', 14 | type: ApplicationCommandOptionType.String, 15 | description: `What you'd like ${BOT.NAME} to Google for someone!`, 16 | required: true 17 | } 18 | ]; 19 | 20 | run(interaction: ChatInputCommandInteraction): Promise | void> { 21 | const query = interaction.options.getString('query'); 22 | const formatted = query.replace(new RegExp(' ', 'g'), '+').replace('%', '%25'); 23 | const link = `https://letmegooglethat.com/?q=${formatted}`; 24 | const embed = new EmbedBuilder() 25 | .setTitle('Let me Google that for you!') 26 | .setURL(link) 27 | .setColor('LuminousVividPink'); 28 | return interaction.reply({ embeds: [embed] }); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/admin/count.ts: -------------------------------------------------------------------------------- 1 | import { ADMIN_PERMS } from '@lib/permissions'; 2 | import { Command } from '@lib/types/Command'; 3 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, CategoryChannel, ChatInputCommandInteraction, 4 | InteractionResponse } from 'discord.js'; 5 | 6 | export default class extends Command { 7 | 8 | description = 'Count channels in a category, use during archiving'; 9 | runInDM = false; 10 | permissions: ApplicationCommandPermissions[] = [ADMIN_PERMS]; 11 | 12 | options: ApplicationCommandOptionData[] = [{ 13 | name: 'category', 14 | description: 'The name of the category you want to check (forum channels not included).', 15 | type: ApplicationCommandOptionType.Channel, 16 | required: true 17 | }]; 18 | 19 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 20 | // grab channel from command parameter 21 | const category = interaction.options.getChannel('category') as CategoryChannel; 22 | let channelCount = 0; 23 | try { 24 | channelCount = category.children.cache.size; 25 | return interaction.reply({ content: `**${category}** has **${channelCount}** channel(s)!`, ephemeral: true }); 26 | } catch (error) { 27 | return interaction.reply({ content: `That's not a valid channel category.`, ephemeral: true }); 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/info/commit.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder, InteractionResponse } from 'discord.js'; 2 | import { execSync } from 'child_process'; 3 | import { homepage as github } from '@root/package.json'; 4 | import { Command } from '@lib/types/Command'; 5 | export default class extends Command { 6 | 7 | description = 'Get info about the most recent commit that is currently running.'; 8 | extendedHelp = 'Merge commits and version bumps are ignored.'; 9 | 10 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 11 | const [hash, message, timestamp, branch] = this.getGitInfo(); 12 | 13 | const embed = new EmbedBuilder() 14 | .setTitle(message) 15 | .setDescription(`Commit [${hash.slice(0, 8)}](${github}/commit/${hash}) on ${branch}`) 16 | .setColor('#fbb848') 17 | .setTimestamp(new Date(timestamp)); 18 | 19 | return interaction.reply({ embeds: [embed] }); 20 | } 21 | 22 | getGitInfo(commitNumber = 0): Array { 23 | const info = execSync(`cd ${__dirname} && git log --max-count=1 --skip=${commitNumber} --no-merges --format="%H%n%an%n%s%n%ci"` + 24 | ' && git branch --show-current').toString().split('\n'); 25 | 26 | if (info[2].toLowerCase().startsWith('version bump')) { 27 | return this.getGitInfo(commitNumber + 1); 28 | } 29 | 30 | return info; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/types/Command.ts: -------------------------------------------------------------------------------- 1 | import { ROLES } from '@root/config'; 2 | import { ApplicationCommandOptionData, ApplicationCommandPermissions, ApplicationCommandPermissionType, ApplicationCommandType, CommandInteraction, InteractionResponse, 3 | Message, 4 | MessageContextMenuCommandInteraction } from 'discord.js'; 5 | 6 | 7 | export abstract class Command { 8 | 9 | // members 10 | name: string; 11 | category: string; 12 | enabled: boolean; 13 | aliases?: Array; 14 | description: string; 15 | usage?: string; 16 | extendedHelp?: string; 17 | runInDM?: boolean = true; 18 | runInGuild?: boolean = true; 19 | options?: ApplicationCommandOptionData[]; 20 | type?: ApplicationCommandType; 21 | permissions?: ApplicationCommandPermissions[] = [{ 22 | id: ROLES.VERIFIED, 23 | type: ApplicationCommandPermissionType.Role, 24 | permission: true 25 | }]; 26 | 27 | // functions 28 | abstract run(interaction: CommandInteraction | MessageContextMenuCommandInteraction): Promise | void | Message>; 29 | // void: Does not return anything (i.e. no interaction; rarely used) 30 | // InteractionResponse: usually means 'return interaction.reply('Text'); 31 | // Message: usually means 'return interaction.followUp('text'); 32 | 33 | } 34 | 35 | export interface CompCommand { 36 | name: string, 37 | description: string, 38 | options: ApplicationCommandOptionData[] 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/configuration/togglepii.ts: -------------------------------------------------------------------------------- 1 | import { DB, MAINTAINERS } from '@root/config'; 2 | import { SageUser } from '@lib/types/SageUser'; 3 | import { ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 4 | import { DatabaseError } from '@lib/types/errors'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = `Toggles whether your email (pii) will be sent to instructors over Discord.`; 10 | 11 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 12 | const entry: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: interaction.user.id }); 13 | 14 | if (!entry) { 15 | interaction.reply({ 16 | content: `Something went wrong when looking you up in our database. ${MAINTAINERS} have been notified.`, 17 | ephemeral: true 18 | }); 19 | throw new DatabaseError(`Member ${interaction.user.username} (${interaction.user.id}) not in database`); 20 | } 21 | 22 | entry.pii = !entry.pii; 23 | 24 | interaction.client.mongo.collection(DB.USERS).updateOne({ discordId: interaction.user.id }, { $set: { pii: entry.pii } }); 25 | 26 | return interaction.reply({ content: `Your personally identifiable information is now${entry.pii ? ' ABLE' : ' UNABLE'} to be sent by instructors over Discord. 27 | ${entry.pii ? '' : '**It is still available to staff outside of Discord.**'}`, ephemeral: true }); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/permissions.ts: -------------------------------------------------------------------------------- 1 | import { ROLES } from '@root/config'; 2 | import { ApplicationCommandPermissions, ApplicationCommandPermissionType, CommandInteraction, Message, Team } from 'discord.js'; 3 | 4 | export function staffPerms(msg: Message): boolean { 5 | return msg.member ? msg.member.roles.cache.has(ROLES.STAFF) : false; 6 | } 7 | 8 | export function adminPerms(msg: Message): boolean { 9 | return msg.member ? msg.member.roles.cache.has(ROLES.ADMIN) : false; 10 | } 11 | 12 | export async function botMasterPerms(msg: Message): Promise { 13 | await msg.client.application.fetch(); 14 | const team = msg.client.application.owner as Team; 15 | return team.members.has(msg.author.id); 16 | } 17 | 18 | export async function tempBotMasterPerms(interaction: CommandInteraction): Promise { 19 | await interaction.client.application.fetch(); 20 | const team = interaction.client.application.owner as Team; 21 | return team.members.has(interaction.user.id) ? interaction.user.id : 'ID not found'; 22 | } 23 | 24 | export const STAFF_PERMS: ApplicationCommandPermissions = { 25 | id: ROLES.STAFF, 26 | permission: true, 27 | type: ApplicationCommandPermissionType.Role 28 | }; 29 | 30 | export const ADMIN_PERMS: ApplicationCommandPermissions = { 31 | id: ROLES.ADMIN, 32 | permission: true, 33 | type: ApplicationCommandPermissionType.Role 34 | }; 35 | 36 | export let BOTMASTER_PERMS: ApplicationCommandPermissions[]; 37 | 38 | export function setBotmasterPerms(data: ApplicationCommandPermissions[]): void { 39 | BOTMASTER_PERMS = data; 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/reminders/viewreminders.ts: -------------------------------------------------------------------------------- 1 | import { DB } from '@root/config'; 2 | import { Reminder } from '@lib/types/Reminder'; 3 | import { ChatInputCommandInteraction, EmbedBuilder, InteractionResponse } from 'discord.js'; 4 | import { reminderTime } from '@root/src/lib/utils/generalUtils'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = 'See your upcoming reminders.'; 10 | extendedHelp = 'Don\'t worry, private reminders will be hidden if you use this command publicly.'; 11 | 12 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 13 | const reminders: Array = await interaction.client.mongo.collection(DB.REMINDERS) 14 | .find({ owner: interaction.user.id }).toArray(); 15 | reminders.sort((a, b) => a.expires.valueOf() - b.expires.valueOf()); 16 | 17 | if (reminders.length < 1) { 18 | interaction.reply({ content: 'You don\'t have any pending reminders!', ephemeral: true }); 19 | } 20 | 21 | const embeds: Array = []; 22 | 23 | reminders.forEach((reminder, i) => { 24 | if (i % 25 === 0) { 25 | embeds.push(new EmbedBuilder() 26 | .setTitle('Pending reminders') 27 | .setColor('DarkAqua')); 28 | } 29 | const hidden = reminder.mode === 'private'; 30 | embeds[Math.floor(i / 25)].addFields({ name: `${i + 1}. ${hidden ? 'Private reminder' : reminder.content}`, 31 | value: hidden ? 'Some time in the future.' : reminderTime(reminder) }); 32 | }); 33 | 34 | interaction.reply({ embeds }); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/pieces/databaseSync.ts: -------------------------------------------------------------------------------- 1 | import { DB, GUILDS, ROLES } from '@root/config'; 2 | import { Client } from 'discord.js'; 3 | import { SageUser } from '../lib/types/SageUser'; 4 | import { schedule } from 'node-cron'; 5 | 6 | async function register(bot: Client): Promise { 7 | handleCron(bot); 8 | schedule('0 3 * * *', () => { // run every day at 3:00am (time chosen because of low activity) 9 | handleCron(bot) 10 | .catch(async error => bot.emit('error', error)); 11 | }); 12 | } 13 | 14 | async function handleCron(bot: Client) { 15 | const guild = await bot.guilds.fetch(GUILDS.MAIN); 16 | await guild.members.fetch(); 17 | guild.members.cache.forEach(async (member) => { 18 | if (member.user.bot || !member.roles.cache.has(ROLES.VERIFIED)) return; // ignore bots/unverified members 19 | 20 | const currentUser = await bot.mongo.collection(DB.USERS).findOne({ discordId: member.user.id }); 21 | if (!currentUser) return; // not in database (for some reason; maybe ID is not linked to a user document) 22 | 23 | const newRoles = []; 24 | const newCourses = []; 25 | 26 | member.roles.cache.forEach(role => { 27 | if (role.name !== '@everyone') { 28 | newRoles.push(role.id); 29 | if (role.name.match(/CISC .{1,}/g)) { // checks if the role name starts with "CISC " and is followed by one or more additional characters 30 | newCourses.push(role.name.substring(5)); 31 | } 32 | } 33 | }); 34 | 35 | await bot.mongo.collection(DB.USERS).updateOne( 36 | { discordId: member.id }, 37 | { $set: { roles: newRoles, courses: newCourses } }); 38 | }); 39 | } 40 | 41 | export default register; 42 | -------------------------------------------------------------------------------- /src/commands/info/feedback.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, TextChannel, ChatInputCommandInteraction, ApplicationCommandOptionData, ApplicationCommandOptionType, InteractionResponse } from 'discord.js'; 2 | import { BOT, CHANNELS, MAINTAINERS } from '@root/config'; 3 | import { Command } from '@lib/types/Command'; 4 | 5 | export default class extends Command { 6 | 7 | description = `Provide feedback or bug reports about ${BOT.NAME}.`; 8 | 9 | options: ApplicationCommandOptionData[] = [ 10 | { 11 | name: 'feedback', 12 | description: 'feedback to be sent to the admins', 13 | type: ApplicationCommandOptionType.String, 14 | required: true 15 | }, 16 | { 17 | name: 'file', 18 | description: 'A file to be posted with the feedback', 19 | type: ApplicationCommandOptionType.Attachment, 20 | required: false 21 | } 22 | ] 23 | 24 | async run(interaction:ChatInputCommandInteraction): Promise> { 25 | const feedback = interaction.options.getString('feedback'); 26 | const file = interaction.options.getAttachment('file'); 27 | const feedbackChannel = await interaction.guild.channels.fetch(CHANNELS.FEEDBACK) as TextChannel; 28 | 29 | const embed = new EmbedBuilder() 30 | .setAuthor({ name: interaction.user.tag, iconURL: interaction.user.avatarURL() }) 31 | .setTitle('New Feedback') 32 | .setDescription(feedback) 33 | .setColor('DarkGreen') 34 | .setTimestamp(); 35 | 36 | if (file) embed.setImage(file.url); 37 | 38 | feedbackChannel.send({ embeds: [embed] }); 39 | 40 | return interaction.reply({ content: `Thanks! I've sent your feedback to ${MAINTAINERS}.`, ephemeral: true }); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/fun/submit.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, EmbedBuilder, InteractionResponse, TextChannel } from 'discord.js'; 2 | import { CHANNELS } from '@root/config'; 3 | import { Command } from '@lib/types/Command'; 4 | 5 | export default class extends Command { 6 | 7 | description = 'Submit an image to the current contest. After using this command upload an image in another message'; // lol thanks 100 char limit 8 | options: ApplicationCommandOptionData[] = [ 9 | { 10 | name: 'file', 11 | description: 'A file to be submitted', 12 | type: ApplicationCommandOptionType.Attachment, 13 | required: true 14 | }, 15 | { 16 | name: 'description', 17 | description: 'Description of your submission', 18 | type: ApplicationCommandOptionType.String, 19 | required: false 20 | } 21 | ] 22 | 23 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 24 | const submissionChannel = await interaction.client.channels.fetch(CHANNELS.FEEDBACK) as TextChannel; 25 | const file = interaction.options.getAttachment('file'); 26 | const description = interaction.options.getString('description'); 27 | 28 | const embed = new EmbedBuilder() 29 | .setTitle(`New contest submission from ${interaction.user.tag}`) 30 | .addFields({ name: 'URL', value: file.url }) 31 | .setImage(file.url) 32 | .setColor('Blue') 33 | .setTimestamp(); 34 | 35 | if (description) embed.setDescription(description); 36 | submissionChannel.send({ embeds: [embed] }).then(() => interaction.reply({ content: `Thanks for your submission, ${interaction.user.username}!` })); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/reminders/cancelreminder.ts: -------------------------------------------------------------------------------- 1 | import { Reminder } from '@lib/types/Reminder'; 2 | import { DB } from '@root/config'; 3 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 4 | import { Command } from '@lib/types/Command'; 5 | 6 | export default class extends Command { 7 | 8 | description = 'Cancel any pending reminders you may have.'; 9 | extendedHelp = 'You can only cancel one reminder at a time'; 10 | 11 | options: ApplicationCommandOptionData[] = [ 12 | { 13 | name: 'remindernumber', 14 | type: ApplicationCommandOptionType.Integer, 15 | required: true, 16 | description: 'ID of the reminder to cancel' 17 | } 18 | ] 19 | 20 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 21 | const remindNum = interaction.options.getInteger('remindernumber') - 1; 22 | 23 | const reminders: Array = await interaction.client.mongo.collection(DB.REMINDERS) 24 | .find({ owner: interaction.user.id }).toArray(); 25 | reminders.sort((a, b) => a.expires.valueOf() - b.expires.valueOf()); 26 | const reminder = reminders[remindNum]; 27 | 28 | if (!reminder) { 29 | interaction.reply({ 30 | content: `I couldn't find reminder **${remindNum}**. Use the \`viewremind\` command to see your current reminders.`, 31 | ephemeral: true 32 | }); 33 | } 34 | 35 | interaction.client.mongo.collection(DB.REMINDERS).findOneAndDelete(reminder); 36 | 37 | const hidden = reminder.mode === 'private'; 38 | return interaction.reply({ 39 | content: `Canceled reminder: **${reminder.content}**`, 40 | ephemeral: hidden 41 | }); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /config.example.ts: -------------------------------------------------------------------------------- 1 | export const BOT = { 2 | TOKEN: '', // Bot token here 3 | CLIENT_ID: '', // Client ID here 4 | NAME: 'Sage' // Bot Name 5 | }; 6 | 7 | export const MONGO = ''; 8 | 9 | export const DB = { 10 | CONNECTION: '', // Mongo connection string here 11 | USERS: 'users', 12 | PVQ: 'pvQuestions', 13 | QTAGS: 'questionTags', 14 | ASSIGNABLE: 'assignable', 15 | COURSES: 'courses', 16 | REMINDERS: 'reminders', 17 | CLIENT_DATA: 'clientData', 18 | POLLS: 'polls' 19 | }; 20 | 21 | export const GUILDS = { // Guild IDs for each guild 22 | MAIN: '', 23 | GATEWAY: '', 24 | GATEWAY_INVITE: '' 25 | }; 26 | 27 | export const ROLES = { // Role IDS for each role 28 | ADMIN: '', 29 | STUDENT_ADMIN: '', 30 | STAFF: '', 31 | VERIFIED: '', 32 | MUTED: '', 33 | LEVEL_ONE: '' 34 | }; 35 | 36 | export const EMAIL = { 37 | SENDER: '', // The email address all emails should be sent from 38 | REPLY_TO: '', // The replyto address for all emails 39 | REPORT_ADDRESSES: [ // A list of all the email address to get the weekly report 40 | '' 41 | ] 42 | }; 43 | 44 | export const CHANNELS = { // Channel IDs 45 | ERROR_LOG: '', 46 | SERVER_LOG: '', 47 | MEMBER_LOG: '', 48 | MOD_LOG: '', 49 | FEEDBACK: '', 50 | SAGE: '', 51 | ANNOUNCEMENTS: '', 52 | ARCHIVE: '', 53 | ROLE_SELECT: '' 54 | }; 55 | 56 | export const ROLE_DROPDOWNS = { 57 | COURSE_ROLES: '', 58 | ASSIGN_ROLES: '' 59 | }; 60 | 61 | export const LEVEL_TIER_ROLES = [ 62 | '', 63 | '', 64 | '', 65 | '', 66 | '' 67 | ]; 68 | 69 | export const FIRST_LEVEL = 10; 70 | export const GITHUB_TOKEN = ''; 71 | export const GITHUB_PROJECT = ''; 72 | export const PREFIX = 's;'; 73 | export const MAINTAINERS = ''; // The current maintainers of this bot 74 | export const SEMESTER_ID = ''; // The current semester ID. i.e. s21 75 | export const BLACKLIST = []; 76 | -------------------------------------------------------------------------------- /src/commands/info/stats.ts: -------------------------------------------------------------------------------- 1 | import { EmbedField, ChatInputCommandInteraction, EmbedBuilder, version as discordVersion, InteractionResponse } from 'discord.js'; 2 | import prettyMilliseconds from 'pretty-ms'; 3 | import { version as sageVersion } from '@root/package.json'; 4 | import { BOT } from '@root/config'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = 'Displays info about Sage\'s current status'; 10 | 11 | async run(interaction:ChatInputCommandInteraction): Promise | void> { 12 | const fields: Array = []; 13 | const bot = interaction.client; 14 | 15 | fields.push({ 16 | name: 'Users', 17 | value: `${bot.users.cache.size}`, 18 | inline: true 19 | }); 20 | fields.push({ 21 | name: 'Channels', 22 | value: `${bot.channels.cache.size}`, 23 | inline: true 24 | }); 25 | fields.push({ 26 | name: 'Servers', 27 | value: `${bot.guilds.cache.size}`, 28 | inline: true 29 | }); 30 | fields.push({ 31 | name: 'Uptime', 32 | value: prettyMilliseconds(bot.uptime), 33 | inline: true 34 | }); 35 | fields.push({ 36 | name: 'Memory Usage', 37 | value: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`, 38 | inline: true 39 | }); 40 | fields.push({ 41 | name: 'Sage Version', 42 | value: `v${sageVersion}`, 43 | inline: false 44 | }); 45 | fields.push({ 46 | name: 'Discord.js Version', 47 | value: `v${discordVersion}`, 48 | inline: true 49 | }); 50 | 51 | const embed = new EmbedBuilder() 52 | .setColor('DarkGreen') 53 | .setAuthor({ name: `${BOT.NAME} Stats`, iconURL: bot.user.displayAvatarURL() }) 54 | .setThumbnail(bot.user.displayAvatarURL()) 55 | .setTimestamp(Date.now()) 56 | .addFields(fields); 57 | 58 | return interaction.reply({ embeds: [embed] }); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/commands/info/serverinfo.ts: -------------------------------------------------------------------------------- 1 | import { ChannelType, ChatInputCommandInteraction, EmbedBuilder, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | 4 | export default class extends Command { 5 | 6 | description = 'Provides information about the UDCIS discord server.'; 7 | runInDM = false; 8 | 9 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 10 | const membersWithRoles = interaction.guild.members.cache.filter(m => m.roles.cache.size > 1).size; 11 | const percentage = Math.floor((interaction.guild.members.cache.filter(m => m.roles.cache.size > 1).size / interaction.guild.memberCount) * 100); 12 | 13 | const embed = new EmbedBuilder() 14 | .addFields([ 15 | { name: 'Total Members', value: interaction.guild.memberCount.toString(), inline: true }, 16 | { name: 'Humans', value: interaction.guild.members.cache.filter(m => !m.user.bot).size.toString(), inline: true }, 17 | { name: 'Bots', value: interaction.guild.members.cache.filter(m => m.user.bot).size.toString(), inline: true }, 18 | { name: 'Text Channels', value: interaction.guild.channels.cache.filter(c => c.type === ChannelType.GuildText).size.toString(), inline: true }, 19 | { name: 'Voice Channels', value: interaction.guild.channels.cache.filter(c => c.type === ChannelType.GuildVoice).size.toString(), inline: true }, 20 | { name: 'Roles', value: interaction.guild.roles.cache.size.toString(), inline: true }, 21 | { name: 'Emojis', value: interaction.guild.emojis.cache.size.toString(), inline: true }, 22 | { name: 'Members with Roles', value: `${membersWithRoles} (${percentage}%)`, inline: true } 23 | ]) 24 | .setAuthor({ name: interaction.guild.name, iconURL: interaction.guild.iconURL() }) 25 | .setColor('DarkVividPink') 26 | .setTimestamp(); 27 | 28 | return interaction.reply({ embeds: [embed] }); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/admin/setassign.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 2 | import { AssignableRole } from '@lib/types/AssignableRole'; 3 | import { ADMIN_PERMS } from '@lib/permissions'; 4 | import { DB } from '@root/config'; 5 | import { Command } from '@lib/types/Command'; 6 | import { updateDropdowns } from '@root/src/lib/utils/generalUtils'; 7 | 8 | export default class extends Command { 9 | 10 | description = `Adds a role to the assignable collection of the database, or removes it if it's there already`; 11 | runInDM = false; 12 | permissions: ApplicationCommandPermissions[] = [ADMIN_PERMS]; 13 | 14 | options: ApplicationCommandOptionData[] = [{ 15 | name: 'role', 16 | description: 'The role to add to the list of self-assignable roles.', 17 | type: ApplicationCommandOptionType.Role, 18 | required: true 19 | }] 20 | 21 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 22 | const apiRole = interaction.options.getRole('role'); 23 | const role = await interaction.guild.roles.fetch(apiRole.id); 24 | 25 | const assignables = interaction.client.mongo.collection(DB.ASSIGNABLE); 26 | const newRole: AssignableRole = { id: role.id }; 27 | 28 | if (await assignables.countDocuments(newRole) > 0) { 29 | await interaction.reply('Removing role...'); 30 | const responseMsg = `The role \`${role.name}\` has been removed.`; 31 | await assignables.findOneAndDelete(newRole); 32 | await updateDropdowns(interaction); 33 | interaction.editReply(responseMsg); 34 | } else { 35 | await interaction.reply('Adding role...'); 36 | await assignables.insertOne(newRole); 37 | await updateDropdowns(interaction); 38 | interaction.editReply(`The role \`${role.name}\` has been added.`); 39 | return; 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/staff/roleinfo.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Role, AttachmentBuilder, ApplicationCommandOptionData, ApplicationCommandPermissions, ChatInputCommandInteraction, ApplicationCommandOptionType, 2 | InteractionResponse } from 'discord.js'; 3 | import { sendToFile } from '@root/src/lib/utils/generalUtils'; 4 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = 'Gives information about a role, including a list of the members who have it.'; 10 | runInDM = false; 11 | options: ApplicationCommandOptionData[] = [ 12 | { 13 | name: 'role', 14 | description: 'Role to get the info of', 15 | type: ApplicationCommandOptionType.Role, 16 | required: true 17 | } 18 | ]; 19 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 20 | 21 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 22 | const role = interaction.options.getRole('role') as Role; 23 | 24 | const memberList = role.members || (await interaction.guild.roles.fetch(role.id)).members; 25 | 26 | const memberStrs = memberList.map(m => m.user.username).sort(); 27 | 28 | const members = memberStrs.join(', ').length > 1000 29 | ? await sendToFile(memberStrs.join('\n'), 'txt', 'MemberList', true) : memberStrs.join(', '); 30 | 31 | const embed = new EmbedBuilder() 32 | .setColor(role.color) 33 | .setTitle(`${role.name} | ${memberList.size} members`) 34 | .setFooter({ text: `Role ID: ${role.id}` }); 35 | 36 | const attachments: AttachmentBuilder[] = []; 37 | 38 | if (members instanceof AttachmentBuilder) { 39 | embed.addFields({ name: 'Members', value: 'Too many to display, see attached file.' }); 40 | attachments.push(members); 41 | } else { 42 | embed.addFields({ name: 'Members', value: memberList.size < 1 ? 'None' : members, inline: true }); 43 | } 44 | return interaction.reply({ embeds: [embed], files: attachments }); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/fun/8ball.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, EmbedBuilder, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | 4 | const MAGIC8BALL_RESPONSES = [ 5 | 'As I see it, yes.', 6 | 'Ask again later.', 7 | 'Better not tell you now.', 8 | 'Cannot predict now.', 9 | 'Concentrate and ask again.', 10 | 'Don’t count on it.', 11 | 'It is certain.', 12 | 'It is decidedly so.', 13 | 'Most likely.', 14 | 'My reply is no.', 15 | 'My sources say no.', 16 | 'Outlook not so good.', 17 | 'Outlook good.', 18 | 'Reply hazy, try again.', 19 | 'Signs point to yes.', 20 | 'Very doubtful.', 21 | 'Without a doubt.', 22 | 'Yes.', 23 | 'Yes – definitely.', 24 | 'You may rely on it.' 25 | ]; 26 | 27 | export default class extends Command { 28 | 29 | description = `Ask the 8-ball a question and you shall get an answer.`; 30 | extendedHelp = `This command requires you to put a question mark ('?') at the end of your message.`; 31 | 32 | options: ApplicationCommandOptionData[] = [ 33 | { 34 | name: 'question', 35 | description: 'The question you want to ask', 36 | type: ApplicationCommandOptionType.String, 37 | required: true 38 | } 39 | ] 40 | 41 | run(interaction: ChatInputCommandInteraction): Promise | void> { 42 | const question = interaction.options.getString('question'); 43 | const response = question.length !== 0 && (question[question.length - 1].endsWith('?') || question.endsWith('?!')) 44 | ? MAGIC8BALL_RESPONSES[Math.floor(Math.random() * MAGIC8BALL_RESPONSES.length)] 45 | : 'The 8-ball only responds to questions smh'; 46 | const responseEmbed = new EmbedBuilder() 47 | .setColor('#000000') 48 | .setTitle('The magic 8-ball says...') 49 | .setDescription(response) 50 | .setImage(`https://i.imgur.com/UFPWxHV.png`) 51 | .setFooter({ text: `${interaction.user.username} asked: ${question}` }); 52 | return interaction.reply({ embeds: [responseEmbed] }); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/admin/activity.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 2 | import { BOT, DB } from '@root/config'; 3 | import { BOTMASTER_PERMS } from '@lib/permissions'; 4 | import { Command } from '@lib/types/Command'; 5 | 6 | const ACTIVITIES = ['Playing', 'Streaming', 'Listening', 'Watching', 'Competing']; 7 | 8 | export default class extends Command { 9 | 10 | description = `Sets ${BOT.NAME}'s activity to the given status and content`; 11 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 12 | 13 | options: ApplicationCommandOptionData[] = [ 14 | { 15 | name: 'status', 16 | description: 'The activity status.', 17 | type: ApplicationCommandOptionType.String, 18 | required: true, 19 | choices: ACTIVITIES.map((activity) => ({ 20 | name: activity, 21 | value: activity 22 | })) 23 | }, 24 | { 25 | name: 'content', 26 | description: 'The activity itself (ex: /help).', 27 | type: ApplicationCommandOptionType.String, 28 | required: true 29 | } 30 | ] 31 | 32 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 33 | const bot = interaction.client; 34 | const content = interaction.options.getString('category'); 35 | const type = interaction.options.getString('status').toUpperCase(); 36 | 37 | // setting Sage's activity status in the guild 38 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 39 | // @ts-ignore - idk why TypeScript is complaining about this when it's literally the correct type 40 | bot.user.setActivity(content, { type }); 41 | // updating Sage's activity status in the database (so that it stays upon a restart) 42 | bot.mongo.collection(DB.CLIENT_DATA).updateOne( 43 | { _id: bot.user.id }, 44 | { $set: { status: { type, content } } }, 45 | { upsert: true }); 46 | 47 | interaction.reply({ content: `Set ${BOT.NAME}'s activity to *${type} ${content}*`, ephemeral: true }); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/commands/admin/enable.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, ChatInputCommandInteraction, Formatters, 2 | InteractionResponse } from 'discord.js'; 3 | import { BOTMASTER_PERMS } from '@lib/permissions'; 4 | import { getCommand } from '@root/src/lib/utils/generalUtils'; 5 | import { DB } from '@root/config'; 6 | import { SageData } from '@lib/types/SageData'; 7 | import { Command } from '@lib/types/Command'; 8 | 9 | export default class extends Command { 10 | 11 | description = 'Enable a command.'; 12 | usage = ''; 13 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 14 | 15 | options: ApplicationCommandOptionData[] = [{ 16 | name: 'command', 17 | description: 'The name of the command to be enabled.', 18 | type: ApplicationCommandOptionType.String, 19 | required: true 20 | }] 21 | 22 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 23 | const commandInput = interaction.options.getString('command'); 24 | const command = getCommand(interaction.client, commandInput); 25 | 26 | // check if command exists or is already enabled 27 | if (!command) return interaction.reply({ content: `I couldn't find a command called \`${command}\``, ephemeral: true }); 28 | if (command.enabled) return interaction.reply({ content: `${command.name} is already enabled.`, ephemeral: true }); 29 | 30 | command.enabled = true; 31 | interaction.client.commands.set(command.name, command); 32 | 33 | const { commandSettings } = await interaction.client.mongo.collection(DB.CLIENT_DATA).findOne({ _id: interaction.client.user.id }) as SageData; 34 | commandSettings[commandSettings.findIndex(cmd => cmd.name === command.name)] = { name: command.name, enabled: true }; 35 | interaction.client.mongo.collection(DB.CLIENT_DATA).updateOne( 36 | { _id: interaction.client.user.id }, 37 | { $set: { commandSettings } }, 38 | { upsert: true } 39 | ); 40 | 41 | return interaction.reply(Formatters.codeBlock('diff', `+>>> ${command.name} Enabled`)); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sage", 3 | "version": "3.3.0", 4 | "engines": { 5 | "node": ">=16.9.0" 6 | }, 7 | "engineStrict": true, 8 | "description": "A purpose build Discord bot to manage the UD CIS Discord server.", 9 | "main": "dist/src/sage.js", 10 | "scripts": { 11 | "start": "node dist/src/sage.js", 12 | "build": "tsc -p .", 13 | "clean": "rm -rf dist", 14 | "test": "eslint src --ext .ts", 15 | "lint": "eslint src --ext .ts --fix", 16 | "dev": "tsc-watch --onSuccess \"node dist/src/sage.js\"", 17 | "onboard": "node dist/onboard/onboard.js", 18 | "nudge": "node dist/onboard/nudge.js", 19 | "autodoc": "node dist/autodoc/writecommands.js && autodoc/movemd.sh" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/ud-cis-discord/SageV2.git" 24 | }, 25 | "author": "Matt Nadar, Leo Chen, Simon Brugel, Blade Tyrrell, Josh Lyon, Ren Ross & Ben Segal", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/ud-cis-discord/SageV2/issues" 29 | }, 30 | "homepage": "https://github.com/ud-cis-discord/SageV2", 31 | "dependencies": { 32 | "@octokit/rest": "^18.3.5", 33 | "axios": "^1.4.0", 34 | "canvas": "^2.8.0", 35 | "console-stamp": "^3.0.2", 36 | "discord.js": "^14.8.0", 37 | "module-alias": "^2.2.2", 38 | "moment": "^2.29.1", 39 | "mongodb": "^3.6.3", 40 | "node-cron": "^2.0.3", 41 | "node-fetch": "^2.6.1", 42 | "nodemailer": "^6.4.17", 43 | "parse-duration": "^0.4.4", 44 | "pretty-ms": "^7.0.1" 45 | }, 46 | "_moduleAliases": { 47 | "@root": "dist", 48 | "@lib": "dist/src/lib", 49 | "@pieces": "dist/src/pieces" 50 | }, 51 | "devDependencies": { 52 | "@types/console-stamp": "^0.2.33", 53 | "@types/mongodb": "^3.6.3", 54 | "@types/node": "^14.14.20", 55 | "@types/node-cron": "^2.0.3", 56 | "@types/node-fetch": "^2.5.7", 57 | "@types/nodemailer": "^6.4.0", 58 | "@typescript-eslint/eslint-plugin": "^4.23.0", 59 | "@typescript-eslint/parser": "^4.23.0", 60 | "eslint": "^7.26.0", 61 | "tsc-watch": "^4.2.9", 62 | "typescript": "^4.1.3" 63 | } 64 | } -------------------------------------------------------------------------------- /src/commands/admin/resetlevels.ts: -------------------------------------------------------------------------------- 1 | import { DB, FIRST_LEVEL, LEVEL_TIER_ROLES, ROLES } from '@root/config'; 2 | import { BOTMASTER_PERMS } from '@lib/permissions'; 3 | import { Command } from '@lib/types/Command'; 4 | import { ApplicationCommandPermissions, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 5 | import { SageUser } from '@lib/types/SageUser'; 6 | 7 | export default class extends Command { 8 | 9 | description = 'Resets every user in the guild\'s level to 1'; 10 | enabled = false; 11 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 12 | 13 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 14 | await interaction.reply('loading... '); 15 | await interaction.guild.roles.fetch(); 16 | const lvl1 = interaction.guild.roles.cache.find(role => role.id === ROLES.LEVEL_ONE); 17 | 18 | await interaction.guild.members.fetch(); 19 | interaction.guild.members.cache.forEach(member => { 20 | if (member.user.bot || !member.roles.cache.has(ROLES.VERIFIED)) return; 21 | let level: number; 22 | let lvlTier = -1; 23 | 24 | member.roles.cache.forEach(role => { 25 | if (role.name.startsWith('Level') && role !== lvl1) { 26 | member.roles.remove(role.id); 27 | level = Number.parseInt(role.name.split(' ')[1]); 28 | if (level >= 2) lvlTier = 0; 29 | if (level >= 5) lvlTier = 1; 30 | if (level >= 10) lvlTier = 2; 31 | if (level >= 15) lvlTier = 3; 32 | if (level >= 20) lvlTier = 4; 33 | level = 0; 34 | } 35 | }); 36 | 37 | if (lvlTier !== -1) member.roles.add(LEVEL_TIER_ROLES[lvlTier]); 38 | lvlTier = -1; 39 | 40 | if (!member.roles.cache.has(ROLES.LEVEL_ONE)) { 41 | member.roles.add(ROLES.LEVEL_ONE); 42 | } 43 | }); 44 | 45 | interaction.client.mongo.collection(DB.USERS).updateMany( 46 | { roles: { $all: [ROLES.VERIFIED] } }, { 47 | $set: { 48 | count: 0, 49 | levelExp: FIRST_LEVEL, 50 | level: 1, 51 | curExp: FIRST_LEVEL 52 | } 53 | }); 54 | 55 | interaction.editReply('I\'ve reset all levels in the guild.'); 56 | return; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/admin/disable.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, ChatInputCommandInteraction, Formatters, 2 | InteractionResponse } from 'discord.js'; 3 | import { BOTMASTER_PERMS } from '@lib/permissions'; 4 | import { getCommand } from '@root/src/lib/utils/generalUtils'; 5 | import { SageData } from '@lib/types/SageData'; 6 | import { DB } from '@root/config'; 7 | import { Command } from '@lib/types/Command'; 8 | 9 | export default class extends Command { 10 | 11 | description = 'Disable a command'; 12 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 13 | 14 | options: ApplicationCommandOptionData[] = [{ 15 | name: 'command', 16 | description: 'The name of the command to be disabled.', 17 | type: ApplicationCommandOptionType.String, 18 | required: true 19 | }] 20 | 21 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 22 | const commandInput = interaction.options.getString('command'); 23 | const command = getCommand(interaction.client, commandInput); 24 | 25 | // check if command exists or is already disabled 26 | if (!command) return interaction.reply({ content: `I couldn't find a command called \`${command}\``, ephemeral: true }); 27 | if (command.enabled === false) return interaction.reply({ content: `${command.name} is already disabled.`, ephemeral: true }); 28 | 29 | if (command.name === 'enable' || command.name === 'disable') { 30 | return interaction.reply({ content: 'Sorry fam, you can\'t disable that one.', ephemeral: true }); 31 | } 32 | 33 | command.enabled = false; 34 | interaction.client.commands.set(command.name, command); 35 | 36 | const { commandSettings } = await interaction.client.mongo.collection(DB.CLIENT_DATA).findOne({ _id: interaction.client.user.id }) as SageData; 37 | commandSettings[commandSettings.findIndex(cmd => cmd.name === command.name)] = { name: command.name, enabled: false }; 38 | interaction.client.mongo.collection(DB.CLIENT_DATA).updateOne( 39 | { _id: interaction.client.user.id }, 40 | { $set: { commandSettings } }, 41 | { upsert: true } 42 | ); 43 | 44 | return interaction.reply(Formatters.codeBlock('diff', `->>> ${command.name} Disabled`)); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/staff/whois.ts: -------------------------------------------------------------------------------- 1 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 2 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, ChatInputCommandInteraction, EmbedBuilder, 3 | InteractionResponse } from 'discord.js'; 4 | import prettyMilliseconds from 'pretty-ms'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = 'Gives an overview of a member\'s info.'; 10 | runInDM = false; 11 | options: ApplicationCommandOptionData[] = [ 12 | { 13 | name: 'user', 14 | description: 'The user to lookup', 15 | type: ApplicationCommandOptionType.User, 16 | required: true 17 | } 18 | ]; 19 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 20 | 21 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 22 | const user = interaction.options.getUser('user'); 23 | const member = await interaction.guild.members.fetch(user.id); 24 | 25 | const roles = member.roles.cache.size > 1 26 | ? [...member.roles.cache.filter(r => r.id !== r.guild.id).sort().values()].join(' ') 27 | : 'none'; 28 | 29 | const accountCreated = `${member.user.createdAt.getMonth()}/${member.user.createdAt.getDate()}/${member.user.createdAt.getFullYear()} 30 | (${prettyMilliseconds(Date.now() - member.user.createdTimestamp)} ago)`; 31 | 32 | const memberSince = `${member.joinedAt.getMonth()}/${member.joinedAt.getDate()}/${member.joinedAt.getFullYear()} 33 | (${prettyMilliseconds(Date.now() - member.joinedTimestamp)} ago)`; 34 | 35 | const embed = new EmbedBuilder() 36 | .setAuthor({ name: `${member.user.username}`, iconURL: member.user.displayAvatarURL() }) 37 | .setColor(member.displayColor) 38 | .setTimestamp() 39 | .setFooter({ text: `Member ID: ${member.id}` }) 40 | .addFields([ 41 | { name: 'Display Name', value: `${member.displayName} (<@${member.id}>)`, inline: true }, 42 | { name: 'Account Created', value: accountCreated, inline: true }, 43 | { name: 'Joined Server', value: memberSince, inline: true }, 44 | { name: 'Roles', value: roles, inline: true } 45 | ]); 46 | 47 | return interaction.reply({ embeds: [embed], ephemeral: true }); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/pieces/logs/errorLog.ts: -------------------------------------------------------------------------------- 1 | import { Client, TextChannel, EmbedBuilder, AttachmentBuilder } from 'discord.js'; 2 | import { sendToFile } from '@root/src/lib/utils/generalUtils'; 3 | import { CommandError } from '@lib/types/errors'; 4 | import { CHANNELS } from '@root/config'; 5 | 6 | async function register(bot: Client): Promise { 7 | const errLog = await bot.channels.fetch(CHANNELS.ERROR_LOG) as TextChannel; 8 | bot.on('error', async error => { 9 | const [embed, attachments] = await generateLogEmbed(error); 10 | errLog.send({ embeds: [embed as EmbedBuilder], files: attachments as AttachmentBuilder[] }); 11 | }); 12 | } 13 | 14 | export default register; 15 | 16 | async function generateLogEmbed(error: CommandError): Promise> { 17 | console.error(error); 18 | const embed = new EmbedBuilder(); 19 | const attachments: AttachmentBuilder[] = []; 20 | 21 | embed.setTitle(error.name ? error.name : error.toString()); 22 | 23 | if (error.message) { 24 | if (error.message.length < 1000) { 25 | embed.setDescription(`\`\`\`\n${error.message}\`\`\``); 26 | } else { 27 | embed.setDescription(`Full error message too big\n\`\`\`js\n${error.message.slice(0, 950)}...\`\`\``); 28 | } 29 | } 30 | 31 | if (error.stack) { 32 | if (error.stack.length < 1000) { 33 | embed.addFields({ name: 'Stack Trace', value: `\`\`\`js\n${error.stack}\`\`\``, inline: false }); 34 | } else { 35 | embed.addFields({ name: 'Stack Trace', value: 'Full stack too big, sent to file.', inline: false }); 36 | attachments.push(await sendToFile(error.stack, 'js', 'error', true)); 37 | } 38 | } 39 | embed.setTimestamp(); 40 | embed.setColor('Red'); 41 | 42 | if (error.command) { 43 | embed.addFields({ name: 'Command run', value: `\`\`\` 44 | ${error.interaction.command.name} (${error.interaction.command.id}) 45 | Type: ${error.interaction.command.type} 46 | \`\`\`` }); 47 | } 48 | if (error.interaction) { 49 | embed.addFields({ name: 'Original interaction', value: `[Check for flies] 50 | \`\`\` 51 | Date: ${error.interaction.createdAt} 52 | Channel: ${error.interaction.channel.id} 53 | Created By: ${error.interaction.member.user.username} (${error.interaction.member.user.id}) 54 | \`\`\` 55 | ` }); 56 | } 57 | 58 | return [embed, attachments]; 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/reminders/remind.ts: -------------------------------------------------------------------------------- 1 | import { BOT, DB } from '@root/config'; 2 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 3 | import { Reminder } from '@lib/types/Reminder'; 4 | import parse from 'parse-duration'; 5 | import { reminderTime } from '@root/src/lib/utils/generalUtils'; 6 | import { Command } from '@lib/types/Command'; 7 | 8 | export default class extends Command { 9 | 10 | 11 | description = `Have ${BOT.NAME} give you a reminder.`; 12 | extendedHelp = 'Reminders can be set to repeat daily or weekly.'; 13 | options: ApplicationCommandOptionData[] = [ 14 | { 15 | name: 'content', 16 | description: 'What you\'d like to be reminded of', 17 | type: ApplicationCommandOptionType.String, 18 | required: true 19 | }, 20 | { 21 | name: 'duration', 22 | description: 'When you\'d like to be reminded', 23 | type: ApplicationCommandOptionType.String, 24 | required: true 25 | }, 26 | { 27 | name: 'repeat', 28 | description: 'How often you want the reminder to repeat', 29 | choices: [{ name: 'Daily', value: 'daily' }, { name: 'Weekly', value: 'weekly' }], 30 | type: ApplicationCommandOptionType.String, 31 | required: false 32 | } 33 | ] 34 | 35 | run(interaction: ChatInputCommandInteraction): Promise | void> { 36 | const content = interaction.options.getString('content'); 37 | const rawDuration = interaction.options.getString('duration'); 38 | const duration = parse(rawDuration); 39 | const repeat = interaction.options.getString('repeat') as 'daily' | 'weekly' || null; 40 | 41 | if (!duration) { 42 | return interaction.reply({ 43 | content: `**${rawDuration}** is not a valid duration. You can use words like hours, minutes, seconds, days, weeks, months, or years.`, 44 | ephemeral: true 45 | }); 46 | } 47 | const reminder: Reminder = { 48 | owner: interaction.user.id, 49 | content, 50 | mode: 'public', // temporary 51 | expires: new Date(duration + Date.now()), 52 | repeat 53 | }; 54 | 55 | interaction.client.mongo.collection(DB.REMINDERS).insertOne(reminder); 56 | 57 | return interaction.reply({ content: `I'll remind you about that at ${reminderTime(reminder)}.`, ephemeral: true }); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/staff/mute.ts: -------------------------------------------------------------------------------- 1 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 2 | import { MAINTAINERS, ROLES } from '@root/config'; 3 | import { ApplicationCommandPermissions, ChatInputCommandInteraction, ApplicationCommandOptionData, ApplicationCommandOptionType, InteractionResponse } from 'discord.js'; 4 | import { Command } from '@lib/types/Command'; 5 | 6 | export default class extends Command { 7 | 8 | description = 'Gives the muted role to the given user.'; 9 | runInDM = false; 10 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 11 | options: ApplicationCommandOptionData[] = [ 12 | { 13 | name: 'user', 14 | description: 'The user to mute', 15 | type: ApplicationCommandOptionType.User, 16 | required: true 17 | } 18 | ] 19 | 20 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 21 | const user = interaction.options.getUser('user'); 22 | const member = await interaction.guild.members.fetch(user.id); 23 | 24 | if (!member) { 25 | interaction.reply({ 26 | content: `Something went wrong. Please contact ${MAINTAINERS} for help. You can give the user the <@&${ROLES.MUTED}> role manually as a quick fix.`, 27 | ephemeral: true 28 | }); 29 | throw new Error('Could not find member based on passed in user'); 30 | } 31 | 32 | if (member.roles.cache.has(ROLES.MUTED)) { 33 | const reason = `${member.user.username} was un-muted by ${interaction.user.tag} (${interaction.user.id})`; 34 | await member.roles.remove(ROLES.MUTED, reason); 35 | return interaction.reply({ content: `${member.user.username} has been un-muted.`, ephemeral: true }); 36 | } 37 | const reason = `${member.user.username} was muted by ${interaction.user.tag} (${interaction.user.id})`; 38 | await member.roles.add(ROLES.MUTED, reason); 39 | 40 | let muteMsg = `${member.user.username} has been muted.`; 41 | 42 | await member.send(`You have been muted on the UD CIS Discord Server by ${interaction.user.tag}. 43 | If you believe this to be a problem, please reach out to them directly.`).catch(() => { 44 | muteMsg += '\n\nThis user has DMs disabled, please make sure to let them know why this happened.'; 45 | return; 46 | }); 47 | 48 | return interaction.reply({ content: muteMsg, ephemeral: true }); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Our Stuff 2 | config.ts 3 | resources/* 4 | !resources/.exists 5 | src/commands/admin/eval.ts 6 | 7 | # macOS stuff 8 | .DS_Store 9 | 10 | # Logs 11 | logs 12 | !src/pieces/logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | lerna-debug.log* 18 | 19 | # Diagnostic reports (https://nodejs.org/api/report.html) 20 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | lib-cov 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | *.lcov 34 | 35 | # nyc test coverage 36 | .nyc_output 37 | 38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | bower_components 43 | 44 | # node-waf configuration 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | build/Release 49 | 50 | # Dependency directories 51 | node_modules/ 52 | jspm_packages/ 53 | 54 | # TypeScript v1 declaration files 55 | typings/ 56 | 57 | # TypeScript cache 58 | *.tsbuildinfo 59 | 60 | # Optional npm cache directory 61 | .npm 62 | 63 | # Optional eslint cache 64 | .eslintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variables file 82 | .env 83 | .env.test 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | 88 | # Next.js build output 89 | .next 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # Serverless directories 105 | .serverless/ 106 | 107 | # FuseBox cache 108 | .fusebox/ 109 | 110 | # DynamoDB Local files 111 | .dynamodb/ 112 | 113 | # TernJS port file 114 | .tern-port 115 | -------------------------------------------------------------------------------- /src/commands/admin/issue.ts: -------------------------------------------------------------------------------- 1 | import { ADMIN_PERMS } from '@lib/permissions'; 2 | import { RequestError } from '@octokit/types'; 3 | import { BOT, GITHUB_PROJECT } from '@root/config'; 4 | import { Command } from '@lib/types/Command'; 5 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 6 | 7 | export default class extends Command { 8 | 9 | description = `Creates an issue in ${BOT.NAME}'s repository.`; 10 | permissions: ApplicationCommandPermissions[] = [ADMIN_PERMS]; 11 | 12 | options: ApplicationCommandOptionData[] = [{ 13 | name: 'title', 14 | description: 'What\'s the issue?', 15 | type: ApplicationCommandOptionType.String, 16 | required: true 17 | }, 18 | { 19 | name: 'labels', 20 | description: 'The issue labels, in a comma-separated list (if multiple).', 21 | type: ApplicationCommandOptionType.String, 22 | required: false 23 | }, 24 | { 25 | name: 'body', 26 | description: 'The issue body', 27 | type: ApplicationCommandOptionType.String, 28 | required: false 29 | }] 30 | 31 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 32 | const title = interaction.options.getString('title'); 33 | const label = interaction.options.getString('labels'); 34 | const body = interaction.options.getString('body'); 35 | 36 | const labels = label ? label.split(', ') : []; 37 | 38 | const newIssue = await interaction.client.octokit.issues.create({ 39 | owner: 'ud-cis-discord', 40 | repo: GITHUB_PROJECT, 41 | title: title, 42 | labels: labels, 43 | body: body || `\n\nCreated by ${interaction.user.username} via ${BOT.NAME}` 44 | }).catch(response => { 45 | console.log(response); 46 | let errormsg = ''; 47 | const { errors } = response as RequestError; 48 | errors.forEach((error: { code; field; }) => { 49 | errormsg += `Value ${error.code} for field ${error.field}.\n`; 50 | }); 51 | interaction.reply({ content: `Issue creation failed. (HTTP Error ${response.status}) 52 | \`\`\`diff 53 | -${errormsg}\`\`\``, ephemeral: true }); 54 | }); 55 | if (newIssue) { 56 | return interaction.reply(`I've created your issue at <${newIssue.data.html_url}>`); 57 | } else { 58 | return interaction.reply('Something went horribly wrong with issue creation! Blame Josh.'); 59 | } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/arguments.ts: -------------------------------------------------------------------------------- 1 | import { Message, Role, GuildMember, TextChannel, Collection, ChannelType } from 'discord.js'; 2 | 3 | export async function roleParser(msg: Message, input: string): Promise { 4 | input = input.replace(/<@&(\d+)>/, '$1').trim(); 5 | 6 | const role = await msg.guild.roles.fetch(input); 7 | if (role) { 8 | return role; 9 | } 10 | 11 | const roleList = msg.guild.roles.cache.filter(r => r.name.toLowerCase() === input.toLowerCase()); 12 | if (roleList.size === 0) { 13 | throw 'No role with that name or ID exists.'; 14 | } 15 | if (roleList.size > 1) { 16 | throw 'Multiple roles with that name exist. Lookup by ID for role information.'; 17 | } 18 | return [...roleList.values()][0]; 19 | } 20 | 21 | export async function userParser(msg: Message, input: string): Promise { 22 | input = input.replace(/<@!?(\d+)>/, '$1').trim().toLowerCase(); 23 | 24 | const gMembers = await msg.guild.members.fetch(); 25 | 26 | let retMembers = gMembers.filter(member => member.user.id === input); 27 | if (retMembers.size !== 1) { 28 | retMembers = gMembers.filter(member => member.user.tag.toLowerCase() === input); 29 | if (retMembers.size !== 1) { 30 | retMembers = gMembers.filter( 31 | member => member.user.username.toLowerCase() === input || member.nickname?.toLowerCase() === input 32 | ); 33 | } 34 | } 35 | 36 | if (retMembers.size < 1) { 37 | throw 'No member with that username, nickname, or ID exists.'; 38 | } 39 | if (retMembers.size > 1) { 40 | throw `The query you entered matches \`${retMembers.map(member => member.user.tag).join('`, `')}\`. Try entering one of these tags to get a specific user.`; 41 | } 42 | 43 | return [...retMembers.values()][0]; 44 | } 45 | 46 | export function channelParser(msg: Message, input: string): TextChannel { 47 | input = input.replace(/<#!?(\d+)>/, '$1').trim().toLowerCase(); 48 | 49 | const gChannels: Collection = msg.guild.channels.cache 50 | .filter(channel => (channel.type === ChannelType.GuildText || channel.type === ChannelType.GuildAnnouncement) 51 | && (channel.id === input || channel.name === input)) as Collection; 52 | 53 | if (!gChannels || gChannels.size < 1) { 54 | throw 'No channel with that name or ID exists'; 55 | } 56 | 57 | if (gChannels.size > 1) { 58 | throw 'More than one channel with that name exists. Please specify a channel ID.'; 59 | } 60 | 61 | return gChannels.first(); 62 | } 63 | -------------------------------------------------------------------------------- /src/commands/staff/resetlevel.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 2 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 3 | import { SageUser } from '@lib/types/SageUser'; 4 | import { DatabaseError } from '@lib/types/errors'; 5 | import { DB } from '@root/config'; 6 | import { Command } from '@lib/types/Command'; 7 | 8 | export default class extends Command { 9 | 10 | description = 'Resets a given user\'s message count.'; 11 | extendedHelp = `Using with no value will reset to 0. A positive integer will 12 | set their message count and a negative will subtract that from their total`; 13 | runInDM = false; 14 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 15 | options: ApplicationCommandOptionData[] = [ 16 | { 17 | name: 'user', 18 | description: 'The user whose message count will be edited', 19 | type: ApplicationCommandOptionType.User, 20 | required: true 21 | }, 22 | { 23 | name: 'value', 24 | description: 'value to use (positive to set, negative to subtract, none to set to 0)', 25 | type: ApplicationCommandOptionType.Integer, 26 | required: false 27 | } 28 | ]; 29 | 30 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 31 | const user = interaction.options.getUser('user'); 32 | const amount = interaction.options.getInteger('value') || 0; 33 | const entry: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: user.id }); 34 | 35 | if (!entry) { 36 | throw new DatabaseError(`User ${user.username} (${user.id}) not in database`); 37 | } 38 | 39 | let retStr: string; 40 | 41 | if (amount < 0) { 42 | entry.count += amount; 43 | if (entry.count < 0) { 44 | entry.count = 0; 45 | retStr = `Subtracted ${amount * -1} from ${user.username}'s message count (bottomed out at 0).`; 46 | } else { 47 | retStr = `Subtracted ${amount * -1} from ${user.username}'s message count.`; 48 | } 49 | } else { 50 | entry.count = amount; 51 | retStr = `Set ${user.username}'s message count to ${amount}.`; 52 | } 53 | 54 | await interaction.client.mongo.collection(DB.USERS).updateOne( 55 | { discordId: user.id }, 56 | { $set: { count: entry.count } }); 57 | 58 | return interaction.reply({ content: retStr, ephemeral: true }); 59 | } 60 | 61 | 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sage 2 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/ud-cis-discord/sagev2?color=000855) ![We use Typescript](https://img.shields.io/badge/written_in-typescript-000855?logo=typescript&logoColor=ddd) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/ud-cis-discord/sagev2/Lint?color=000855&logo=github) ![GitHub repo size](https://img.shields.io/github/repo-size/ud-cis-discord/sagev2?color=000855&logo=github) ![Lines of code](https://img.shields.io/tokei/lines/github/ud-cis-discord/sagev2?color=000855&logo=github) 3 | 4 | Sage is a purpose build Discord bot to manage the UD CIS Discord server. Sage features a number of systems to encourage engagement and help facilitate learning. 5 |
6 | Some features of sage include: 7 | - 🧙‍♂️ Self-assignable roles 8 | - 🎫 Question tagging so you can easily find questions others have asked 9 | - 🐱‍👤 Private and anonymous questions 10 | - 🔥 and many more! 11 | 12 | Sage was originally created by: 13 | | | | 14 | |-|-|- 15 | 16 | Sage is currently maintained by: 17 | | | | 18 | |-|-|- 19 | -------------------------------------------------------------------------------- /src/commands/admin/refresh.ts: -------------------------------------------------------------------------------- 1 | import { BOT } from '@root/config'; 2 | import { BOTMASTER_PERMS } from '@root/src/lib/permissions'; 3 | import { Command } from '@root/src/lib/types/Command'; 4 | import { readdirRecursive } from '@root/src/lib/utils/generalUtils'; 5 | import { ActivityType, ApplicationCommandData, ApplicationCommandType, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 6 | 7 | export default class extends Command { 8 | 9 | description = `Re-loads all of ${BOT.NAME}'s commands. WARNING: This takes forever`; 10 | permissions = BOTMASTER_PERMS; 11 | 12 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 13 | const commandFiles = readdirRecursive(`${__dirname}/..`).filter(file => file.endsWith('.js')); 14 | interaction.deferReply(); 15 | 16 | const commands: ApplicationCommandData[] = []; 17 | for (const file of commandFiles) { 18 | const commandModule = await import(file); 19 | 20 | const dirs = file.split('/'); 21 | const name = dirs[dirs.length - 1].split('.')[0]; 22 | 23 | // semi type-guard, typeof returns function for classes 24 | if (!(typeof commandModule.default === 'function')) { 25 | console.log(`Invalid command ${name}`); 26 | continue; 27 | } 28 | 29 | // eslint-disable-next-line new-cap 30 | const command: Command = new commandModule.default; 31 | 32 | command.name = name; 33 | 34 | if ((!command.description || command.description.length >= 100 || command.description.length <= 0) && (command.type === ApplicationCommandType.ChatInput)) { 35 | throw `Command ${command.name}'s description must be between 1 and 100 characters.`; 36 | } 37 | 38 | command.category = dirs[dirs.length - 2]; 39 | 40 | commands.push({ 41 | name: command.name, 42 | description: command.description, 43 | options: command?.options || [], 44 | defaultPermission: false 45 | } as ApplicationCommandData); 46 | } 47 | await interaction.channel.send(`Clearing ${BOT.NAME}'s commands...`); 48 | await interaction.guild.commands.set([]); 49 | await interaction.channel.send(`Setting ${BOT.NAME}'s commands...`); 50 | await interaction.guild.commands.set(commands); 51 | await interaction.followUp(`Successfully refreshed ${BOT.NAME}'s commands. Restarting...`); 52 | interaction.client.user.setActivity(`Restarting...`, { type: ActivityType.Playing }); 53 | interaction.channel.send(`Restarting ${BOT.NAME}`) 54 | .then(() => { 55 | interaction.client.destroy(); 56 | process.exit(0); 57 | }); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/partial visibility question/reply.ts: -------------------------------------------------------------------------------- 1 | import { PVQuestion } from '@lib/types/PVQuestion'; 2 | import { BOT, DB } from '@root/config'; 3 | import { Command } from '@lib/types/Command'; 4 | import { EmbedBuilder, TextChannel, ChatInputCommandInteraction, ApplicationCommandOptionData, ApplicationCommandOptionType, InteractionResponse } from 'discord.js'; 5 | import { generateErrorEmbed } from '@lib/utils/generalUtils'; 6 | 7 | 8 | export default class extends Command { 9 | 10 | description = `Reply to a question you previously asked with ${BOT.NAME}.`; 11 | options: ApplicationCommandOptionData[] = [ 12 | { 13 | name: 'questionid', 14 | description: 'The ID of the question you would like to reply to', 15 | type: ApplicationCommandOptionType.String, 16 | required: true 17 | }, 18 | { 19 | name: 'response', 20 | description: 'What you would like to reply with', 21 | type: ApplicationCommandOptionType.String, 22 | required: true 23 | }, 24 | { 25 | name: 'file', 26 | description: 'A file to be posted with the reply', 27 | type: ApplicationCommandOptionType.Attachment, 28 | required: false 29 | } 30 | ] 31 | 32 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 33 | const id = interaction.options.getString('questionid'); 34 | const file = interaction.options.getAttachment('file'); 35 | const question: PVQuestion = await interaction.client.mongo.collection(DB.PVQ).findOne({ questionId: id }); 36 | 37 | if (!question || question.type === 'private') { 38 | return interaction.reply({ embeds: [generateErrorEmbed(`Could not find an *anonymous* question with an ID of **${id}**.`)], ephemeral: true }); 39 | } 40 | if (question.owner !== interaction.user.id) { 41 | return interaction.reply({ embeds: [generateErrorEmbed(`You are not the owner of question ID ${question.questionId}.`)], ephemeral: true }); 42 | } 43 | 44 | const [, channelId] = question.messageLink.match(/\d\/(\d+)\//); 45 | const channel = await interaction.client.channels.fetch(channelId) as TextChannel; 46 | 47 | const embed = new EmbedBuilder() 48 | .setAuthor({ name: `Anonymous responded to ${question.questionId}`, iconURL: interaction.client.user.avatarURL() }) 49 | .setDescription(`${interaction.options.getString('response')}\n\n[Jump to question](${question.messageLink})`); 50 | 51 | if (file) embed.setImage(file.url); 52 | 53 | channel.send({ embeds: [embed] }); 54 | 55 | interaction.reply({ content: 'I\'ve forwarded your message along.', ephemeral: true }); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/commands/staff/blockpy.ts: -------------------------------------------------------------------------------- 1 | import { DB, EMAIL } from '@root/config'; 2 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 3 | import { SageUser } from '@lib/types/SageUser'; 4 | import { ChatInputCommandInteraction, ApplicationCommandPermissions, ApplicationCommandOptionData, ApplicationCommandOptionType, InteractionResponse } from 'discord.js'; 5 | import nodemailer from 'nodemailer'; 6 | import { Command } from '@lib/types/Command'; 7 | 8 | export default class extends Command { 9 | 10 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 11 | description = 'Emails you a link to the students blockpy submissions'; 12 | runInDM = false; 13 | options: ApplicationCommandOptionData[] = [ 14 | { 15 | name: 'user', 16 | type: ApplicationCommandOptionType.User, 17 | description: 'The member to look up', 18 | required: true 19 | } 20 | ]; 21 | 22 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 23 | const user = interaction.options.getUser('user'); 24 | const entry: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: user.id }); 25 | const member = await interaction.guild.members.fetch(user.id); 26 | 27 | if (!entry) { 28 | return interaction.reply({ content: `User ${user.tag} has not verified.`, ephemeral: true }); 29 | } 30 | 31 | const sender: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: interaction.user.id }); 32 | this.sendEmail(sender.email, member.displayName, user.tag, entry); 33 | return interaction.reply( 34 | { content: `An email has been sent to you containing the requested data about \`${user.tag}\`.`, 35 | ephemeral: true }); 36 | } 37 | 38 | sendEmail(recipient: string, displayName: string, username: string, entry: SageUser): void { 39 | const mailer = nodemailer.createTransport({ 40 | host: 'mail.udel.edu', 41 | port: 25 42 | }); 43 | 44 | mailer.sendMail({ 45 | from: EMAIL.SENDER, 46 | replyTo: EMAIL.REPLY_TO, 47 | to: recipient, 48 | subject: `UD CIS Discord:requested student blockpy link`, 49 | html: ` 50 | 51 | 52 |

Your requested user information:

53 | BlockPy submissions for this student (${displayName}, also known as ${username}). Their email is ${entry.email} 55 |


Thank you for using the UD CIS Discord Server and Sage!

56 | 57 | ` 58 | }); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/commands/staff/addassignment.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandPermissions, ChatInputCommandInteraction, EmbedField, EmbedBuilder, ApplicationCommandOptionType, 2 | InteractionResponse } from 'discord.js'; 3 | import { Course } from '@lib/types/Course'; 4 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 5 | import { DB } from '@root/config'; 6 | import { Command } from '@lib/types/Command'; 7 | 8 | export default class extends Command { 9 | 10 | // Never assume staff are not dumb (the reason this is so long) 11 | 12 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 13 | description = 'Adds an assignment to a given course ID\'s assignment list'; 14 | runInDM = false; 15 | options: ApplicationCommandOptionData[] =[ 16 | { 17 | name: 'course', 18 | description: 'The course ID to add an assignment to', 19 | type: ApplicationCommandOptionType.String, 20 | required: true 21 | }, 22 | { 23 | name: 'newassignments', 24 | description: 'A | separated list of new assignments', 25 | type: ApplicationCommandOptionType.String, 26 | required: true 27 | } 28 | ] 29 | 30 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 31 | const course = interaction.options.getString('course'); 32 | const newAssignments = interaction.options.getString('newassignments').split('|').map(assign => assign.trim()); 33 | const entry: Course = await interaction.client.mongo.collection(DB.COURSES).findOne({ name: course }); 34 | 35 | const added: Array = []; 36 | const failed: Array = []; 37 | newAssignments.forEach(assignment => { 38 | if (entry.assignments.includes(assignment)) { 39 | failed.push(assignment); 40 | } else { 41 | added.push(assignment); 42 | entry.assignments.push(assignment); 43 | } 44 | }); 45 | 46 | interaction.client.mongo.collection(DB.COURSES).updateOne({ name: course }, { $set: { ...entry } }); 47 | 48 | const fields: Array = []; 49 | if (added.length > 0) { 50 | fields.push({ 51 | name: `Added assignment${added.length === 1 ? '' : 's'}`, 52 | value: added.join('\n'), 53 | inline: true 54 | }); 55 | } 56 | if (failed.length > 0) { 57 | fields.push({ 58 | name: `Pre-existing assignment${failed.length === 1 ? '' : 's'}`, 59 | value: failed.join('\n'), 60 | inline: true 61 | }); 62 | } 63 | const embed = new EmbedBuilder() 64 | .setTitle(`Course ${course}`) 65 | .addFields(fields) 66 | .setColor('Gold'); 67 | 68 | return interaction.reply({ embeds: [embed] }); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/check.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, ChatInputCommandInteraction, ApplicationCommandOptionData, InteractionResponse, ApplicationCommandOptionType } from 'discord.js'; 2 | import { SageUser } from '@lib/types/SageUser'; 3 | import { DB, MAINTAINERS } from '@root/config'; 4 | import { Command } from '@lib/types/Command'; 5 | 6 | export default class extends Command { 7 | 8 | description = 'Displays the users current message count.'; 9 | options: ApplicationCommandOptionData[] = [ 10 | { 11 | name: 'hide', 12 | description: 'determines if you want stats public or private', 13 | type: ApplicationCommandOptionType.Boolean, 14 | required: false 15 | } 16 | ] 17 | 18 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 19 | const user: SageUser = await interaction.user.client.mongo.collection(DB.USERS).findOne({ discordId: interaction.user.id }); 20 | 21 | if (!user) { 22 | interaction.reply(`I couldn't find you in the database, if you think this is an error please contact ${MAINTAINERS}.`); 23 | return; 24 | } 25 | 26 | const embed = new EmbedBuilder() 27 | .setTitle(`${interaction.user.username}'s Progress`) 28 | .setThumbnail(interaction.user.avatarURL()) 29 | .addFields({ name: 'Message Count', value: `You have sent **${user.count}** message${user.count === 1 ? '' : 's'} this week in academic course channels.`, inline: true }) 30 | .addFields({ name: 'Level Progress', value: `You're **${user.curExp}** message${user.curExp === 1 ? '' : 's'} away from **Level ${user.level + 1}** 31 | ${this.progressBar(user.levelExp - user.curExp, user.levelExp, 18)}`, inline: false }); 32 | if (interaction.options.getBoolean('hide') === true) { 33 | interaction.reply({ embeds: [embed], ephemeral: true }); 34 | } else { 35 | interaction.reply({ embeds: [embed] }); 36 | } 37 | return; 38 | } 39 | 40 | progressBar(value: number, maxValue: number, size: number): string { 41 | const percentage = value / maxValue; // Calculate the percentage of the bar 42 | const progress = Math.round(size * percentage); // Calculate the number of square caracters to fill the progress side. 43 | const emptyProgress = size - progress; // Calculate the number of dash caracters to fill the empty progress side. 44 | 45 | const progressText = `${'🟩'.repeat(Math.max(progress - 1, 0))}✅`; // Repeat is creating a string with progress * caracters in it 46 | const emptyProgressText = '⚫'.repeat(emptyProgress); // Repeat is creating a string with empty progress * caracters in it 47 | const percentageText = `${Math.round(percentage * 100)}%`; // Displaying the percentage of the bar 48 | 49 | return `${progressText}${emptyProgressText} **${percentageText}**`; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/commands/admin/announce.ts: -------------------------------------------------------------------------------- 1 | import { BOTMASTER_PERMS } from '@lib/permissions'; 2 | import { TextChannel, ApplicationCommandPermissions, ChatInputCommandInteraction, ApplicationCommandOptionData, ModalBuilder, ActionRowBuilder, 3 | ModalActionRowComponentBuilder, InteractionResponse, TextInputBuilder, TextInputStyle, ApplicationCommandOptionType } from 'discord.js'; 4 | import { CHANNELS } from '@root/config'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = 'Sends an announcement from Sage to a specified channel or announcements if no channel is given.'; 10 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 11 | 12 | options: ApplicationCommandOptionData[] = [{ 13 | name: 'channel', 14 | description: 'The channel to send the announcement in', 15 | type: ApplicationCommandOptionType.Channel, 16 | required: true 17 | }, 18 | { 19 | name: 'file', 20 | description: 'A file to be posted with the announcement', 21 | type: ApplicationCommandOptionType.Attachment, 22 | required: false 23 | }] 24 | 25 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 26 | const announceChannel = interaction.guild.channels.cache.get(CHANNELS.ANNOUNCEMENTS); 27 | const channelOption = interaction.options.getChannel('channel'); 28 | const file = interaction.options.getAttachment('file'); 29 | 30 | const channel = (channelOption || announceChannel) as TextChannel; 31 | 32 | const modal = new ModalBuilder() 33 | .setTitle('Announce') 34 | .setCustomId('announce'); 35 | 36 | const contentsComponent = new TextInputBuilder() 37 | .setCustomId('content') 38 | .setLabel('Content to send with this announcement') 39 | .setStyle(TextInputStyle.Paragraph) 40 | .setRequired(true); 41 | 42 | const channelComponent = new TextInputBuilder() 43 | .setCustomId('channel') 44 | .setLabel('ID of receiving channel (auto-filled)') 45 | .setStyle(TextInputStyle.Short) 46 | .setRequired(true) 47 | .setValue(channel.id); 48 | 49 | const fileComponent = new TextInputBuilder() 50 | .setCustomId('file') 51 | .setLabel('URL to attached file (auto-filled)') 52 | .setStyle(TextInputStyle.Short) 53 | .setRequired(false) 54 | .setValue(file ? file.url : ''); 55 | 56 | const modalRows: ActionRowBuilder[] = [ 57 | new ActionRowBuilder().addComponents(contentsComponent), 58 | new ActionRowBuilder().addComponents(channelComponent), 59 | new ActionRowBuilder().addComponents(fileComponent) 60 | ]; 61 | modal.addComponents(...modalRows); 62 | 63 | await interaction.showModal(modal); 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/pieces/report.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'discord.js'; 2 | import { schedule } from 'node-cron'; 3 | import nodemailer from 'nodemailer'; 4 | import moment from 'moment'; 5 | import { SageUser } from '@lib/types/SageUser'; 6 | import { DB, EMAIL } from '@root/config'; 7 | import { Course } from '@lib/types/Course'; 8 | import { Attachment } from 'nodemailer/lib/mailer'; 9 | 10 | 11 | async function register(bot: Client): Promise { 12 | // 0 0 * * SUN :: 0 minutes, 0 hours, any day of month, any month, on Sundays (AKA midnight between Sat & Sun) 13 | schedule('0 0 * * SUN', () => { 14 | handleCron(bot) 15 | .catch(async error => bot.emit('error', error)); 16 | }); 17 | } 18 | 19 | async function handleCron(bot: Client): Promise { 20 | const users: Array = await bot.mongo.collection(DB.USERS).find().toArray(); 21 | const courses: Array = await bot.mongo.collection(DB.COURSES).find().toArray(); 22 | 23 | const mailer = nodemailer.createTransport({ 24 | host: 'mail.udel.edu', 25 | port: 25 26 | }); 27 | 28 | // send the "lite" course reports to professors 29 | const reportProfs: Array = users.filter(user => EMAIL.REPORT_ADDRESSES.includes(user.email)); 30 | reportProfs.forEach(prof => { 31 | const reports: Attachment[] = []; 32 | const coursesTaught = courses.filter(course => prof.roles.includes(course.roles.staff)); 33 | coursesTaught.forEach(course => { 34 | const courseUsers = users.filter(user => user.courses.includes(course.name)); 35 | reports.push({ 36 | filename: `CISC${course.name}_${moment().format('M-D-YY_HH-mm-ss')}.csv`, 37 | content: `Email,Count\n${courseUsers.map(user => `${user.email},${user.count}`).join('\n')}` 38 | }); 39 | }); 40 | 41 | mailer.sendMail({ 42 | from: EMAIL.SENDER, 43 | replyTo: EMAIL.REPLY_TO, 44 | bcc: prof.email, 45 | subject: 'Discord weekly Report', 46 | html: ` 47 | 48 |

Here is your weekly Discord participation report.

49 |

- The Discord Admin Team

50 | `, 51 | attachments: reports 52 | }); 53 | }); 54 | 55 | // send the full report to admins 56 | let fullReport = `Email,Count,${courses.join(',')}\n`; 57 | users.forEach(user => { 58 | fullReport += `${user.email},${user.count}`; 59 | courses.forEach(course => { 60 | fullReport += `,${user.courses.includes(course.name)}`; 61 | }); 62 | fullReport += '\n'; 63 | }); 64 | 65 | mailer.sendMail({ 66 | from: EMAIL.SENDER, 67 | replyTo: EMAIL.REPLY_TO, 68 | bcc: EMAIL.REPORT_ADDRESSES, 69 | subject: 'Discord weekly Report', 70 | html: ` 71 | 72 |

Here is your weekly Discord participation report.

73 |

- The Discord Admin Team

74 | `, 75 | attachments: [{ 76 | filename: `report${moment().format('M-D-YY_HH-mm-ss')}.csv`, 77 | content: fullReport 78 | }] 79 | }); 80 | 81 | bot.mongo.collection(DB.USERS).updateMany({}, { $set: { count: 0 } }); 82 | } 83 | 84 | export default register; 85 | -------------------------------------------------------------------------------- /src/commands/admin/edit.ts: -------------------------------------------------------------------------------- 1 | import { BOTMASTER_PERMS } from '@lib/permissions'; 2 | import { ApplicationCommandOptionData, ApplicationCommandPermissions, ChatInputCommandInteraction, ActionRowBuilder, ModalActionRowComponentBuilder, TextChannel, 3 | TextInputBuilder, ApplicationCommandOptionType, InteractionResponse, ModalBuilder, TextInputStyle } from 'discord.js'; 4 | import { BOT } from '@root/config'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = `Edits a message sent by ${BOT.NAME}.`; 10 | usage = '|'; 11 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 12 | 13 | options: ApplicationCommandOptionData[] = [{ 14 | name: 'msg_link', 15 | description: 'A message link', 16 | type: ApplicationCommandOptionType.String, 17 | required: true 18 | }] 19 | 20 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 21 | const link = interaction.options.getString('msg_link'); 22 | 23 | // for discord canary users, links are different 24 | const newLink = link.replace('canary.', ''); 25 | const match = newLink.match(/https:\/\/discord\.com\/channels\/(\d+)\/(\d+)\/(\d+)/); 26 | if (!match) return interaction.reply('Please provide a valid message link.'); 27 | 28 | // find the message 29 | const [,, channelID, messageID] = match; 30 | const message = await interaction.client.channels.fetch(channelID) 31 | .then((channel: TextChannel) => channel.messages.fetch(messageID)) 32 | .catch(() => { throw 'I can\'t seem to find that message'; }); 33 | 34 | // check if the message can be edited 35 | if (!message.editable) { 36 | return interaction.reply( 37 | { content: `It seems I can't edit that message. You'll need to tag a message that was sent by me, ${BOT.NAME}`, 38 | ephemeral: true }); 39 | } 40 | 41 | const modal = new ModalBuilder() 42 | .setTitle('Edit') 43 | .setCustomId('edit'); 44 | 45 | const contentsComponent = new TextInputBuilder() 46 | .setCustomId('content') 47 | .setLabel('New message content') 48 | .setStyle(TextInputStyle.Paragraph) 49 | .setRequired(true); 50 | 51 | const messageComponent = new TextInputBuilder() 52 | .setCustomId('message') 53 | .setLabel('ID of message to be edited (auto-filled)') 54 | .setStyle(TextInputStyle.Short) 55 | .setRequired(true) 56 | .setValue(message.id); 57 | 58 | const channelComponent = new TextInputBuilder() 59 | .setCustomId('channel') 60 | .setLabel('The channel this message is in (auto-filled)') 61 | .setStyle(TextInputStyle.Short) 62 | .setRequired(true) 63 | .setValue(message.channelId); 64 | 65 | const modalRows: ActionRowBuilder[] = [ 66 | new ActionRowBuilder().addComponents(contentsComponent), 67 | new ActionRowBuilder().addComponents(messageComponent), 68 | new ActionRowBuilder().addComponents(channelComponent) 69 | ]; 70 | modal.addComponents(...modalRows); 71 | 72 | await interaction.showModal(modal); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/sage.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | import consoleStamp from 'console-stamp'; 3 | import { MongoClient } from 'mongodb'; 4 | import { ApplicationCommandPermissions, Client, IntentsBitField, Partials, Team, ActivityType, ApplicationCommandPermissionType } from 'discord.js'; 5 | import { readdirRecursive } from '@root/src/lib/utils/generalUtils'; 6 | import { DB, BOT, PREFIX, GITHUB_TOKEN } from '@root/config'; 7 | import { Octokit } from '@octokit/rest'; 8 | import { version as sageVersion } from '@root/package.json'; 9 | import { registerFont } from 'canvas'; 10 | import { SageData } from '@lib/types/SageData'; 11 | import { setBotmasterPerms } from './lib/permissions'; 12 | 13 | const BOT_INTENTS = [ 14 | IntentsBitField.Flags.DirectMessages, 15 | IntentsBitField.Flags.Guilds, 16 | IntentsBitField.Flags.GuildModeration, 17 | IntentsBitField.Flags.GuildEmojisAndStickers, 18 | IntentsBitField.Flags.GuildIntegrations, 19 | IntentsBitField.Flags.GuildMembers, 20 | IntentsBitField.Flags.GuildMessages, 21 | IntentsBitField.Flags.GuildMessageReactions 22 | ]; 23 | 24 | const BOT_PARTIALS = [ 25 | Partials.Channel, 26 | Partials.Message, 27 | Partials.GuildMember 28 | ]; 29 | 30 | consoleStamp(console, { 31 | format: ':date(dd/mm/yy hh:MM:ss.L tt)' 32 | }); 33 | 34 | async function main() { 35 | const bot = new Client({ 36 | partials: BOT_PARTIALS, 37 | intents: BOT_INTENTS, 38 | allowedMentions: { parse: ['users'] } 39 | }); 40 | 41 | await MongoClient.connect(DB.CONNECTION, { useUnifiedTopology: true }).then((client) => { 42 | bot.mongo = client.db(BOT.NAME); 43 | }); 44 | 45 | bot.login(BOT.TOKEN); 46 | 47 | bot.octokit = new Octokit({ 48 | auth: GITHUB_TOKEN, 49 | userAgent: `Sage v${sageVersion}` 50 | }); 51 | 52 | registerFont(`${__dirname}/../../assets/Roboto-Regular.ttf`, { family: 'Roboto' }); 53 | 54 | bot.once('ready', async () => { 55 | // I'm mad about this - Josh { 58 | const permData: ApplicationCommandPermissions = { 59 | id: value.id, 60 | permission: true, 61 | type: ApplicationCommandPermissionType.User 62 | }; 63 | return permData; 64 | })); 65 | 66 | const pieceFiles = readdirRecursive(`${__dirname}/pieces`); 67 | for (const file of pieceFiles) { 68 | const piece = await import(file); 69 | const dirs = file.split('/'); 70 | const name = dirs[dirs.length - 1].split('.')[0]; 71 | if (typeof piece.default !== 'function') throw `Invalid piece: ${name}`; 72 | piece.default(bot); 73 | console.log(`${name} piece loaded.`); 74 | } 75 | 76 | console.log(`${BOT.NAME} online`); 77 | console.log(`${bot.ws.ping}ms WS ping`); 78 | console.log(`Logged into ${bot.guilds.cache.size} guilds`); 79 | console.log(`Serving ${bot.users.cache.size} users`); 80 | 81 | // eslint-disable-next-line no-extra-parens 82 | const status = (await bot.mongo.collection(DB.CLIENT_DATA).findOne({ _id: bot.user.id }) as SageData)?.status; 83 | 84 | const activity = status?.name || `${PREFIX}help`; 85 | 86 | // fix this so supports all types 87 | bot.user.setActivity(`${activity} (v${sageVersion})`, { type: ActivityType.Playing }); 88 | setTimeout(() => bot.user.setActivity(activity, { type: ActivityType.Playing }), 30e3); 89 | }); 90 | } 91 | 92 | main(); 93 | -------------------------------------------------------------------------------- /src/commands/admin/addbutton.ts: -------------------------------------------------------------------------------- 1 | import { BOTMASTER_PERMS } from '@lib/permissions'; 2 | import { BOT } from '@root/config'; 3 | import { Command } from '@lib/types/Command'; 4 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ApplicationCommandPermissions, ChatInputCommandInteraction, ActionRowBuilder, ButtonBuilder, 5 | ButtonStyle, TextChannel, InteractionResponse } from 'discord.js'; 6 | 7 | const STYLES = ['primary', 'secondary', 'success', 'danger']; 8 | 9 | export default class extends Command { 10 | 11 | description = `Edits a message sent by ${BOT.NAME} to include a button.`; 12 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 13 | 14 | options: ApplicationCommandOptionData[] = [{ 15 | name: 'msg_link', 16 | description: 'A message link', 17 | type: ApplicationCommandOptionType.String, 18 | required: true 19 | }, 20 | { 21 | name: 'label', 22 | description: 'The button text', 23 | type: ApplicationCommandOptionType.String, 24 | required: true 25 | }, 26 | { 27 | name: 'custom_id', 28 | description: 'The button/s custom ID', 29 | type: ApplicationCommandOptionType.String, 30 | required: true 31 | }, 32 | { 33 | name: 'style', 34 | description: 'The button\'s style type', 35 | type: ApplicationCommandOptionType.String, 36 | required: true, 37 | choices: STYLES.map((status) => ({ 38 | name: status, 39 | value: status 40 | })) 41 | }] 42 | 43 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 44 | const msg = interaction.options.getString('msg_link'); 45 | const buttonLabel = interaction.options.getString('label'); 46 | const customID = interaction.options.getString('custom_id'); 47 | const buttonStyleInput = interaction.options.getString('style').toUpperCase(); 48 | 49 | // this is dumb 50 | let buttonStyle; 51 | if (buttonStyleInput === 'PRIMARY') { 52 | buttonStyle = ButtonStyle.Primary; 53 | } else if (buttonStyleInput === 'SECONDARY') { 54 | buttonStyle = ButtonStyle.Secondary; 55 | } else if (buttonStyleInput === 'SUCCESS') { 56 | buttonStyle = ButtonStyle.Success; 57 | } else if (buttonStyleInput === 'DANGER') { 58 | buttonStyle = ButtonStyle.Danger; 59 | } 60 | 61 | // for discord canary users, links are different 62 | const newLink = msg.replace('canary.', ''); 63 | const match = newLink.match(/https:\/\/discord\.com\/channels\/(\d+)\/(\d+)\/(\d+)/); 64 | if (!match) return interaction.reply('Please provide a valid message link.'); 65 | 66 | // find the message 67 | const [,, channelID, messageID] = match; 68 | const message = await interaction.client.channels.fetch(channelID) 69 | .then((channel: TextChannel) => channel.messages.fetch(messageID)) 70 | .catch(() => { throw 'I can\'t seem to find that message'; }); 71 | 72 | // check if the message can be edited 73 | if (!message.editable) { 74 | return interaction.reply( 75 | { content: `It seems I can't edit that message. You'll need to tag a message that was sent by me, ${BOT.NAME}`, 76 | ephemeral: true }); 77 | } 78 | 79 | const btn = new ButtonBuilder() 80 | .setLabel(buttonLabel) 81 | .setCustomId(customID) 82 | .setStyle(buttonStyle); 83 | 84 | await message.edit({ content: message.content, components: [new ActionRowBuilder({ components: [btn] })] }); 85 | interaction.reply({ content: 'Your message has been given a button', ephemeral: true }); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /onboard/nudge.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | import { MongoClient, ObjectID } from 'mongodb'; 3 | import nodemailer from 'nodemailer'; 4 | import { SageUser } from '@lib/types/SageUser'; 5 | import { DB, BOT, EMAIL, GUILDS } from '@root/config'; 6 | 7 | const MESSAGE = ` 8 | 9 | 10 | 11 | Discord Verification 12 | 13 | 14 | 15 | 16 |

UD CIS Discord Verification

17 |

You're getting this email because you're part of a class in the UD CIS Department that is using Discord 18 | as its primary means of communication, and you haven't yet verified. Our records show that you recieved an email about the verification process on $timestamp.

19 |

Please follow the steps below to verify so you can get started using the UD CIS Discord.

20 |

If you don't have a Discord account already, click here to sign up for one.

21 |

22 | Click here for the verification site. 23 |

Once you're on the guild, right click Sage's name on the right side of the screen and select 'Message'. 24 |
Send just your hash code and join the official guild using the link that Sage sends back. 25 |

26 |

Your hash code is: $hash

27 |


We hope to see you on the guild soon!
- The Discord Admin Team

28 | 29 | 30 | 31 | 32 | 33 | `; 34 | 35 | const mailer = nodemailer.createTransport({ 36 | host: 'mail.udel.edu', 37 | port: 25 38 | }); 39 | 40 | async function main() { 41 | const client = await MongoClient.connect(DB.CONNECTION, { useUnifiedTopology: true }); 42 | const db = client.db(BOT.NAME).collection(DB.USERS); 43 | const users: Array = await db.find().toArray(); 44 | 45 | const args = process.argv.slice(2); 46 | 47 | if (args.length > 0) { 48 | for (const email of args) { 49 | let user: DatabaseUser; 50 | if (!(user = users.find(usr => usr.email === email))) { // user not in db 51 | console.log(`${email} was not previously in the database. Run the onboard script with this email to onboard.`); 52 | continue; 53 | } 54 | 55 | if (user.isVerified) { // user already verified 56 | console.log(`${email} is already verified.`); 57 | continue; 58 | } 59 | 60 | console.log(`Emailing: ${user.email}`); // do the thing 61 | await sendEmail(user); 62 | await sleep(1100); 63 | } 64 | } else { 65 | for (const user of users) { 66 | if (user.isVerified) continue; 67 | 68 | console.log(`Emailing: ${user.email}`); 69 | await sendEmail(user); 70 | await sleep(1100); 71 | } 72 | } 73 | 74 | client.close(); 75 | } 76 | 77 | function sendEmail(user: DatabaseUser) { 78 | return mailer.sendMail({ 79 | from: EMAIL.SENDER, 80 | replyTo: EMAIL.REPLY_TO, 81 | to: user.email, 82 | subject: 'Dont forget to verify on the UD CIS Discord.', 83 | html: MESSAGE 84 | .replace('$hash', user.hash) 85 | .replace('$invCode', GUILDS.GATEWAY_INVITE) 86 | .replace('$timestamp', user._id.getTimestamp().toDateString()) 87 | }); 88 | } 89 | 90 | function sleep(ms: number) { 91 | return new Promise((resolve) => { 92 | setTimeout(resolve, ms); 93 | }); 94 | } 95 | 96 | interface DatabaseUser extends SageUser { 97 | _id: ObjectID 98 | } 99 | 100 | main(); 101 | -------------------------------------------------------------------------------- /src/commands/question tagging/tagquestion.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, InteractionResponse, TextChannel } from 'discord.js'; 2 | import { Course } from '@lib/types/Course'; 3 | import { QuestionTag } from '@lib/types/QuestionTag'; 4 | import { DB } from '@root/config'; 5 | import { Command } from '@lib/types/Command'; 6 | import { generateErrorEmbed } from '@root/src/lib/utils/generalUtils'; 7 | 8 | export default class extends Command { 9 | 10 | description = 'Tags a message with a given course/assignment ID. Must be run in a class-specific channel.'; 11 | options: ApplicationCommandOptionData[] = [ 12 | { 13 | name: 'message', 14 | description: 'The link of the message you want to tag', 15 | type: ApplicationCommandOptionType.String, 16 | required: true 17 | }, 18 | { 19 | name: 'assignmentid', 20 | description: 'The assignment name tag to add to this message', 21 | type: ApplicationCommandOptionType.String, 22 | required: true 23 | } 24 | ] 25 | 26 | // never assume that students are not dumb 27 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 28 | const msgLink = interaction.options.getString('message'); 29 | const assignmentId = interaction.options.getString('assignmentid'); 30 | 31 | if (!('parentId' in interaction.channel)) return interaction.reply({ embeds: [generateErrorEmbed('This command is only available in text channels.')], ephemeral: true }); 32 | // eslint-disable-next-line no-extra-parens 33 | const course: Course = await interaction.client.mongo.collection(DB.COURSES).findOne({ 'channels.category': interaction.channel.parentId }); 34 | 35 | if (!course) return interaction.reply({ embeds: [generateErrorEmbed('This command must be run in a class specific channel')], ephemeral: true }); 36 | 37 | if (!course.assignments.includes(assignmentId)) { 38 | const desc = `Could not find assignment **${assignmentId}** in course: **${course.name}**.\n` + 39 | `CISC ${course.name} currently has these assignments: ${course.assignments.length > 0 40 | ? `\`${course.assignments.join('`, `')}\`` 41 | : 'It looks like there aren\'t any yet, ask a staff member to add some.'}`; 42 | return interaction.reply({ embeds: [generateErrorEmbed(desc)], ephemeral: true }); 43 | } 44 | 45 | const entry = await interaction.client.mongo.collection(DB.QTAGS).findOne({ link: msgLink, course: course.name, assignment: assignmentId }); 46 | 47 | if (entry) return interaction.reply({ embeds: [generateErrorEmbed(`That message has already been tagged for ${assignmentId}`)], ephemeral: true }); 48 | 49 | const [guildId, channelId, messageId] = msgLink.match(/(\d)+/g); 50 | const channel = interaction.client.guilds.cache.get(guildId).channels.cache.get(channelId) as TextChannel; 51 | const question = await channel.messages.fetch(messageId); 52 | 53 | if (!question) return interaction.reply({ embeds: [generateErrorEmbed('I couldn\'t find a message with that message link.')], ephemeral: true }); 54 | 55 | let header: string; 56 | if (question.embeds[0]) { 57 | header = question.embeds[0].description; 58 | } else { 59 | header = question.cleanContent; 60 | } 61 | 62 | const newQuestion: QuestionTag = { 63 | link: msgLink, 64 | course: course.name, 65 | assignment: assignmentId, 66 | header: header.length < 200 ? header : `${header.slice(0, 200)}...` 67 | }; 68 | 69 | interaction.client.mongo.collection(DB.QTAGS).insertOne(newQuestion); 70 | interaction.reply({ content: 'Added that message to the database.', ephemeral: true }); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/commands/staff/warn.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandPermissions, ChatInputCommandInteraction, Message, EmbedBuilder, TextChannel, ApplicationCommandOptionType } from 'discord.js'; 2 | import nodemailer from 'nodemailer'; 3 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 4 | import { Course } from '@lib/types/Course'; 5 | import { SageUser } from '@lib/types/SageUser'; 6 | import { DB, EMAIL } from '@root/config'; 7 | import { Command } from '@lib/types/Command'; 8 | import { getMsgIdFromLink } from '@root/src/lib/utils/generalUtils'; 9 | 10 | export default class extends Command { 11 | 12 | runInDM = false; 13 | description = 'Warns a user for breaking the rules and deletes the offending message.'; 14 | extendedHelp = 'This command must be used when replying to a message.'; 15 | options: ApplicationCommandOptionData[] = [ 16 | { 17 | name: 'msglink', 18 | description: 'Link to the offending message', 19 | type: ApplicationCommandOptionType.String, 20 | required: true 21 | }, 22 | { 23 | name: 'reason', 24 | description: 'Reason for warning the user', 25 | type: ApplicationCommandOptionType.String, 26 | required: false 27 | } 28 | ] 29 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 30 | 31 | async run(interaction: ChatInputCommandInteraction): Promise { 32 | const target = await interaction.channel.messages.fetch(getMsgIdFromLink(interaction.options.getString('msglink'))); 33 | const reason = interaction.options.getString('reason') || 'Breaking server rules'; 34 | if ('parentId' in interaction.channel) { 35 | const course: Course = await interaction.client.mongo.collection(DB.COURSES) 36 | .findOne({ 'channels.category': interaction.channel.parentId }); 37 | 38 | if (course) { 39 | const staffChannel = interaction.guild.channels.cache.get(course.channels.staff) as TextChannel; 40 | const embed = new EmbedBuilder() 41 | .setTitle(`${interaction.user.tag} Warned ${target.author.tag}`) 42 | .setFooter({ text: `${target.author.tag}'s ID: ${target.author.id} | ${interaction.user.tag}'s ID: ${interaction.user.id}` }) 43 | .addFields([{ 44 | name: 'Reason', 45 | value: reason 46 | }, { 47 | name: 'Message content', 48 | value: target.content || '*This message had no text content*' 49 | }]); 50 | staffChannel.send({ embeds: [embed] }); 51 | } 52 | } 53 | 54 | target.author.send(`Your message was deleted in ${target.channel} by ${interaction.user.tag}. Below is the given reason:\n${reason}`) 55 | .catch(async () => { 56 | const targetUser: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: target.author.id }); 57 | if (!targetUser) throw `${target.author.tag} (${target.author.id}) is not in the database`; 58 | this.sendEmail(targetUser.email, interaction.user.tag, reason); 59 | }); 60 | 61 | interaction.reply({ content: `${target.author.username} has been warned.`, ephemeral: true }); 62 | return target.delete(); 63 | } 64 | 65 | sendEmail(recipient: string, mod: string, reason: string): void { 66 | const mailer = nodemailer.createTransport({ 67 | host: 'mail.udel.edu', 68 | port: 25 69 | }); 70 | 71 | mailer.sendMail({ 72 | from: EMAIL.SENDER, 73 | replyTo: EMAIL.REPLY_TO, 74 | to: recipient, 75 | subject: `UD CIS Discord Warning`, 76 | html: ` 77 | 78 | 79 | 80 |

You were issued a warning on the UD CIS Discord server by ${mod}

81 |

Reason for warning:

82 |

${reason}

83 | 84 | 85 | 86 | ` 87 | }); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/pieces/memberHandler.ts: -------------------------------------------------------------------------------- 1 | import { Client, GuildMember, PartialGuildMember } from 'discord.js'; 2 | import { SageUser } from '@lib/types/SageUser'; 3 | import { DatabaseError } from '@lib/types/errors'; 4 | import { DB, FIRST_LEVEL, GUILDS, ROLES } from '@root/config'; 5 | 6 | async function memberAdd(member: GuildMember): Promise { 7 | if (member.guild.id !== GUILDS.MAIN) return; 8 | member.guild.roles.fetch(); 9 | 10 | const entry: SageUser = await member.client.mongo.collection(DB.USERS).findOne({ discordId: member.id }); 11 | 12 | // commented codeblock depreciated due to verification revamp, saved for future modification 13 | // if (!entry) { 14 | // await member.send(`We couldn't find you in our database, you likely used the invite link with a different account than the one you verified with. 15 | // Please join the server with the account you used to send your hash, or contact ${MAINTAINERS} if you think this is an error.`); 16 | // await member.kick('This person wasn\'t in the database, they probably used a different account to verify than they used the invite with'); 17 | // throw new DatabaseError(`User ${member.user.tag} (${member.id}) does not exist in the database.`); 18 | // } 19 | // if (!entry.isVerified) { 20 | // throw new Error(`User ${member.user.tag} (${member.id}) is not verified.`); 21 | // } 22 | 23 | entry.roles.forEach(role => { 24 | // This might happen if a course was removed between when they left and when they re-joined. 25 | if (!member.guild.roles.cache.has(role)) return; 26 | 27 | member.roles.add(role, 'Automatically assigned by Role Handler on join.') 28 | .catch(async error => member.client.emit('error', error)); 29 | }); 30 | } 31 | 32 | async function memberUpdate(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember): Promise { 33 | if (newMember.roles.cache.size === oldMember.roles.cache.size || newMember.guild.id !== GUILDS.MAIN) return; 34 | 35 | const updated = await newMember.client.mongo.collection(DB.USERS).updateOne({ discordId: newMember.id }, { 36 | $set: { 37 | roles: [...newMember.roles.cache.keys()].filter(role => role !== GUILDS.MAIN) 38 | } 39 | }); 40 | 41 | if (updated.matchedCount !== 1) { 42 | throw new DatabaseError(`User ${newMember.user.tag} (${newMember.id}) does not exist in the database.`); 43 | } 44 | } 45 | 46 | async function memberRemove(member: GuildMember | PartialGuildMember): Promise { 47 | if (member.guild.id !== GUILDS.MAIN) return; 48 | await member.guild.roles.fetch(); 49 | 50 | let dbMember: SageUser = await member.client.mongo.collection(DB.USERS).findOne({ discordId: member.id }); 51 | 52 | if (!dbMember) return; 53 | 54 | dbMember = { 55 | ...dbMember, 56 | isVerified: false, 57 | discordId: '', 58 | roles: dbMember.roles.filter(role => { 59 | const levelRole = member.guild.roles.cache.find(guildRole => guildRole.name.toLowerCase() === `level ${dbMember.level}`); 60 | console.log(levelRole.id); 61 | return role !== ROLES.VERIFIED 62 | && role !== ROLES.STAFF 63 | && role !== levelRole.id; 64 | }), 65 | isStaff: false, 66 | level: 1, 67 | curExp: FIRST_LEVEL, 68 | levelExp: FIRST_LEVEL, 69 | count: 0 70 | }; 71 | dbMember.roles.push(ROLES.LEVEL_ONE); 72 | 73 | await member.client.mongo.collection(DB.USERS).replaceOne({ discordId: member.id }, dbMember); 74 | } 75 | 76 | async function register(bot: Client): Promise { 77 | bot.on('guildMemberAdd', member => { 78 | memberAdd(member) 79 | .catch(async error => bot.emit('error', error)); 80 | }); 81 | bot.on('guildMemberUpdate', async (oldMember, newMember) => { 82 | memberUpdate(oldMember, newMember) 83 | .catch(async error => bot.emit('error', error)); 84 | }); 85 | bot.on('guildMemberRemove', async (member) => { 86 | memberRemove(member); 87 | }); 88 | } 89 | 90 | export default register; 91 | -------------------------------------------------------------------------------- /src/commands/info/discordstatus.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { EmbedBuilder, EmbedField, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 3 | import fetch from 'node-fetch'; 4 | import moment from 'moment'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = 'Check Discord\'s current status.'; 10 | 11 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 12 | await interaction.deferReply(); 13 | const url = 'https://srhpyqt94yxb.statuspage.io/api/v2/summary.json'; 14 | const currentStatus = await fetch(url, { method: 'Get' }).then(r => r.json()) as DiscordStatus; 15 | 16 | const fields: Array = []; 17 | 18 | if (currentStatus.components.every(component => component.status === 'operational')) { 19 | fields.push({ 20 | name: 'All components operational', 21 | value: 'No errors to report', 22 | inline: false 23 | }); 24 | } else { 25 | currentStatus.components.forEach(component => { 26 | if (component.status !== 'operational') { 27 | fields.push({ 28 | name: component.name, 29 | value: component.status, 30 | inline: true 31 | }); 32 | } 33 | }); 34 | } 35 | 36 | if (currentStatus.scheduled_maintenances.length > 0) { 37 | fields.push({ 38 | name: 'Scheduled Maintenance', 39 | value: currentStatus.scheduled_maintenances.map(maintenance => `${maintenance.name} | Impact: ${maintenance.impact}`).join('\n'), 40 | inline: false 41 | }); 42 | } 43 | 44 | const embed = new EmbedBuilder() 45 | .setTitle(currentStatus.status.description) 46 | .setDescription(`[Discord Status](${currentStatus.page.url})\n\n${currentStatus.incidents[0] 47 | ? `Current incidents:\n${currentStatus.incidents.map(i => i.name).join('\n')}` 48 | : 'There are no active incidents.'}`) 49 | .addFields(fields) 50 | .setThumbnail('https://discord.com/assets/2c21aeda16de354ba5334551a883b481.png') 51 | .setTimestamp() 52 | .setFooter({ text: `Last changed ${moment(currentStatus.page.updated_at).format('YYYY MMM Do')}` }) 53 | .setColor('Blurple'); 54 | 55 | interaction.editReply({ embeds: [embed] }); 56 | } 57 | 58 | } 59 | 60 | interface DiscordStatus { 61 | page: Page; 62 | status: Status; 63 | components?: (ComponentsEntity)[] | null; 64 | incidents?: (IncidentsEntity)[] | null; 65 | scheduled_maintenances?: (ScheduledMaintenancesEntity)[] | null; 66 | } 67 | interface Page { 68 | id: string; 69 | name: string; 70 | url: string; 71 | updated_at: string; 72 | } 73 | interface Status { 74 | description: string; 75 | indicator: string; 76 | } 77 | interface ComponentsEntity { 78 | created_at: string; 79 | description?: string|null; 80 | id: string; 81 | name: string; 82 | page_id: string; 83 | position: number; 84 | status: string; 85 | updated_at: string; 86 | only_show_if_degraded: boolean; 87 | } 88 | interface IncidentsEntity { 89 | created_at: string; 90 | id: string; 91 | impact: string; 92 | incident_updates?: (IncidentUpdatesEntity)[] | null; 93 | monitoring_at?: null; 94 | name: string; 95 | page_id: string; 96 | resolved_at?: null; 97 | shortlink: string; 98 | status: string; 99 | updated_at: string; 100 | } 101 | interface IncidentUpdatesEntity { 102 | body: string; 103 | created_at: string; 104 | display_at: string; 105 | id: string; 106 | incident_id: string; 107 | status: string; 108 | updated_at: string; 109 | } 110 | interface ScheduledMaintenancesEntity { 111 | created_at: string; 112 | id: string; 113 | impact: string; 114 | incident_updates?: (IncidentUpdatesEntity)[] | null; 115 | monitoring_at?: null; 116 | name: string; 117 | page_id: string; 118 | resolved_at?: null; 119 | scheduled_for: string; 120 | scheduled_until: string; 121 | shortlink: string; 122 | status: string; 123 | updated_at: string; 124 | } 125 | -------------------------------------------------------------------------------- /src/commands/fun/latex.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, AttachmentBuilder, ChatInputCommandInteraction, EmbedBuilder, 2 | InteractionResponse, Message } from 'discord.js'; 3 | import fetch from 'node-fetch'; 4 | import { createCanvas, loadImage } from 'canvas'; 5 | import { Command } from '@lib/types/Command'; 6 | import { generateErrorEmbed } from '@root/src/lib/utils/generalUtils'; 7 | 8 | const BACKGROUND_COLOR = `rgb(${255 - 47}, ${255 - 49}, ${255 - 54})`; 9 | const IMAGE_RESIZE_FACTOR = 1.25; 10 | const PADDING = 4; 11 | 12 | export default class extends Command { 13 | 14 | // Made by Brendan Lewis (@craftablescience) 15 | 16 | description = `Accepts a LaTeX expression and posts it as a rendered image.`; 17 | 18 | options: ApplicationCommandOptionData[] = [ 19 | { 20 | name: 'input', 21 | description: 'The LaTeX expression to render', 22 | type: ApplicationCommandOptionType.String, 23 | required: true 24 | } 25 | ] 26 | 27 | async run(interaction: ChatInputCommandInteraction): Promise | void | Message> { 28 | // Might take a few seconds to respond in rare cases 29 | await interaction.deferReply(); 30 | 31 | const tex = encodeURIComponent(interaction.options.getString('input')); 32 | const errorResponse = "Sorry, I couldn't render that LaTeX expression."; 33 | let usingBackup = false; 34 | let image; 35 | try { 36 | const response = await fetch(`https://latex.codecogs.com/svg.json?${tex}`, { method: 'Get' }); 37 | if (response.ok) { 38 | const imageAsBase64JSON = await response.json(); 39 | image = await loadImage(Buffer.from(imageAsBase64JSON.latex.base64, 'base64')); 40 | } else { 41 | usingBackup = true; 42 | const backupResponse = await fetch(`http://chart.apis.google.com/chart?cht=tx&chl=${tex}`, { method: 'Get' }); 43 | if (!backupResponse.ok) { 44 | // Both of these breaking is very unlikely 45 | throw new Error(errorResponse); 46 | } 47 | image = await loadImage(await backupResponse.buffer(), 'png'); 48 | } 49 | } catch (error) { 50 | return interaction.followUp({ embeds: [generateErrorEmbed(errorResponse)] }); 51 | } 52 | 53 | // Image will have 4 pixels of padding on all sides 54 | const canvasWidth = (image.width * IMAGE_RESIZE_FACTOR) + (PADDING * 2); 55 | const canvasHeight = (image.height * IMAGE_RESIZE_FACTOR) + (PADDING * 2); 56 | 57 | const canvas = createCanvas(canvasWidth, canvasHeight); 58 | const ctx = canvas.getContext('2d'); 59 | ctx.clearRect(0, 0, canvasWidth, canvasHeight); 60 | 61 | // Invert the default Discord embed background color, entire canvas is inverted later 62 | ctx.beginPath(); 63 | ctx.fillStyle = BACKGROUND_COLOR; 64 | ctx.fillRect(0, 0, canvasWidth, canvasHeight); 65 | 66 | // Draw image and invert color - necessary because the text is black and unreadable by default 67 | ctx.drawImage(image, PADDING, PADDING, image.width * IMAGE_RESIZE_FACTOR, image.height * IMAGE_RESIZE_FACTOR); 68 | try { 69 | const canvasData = ctx.getImageData(0, 0, canvasWidth, canvasHeight); 70 | 71 | for (let i = 0; i < canvasData.data.length; i += 4) { 72 | if (usingBackup && canvasData.data[i] > 0xE8 && canvasData.data[i + 1] > 0xE8 && canvasData.data[i + 2] > 0xE8) { 73 | canvasData.data[i] = 47; 74 | canvasData.data[i + 1] = 49; 75 | canvasData.data[i + 2] = 54; 76 | } else { 77 | canvasData.data[i] = 0xFF - canvasData.data[i]; 78 | canvasData.data[i + 1] = 0xFF - canvasData.data[i + 1]; 79 | canvasData.data[i + 2] = 0xFF - canvasData.data[i + 2]; 80 | } 81 | } 82 | 83 | ctx.putImageData(canvasData, 0, 0); 84 | } catch (error) { 85 | return interaction.followUp({ embeds: [generateErrorEmbed(errorResponse)] }); 86 | } 87 | 88 | const file = new AttachmentBuilder(canvas.toBuffer(), { name: 'tex.png' }); 89 | const embed = new EmbedBuilder().setImage('attachment://tex.png'); 90 | 91 | interaction.editReply({ embeds: [embed], files: [file] }); 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/commands/question tagging/question.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ChatInputCommandInteraction, EmbedField, EmbedBuilder, ApplicationCommandOptionType, InteractionResponse } from 'discord.js'; 2 | import { Course } from '@lib/types/Course'; 3 | import { QuestionTag } from '@lib/types/QuestionTag'; 4 | import { SageUser } from '@lib/types/SageUser'; 5 | import { BOT, DB, MAINTAINERS } from '@root/config'; 6 | import { Command } from '@lib/types/Command'; 7 | import { generateErrorEmbed } from '@root/src/lib/utils/generalUtils'; 8 | 9 | export default class extends Command { 10 | 11 | description = 'Filters the questionTags collection for a given class and assignment'; 12 | extendedHelp = `${BOT.NAME} will automatically determine your course if you are only enrolled in one!`; 13 | options: ApplicationCommandOptionData[] = [ 14 | { 15 | name: 'assignment', 16 | description: 'The ID of the assignment to filter questions from', 17 | type: ApplicationCommandOptionType.String, 18 | required: true 19 | }, 20 | { 21 | name: 'course', 22 | description: 'What course would you like to filter questions from?', 23 | type: ApplicationCommandOptionType.String, 24 | required: false 25 | } 26 | ] 27 | 28 | // never assume that students are not dumb 29 | 30 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 31 | const user: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: interaction.user.id }); 32 | 33 | if (!user) { 34 | return interaction.reply({ embeds: [generateErrorEmbed(`Something went wrong. Please contact ${MAINTAINERS}`)], ephemeral: true }); 35 | } 36 | 37 | let course: Course; 38 | const assignment = interaction.options.getString('assignment'); 39 | const courses: Array = await interaction.client.mongo.collection(DB.COURSES).find().toArray(); 40 | 41 | if (user.courses.length === 1) { 42 | course = courses.find(c => c.name === user.courses[0]); 43 | } else { 44 | const inputtedCourse = courses.find(c => c.name === interaction.options.getString('course')); 45 | if (!inputtedCourse) { 46 | const desc = 'I wasn\'t able to determine your course based off of your enrollment or your input. Please specify the course at the beginning of your question.' + 47 | `\nAvailable courses: \`${courses.map(c => c.name).sort().join('`, `')}\``; 48 | return interaction.reply({ embeds: [generateErrorEmbed(desc)], ephemeral: true }); 49 | } 50 | course = inputtedCourse; 51 | } 52 | 53 | if (!course.assignments.includes(assignment)) { 54 | const desc = `I couldn't find an assignment called **${assignment}** for CISC ${course.name}\n` + 55 | `Assignments for CISC ${course.name}: ${course.assignments.length > 0 ? `\`${course.assignments.join('`, `')}\`` 56 | : 'It looks like there aren\'t any yet, ask a staff member to add some.'}`; 57 | return interaction.reply({ embeds: [generateErrorEmbed(desc)], ephemeral: true }); 58 | } 59 | 60 | const entries: Array = await interaction.client.mongo.collection(DB.QTAGS).find({ course: course.name, assignment: assignment }).toArray(); 61 | const fields: Array = []; 62 | if (entries.length === 0) { 63 | return interaction.reply({ content: `There are no questions for ${course.name}, ${assignment}. 64 | To add questions, use the tag command (\`/help tag\`)`.replace('\t', ''), ephemeral: true }); 65 | } 66 | entries.forEach(doc => { 67 | fields.push({ name: doc.header.replace(/\n/g, ' '), value: `[Click to view](${doc.link})`, inline: false }); 68 | }); 69 | const embeds: Array = [new EmbedBuilder() 70 | .setTitle(`Questions for ${course} ${assignment}`) 71 | .addFields(fields.splice(0, 25)) 72 | .setColor('DarkAqua')]; 73 | 74 | while (fields.length > 0) { 75 | embeds.push(new EmbedBuilder() 76 | .addFields(fields.splice(0, 25)) 77 | .setColor('DarkAqua')); 78 | } 79 | 80 | return interaction.reply({ embeds: embeds, ephemeral: true }); 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /autodoc/writecommands.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | import { readdirRecursive } from '@root/src/lib/utils/generalUtils'; 3 | import { Command } from '@lib/types/Command'; 4 | import fs from 'fs'; 5 | 6 | let cmdMd = `--- 7 | waltz: 8 | title: Commands 9 | resource: page 10 | published: false 11 | layout: page 12 | title: Commands 13 | permalink: pages/commands 14 | --- 15 | Here is a list of all of the commands available for Sage, usable with \`/\`. 16 |
17 | Note, any arguments to the commands will be shown with descriptions when you select the command you want to run. 18 | `; 19 | 20 | const staffInfo = `### Staff Commands 21 | All of the staff-only commands can be found on [this page](https://ud-cis-discord.github.io/staff_pages/staff%20commands).`; 22 | 23 | let staffCmdMd = `--- 24 | waltz: 25 | title: Staff Commands 26 | resource: page 27 | published: false 28 | layout: page 29 | title: Staff Commands 30 | permalink: staff_pages/staff%20commands 31 | --- 32 | ## Running Commands 33 | 34 | As staff, you have access to some commands not listed in the general [commands page][29]. You run them the same as the 35 | other commands, using \`/\`in any channel that Sage is in, although we recommend running staff 36 | commands in staff-only channels. 37 | 38 | `; 39 | 40 | async function main() { 41 | const categories = new Map(); 42 | 43 | const commandFiles = readdirRecursive(`${__dirname}/../src/commands`).filter(file => file.endsWith('.js')); 44 | for (const file of commandFiles) { 45 | const commandModule = await import(file); 46 | 47 | if (!(typeof commandModule.default === 'function')) { 48 | console.log(`Invalid command ${file}`); 49 | continue; 50 | } 51 | 52 | // eslint-disable-next-line new-cap 53 | const command: Command = new commandModule.default; 54 | 55 | // scrape commands 56 | const dirs = file.split('/'); 57 | const name = dirs[dirs.length - 1].split('.')[0]; 58 | command.name = name; 59 | command.category = dirs[dirs.length - 2] === 'commands' ? 'general' : dirs[dirs.length - 2]; 60 | 61 | if (command.category === 'admin') { 62 | continue; 63 | } 64 | 65 | if (!categories.has(command.category)) { 66 | const catWords = command.category.split(' '); 67 | const formattedCat = catWords.map(word => word[0].toUpperCase() + word.substring(1)).join(' '); 68 | categories.set(command.category, `### ${formattedCat} Commands`); 69 | } 70 | 71 | let newCatText = `${categories.get(command.category)}\n\n**${command.name}**\n`; 72 | 73 | newCatText += command.description ? `\n- Description: ${command.description}\n` : ``; 74 | newCatText += command.extendedHelp ? `\n- More info: ${command.extendedHelp}\n` : ``; 75 | if (command.options) { 76 | newCatText += '\n- Parameters:\n'; 77 | newCatText += command.options.map(param => 78 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 79 | // @ts-ignore: see Note 1 comment block in help.ts 80 | ` - ${param.name} (${param.required ? 'required' : 'optional'}): ${param.description}` 81 | ).join('\n'); 82 | } 83 | categories.set(command.category, newCatText); 84 | } 85 | 86 | if (categories.get('question tagging')) { 87 | const qtLink = `${categories.get('question tagging')} 88 | More info related to the question tagging system can also be found on [this page](https://ud-cis-discord.github.io/pages/Question%20Tagging).\n`; 89 | categories.set('question tagging', qtLink); 90 | } 91 | 92 | categories.forEach((_value, key) => { 93 | cmdMd += key === 'staff' ? `` : categories.get(key); 94 | }); 95 | 96 | cmdMd += staffInfo; 97 | 98 | staffCmdMd += categories.get('staff'); 99 | staffCmdMd += '\n[29]: https://ud-cis-discord.github.io/pages/commands (Commands)'; 100 | 101 | fs.writeFileSync(`${__dirname}/../../Commands.md`, cmdMd); 102 | 103 | fs.writeFileSync(`${__dirname}/../../Staff Commands.md`, staffCmdMd); 104 | } 105 | 106 | main(); 107 | -------------------------------------------------------------------------------- /src/commands/staff/lookup.ts: -------------------------------------------------------------------------------- 1 | import { DB, EMAIL } from '@root/config'; 2 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 3 | import { SageUser } from '@lib/types/SageUser'; 4 | import { EmbedBuilder, ChatInputCommandInteraction, ApplicationCommandPermissions, ApplicationCommandOptionData, ApplicationCommandOptionType, 5 | InteractionResponse } from 'discord.js'; 6 | import nodemailer from 'nodemailer'; 7 | import { Command } from '@lib/types/Command'; 8 | 9 | export default class extends Command { 10 | 11 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 12 | description = 'Looks up information about a given user'; 13 | runInDM = false; 14 | options: ApplicationCommandOptionData[] = [ 15 | { 16 | name: 'user', 17 | type: ApplicationCommandOptionType.User, 18 | description: 'The member to look up', 19 | required: true 20 | } 21 | ]; 22 | 23 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 24 | const user = interaction.options.getUser('user'); 25 | const entry: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: user.id }); 26 | const member = await interaction.guild.members.fetch(user.id); 27 | 28 | if (!entry) { 29 | return interaction.reply({ content: `User ${user.tag} has not verified.`, ephemeral: true }); 30 | } 31 | 32 | const embed = new EmbedBuilder() 33 | .setTitle(`Looking Up: ${member.displayName}`) 34 | .setThumbnail(user.avatarURL()) 35 | .setColor('Green') 36 | .setFooter({ text: `Member ID: ${user.id}` }) 37 | .setTimestamp() 38 | .addFields([ 39 | { name: 'Display Name', value: `<@${member.id}>`, inline: true }, 40 | { name: 'Username', value: `${user.tag}`, inline: false }, 41 | { name: 'UD Email:', value: entry.email, inline: false }, 42 | { name: 'Message count: ', value: `This week: ${entry.count.toString()}`, inline: false } 43 | ]); 44 | 45 | if (!entry.pii) { 46 | const sender: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: interaction.user.id }); 47 | this.sendEmail(sender.email, member.displayName, user.tag, entry); 48 | return interaction.reply( 49 | { content: `\`${user.tag}\` has not opted to have their information shared over Discord.\nInstead, an email has been sent to you containing the requested data.`, 50 | ephemeral: true }); 51 | } 52 | 53 | return interaction.reply({ embeds: [embed], ephemeral: true }); 54 | } 55 | 56 | sendEmail(recipient: string, displayName: string, username: string, entry: SageUser): void { 57 | const mailer = nodemailer.createTransport({ 58 | host: 'mail.udel.edu', 59 | port: 25 60 | }); 61 | 62 | mailer.sendMail({ 63 | from: EMAIL.SENDER, 64 | replyTo: EMAIL.REPLY_TO, 65 | to: recipient, 66 | subject: `UD CIS Discord:requested student information`, 67 | html: ` 68 | 69 | 70 |

Your requested user information:

71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 |
Display NameUsernameUniversity EmailMessage CountMember ID
${displayName}${username}${entry.email}This week: ${entry.count}${entry.discordId}
85 |


Thank you for using the UD CIS Discord Server and Sage!

86 | 87 | ` 88 | }); 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/commands/fun/diceroll.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ChatInputCommandInteraction, EmbedBuilder, ApplicationCommandOptionType, InteractionResponse } from 'discord.js'; 2 | import { Command } from '@lib/types/Command'; 3 | import { generateErrorEmbed } from '@root/src/lib/utils/generalUtils'; 4 | 5 | const DEFAULT_RANGE = [1, 6]; 6 | const DEFAULT_ROLLS = 1; 7 | export default class extends Command { 8 | 9 | description = `Get \`numdice\` random integers between \`minimum\` and \`maximum\`.`; 10 | extendedHelp = `User specified minimum and maximum are inclusive. If no range is specified, defaults to one number ranging from ${DEFAULT_RANGE[0]} to ${DEFAULT_RANGE[1]}.`; 11 | 12 | options: ApplicationCommandOptionData[] = [ 13 | { 14 | name: 'minimum', 15 | description: `Minimum of the roll range (defaults to ${DEFAULT_RANGE[0]})`, 16 | type: ApplicationCommandOptionType.Number, 17 | required: false 18 | }, 19 | { 20 | name: 'maximum', 21 | description: `Maximum of the roll range (defaults to ${DEFAULT_RANGE[1]})`, 22 | type: ApplicationCommandOptionType.Number, 23 | required: false 24 | }, 25 | { 26 | name: 'numdice', 27 | description: `Number of dice to roll (1-10) (defaults to ${DEFAULT_ROLLS})`, 28 | type: ApplicationCommandOptionType.Number, 29 | required: false 30 | }, 31 | { 32 | name: 'keephighest', 33 | description: `How many dice to keep/total (defaults to the number of dice you're rolling)`, 34 | type: ApplicationCommandOptionType.Number, 35 | required: false 36 | } 37 | 38 | ] 39 | 40 | run(interaction: ChatInputCommandInteraction): Promise | void> { 41 | let min = interaction.options.getNumber('minimum'); 42 | let max = interaction.options.getNumber('maximum'); 43 | const numRolls = interaction.options.getNumber('numdice') || DEFAULT_ROLLS; 44 | const keepHighest = interaction.options.getNumber('keephighest') || numRolls; 45 | 46 | if (!min) { 47 | [min, max] = [DEFAULT_RANGE[0], max || DEFAULT_RANGE[1]]; 48 | } else if (!max && min) { 49 | return interaction.reply({ embeds: [generateErrorEmbed('If you provide a minimum, you must also provide a maximum.')], ephemeral: true }); 50 | } else if (max < min) { 51 | return interaction.reply({ embeds: [generateErrorEmbed('Your maximum must be greater than your minimum.')], ephemeral: true }); 52 | } if (!Number.isInteger(min) || !Number.isInteger(max)) { 53 | return interaction.reply({ embeds: [generateErrorEmbed('The values you entered were not whole numbers. Remember that this command works with integers only.')], ephemeral: true }); 54 | } if (numRolls < 1 || numRolls > 10 || !Number.isInteger(numRolls)) { 55 | return interaction.reply({ embeds: [generateErrorEmbed('You can only roll between 1 and 10 whole dice.')], ephemeral: true }); 56 | } if (!Number.isInteger(keepHighest) || keepHighest <= 0) { 57 | return interaction.reply({ embeds: [generateErrorEmbed('The number of dice you keep must be a **positive integer**.')], ephemeral: true }); 58 | } if (keepHighest > numRolls) { 59 | return interaction.reply({ embeds: [generateErrorEmbed('The number of dice you keep must be lower than the number of dice you roll.')], ephemeral: true }); 60 | } 61 | 62 | const results = []; 63 | for (let i = 0; i < numRolls; i++) { 64 | results.push(Math.floor((Math.random() * (max - min + 1)) + min)); 65 | } 66 | 67 | const sorted = [...results].sort((a, b) => b - a); 68 | const total: number = sorted.splice(0, keepHighest).reduce((prev, cur) => prev + cur, 0); 69 | 70 | const totalText = keepHighest === 1 71 | ? `Your total roll is **${total}**.` 72 | : `The total of the ${keepHighest} highest dice is **${total}**`; 73 | 74 | const nums = results.join(', '); 75 | const embedFields = [ 76 | { 77 | name: `Roll${results.length === 1 ? '' : 's'}`, 78 | value: `Your random number${results.length === 1 ? ' is' : 's are'} ${nums}.`, 79 | inline: true 80 | }, 81 | { 82 | name: 'Result', 83 | value: totalText 84 | } 85 | ]; 86 | 87 | const responseEmbed = new EmbedBuilder() 88 | .setColor(Math.floor(Math.random() * 16777215)) 89 | .setTitle('Random Integer Generator') 90 | .setFields(embedFields) 91 | .setFooter({ text: `${interaction.user.username} rolled ${numRolls} dice ranging from ${min} to ${max}` }); 92 | return interaction.reply({ embeds: [responseEmbed] }); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/commands/partial visibility question/anonymous.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, EmbedBuilder, InteractionResponse, TextChannel } from 'discord.js'; 2 | import { generateErrorEmbed, generateQuestionId } from '@lib/utils/generalUtils'; 3 | import { Course } from '@lib/types/Course'; 4 | import { SageUser } from '@lib/types/SageUser'; 5 | import { PVQuestion } from '@lib/types/PVQuestion'; 6 | import { BOT, DB, MAINTAINERS } from '@root/config'; 7 | import { Command } from '@lib/types/Command'; 8 | 9 | export default class extends Command { 10 | 11 | description = 'Send an anonymous question in your classes general channel.'; 12 | extendedHelp = `${BOT.NAME} will automatically determine your course if you are only enrolled in one!`; 13 | options: ApplicationCommandOptionData[] = [ 14 | { 15 | name: 'question', 16 | description: 'What would you like to ask?', 17 | type: ApplicationCommandOptionType.String, 18 | required: true 19 | }, 20 | { 21 | name: 'course', 22 | description: 'What course chat would you like to ask your question in?', 23 | type: ApplicationCommandOptionType.String, 24 | required: false 25 | }, 26 | { 27 | name: 'file', 28 | description: 'A file to be posted with the question', 29 | type: ApplicationCommandOptionType.Attachment, 30 | required: false 31 | } 32 | ] 33 | 34 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 35 | const user: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: interaction.user.id }); 36 | const file = interaction.options.getAttachment('file'); 37 | 38 | if (!user) { 39 | return interaction.reply({ embeds: [generateErrorEmbed(`Something went wrong. Please contact ${MAINTAINERS}`)], ephemeral: true }); 40 | } 41 | 42 | let course: Course; 43 | const question = interaction.options.getString('question'); 44 | const courses: Array = await interaction.client.mongo.collection(DB.COURSES).find().toArray(); 45 | 46 | if (user.courses.length === 1) { 47 | course = courses.find(c => c.name === user.courses[0]); 48 | } else { 49 | const inputtedCourse = courses.find(c => c.name === interaction.options.getString('course')); 50 | if (!inputtedCourse) { 51 | const desc = 'I wasn\'t able to determine your course based off of your enrollment or your input. Please specify the course at the beginning of your question.' + 52 | `\nAvailable courses: \`${courses.map(c => c.name).sort().join('`, `')}\``; 53 | return interaction.reply({ embeds: [generateErrorEmbed(desc)], ephemeral: true }); 54 | } 55 | course = inputtedCourse; 56 | } 57 | 58 | if (!question) { 59 | return interaction.reply({ embeds: [generateErrorEmbed('Please provide a question.')], ephemeral: true }); 60 | } 61 | 62 | const questionId = await generateQuestionId(interaction); 63 | 64 | const studentEmbed = new EmbedBuilder() 65 | .setAuthor({ name: `Anonymous asked Question ${questionId}`, iconURL: interaction.client.user.avatarURL() }) 66 | .setDescription(question); 67 | 68 | if (file) studentEmbed.setImage(file.url); 69 | 70 | const generalChannel = await interaction.client.channels.fetch(course.channels.general) as TextChannel; 71 | const questionMessage = await generalChannel.send({ embeds: [studentEmbed] }); 72 | const messageLink = `https://discord.com/channels/${questionMessage.guild.id}/${questionMessage.channel.id}/${questionMessage.id}`; 73 | 74 | const staffEmbed = new EmbedBuilder() 75 | .setAuthor({ name: `${interaction.user.tag} (${interaction.user.id}) asked Question ${questionId}`, iconURL: interaction.user.avatarURL() }) 76 | .setDescription(`[Click to jump](${messageLink}) 77 | It is recommended you reply in public, but sudoreply can be used **in a staff channel** to reply in private if necessary.`); 78 | 79 | const privateChannel = await interaction.client.channels.fetch(course.channels.private) as TextChannel; 80 | await privateChannel.send({ embeds: [staffEmbed] }); 81 | 82 | const entry: PVQuestion = { 83 | owner: interaction.user.id, 84 | type: 'anonymous', 85 | questionId, 86 | messageLink 87 | }; 88 | 89 | interaction.client.mongo.collection(DB.PVQ).insertOne(entry); 90 | 91 | return interaction.reply({ content: `Your question has been sent to your course anonymously. To reply anonymously, use \`/reply ${questionId} \`.`, ephemeral: true }); 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/pieces/blacklist.ts: -------------------------------------------------------------------------------- 1 | import { BLACKLIST } from '@root/config'; 2 | import { Client, Message } from 'discord.js'; 3 | 4 | // I stole these from https://github.com/powercord-org/powercord-backend/blob/my-awesome-branch/packages/boat/src/modules/mod/automod.ts#L52 5 | const CLEANER = /[\u200B-\u200F\u2060-\u2063\uFEFF\u00AD\u180E]|[\u0300-\u036f]|[\u202A-\u202E]|[/\\]/g; 6 | const NORMALIZE: [RegExp, string][] = [ 7 | [/Α|А|₳|Ꭿ|Ꭺ|Λ|@|🅰|🅐|\uD83C\uDDE6/g, 'A'], 8 | [/Β|В|в|฿|₿|Ᏸ|Ᏼ|🅱|🅑|\uD83C\uDDE7/g, 'B'], 9 | [/С|Ⅽ|₡|₵|Ꮳ|Ꮯ|Ꮸ|ᑕ|🅲|🅒|\uD83C\uDDE8/g, 'C'], 10 | [/Ⅾ|ↁ|ↇ|Ꭰ|🅳|🅓|\uD83C\uDDE9/g, 'D'], 11 | [/Ε|Ξ|ξ|Е|Ꭼ|Ꮛ|℮|🅴|🅔|\uD83C\uDDEA/g, 'E'], 12 | [/Ғ|ғ|₣|🅵|🅕|\uD83C\uDDEB/g, 'F'], 13 | [/₲|Ꮆ|Ᏻ|Ᏽ|🅶|🅖|\uD83C\uDDEC/g, 'G'], 14 | [/Η|Н|н|Ӊ|ӊ|Ң|ң|Ӈ|ӈ|Ҥ|ҥ|Ꮋ|🅷|🅗|\uD83C\uDDED/g, 'H'], 15 | [/Ι|І|Ӏ|ӏ|Ⅰ|Ꮖ|Ꮠ|🅸|🅘|\uD83C\uDDEE/g, 'I'], 16 | [/Ј|Ꭻ|🅹|🅙|\uD83C\uDDEF/g, 'J'], 17 | [/Κ|κ|К|к|Қ|қ|Ҟ|ҟ|Ҡ|ҡ|Ӄ|ӄ|Ҝ|ҝ|₭|Ꮶ|🅺|🅚|\uD83C\uDDF0/g, 'K'], 18 | [/Ⅼ|£|Ł|Ꮮ|🅻|🅛|\uD83C\uDDF1/g, 'L'], 19 | [/Μ|М|м|Ӎ|ӎ|Ⅿ|Ꮇ|🅼|🅜|\uD83C\uDDF2/g, 'M'], 20 | [/Ν|И|и|Ҋ|ҋ|₦|🅽|🅝|\uD83C\uDDF3/g, 'N'], 21 | [/Θ|θ|Ο|О|Ө|Ø|Ꮎ|Ꮻ|Ꭴ|Ꮕ|🅾|🅞|\uD83C\uDDF4/g, 'O'], 22 | [/Ρ|Р|Ҏ|₽|₱|Ꭾ|Ꮅ|Ꮲ|🅿|🆊|🅟|\uD83C\uDDF5/g, 'P'], 23 | [/🆀|🅠|\uD83C\uDDF6/g, 'Q'], 24 | [/Я|я|Ꭱ|Ꮢ|🆁|🅡|\uD83C\uDDF7/g, 'R'], 25 | [/Ѕ|\$|Ꭶ|Ꮥ|Ꮪ|🆂|🅢|\uD83C\uDDF8/g, 'S'], 26 | [/Τ|Т|т|Ҭ|ҭ|₮|₸|Ꭲ|🆃|🅣|\uD83C\uDDF9/g, 'T'], 27 | [/🆄|🅤|\uD83C\uDDFA/g, 'U'], 28 | [/Ⅴ|Ꮴ|Ꮙ|Ꮩ|🆅|🅥|\uD83C\uDDFB/g, 'V'], 29 | [/₩|Ꮃ|Ꮤ|🆆|🅦|\uD83C\uDDFC/g, 'W'], 30 | [/Χ|χ|Х|Ҳ|🆇|🅧|\uD83C\uDDFD/g, 'X'], 31 | [/Υ|У|Ү|Ұ|¥|🆈|🅨|\uD83C\uDDFE/g, 'Y'], 32 | [/Ζ|Ꮓ|🆉|🅩|\uD83C\uDDFF/g, 'Z'], 33 | [/α|а/g, 'a'], 34 | [/β|Ꮟ/g, 'b'], 35 | [/ϲ|с|ⅽ|↻|¢|©️/g, 'c'], 36 | [/đ|ⅾ|₫|Ꮷ|ժ|🆥/g, 'd'], 37 | [/ε|е|Ҽ|ҽ|Ҿ|ҿ|Є|є|€/g, 'e'], 38 | [/ƒ/g, 'f'], 39 | [/Ћ|ћ|Һ|һ|Ꮒ|Ꮵ/g, 'h'], 40 | [/ι|і|ⅰ|Ꭵ|¡/g, 'i'], 41 | [/ј/g, 'j'], 42 | [/ⅼ|£|₤/g, 'l'], 43 | [/ⅿ|₥/g, 'm'], 44 | [/ο|о|օ|ө|ø|¤|๏/g, 'o'], 45 | [/ρ|р|ҏ|Ꮘ|φ|ק/g, 'p'], 46 | [/ɾ/g, 'r'], 47 | [/ѕ/g, 's'], 48 | [/τ/g, 't'], 49 | [/μ|υ/g, 'u'], 50 | [/ν|ⅴ/g, 'v'], 51 | [/ω|ա|山/g, 'w'], 52 | [/х|ҳ|ⅹ/g, 'x'], 53 | [/γ|у|ү|ұ|Ꭹ|Ꮍ/g, 'y'], 54 | [/⓿/g, '0'], 55 | [/⓵/g, '1'], 56 | [/⓶/g, '2'], 57 | [/⓷/g, '3'], 58 | [/Ꮞ|⓸/g, '4'], 59 | [/⓹/g, '5'], 60 | [/⓺/g, '6'], 61 | [/⓻/g, '7'], 62 | [/⓼/g, '8'], 63 | [/⓽/g, '9'], 64 | [/⓾/g, '10'], 65 | [/⓫/g, '11'], 66 | [/⓬/g, '12'], 67 | [/⓭/g, '13'], 68 | [/⓮/g, '14'], 69 | [/⓯/g, '15'], 70 | [/⓰/g, '16'], 71 | [/⓱/g, '17'], 72 | [/⓲/g, '18'], 73 | [/⓳/g, '19'], 74 | [/⓴/g, '20'], 75 | [/1/g, 'i'], 76 | [/3/g, 'e'], 77 | [/4/g, 'a'], 78 | [/9/g, 'g'], 79 | [/0/g, 'o'] 80 | ]; 81 | 82 | async function register(bot: Client): Promise { 83 | bot.on('messageCreate', async (msg) => { 84 | filterMessages(msg).catch(async error => bot.emit('error', error)); 85 | }); 86 | bot.on('messageUpdate', async (_, msg) => { 87 | // Handel partials 88 | if (msg.partial) { 89 | msg = await msg.fetch(); 90 | } 91 | msg = msg as Message; 92 | 93 | filterMessages(msg).catch(async error => bot.emit('error', error)); 94 | }); 95 | } 96 | 97 | async function filterMessages(msg: Message): Promise { 98 | let normalizedMessage = msg.content.normalize('NFKD'); 99 | let attemptedBypass = false; 100 | for (const [re, rep] of NORMALIZE) { 101 | const cleanerString = normalizedMessage.replace(re, rep); 102 | attemptedBypass = attemptedBypass || normalizedMessage !== cleanerString; 103 | normalizedMessage = cleanerString; 104 | } 105 | 106 | const cleanNormalizedMessage = normalizedMessage.replace(CLEANER, ''); 107 | const cleanMessage = msg.content.replace(CLEANER, ''); 108 | 109 | const lowercaseMessage = msg.content.toLowerCase(); 110 | const cleanLowercaseMessage = cleanMessage.toLowerCase(); 111 | const cleanNormalizedLowercaseMessage = cleanNormalizedMessage.toLowerCase(); 112 | 113 | for (const word of BLACKLIST) { 114 | const simpleContains = lowercaseMessage.includes(word); 115 | if (simpleContains || cleanLowercaseMessage.includes(word) || cleanNormalizedLowercaseMessage.includes(word)) { 116 | msg.delete(); 117 | 118 | return msg.author.send(`You used a restricted word. Please refrain from doing so again.`) 119 | .catch(() => { 120 | msg.channel.send(`${msg.member}, you used a restricted word. Please refrain from doing so again.`); 121 | }); 122 | } 123 | } 124 | } 125 | 126 | 127 | export default register; 128 | -------------------------------------------------------------------------------- /src/commands/fun/rockpaperscissors.ts: -------------------------------------------------------------------------------- 1 | import { BOT } from '@root/config'; 2 | import { ButtonInteraction, ChatInputCommandInteraction, ActionRowBuilder, ButtonBuilder, EmbedBuilder, InteractionResponse, ButtonStyle } from 'discord.js'; 3 | import { Command } from '@lib/types/Command'; 4 | import { SageInteractionType } from '@lib/types/InteractionType'; 5 | import { buildCustomId, getDataFromCustomId } from '@lib/utils/interactionUtils'; 6 | 7 | const DECISION_TIMEOUT = 10; 8 | const CHOICES = ['rock', 'paper', 'scissors']; 9 | 10 | export default class extends Command { 11 | 12 | description = `The ultimate battle of human vs program. Can you best ${BOT.NAME} in a round of rock paper scissors?`; 13 | 14 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 15 | const choiceEmbed = new EmbedBuilder() 16 | .setTitle(`Make your choice, ${interaction.user.username}...`) 17 | .setColor('Red') 18 | .setFooter({ text: `You have ${DECISION_TIMEOUT} seconds to make up your mind.` }); 19 | 20 | const timer = setInterval(this.timeoutMessage, DECISION_TIMEOUT * 1000, interaction); 21 | const confirmBtns = [ 22 | new ButtonBuilder({ 23 | label: 'Rock', 24 | customId: buildCustomId({ 25 | type: SageInteractionType.RPS, 26 | commandOwner: interaction.user.id, 27 | additionalData: ['rock', `${timer[Symbol.toPrimitive]()}`] 28 | }), 29 | style: ButtonStyle.Primary, 30 | emoji: '👊' 31 | }), 32 | new ButtonBuilder({ 33 | label: 'Paper', 34 | customId: buildCustomId({ 35 | type: SageInteractionType.RPS, 36 | commandOwner: interaction.user.id, 37 | additionalData: ['paper', `${timer[Symbol.toPrimitive]()}`] 38 | }), 39 | style: ButtonStyle.Primary, 40 | emoji: '✋' 41 | }), 42 | new ButtonBuilder({ 43 | label: 'Scissors', 44 | customId: buildCustomId({ 45 | type: SageInteractionType.RPS, 46 | commandOwner: interaction.user.id, 47 | additionalData: ['scissors', `${timer[Symbol.toPrimitive]()}`] 48 | }), 49 | style: ButtonStyle.Primary, 50 | emoji: '✌' 51 | }) 52 | ]; 53 | 54 | await interaction.reply({ 55 | embeds: [choiceEmbed], 56 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 57 | // @ts-ignore: you are literally the right type shut up 58 | components: [new ActionRowBuilder().addComponents(confirmBtns)] 59 | }); 60 | 61 | return; 62 | } 63 | 64 | timeoutMessage(i: ChatInputCommandInteraction): void { 65 | const failEmbed = new EmbedBuilder() 66 | .setTitle(`${i.user.username} couldn't make up their mind! Command timed out.`) 67 | .setColor('Red'); 68 | 69 | i.editReply({ 70 | components: [], 71 | embeds: [failEmbed] 72 | }); 73 | } 74 | 75 | } 76 | 77 | function checkWinner(playerNum: number, botNum: number): string { 78 | if (playerNum === botNum) return 'Nobody'; 79 | if ((playerNum > botNum && playerNum - botNum === 1) || (botNum > playerNum && botNum - playerNum === 2)) { 80 | return 'You'; 81 | } else { 82 | return BOT.NAME; 83 | } 84 | } 85 | 86 | 87 | export async function handleRpsOptionSelect(i: ButtonInteraction): Promise { 88 | const interactionData = getDataFromCustomId(i.customId); 89 | const choice = interactionData.additionalData[0]; 90 | const timer = interactionData.additionalData[1]; 91 | if (i.user.id !== interactionData.commandOwner) { 92 | await i.reply({ 93 | content: 'You cannot respond to a command you did not execute', 94 | ephemeral: true 95 | }); 96 | return; 97 | } 98 | 99 | clearInterval(Number.parseInt(timer)); 100 | const msg = await i.channel.messages.fetch(i.message.id); 101 | 102 | const botMove = CHOICES[Math.floor(Math.random() * CHOICES.length)]; 103 | const winner = checkWinner(CHOICES.indexOf(choice), CHOICES.indexOf(botMove)); 104 | 105 | let winEmbed: EmbedBuilder; 106 | 107 | if (winner === BOT.NAME) { 108 | winEmbed = new EmbedBuilder() 109 | .setTitle(`${i.user.username} threw ${choice} and ${BOT.NAME} threw ${botMove}. ${winner} won - the machine triumphs!`) 110 | .setColor('Red'); 111 | } else if (winner === 'Nobody') { 112 | winEmbed = new EmbedBuilder() 113 | .setTitle(`Both ${i.user.username} and ${BOT.NAME} threw ${choice}. It's a draw!`) 114 | .setColor('Blue'); 115 | } else { 116 | winEmbed = new EmbedBuilder() 117 | .setTitle(`${i.user.username} threw ${choice} and ${BOT.NAME} threw ${botMove}. ${i.user.username} won - humanity triumphs!`) 118 | .setColor('Green'); 119 | } 120 | await msg.edit({ 121 | components: [], 122 | embeds: [winEmbed] 123 | }); 124 | await i.deferUpdate(); 125 | 126 | return; 127 | } 128 | -------------------------------------------------------------------------------- /src/commands/admin/prune.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@lib/types/Command'; 2 | import { ROLES } from '@root/config'; 3 | import { BOTMASTER_PERMS } from '@lib/permissions'; 4 | import { ApplicationCommandPermissions, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; 5 | 6 | export default class extends Command { 7 | 8 | description = `Prunes all members who don't have the <@&${ROLES.VERIFIED}> role`; 9 | runInDM = false; 10 | permissions: ApplicationCommandPermissions[] = BOTMASTER_PERMS; 11 | 12 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 13 | // let timeout = PRUNE_TIMEOUT; 14 | 15 | // await interaction.guild.members.fetch(); 16 | // const toKick = interaction.guild.members.cache.filter(member => !member.user.bot && !member.roles.cache.has(ROLES.VERIFIED)); 17 | // if (toKick.size === 0) return interaction.reply('No prunable members.'); 18 | 19 | // const confirmEmbed = new EmbedBuilder() 20 | // .setTitle(`Server prune will kick ${toKick.size} members from the guild. Proceed?`) 21 | // .setColor('Red') 22 | // .setFooter({ text: `This command will expire in ${PRUNE_TIMEOUT}s` }); 23 | 24 | // const confirmBtns = [ 25 | // new MessageButton({ label: 'Cancel', customId: 'cancel', style: 'SECONDARY' }), 26 | // new MessageButton({ label: 'Proceed', customId: 'proceed', style: 'DANGER' }) 27 | // ]; 28 | 29 | // const confirmMsg = await interaction.channel.send({ 30 | // embeds: [confirmEmbed], 31 | // components: [new ActionRowBuilder({ components: confirmBtns })] 32 | // }); 33 | 34 | // const collector = interaction.channel.createMessageComponentCollector({ 35 | // filter: i => i.message.id === confirmMsg.id, time: PRUNE_TIMEOUT * 1000 36 | // }); 37 | 38 | // const countdown = setInterval(() => this.countdown(confirmMsg, --timeout, confirmBtns, confirmEmbed), 1000); 39 | 40 | // collector.on('collect', async (btnClick: ButtonInteraction) => { 41 | // if (btnClick.user.id !== interaction.user.id) { 42 | // return await interaction.reply({ 43 | // content: 'You cannot respond to a command you did not execute.', 44 | // ephemeral: true 45 | // }); 46 | // } 47 | // interaction.deferReply({ ephemeral: true }); 48 | // clearInterval(countdown); 49 | 50 | // confirmBtns.forEach(btn => btn.setDisabled(true)); 51 | 52 | 53 | // if (btnClick.customId === 'cancel') { 54 | // confirmEmbed.setColor('BLUE') 55 | // .setTitle(`Prune cancelled. ${interaction.user.username} got cold feet!`); 56 | // confirmMsg.edit({ embeds: [confirmEmbed], components: [new ActionRowBuilder({ components: confirmBtns })] }); 57 | // } else { 58 | // confirmEmbed.setTitle(` Pruning ${toKick.size} members...`); 59 | // confirmMsg.edit({ embeds: [confirmEmbed], components: [new ActionRowBuilder({ components: confirmBtns })] }); 60 | 61 | // const awaitedKicks: Promise[] = []; 62 | // toKick.forEach(member => { 63 | // awaitedKicks.push(member.kick(`Pruned by ${interaction.user.username} (${interaction.user.id})`)); 64 | // return; 65 | // }); 66 | // await Promise.all(awaitedKicks); 67 | 68 | // confirmEmbed.setTitle(`:white_check_mark: Pruned ${toKick.size} members!`); 69 | // confirmMsg.edit({ 70 | // embeds: [confirmEmbed], 71 | // components: [new ActionRowBuilder({ components: confirmBtns })] 72 | // }); 73 | // } 74 | // collector.stop(); 75 | // }).on('end', async collected => { 76 | // const validCollected = collected.filter(i => i.isButton() 77 | // && i.message.id === confirmMsg.id 78 | // && i.user.id === interaction.user.id); 79 | 80 | // if (validCollected.size === 0) { 81 | // clearInterval(countdown); 82 | // confirmBtns.forEach(btn => btn.setDisabled(true)); 83 | // confirmEmbed.setColor('BLUE').setDescription('Prune timed out.'); 84 | // } 85 | // confirmEmbed.setFooter({ text: '' }); 86 | // confirmMsg.edit({ embeds: [confirmEmbed], components: [new ActionRowBuilder({ components: confirmBtns })] }); 87 | 88 | // collected.forEach(interactionX => { 89 | // if (validCollected.has(interactionX.id)) interactionX.followUp({ content: 'Done!' }); 90 | // }); 91 | // }); 92 | 93 | return interaction.reply('To be implemented again soon...'); 94 | } 95 | 96 | // countdown(msg: Message, timeout: number, confirmBtns: MessageButton[], confirmEmbed: EmbedBuilder): void { 97 | // confirmEmbed.setFooter({ text: `This command will expire in ${timeout}s` }); 98 | // msg.edit({ embeds: [confirmEmbed], components: [new ActionRowBuilder({ components: confirmBtns })] }); 99 | // } 100 | 101 | } 102 | 103 | -------------------------------------------------------------------------------- /src/commands/staff/sudoreply.ts: -------------------------------------------------------------------------------- 1 | import { PVQuestion } from '@lib/types/PVQuestion'; 2 | import { BOT, DB, MAINTAINERS } from '@root/config'; 3 | import { ADMIN_PERMS, STAFF_PERMS } from '@lib/permissions'; 4 | import { ApplicationCommandOptionData, ApplicationCommandPermissions, ChatInputCommandInteraction, GuildChannel, Message, EmbedBuilder, TextChannel, ThreadChannel, 5 | ApplicationCommandOptionType, ChannelType, InteractionResponse } from 'discord.js'; 6 | import { Command } from '@lib/types/Command'; 7 | import { Course } from '@lib/types/Course'; 8 | 9 | export default class extends Command { 10 | 11 | description = `Reply to a question asked through ${BOT.NAME}.`; 12 | extendedHelp = 'Responses are put into a private thread between you and the asker.'; 13 | runInDM = false; 14 | options: ApplicationCommandOptionData[] = [ 15 | { 16 | name: 'questionid', 17 | description: 'ID of question you are replying to', 18 | type: ApplicationCommandOptionType.String, 19 | required: true 20 | }, 21 | { 22 | name: 'response', 23 | description: 'Response to the question', 24 | type: ApplicationCommandOptionType.String, 25 | required: true 26 | } 27 | ] 28 | permissions: ApplicationCommandPermissions[] = [STAFF_PERMS, ADMIN_PERMS]; 29 | 30 | async run(interaction: ChatInputCommandInteraction): Promise | void | Message> { 31 | const idArg = interaction.options.getString('questionid'); 32 | if (isNaN(Number.parseInt(idArg))) return interaction.reply({ content: `**${idArg}** is not a valid question ID`, ephemeral: true }); 33 | 34 | const question: PVQuestion = await interaction.client.mongo.collection(DB.PVQ) 35 | .findOne({ questionId: `${interaction.options.getString('questionid')}` }); 36 | if (!question) return interaction.reply({ content: `I could not find a question with ID **${idArg}**.`, ephemeral: true }); 37 | 38 | const response = interaction.options.getString('response'); 39 | const bot = interaction.client; 40 | const asker = await interaction.guild.members.fetch(question.owner); 41 | 42 | if (interaction.channel.type !== ChannelType.GuildText) { 43 | return interaction.reply({ 44 | content: `You must use this command in a regular text channel. If you think there is a problem, please contact ${MAINTAINERS} for help.`, 45 | ephemeral: true 46 | }); 47 | } 48 | 49 | const channel = interaction.channel as TextChannel; 50 | 51 | const course = await bot.mongo.collection(DB.COURSES).findOne({ 'channels.category': channel.parentId }); 52 | 53 | if (question.type === 'private') { 54 | const splitLink = question.messageLink.split('/'); 55 | const threadId = splitLink[splitLink.length - 2]; 56 | return interaction.reply({ 57 | content: `\`/sudoreply\` has been depreciated for private questions. Please reply in thread <#${threadId}>.`, 58 | ephemeral: true 59 | }); 60 | } 61 | 62 | const courseGeneral = (await bot.channels.fetch(course.channels.general)) as GuildChannel; 63 | let privThread: ThreadChannel; 64 | if (courseGeneral.type === ChannelType.GuildText) { 65 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 66 | // @ts-ignore 67 | privThread = await courseGeneral.threads.create({ 68 | name: `${interaction.user.username}‘s anonymous question (${question.questionId})'`, 69 | autoArchiveDuration: 4320, 70 | reason: `${interaction.user.username} asked an anonymous question`, 71 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 72 | // @ts-ignore 73 | type: `GUILD_PRIVATE_THREAD` 74 | }); 75 | } else { 76 | throw `Something went wrong creating ${asker.user.username}'s private thread. Please contact ${MAINTAINERS} for assistance!'`; 77 | } 78 | 79 | privThread.guild.members.fetch(); 80 | privThread.members.add(interaction.user.id); 81 | privThread.members.add(question.owner); 82 | 83 | const embed = new EmbedBuilder() 84 | .setDescription(`I've sent your response to this thread: <#${privThread.id}>\n\n Please have any further conversation there.`); 85 | 86 | await interaction.reply({ 87 | embeds: [embed] 88 | }); 89 | 90 | embed.setDescription(`${question.messageLink}`); 91 | embed.setTitle(`${asker.user.tag}'s Question`); 92 | embed.setFooter({ text: `When you're done with this question, you can send \`/archive\` to close it` }); 93 | await privThread.send({ 94 | embeds: [embed] 95 | }); 96 | 97 | const threadEmbed = new EmbedBuilder() 98 | .setAuthor({ name: `${interaction.user.tag}`, iconURL: interaction.user.avatarURL() }) 99 | .setDescription(response) 100 | .setFooter({ text: `Please have any further conversation in this thread!` }); 101 | 102 | return privThread.send({ embeds: [threadEmbed] }); 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/commands/admin/addcourse.ts: -------------------------------------------------------------------------------- 1 | import { OverwriteResolvable, Guild, TextChannel, ApplicationCommandPermissions, ChatInputCommandInteraction, ApplicationCommandOptionData, ApplicationCommandOptionType, 2 | InteractionResponse, ChannelType } from 'discord.js'; 3 | import { Course } from '@lib/types/Course'; 4 | import { ADMIN_PERMS } from '@lib/permissions'; 5 | import { DB, GUILDS, ROLES } from '@root/config'; 6 | import { Command } from '@lib/types/Command'; 7 | import { updateDropdowns } from '@lib/utils/generalUtils'; 8 | 9 | export default class extends Command { 10 | 11 | description = 'Creates a courses category and adds all necessary channels/roles.'; 12 | runInDM = false; 13 | permissions: ApplicationCommandPermissions[] = [ADMIN_PERMS]; 14 | 15 | options: ApplicationCommandOptionData[] = [{ 16 | name: 'course', 17 | description: 'The three-digit course ID of the course to be added (ex: 108).', 18 | type: ApplicationCommandOptionType.String, 19 | required: true 20 | }] 21 | 22 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 23 | interaction.reply(' working...'); 24 | 25 | const course = interaction.options.getString('course'); 26 | // make sure course does not exist already 27 | if (await interaction.client.mongo.collection(DB.COURSES).countDocuments({ name: course }) > 0) { 28 | interaction.editReply({ content: `${course} has already been registered as a course.` }); 29 | } 30 | const reason = `Creating new course \`${course}\` as requested 31 | by \`${interaction.user.username}\` \`(${interaction.user.id})\`.`; 32 | 33 | // create staff role for course 34 | const staffRole = await interaction.guild.roles.create({ 35 | name: `${course} Staff`, 36 | permissions: BigInt(0), 37 | mentionable: true, 38 | reason: reason 39 | }); 40 | 41 | // create student role for course 42 | const studentRole = await interaction.guild.roles.create({ 43 | name: `CISC ${course}`, 44 | permissions: BigInt(0), 45 | reason: reason 46 | }); 47 | 48 | // set permissions for the course 49 | const standardPerms: Array = [{ 50 | id: ROLES.ADMIN, 51 | allow: 'ViewChannel' 52 | }, { 53 | id: staffRole.id, 54 | allow: 'ViewChannel' 55 | }, { 56 | id: GUILDS.MAIN, 57 | deny: 'ViewChannel' 58 | }, { 59 | id: studentRole.id, 60 | allow: 'ViewChannel' 61 | }, { 62 | id: ROLES.MUTED, 63 | deny: 'SendMessages' 64 | }]; 65 | const staffPerms = [standardPerms[0], standardPerms[1], standardPerms[2]]; 66 | 67 | // create course category 68 | const categoryChannel = await interaction.guild.channels.create({ 69 | name: `CISC ${course}`, 70 | type: ChannelType.GuildCategory, 71 | permissionOverwrites: standardPerms, 72 | reason 73 | }); 74 | 75 | // create each channel in the category 76 | const generalChannel = await this.createTextChannel(interaction.guild, `${course}_general`, standardPerms, categoryChannel.id, reason); 77 | await this.createTextChannel(interaction.guild, `${course}_homework`, standardPerms, categoryChannel.id, reason); 78 | await this.createTextChannel(interaction.guild, `${course}_labs`, standardPerms, categoryChannel.id, reason); 79 | await this.createTextChannel(interaction.guild, `${course}_projects`, standardPerms, categoryChannel.id, reason); 80 | const staffChannel = await interaction.guild.channels.create({ 81 | name: `${course}_staff`, 82 | type: ChannelType.GuildText, 83 | parent: categoryChannel.id, 84 | topic: '[no message count]', 85 | permissionOverwrites: staffPerms, 86 | reason 87 | }); 88 | const privateQuestionChannel = await interaction.guild.channels.create({ 89 | name: `${course}_private_qs`, 90 | type: ChannelType.GuildText, 91 | parent: categoryChannel.id, 92 | topic: '[no message count]', 93 | permissionOverwrites: staffPerms, 94 | reason 95 | }); 96 | 97 | // adding the course to the database 98 | const newCourse: Course = { 99 | name: course, 100 | channels: { 101 | category: categoryChannel.id, 102 | general: generalChannel.id, 103 | staff: staffChannel.id, 104 | private: privateQuestionChannel.id 105 | }, 106 | roles: { 107 | staff: staffRole.id, 108 | student: studentRole.id 109 | }, 110 | assignments: ['hw1', 'hw2', 'hw3', 'hw4', 'hw5', 'lab1', 'lab2', 'lab3', 'lab4', 'lab5'] 111 | }; 112 | await interaction.client.mongo.collection(DB.COURSES).insertOne(newCourse); 113 | 114 | await updateDropdowns(interaction); 115 | 116 | interaction.editReply(`Successfully added course with ID ${course}`); 117 | } 118 | 119 | async createTextChannel(guild: Guild, name: string, permissionOverwrites: Array, parent: string, reason: string): Promise { 120 | return guild.channels.create({ 121 | name, 122 | type: ChannelType.GuildText, 123 | parent, 124 | permissionOverwrites, 125 | reason 126 | }); 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /src/pieces/logs/modLog.ts: -------------------------------------------------------------------------------- 1 | import { Client, TextChannel, EmbedField, EmbedBuilder, GuildMember, PartialGuildMember, GuildBan, AuditLogEvent } from 'discord.js'; 2 | import { GUILDS, CHANNELS, ROLES } from '@root/config'; 3 | 4 | async function processBanAdd(ban: GuildBan, modLog: TextChannel): Promise { 5 | const { guild, user } = ban; 6 | if (guild.id !== GUILDS.MAIN) return; 7 | 8 | const logs = (await guild.fetchAuditLogs({ type: AuditLogEvent.MemberBanAdd, limit: 1 })).entries; 9 | const [logEntry] = [...logs.values()]; 10 | 11 | const fields: Array = []; 12 | 13 | if (logEntry.reason) { 14 | fields.push({ 15 | name: 'Reason', 16 | value: logEntry.reason, 17 | inline: false 18 | }); 19 | } 20 | 21 | const embed = new EmbedBuilder() 22 | .setAuthor({ name: logEntry.executor.tag, iconURL: logEntry.executor.avatarURL() }) 23 | .setTitle(`${user.tag} was banned.`) 24 | .addFields(fields) 25 | .setColor('Greyple') 26 | .setFooter({ text: `Mod ID: ${logEntry.executor.id} | Target ID: ${user.id}` }) 27 | .setTimestamp(); 28 | modLog.send({ embeds: [embed] }); 29 | } 30 | 31 | async function processBanRemove(ban: GuildBan, modLog: TextChannel): Promise { 32 | const { guild, user } = ban; 33 | if (ban.guild.id !== GUILDS.MAIN) return; 34 | 35 | const logs = (await guild.fetchAuditLogs({ type: AuditLogEvent.MemberBanRemove, limit: 1 })).entries; 36 | const [logEntry] = [...logs.values()]; 37 | 38 | const fields: Array = []; 39 | 40 | if (logEntry.reason) { 41 | fields.push({ 42 | name: 'Reason', 43 | value: logEntry.reason, 44 | inline: false 45 | }); 46 | } 47 | 48 | const embed = new EmbedBuilder() 49 | .setAuthor({ name: logEntry.executor.tag, iconURL: logEntry.executor.avatarURL() }) 50 | .setTitle(`${user.tag} was unbanned.`) 51 | .addFields(fields) 52 | .setColor('Greyple') 53 | .setFooter({ text: `Mod ID: ${logEntry.executor.id} | Target ID: ${user.id}` }) 54 | .setTimestamp(); 55 | modLog.send({ embeds: [embed] }); 56 | } 57 | 58 | async function processMemberUpdate(oldMember: GuildMember | PartialGuildMember, member: GuildMember, modLog: TextChannel): Promise { 59 | if (member.guild.id !== GUILDS.MAIN || oldMember.roles.cache.equals(member.roles.cache)) return; 60 | 61 | const logs = (await member.guild.fetchAuditLogs({ type: AuditLogEvent.MemberRoleUpdate, limit: 5 })).entries; 62 | const logEntry = [...logs.values()].find(entry => { 63 | if (!('id' in entry.target)) return false; 64 | return entry.target.id === member.id; 65 | }); 66 | 67 | if (!logEntry) return; 68 | 69 | let muted: 'muted' | 'unmuted' | null = null; 70 | 71 | if (logEntry.changes.find(change => change.key === '$add')?.new[0]?.id === ROLES.MUTED) { 72 | muted = 'muted'; 73 | } else if (logEntry.changes.find(change => change.key === '$remove')?.new[0]?.id === ROLES.MUTED) { 74 | muted = 'unmuted'; 75 | } 76 | 77 | if (muted !== null) { 78 | const embed = new EmbedBuilder() 79 | .setTitle(`${member.user.tag} ${muted} by ${logEntry.executor.tag}`) 80 | .setDescription(logEntry.reason ? `With reason: \n${logEntry.reason}` : '') 81 | .setColor('DarkRed') 82 | .setFooter({ text: `TargetID: ${member.id} | Mod ID: ${logEntry.executor.id}` }) 83 | .setTimestamp(); 84 | modLog.send({ embeds: [embed] }); 85 | } 86 | } 87 | 88 | async function processMemberRemove(member: GuildMember | PartialGuildMember, modLog: TextChannel): Promise { 89 | if (member.guild.id !== GUILDS.MAIN) return; 90 | 91 | const logs = (await member.guild.fetchAuditLogs({ type: AuditLogEvent.MemberKick, limit: 1 })).entries; 92 | const [logEntry] = [...logs.values()]; 93 | if (!logEntry) return; 94 | 95 | if (!('id' in logEntry.target) 96 | || logEntry.target.id !== member.id 97 | || (Date.now() - logEntry.createdTimestamp) > 10e3) return; 98 | 99 | const embed = new EmbedBuilder() 100 | .setTitle(`${member.user.tag} kicked by ${logEntry.executor.tag}`) 101 | .setDescription(logEntry.reason ? `With reason: \n${logEntry.reason}` : '') 102 | .setColor('Yellow') 103 | .setFooter({ text: `TargetID: ${member.id} | Mod ID: ${logEntry.executor.id}` }) 104 | .setTimestamp(); 105 | modLog.send({ embeds: [embed] }); 106 | } 107 | 108 | async function register(bot: Client): Promise { 109 | const modLog = await bot.channels.fetch(CHANNELS.MOD_LOG) as TextChannel; 110 | 111 | bot.on('guildBanAdd', ban => { 112 | processBanAdd(ban, modLog).catch(async error => bot.emit('error', error)); 113 | return; 114 | }); 115 | 116 | bot.on('guildBanRemove', ban => { 117 | processBanRemove(ban, modLog).catch(async error => bot.emit('error', error)); 118 | return; 119 | }); 120 | 121 | bot.on('guildMemberUpdate', (oldMember, newMember) => { 122 | processMemberUpdate(oldMember, newMember, modLog) 123 | .catch(async error => bot.emit('error', error)); 124 | }); 125 | 126 | bot.on('guildMemberRemove', member => { 127 | processMemberRemove(member, modLog) 128 | .catch(async error => bot.emit('error', error)); 129 | }); 130 | } 131 | 132 | export default register; 133 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | def boolean stage_results = false 2 | 3 | pipeline { 4 | agent any 5 | environment { 6 | DISCORD_WEBHOOK=credentials('3fbb794c-1c40-4471-9eee-d147d4506046') 7 | MAIN_BRANCH='main' 8 | SAGE_DIR='/usr/local/sage/SageV2' 9 | JENKINS_NODE_COOKIE='dontKillMe' 10 | } 11 | stages { 12 | stage('Test Build') { 13 | steps { 14 | catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { 15 | sh 'find /tmp -user jenkins -print0 | xargs -0 rm -rf' 16 | sh 'echo "running build in temp workspace"' 17 | sh 'mv config.example.ts config.ts' 18 | sh 'npm run clean' 19 | sh 'npm cache clean --force' 20 | sh 'rm -rf node_modules' 21 | sh 'npm i' 22 | sh 'npm run build' 23 | script{ stage_results = true } 24 | } 25 | script { 26 | discordSend( 27 | description: "Test build " + currentBuild.currentResult + " on branch [" + env.BRANCH_NAME + 28 | "](https://github.com/ud-cis-discord/SageV2/commit/" + env.GIT_COMMIT + ")", 29 | footer: env.BUILD_TAG, 30 | link: env.BUILD_URL, 31 | result: currentBuild.currentResult, 32 | title: JOB_NAME + " -- Test Build", 33 | webhookURL: env.DISCORD_WEBHOOK 34 | ) 35 | if (stage_results == false) { 36 | sh 'exit 1' 37 | } 38 | stage_results = false 39 | } 40 | 41 | } 42 | } 43 | stage('Lint') { 44 | steps { 45 | catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { 46 | sh 'echo "testing in temp workspace..."' 47 | sh 'npm run test' 48 | script{ stage_results = true } 49 | } 50 | script { 51 | discordSend( 52 | description: "Lint " + currentBuild.currentResult + " on branch [" + env.BRANCH_NAME + 53 | "](https://github.com/ud-cis-discord/SageV2/commit/" + env.GIT_COMMIT + ")", 54 | footer: env.BUILD_TAG, 55 | link: env.BUILD_URL, 56 | result: currentBuild.currentResult, 57 | title: JOB_NAME + " -- Lint", 58 | webhookURL: env.DISCORD_WEBHOOK 59 | ) 60 | if (stage_results == false) { 61 | sh 'exit 1' 62 | } 63 | stage_results = false 64 | } 65 | } 66 | } 67 | stage('Deploy') { 68 | steps { 69 | catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { 70 | script { 71 | if(env.BRANCH_NAME == env.MAIN_BRANCH) { 72 | sh 'echo "rebuilding and deploying in prod directory..."' 73 | sh 'cd ' + env.SAGE_DIR + ' && git pull && npm run clean && npm i && npm run build && sudo /bin/systemctl restart sage' 74 | } else { 75 | echo 'build done, branch OK' 76 | } 77 | stage_results = true 78 | } 79 | } 80 | script { 81 | def discord_desc = "Deploy " + currentBuild.currentResult + " on branch [" + env.BRANCH_NAME + "](https://github.com/ud-cis-discord/SageV2/commit/" + env.GIT_COMMIT + ")" 82 | if(stage_results == false && env.BRANCH_NAME == env.MAIN_BRANCH) { 83 | discord_desc = "URGENT!! -- " + discord_desc 84 | } 85 | if(env.BRANCH_NAME == env.MAIN_BRANCH) { 86 | discordSend( 87 | description: discord_desc, 88 | footer: env.BUILD_TAG, 89 | link: env.BUILD_URL, 90 | result: currentBuild.currentResult, 91 | title: JOB_NAME + " -- Deploy", 92 | webhookURL: env.DISCORD_WEBHOOK 93 | )} 94 | if (stage_results == false) { 95 | sh 'exit 1' 96 | } 97 | } 98 | } 99 | } 100 | stage('Update Docs') { 101 | steps { 102 | catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { 103 | script { 104 | if(env.BRANCH_NAME == env.MAIN_BRANCH) { 105 | sh 'echo "automatically updating the documentation website"' 106 | sh 'cd ' + env.SAGE_DIR + ' && npm run autodoc' 107 | } 108 | stage_results = true 109 | } 110 | } 111 | script { 112 | def discord_desc = "doc automation" + currentBuild.currentResult + " on branch [" + env.BRANCH_NAME + "](https://github.com/ud-cis-discord/SageV2/commit/" + env.GIT_COMMIT + ")" 113 | if(stage_results == false && env.BRANCH_NAME == env.MAIN_BRANCH) { 114 | discord_desc = "URGENT!! -- " + discord_desc 115 | } 116 | if(env.BRANCH_NAME == env.MAIN_BRANCH) { 117 | discordSend( 118 | description: discord_desc, 119 | footer: env.BUILD_TAG, 120 | link: env.BUILD_URL, 121 | result: currentBuild.currentResult, 122 | title: JOB_NAME + " -- Documentation Update", 123 | webhookURL: env.DISCORD_WEBHOOK 124 | )} 125 | if (stage_results == false) { 126 | sh 'exit 1' 127 | } 128 | } 129 | } 130 | } 131 | } 132 | post { 133 | always { 134 | discordSend( 135 | description: "Pipeline " + currentBuild.currentResult + " on branch [" + env.BRANCH_NAME + 136 | "](https://github.com/ud-cis-discord/SageV2/commit/" + env.GIT_COMMIT + ")", 137 | footer: env.BUILD_TAG, 138 | link: env.BUILD_URL, 139 | result: currentBuild.currentResult, 140 | title: JOB_NAME, 141 | webhookURL: env.DISCORD_WEBHOOK 142 | ) 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/commands/info/leaderboard.ts: -------------------------------------------------------------------------------- 1 | import { SageUser } from '@lib/types/SageUser'; 2 | import { Leaderboard } from '@lib/enums'; 3 | import { Command } from '@lib/types/Command'; 4 | import { createCanvas, CanvasRenderingContext2D, loadImage } from 'canvas'; 5 | import { EmbedBuilder, ApplicationCommandOptionData, ChatInputCommandInteraction, ApplicationCommandOptionType, InteractionResponse, ImageURLOptions } from 'discord.js'; 6 | 7 | export default class extends Command { 8 | 9 | description = 'Gives the top 10 users in the guild'; 10 | extendedHelp = 'Enter a page number to look further down the leaderboard'; 11 | runInDM = false; 12 | 13 | options: ApplicationCommandOptionData[] = [ 14 | { 15 | name: 'pagenumber', 16 | description: 'leaderboard page to view', 17 | type: ApplicationCommandOptionType.Number, 18 | required: false 19 | } 20 | ] 21 | 22 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 23 | await interaction.deferReply(); 24 | await interaction.guild.members.fetch(); 25 | 26 | // eslint-disable-next-line no-extra-parens 27 | const users: Array = (await interaction.client.mongo.collection('users').find().toArray() as Array) 28 | .filter(user => interaction.guild.members.cache.has(user.discordId)) 29 | .sort((ua, ub) => ua.level - ub.level !== 0 ? ua.level > ub.level ? -1 : 1 : ua.curExp < ub.curExp ? -1 : 1); // filter on level first, then remaining xp 30 | 31 | const dbAuthor = users.find(user => interaction.user.id === user.discordId); 32 | const askerRank = users.indexOf(dbAuthor) + 1; 33 | 34 | let page = interaction.options.getNumber('pagenumber') ?? Math.floor((askerRank - 1) / 10) + 1; 35 | 36 | page = page * 10 > users.length ? Math.floor(users.length / 10) + 1 : page; 37 | 38 | const start = (page * 10) - 10; 39 | const end = page * 10 > users.length ? undefined : page * 10; 40 | 41 | const displUsers = users.slice(start, end); 42 | 43 | const canvas = createCanvas(Leaderboard.width, (Leaderboard.userPillHeight + 5) * displUsers.length); 44 | const ctx = canvas.getContext('2d'); 45 | 46 | for (const user of displUsers) { 47 | const i = displUsers.indexOf(user); 48 | const discUser = interaction.guild.members.cache.get(user.discordId); 49 | const rank = i + 1 + ((page - 1) * 10); 50 | const { level } = user; 51 | const exp = user.levelExp - user.curExp; 52 | 53 | const cursor = { x: 0, y: i * (Leaderboard.userPillHeight + Leaderboard.margin) }; 54 | 55 | ctx.fillStyle = Leaderboard.userPillColor; 56 | this.roundedRect(ctx, cursor.x, cursor.y, Leaderboard.width, Leaderboard.userPillHeight, 10); 57 | 58 | const pfp = await loadImage(discUser.user.displayAvatarURL({ extension: 'png' } as ImageURLOptions)); 59 | ctx.drawImage(pfp, 0, cursor.y, Leaderboard.userPillHeight, Leaderboard.userPillHeight); 60 | cursor.x += Leaderboard.userPillHeight + 15; 61 | cursor.y += Leaderboard.userPillHeight / 2; 62 | 63 | ctx.font = Leaderboard.font; 64 | ctx.textBaseline = 'middle'; 65 | switch (rank) { 66 | case 1: 67 | ctx.fillStyle = Leaderboard.firstColor; 68 | break; 69 | case 2: 70 | ctx.fillStyle = Leaderboard.secondColor; 71 | break; 72 | case 3: 73 | ctx.fillStyle = Leaderboard.thirdColor; 74 | break; 75 | default: 76 | ctx.fillStyle = Leaderboard.textColor; 77 | break; 78 | } 79 | ctx.fillText(`#${rank}`, cursor.x, cursor.y); 80 | cursor.x += 75; 81 | 82 | ctx.fillStyle = Leaderboard.textColor; 83 | ctx.fillText(discUser.displayName, cursor.x, cursor.y, 325); 84 | cursor.x = 450; 85 | 86 | ctx.fillStyle = discUser.displayHexColor !== '#000000' ? discUser.displayHexColor : Leaderboard.textColor; 87 | ctx.fillText(`Level ${level}`, cursor.x, cursor.y); 88 | cursor.x += 150; 89 | 90 | ctx.fillStyle = Leaderboard.textColor; 91 | ctx.fillText(`${exp} exp`, cursor.x, cursor.y); 92 | } 93 | const { level: askerLevel } = dbAuthor; 94 | const askerExp = dbAuthor.levelExp - dbAuthor.curExp; 95 | const content = `You are #${askerRank} and at level ${askerLevel} with ${askerExp} exp.`; 96 | 97 | 98 | const embed = new EmbedBuilder() 99 | .setTitle('UD CIS Discord Leaderboard') 100 | .setFooter({ text: `Showing page ${page} (${start + 1} - ${end || users.length})` }) 101 | .setColor(interaction.guild.members.cache.get(displUsers[0].discordId).displayHexColor) 102 | .setDescription(content) 103 | .setImage('attachment://leaderboard.png'); 104 | 105 | interaction.followUp({ 106 | embeds: [embed], 107 | files: [{ name: 'leaderboard.png', attachment: canvas.toBuffer() }] 108 | }); 109 | return; 110 | } 111 | 112 | roundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number): void { 113 | ctx.beginPath(); 114 | ctx.moveTo(x, y + radius); 115 | ctx.lineTo(x, y + height - radius); 116 | ctx.arcTo(x, y + height, x + radius, y + height, radius); 117 | ctx.lineTo(x + width - radius, y + height); 118 | ctx.arcTo(x + width, y + height, x + width, y + height - radius, radius); 119 | ctx.lineTo(x + width, y + radius); 120 | ctx.arcTo(x + width, y, x + width - radius, y, radius); 121 | ctx.lineTo(x + radius, y); 122 | ctx.arcTo(x, y, x, y + radius, radius); 123 | ctx.fill(); 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /src/pieces/tasks.ts: -------------------------------------------------------------------------------- 1 | import { CHANNELS, DB } from '@root/config'; 2 | import { ChannelType, Client, EmbedBuilder, TextChannel } from 'discord.js'; 3 | import { schedule } from 'node-cron'; 4 | import { Reminder } from '@lib/types/Reminder'; 5 | import { Poll, PollResult } from '@lib/types/Poll'; 6 | 7 | async function register(bot: Client): Promise { 8 | schedule('0/30 * * * * *', () => { 9 | handleCron(bot) 10 | .catch(async error => bot.emit('error', error)); 11 | }); 12 | } 13 | 14 | async function handleCron(bot: Client): Promise { 15 | checkPolls(bot); 16 | checkReminders(bot); 17 | } 18 | 19 | async function checkPolls(bot: Client): Promise { 20 | const polls: Poll[] = await bot.mongo.collection(DB.POLLS).find({ 21 | expires: { $lte: new Date() } 22 | }).toArray(); 23 | const emotes = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; 24 | 25 | polls.forEach(async poll => { 26 | const mdTimestamp = ``; 27 | 28 | // figure out the winner and also put the results in a map for ease of use 29 | const resultMap = new Map(); 30 | let winners: PollResult[] = []; 31 | poll.results.forEach(res => { 32 | resultMap.set(res.option, res.users.length); 33 | if (!winners[0]) { 34 | winners = [res]; 35 | return; 36 | } 37 | if (winners[0] && res.users.length > winners[0].users.length) winners = [res]; 38 | else if (res.users.length === winners[0].users.length) winners.push(res); 39 | }); 40 | 41 | // build up the win string 42 | let winMessage: string; 43 | const winCount = winners[0].users.length; 44 | if (winCount === 0) { 45 | winMessage = `It looks like no one has voted!`; 46 | } else if (winners.length === 1) { 47 | winMessage = `**${winners[0].option}** has won the poll with ${winCount} vote${winCount === 1 ? '' : 's'}!`; 48 | } else { 49 | winMessage = `**${ 50 | winners.slice(0, -1).map(win => win.option).join(', ') 51 | } and ${ 52 | winners.slice(-1)[0].option 53 | }** have won the poll with ${winners[0].users.length} vote${winCount === 1 ? '' : 's'} each!`; 54 | } 55 | 56 | // build up the text that is on the final poll embed 57 | let choiceText = ''; 58 | let count = 0; 59 | resultMap.forEach((value, key) => { 60 | choiceText += `${emotes[count++]} ${key}: ${value} vote${value === 1 ? '' : 's'}\n`; 61 | }); 62 | 63 | const pollChannel = await bot.channels.fetch(poll.channel); 64 | if (pollChannel.type !== ChannelType.GuildText) throw 'something went wrong fetching the poll\'s channel'; 65 | const pollMsg = await pollChannel.messages.fetch(poll.message); 66 | const owner = await pollMsg.guild.members.fetch(poll.owner); 67 | const pollEmbed = new EmbedBuilder() 68 | .setTitle(poll.question) 69 | .setDescription(`This poll was created by ${owner.displayName} and ended **${mdTimestamp}**`) 70 | .addFields({ name: `Winner${winners.length === 1 ? '' : 's'}`, value: winMessage }) 71 | .addFields({ name: 'Choices', value: choiceText }) 72 | .setColor('Random'); 73 | 74 | pollMsg.edit({ embeds: [pollEmbed], components: [] }); 75 | 76 | 77 | pollMsg.channel.send({ embeds: [new EmbedBuilder() 78 | .setTitle(poll.question) 79 | .setDescription(`${owner}'s poll has ended!`) 80 | .addFields({ name: `Winner${winners.length === 1 ? '' : 's'}`, value: winMessage }) 81 | .addFields({ name: 'Original poll', value: `Click [here](${pollMsg.url}) to see the original poll.` }) 82 | .setColor('Random') 83 | ] }); 84 | 85 | await bot.mongo.collection(DB.POLLS).findOneAndDelete(poll); 86 | }); 87 | } 88 | 89 | async function checkReminders(bot: Client): Promise { 90 | const reminders: Array = await bot.mongo.collection(DB.REMINDERS).find({ 91 | expires: { $lte: new Date() } 92 | }).toArray(); 93 | const pubChan = await bot.channels.fetch(CHANNELS.SAGE) as TextChannel; 94 | 95 | reminders.forEach(reminder => { 96 | const message = `<@${reminder.owner}>, here's the reminder you asked for: **${reminder.content}**`; 97 | 98 | if (reminder.mode === 'public') { 99 | pubChan.send(message); 100 | } else { 101 | bot.users.fetch(reminder.owner).then(user => user.send(message).catch(() => { 102 | pubChan.send(`<@${reminder.owner}>, I tried to send you a DM about your private reminder but it looks like you have 103 | DMs closed. Please enable DMs in the future if you'd like to get private reminders.`); 104 | })); 105 | } 106 | 107 | // copied value by value for several reasons, change it and I take no responsibility for it breaking. 108 | const newReminder: Reminder = { 109 | content: reminder.content, 110 | expires: new Date(reminder.expires), 111 | mode: reminder.mode, 112 | repeat: reminder.repeat, 113 | owner: reminder.owner 114 | }; 115 | 116 | if (reminder.repeat === 'daily') { 117 | newReminder.expires.setDate(reminder.expires.getDate() + 1); 118 | bot.mongo.collection(DB.REMINDERS).findOneAndReplace(reminder, newReminder); 119 | } else if (reminder.repeat === 'weekly') { 120 | newReminder.expires.setDate(reminder.expires.getDate() + 7); 121 | bot.mongo.collection(DB.REMINDERS).findOneAndReplace(reminder, newReminder); 122 | } else { 123 | bot.mongo.collection(DB.REMINDERS).findOneAndDelete(reminder); 124 | } 125 | }); 126 | } 127 | 128 | export default register; 129 | -------------------------------------------------------------------------------- /src/commands/partial visibility question/private.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ChatInputCommandInteraction, GuildChannel, EmbedBuilder, TextChannel, ThreadChannel, ApplicationCommandOptionType, 2 | InteractionResponse, ChannelType } from 'discord.js'; 3 | import { Course } from '@lib/types/Course'; 4 | import { PVQuestion } from '@lib/types/PVQuestion'; 5 | import { SageUser } from '@lib/types/SageUser'; 6 | import { BOT, DB, MAINTAINERS } from '@root/config'; 7 | import { generateErrorEmbed, generateQuestionId } from '@lib/utils/generalUtils'; 8 | import { Command } from '@lib/types/Command'; 9 | 10 | export default class extends Command { 11 | 12 | description = 'Send a question to all course staff privately.'; 13 | extendedHelp = `${BOT.NAME} will automatically determine your course if you are only enrolled in one!`; 14 | options: ApplicationCommandOptionData[] = [ 15 | { 16 | name: 'question', 17 | description: 'What you would like to ask', 18 | type: ApplicationCommandOptionType.String, 19 | required: true 20 | }, 21 | { 22 | name: 'course', 23 | description: 'What course chat would you like to ask your question in?', 24 | type: ApplicationCommandOptionType.String, 25 | required: false 26 | } 27 | ] 28 | 29 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 30 | const user: SageUser = await interaction.client.mongo.collection(DB.USERS).findOne({ discordId: interaction.user.id }); 31 | 32 | if (!user) { 33 | return interaction.reply({ embeds: [generateErrorEmbed(`Something went wrong. Please contact ${MAINTAINERS}`)], ephemeral: true }); 34 | } 35 | 36 | let course: Course; 37 | const question = interaction.options.getString('question'); 38 | const courses: Array = await interaction.client.mongo.collection(DB.COURSES).find().toArray(); 39 | 40 | if (user.courses.length === 1) { 41 | course = courses.find(c => c.name === user.courses[0]); 42 | } else { 43 | const inputtedCourse = courses.find(c => c.name === interaction.options.getString('course')); 44 | if (!inputtedCourse) { 45 | const desc = 'I wasn\'t able to determine your course based off of your enrollment or your input. Please specify the course at the beginning of your question.' + 46 | `\nAvailable courses: \`${courses.map(c => c.name).sort().join('`, `')}\``; 47 | return interaction.reply({ embeds: [generateErrorEmbed(desc)], ephemeral: true }); 48 | } else if (!user.courses.includes(inputtedCourse.name)) { 49 | return interaction.reply({ embeds: [generateErrorEmbed(`You aren't enrolled in this course!\n\nIf you believe this message is in error, please contact the admins.`)], ephemeral: true }); 50 | } 51 | course = inputtedCourse; 52 | } 53 | 54 | if (!question) { 55 | return interaction.reply({ embeds: [generateErrorEmbed('Please provide a question.')], ephemeral: true }); 56 | } 57 | 58 | const bot = interaction.client; 59 | const questionId = await generateQuestionId(interaction); 60 | 61 | const courseGeneral = (await bot.channels.fetch(course.channels.general)) as GuildChannel; 62 | let privThread: ThreadChannel; 63 | if (courseGeneral.type === ChannelType.GuildText) { 64 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 65 | // @ts-ignore: yet another case of if I ignore it, it works *shrug* 66 | privThread = await courseGeneral.threads.create({ 67 | name: `${interaction.user.username}‘s private question (${questionId})`, 68 | autoArchiveDuration: 4320, 69 | reason: `${interaction.user.username} asked a private question`, 70 | type: ChannelType.PrivateThread 71 | }); 72 | } else { 73 | throw `Something went wrong creating ${interaction.user.username}'s private thread. Please contact ${MAINTAINERS} for assistance!'`; 74 | } 75 | 76 | privThread.guild.members.fetch(); 77 | privThread.guild.members.cache.filter(mem => mem.roles.cache.has(course.roles.staff) 78 | ).forEach(staff => { 79 | privThread.members.add(staff); 80 | }); 81 | privThread.members.add(interaction.user.id); 82 | 83 | const embed = new EmbedBuilder() 84 | .setAuthor({ name: `${interaction.user.tag} (${interaction.user.id}) asked Question ${questionId}`, iconURL: interaction.user.avatarURL() }) 85 | .setDescription(`${question}\n\n To respond to this question, reply in this thread: <#${privThread.id}>`); 86 | 87 | const privateChannel = await interaction.client.channels.fetch(course.channels.private) as TextChannel; 88 | await privateChannel.send({ 89 | embeds: [embed] 90 | }); 91 | 92 | embed.setDescription(question); 93 | embed.setTitle(`${interaction.user.username}'s Question`); 94 | embed.setFooter({ text: `When you're done with this question, you can send \`/archive\` to close it` }); 95 | const questionMessage = await privThread.send({ 96 | embeds: [embed] 97 | }); 98 | const messageLink = `https://discord.com/channels/${questionMessage.guild.id}/${questionMessage.channel.id}/${questionMessage.id}`; 99 | 100 | const entry: PVQuestion = { 101 | owner: interaction.user.id, 102 | type: 'private', 103 | questionId, 104 | messageLink 105 | }; 106 | 107 | interaction.client.mongo.collection(DB.PVQ).insertOne(entry); 108 | 109 | return interaction.reply({ content: `Your question has been sent to the staff. Any conversation about it will be had here: <#${privThread.id}>`, ephemeral: true }); 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /onboard/onboard.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | import fs from 'fs'; 3 | import crypto from 'crypto'; 4 | import nodemailer from 'nodemailer'; 5 | import { MongoClient } from 'mongodb'; 6 | import { SageUser } from '@lib/types/SageUser'; 7 | import { Course } from '@lib/types/Course'; 8 | import { BOT, DB, EMAIL, GUILDS, ROLES, FIRST_LEVEL } from '@root/config'; 9 | 10 | const MESSAGE = ` 11 | 12 | 13 | 14 | Discord Verification 15 | 16 | 17 | 18 | 19 |

Welcome!

20 |

You're getting this email because you're part of a class in the UD CIS Department that is using Discord as its primary means of communication.

21 |

For further information about the UD CIS Discord, see this page.

22 |

If you don't have a Discord account already, click here to sign up for one.

23 |

24 | Once you are ready, click here to join the server and get yourself verified. 25 |

Once you're on the server, follow the instructions given to you in the channel called "getting-verified". Make sure you have your hash code (given below) ready! 26 |

27 | 28 |

Further, usage of this Discord server means that you agree to these rules. Please take a moment to review them.

29 | 30 |

Your hash code is: $hash

31 |


We hope to see you on the server soon!
- The Discord Admin Team

32 | 33 | 34 | 35 | 36 | `; 37 | 38 | const mailer = nodemailer.createTransport({ 39 | host: 'mail.udel.edu', 40 | port: 25 41 | }); 42 | 43 | async function main() { 44 | const client = await MongoClient.connect(DB.CONNECTION, { useUnifiedTopology: true }); 45 | const db = client.db(BOT.NAME).collection(DB.USERS); 46 | const args = process.argv.slice(2); 47 | let emails: Array; 48 | let course: Course; 49 | 50 | if (args.length > 0) { 51 | if (args[0].toLowerCase() === 'staff') { 52 | emails = args; 53 | } else { 54 | emails = ['STUDENT', ...args]; 55 | } 56 | } else { 57 | const data = fs.readFileSync('./resources/emails.csv'); 58 | emails = data.toString().split('\n').map(email => email.trim()); 59 | let courseId: string; 60 | [emails[0], courseId] = emails[0].split(',').map(str => str.trim()); 61 | course = await client.db(BOT.NAME).collection(DB.COURSES).findOne({ name: courseId }); 62 | } 63 | 64 | let isStaff: boolean; 65 | 66 | if (emails[0].toLowerCase() === 'staff') { 67 | isStaff = true; 68 | } else if (emails[0].toLowerCase() === 'student') { 69 | isStaff = false; 70 | } else { 71 | console.error('First value must be STAFF or STUDENT'); 72 | process.exit(); 73 | } 74 | 75 | emails.shift(); 76 | console.log(`${'email'.padEnd(18)} | ${'staff'.padEnd(5)} | hash 77 | -------------------------------------------------------------------------`); 78 | for (const email of emails) { 79 | if (email === '') continue; 80 | if (!email.endsWith('@udel.edu')) { 81 | console.error(`${email} is not a valid udel email.`); 82 | continue; 83 | } 84 | 85 | const hash = crypto.createHash('sha256').update(email).digest('base64').toString(); 86 | 87 | const entry: SageUser = await db.findOne({ email: email, hash: hash }); 88 | 89 | const newUser: SageUser = { 90 | email: email, 91 | hash: hash, 92 | isStaff: isStaff, 93 | discordId: '', 94 | count: 0, 95 | levelExp: FIRST_LEVEL, 96 | curExp: FIRST_LEVEL, 97 | level: 1, 98 | levelPings: true, 99 | isVerified: false, 100 | pii: false, 101 | roles: [], 102 | courses: [] 103 | }; 104 | 105 | if (course) { 106 | if (isStaff) { 107 | newUser.roles.push(course.roles.staff); 108 | } else { 109 | newUser.roles.push(course.roles.student); 110 | newUser.courses.push(course.name); 111 | } 112 | } 113 | 114 | if (isStaff) { 115 | newUser.roles.push(ROLES.STAFF); 116 | } 117 | newUser.roles.push(ROLES.LEVEL_ONE); 118 | 119 | if (entry) { // User already on-boarded 120 | if (isStaff && entry.isVerified) { // Make staff is not already 121 | await db.updateOne(entry, { $set: { isStaff: true } }); 122 | console.log(`${email} was already in verified. Add staff roles manually. Discord ID ${entry.discordId}`); 123 | } else if (isStaff && !entry.isVerified) { 124 | await db.updateOne(entry, { $set: { ...newUser } }); 125 | } 126 | continue; 127 | } 128 | 129 | await db.insertOne(newUser); 130 | 131 | console.log(`${email.padEnd(18)} | ${isStaff.toString().padEnd(5)} | ${hash}`); 132 | 133 | sendEmail(email, hash); 134 | await sleep(1100); 135 | } 136 | 137 | client.close(); 138 | } 139 | 140 | 141 | async function sendEmail(email: string, hash: string): Promise { 142 | mailer.sendMail({ 143 | from: EMAIL.SENDER, 144 | replyTo: EMAIL.REPLY_TO, 145 | to: email, 146 | subject: 'Welcome to the UD CIS Discord!', 147 | html: MESSAGE.replace('$hash', hash).replace('$invCode', GUILDS.GATEWAY_INVITE) 148 | }); 149 | } 150 | 151 | function sleep(ms: number) { 152 | return new Promise((resolve) => { 153 | setTimeout(resolve, ms); 154 | }); 155 | } 156 | 157 | main(); 158 | -------------------------------------------------------------------------------- /src/commands/info/help.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionData, ChatInputCommandInteraction, EmbedField, EmbedBuilder, GuildMember, ApplicationCommandOptionType, 2 | InteractionResponse } from 'discord.js'; 3 | import { getCommand } from '@root/src/lib/utils/generalUtils'; 4 | import { BOT, PREFIX } from '@root/config'; 5 | import { Command } from '@lib/types/Command'; 6 | 7 | export default class extends Command { 8 | 9 | description = `Provides info about all ${BOT.NAME} commands`; 10 | extendedHelp = 'If given no arguments, a list of all commands you have access to will be sent to your DMs'; 11 | 12 | options: ApplicationCommandOptionData[] = [ 13 | { 14 | name: 'cmd', 15 | description: 'command you would like to know more about', 16 | type: ApplicationCommandOptionType.String, 17 | required: false 18 | } 19 | ] 20 | 21 | async run(interaction: ChatInputCommandInteraction): Promise | void> { 22 | const cmd = interaction.options.getString('cmd'); 23 | const { commands } = interaction.client; 24 | const website = 'https://ud-cis-discord.github.io/pages/commands'; 25 | 26 | if (cmd) { 27 | const command = getCommand(interaction.client, cmd); 28 | if (!command) { 29 | return interaction.reply({ content: `**${cmd}** is not a valid command.`, ephemeral: true }); 30 | } 31 | 32 | const fields: Array = []; 33 | 34 | if (command.extendedHelp) { 35 | fields.push({ 36 | name: 'Extended Help', 37 | value: command.extendedHelp, 38 | inline: false 39 | }); 40 | } 41 | 42 | if (command.options) { 43 | let val = ''; 44 | for (const param of command.options) { 45 | let reqValue = 'required'; 46 | if ('required' in param) { // see Note 1 below 47 | reqValue = param.required ? 'required' : 'optional'; 48 | } 49 | val += `**${param.name}** (${reqValue}) : ${param.description}\n`; 50 | } 51 | 52 | fields.push({ 53 | name: 'Parameters', 54 | value: val, 55 | inline: false 56 | }); 57 | } 58 | 59 | /* 60 | Note 1 61 | Param, according to TS, can be either an ApplicationCommandOptionData or ApplicationCommandSubGroupData object. Here, it's obviously 62 | the former. However, the latter does not have a 'required' property. This has been an issue since at least discord.js v13.6. 63 | 64 | TS assumes the worst and thinks 'param' is the latter. This checks if there is a 'required' property on the object anyways (which there always will be). 65 | This was mainly just to calm the compiler down. 66 | */ 67 | 68 | fields.push({ 69 | name: 'More commands', 70 | value: `[Visit our website!](${website})`, 71 | inline: false 72 | }); 73 | 74 | const embed = new EmbedBuilder() 75 | .setTitle(command.name) 76 | .setDescription(command.description ? command.description : '') 77 | .addFields(fields) 78 | .setThumbnail(interaction.client.user.avatarURL()) 79 | .setTimestamp(Date.now()) 80 | .setColor('Random'); 81 | 82 | return interaction.reply({ embeds: [embed] }); 83 | } else { 84 | // if no command given 85 | let helpStr = `You can do \`/help \` to get more information about any command, or you can visit our website here:\n<${website}>\n`; 86 | const categories: Array = []; 87 | commands.forEach(command => { 88 | if (!categories.includes(command.category)) categories.push(command.category); 89 | }); 90 | 91 | const member = interaction.member as GuildMember; 92 | const staff = interaction.guild.roles.cache.find(r => r.name === 'Staff'); 93 | const admin = interaction.guild.roles.cache.find(r => r.name === 'admin'); 94 | categories.forEach(cat => { 95 | let useableCmds = commands.filter(command => 96 | command.category === cat 97 | && command.enabled !== false); 98 | // check if user isn't admin and filter accordingly 99 | if (!member.roles.cache.has(admin.id)) { 100 | useableCmds = useableCmds.filter(command => command.category !== 'admin'); 101 | } 102 | // check if user isn't staff and filter accordingly 103 | if (!member.roles.cache.has(staff.id)) { 104 | useableCmds = useableCmds.filter(command => command.category !== 'staff' && command.category !== 'admin'); 105 | } 106 | const categoryName = cat === 'commands' ? 'General' : `${cat[0].toUpperCase()}${cat.slice(1)}`; 107 | if (useableCmds.size > 0) { 108 | helpStr += `\n**${categoryName} Commands**\n`; 109 | useableCmds.forEach(command => { 110 | helpStr += `\`${PREFIX}${command.name}\` ⇒ ${command.description ? command.description : 'No description provided'}\n`; 111 | }); 112 | } 113 | }); 114 | 115 | const splitStr = helpStr.split(/\n\s*\n/).map(line => line === '' ? '\n' : line); // split string on blank lines, effectively one message for each category 116 | 117 | let notified = false; 118 | splitStr.forEach((helpMsg) => { 119 | const embed = new EmbedBuilder() 120 | .setTitle(`-- Commands --`) 121 | .setDescription(helpMsg) 122 | .setColor('Random'); 123 | interaction.user.send({ embeds: [embed] }) 124 | .then(() => { 125 | if (!notified) { 126 | interaction.reply({ content: 'I\'ve sent all commands to your DMs.', ephemeral: true }); 127 | notified = true; 128 | } 129 | }) 130 | .catch(() => { 131 | if (!notified) { 132 | interaction.reply({ content: 'I couldn\'t send you a DM. Please enable DMs and try again.', ephemeral: true }); 133 | notified = true; 134 | } 135 | }); 136 | }); 137 | } 138 | } 139 | 140 | } 141 | --------------------------------------------------------------------------------