├── .dockerignore ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .releaserc.json ├── .snyk ├── .vscode └── settings.json ├── .yarn └── releases │ └── yarn-3.2.0.cjs ├── .yarnrc.yml ├── Dockerfile ├── README.md ├── deploy.sh ├── docker-compose.yml ├── docs └── influx │ └── setup.md ├── jest.config.js ├── license.md ├── migrations ├── 20210216193600_init │ └── migration.sql ├── 20210216193736_user_balance_float │ └── migration.sql ├── 20210910101532_fkey_updates │ └── migration.sql └── migration_lock.toml ├── package.json ├── renovate.json ├── schema.prisma ├── src ├── commands │ ├── admin │ │ ├── load.ts │ │ ├── reload.ts │ │ └── unload.ts │ ├── developer │ │ ├── base-64.ts │ │ ├── color.ts │ │ ├── dev-help.ts │ │ ├── generate-token.ts │ │ ├── hello-js.ts │ │ └── shard-sankey.ts │ ├── economy │ │ ├── balance.ts │ │ ├── convert.ts │ │ ├── daily.ts │ │ ├── dice-game.test.ts │ │ ├── dice-game.ts │ │ ├── leaderboard.ts │ │ ├── rates.ts │ │ └── transfer.ts │ ├── fun │ │ ├── cat.ts │ │ ├── clap.ts │ │ ├── dog.ts │ │ ├── roll.ts │ │ ├── say.ts │ │ ├── spoiler.ts │ │ └── xkcd.ts │ ├── minecraft │ │ ├── avatar.ts │ │ ├── body.ts │ │ ├── cape.ts │ │ ├── head.ts │ │ └── skin.ts │ ├── moderation │ │ ├── ban.ts │ │ ├── kick.ts │ │ ├── notifications.ts │ │ ├── prune.ts │ │ ├── toggle-nsfw.ts │ │ └── unban.ts │ ├── selfroles │ │ ├── add-self-role.ts │ │ ├── delete-self-role.ts │ │ ├── get-self-role.ts │ │ ├── list-self-roles.ts │ │ ├── remove-self-role.ts │ │ └── self-role.ts │ ├── single │ │ ├── invite.ts │ │ ├── nitro.ts │ │ ├── support.ts │ │ └── vote.ts │ ├── tags │ │ ├── create-tag.ts │ │ ├── delete-tag.ts │ │ ├── edit-tag.ts │ │ ├── get-tag.ts │ │ ├── list-tags.ts │ │ └── tags.ts │ └── utility │ │ ├── age.ts │ │ ├── aqi.ts │ │ ├── average.ts │ │ ├── blacklist.ts │ │ ├── bot-info.ts │ │ ├── choose.ts │ │ ├── db-ping.ts │ │ ├── emoji.ts │ │ ├── eval.ts │ │ ├── help.ts │ │ ├── ping.ts │ │ ├── prefix.ts │ │ ├── roman.test.ts │ │ ├── roman.ts │ │ └── stats.ts ├── config.ts ├── constants.ts ├── docs.ts ├── docs │ ├── generate.test.ts │ └── generate.ts ├── index.ts ├── inhibitors │ └── blacklist.ts ├── listeners │ ├── client │ │ ├── debug.ts │ │ ├── error.ts │ │ ├── guildBanAdd.ts │ │ ├── guildBanRemove.ts │ │ ├── guildCreate.ts │ │ ├── guildDelete.ts │ │ ├── guildMemberAdd.ts │ │ ├── guildMemberRemove.ts │ │ ├── guildMemberUpdate.ts │ │ ├── message.ts │ │ ├── messageDelete.ts │ │ ├── messageUpdate.ts │ │ ├── ready.ts │ │ ├── userUpdate.ts │ │ ├── voiceStateUpdate.ts │ │ └── warn.ts │ └── commandHandler │ │ ├── commandStarted.ts │ │ ├── error.ts │ │ └── messageInvalid.ts ├── lite.ts ├── logging │ ├── logger.test.ts │ └── logger.ts ├── structures │ ├── DiceClient.ts │ ├── DiceCluster.ts │ ├── DiceCommand.ts │ ├── DiceInhibitor.ts │ ├── DiceListener.ts │ ├── DiceUser.ts │ ├── DiscordInfluxUtil.ts │ ├── GuildSettingsCache.ts │ ├── NoFlyList.ts │ ├── SingleResponseCommand.test.ts │ ├── SingleResponseCommand.ts │ └── TopGgVoteWebhookHandler.ts ├── types │ ├── anyUser.ts │ ├── crafatar.d.ts │ └── minecraftUser.ts └── util │ ├── commandHandler.test.ts │ ├── commandHandler.ts │ ├── crafatar.ts │ ├── format.ts │ ├── meili-search.ts │ ├── notifications.ts │ ├── permissions.ts │ ├── player-db.ts │ ├── register-sharder-events.ts │ ├── self-roles.ts │ └── shard.ts ├── telegraf.conf ├── tsconfig.json ├── types ├── airnow.d.ts ├── discord.d.ts ├── ghActions.d.ts ├── google.d.ts ├── node.d.ts └── opaque.d.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .vscode 3 | migrations 4 | node_modules 5 | renovate.json 6 | tsc_output 7 | .editorconfig 8 | *.env 9 | .gitignore 10 | .prettierrc 11 | googleCloudServiceAccount.json 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{y,ya}ml] 12 | indent_style = space 13 | 14 | [*.sql] 15 | indent_style = space 16 | indent_size = 4 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: jonahsnider 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "endOfLine": "lf", 5 | "jsxBracketSameLine": false, 6 | "jsxSingleQuote": true, 7 | "printWidth": 160, 8 | "semi": true, 9 | "singleQuote": true, 10 | "tabWidth": 2, 11 | "trailingComma": "none", 12 | "useTabs": true 13 | } 14 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "@semantic-release/commit-analyzer", 5 | { 6 | "preset": "angular" 7 | } 8 | ], 9 | "@semantic-release/release-notes-generator", 10 | "@semantic-release/github", 11 | ["@semantic-release/exec", {"prepareCmd": "docker pull dicediscord/bot"}], 12 | [ 13 | "@semantic-release/exec", 14 | { 15 | "prepareCmd": "docker build -t dicediscord/bot ." 16 | } 17 | ], 18 | [ 19 | "semantic-release-docker", 20 | { 21 | "name": "dicediscord/bot" 22 | } 23 | ], 24 | "@eclass/semantic-release-sentry-releases" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.14.1 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | SNYK-JS-LODASH-567746: 7 | - sqreen > sq-ecosystem > lab > eslint > lodash: 8 | patched: '2020-06-09T14:53:50.683Z' 9 | - sqreen > sq-ecosystem > lab > eslint > inquirer > lodash: 10 | patched: '2020-06-09T14:53:50.683Z' 11 | - sqreen > sq-ecosystem > lab > eslint > table > lodash: 12 | patched: '2020-06-09T14:53:50.683Z' 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": false 4 | }, 5 | "files.watcherExclude": { 6 | "**/.git/objects/**": true, 7 | "**/.git/subtree-cache/**": true, 8 | "**/.hg/store/**": true 9 | }, 10 | "typescript.tsdk": "node_modules\\typescript\\lib" 11 | } 12 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.2.0.cjs 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### INSTALLER STAGE ### 2 | FROM node:16.14.2-alpine AS installer 3 | 4 | # Create app directory 5 | WORKDIR /usr/src/installer 6 | 7 | ENV NODE_ENV=production 8 | 9 | # Prisma needs to have a schema present because of the postinstall script that generates the SDK 10 | COPY package.json yarn.lock .yarnrc.yml schema.prisma .snyk ./ 11 | COPY .yarn ./.yarn 12 | 13 | # Install build tools for native dependencies 14 | # hadolint ignore=DL3018 15 | RUN apk add --no-cache make gcc g++ python3 16 | RUN yarn install --immutable 17 | 18 | ### BUILDER STAGE ### 19 | FROM node:16.14.2-alpine AS builder 20 | 21 | # Create app directory 22 | WORKDIR /usr/src/builder 23 | 24 | ENV NODE_ENV=production 25 | 26 | # Install dependencies and copy Prisma schema 27 | COPY package.json yarn.lock .yarnrc.yml schema.prisma .snyk ./ 28 | 29 | # Copy dependencies that were installed before 30 | COPY --from=installer /usr/src/installer/node_modules node_modules 31 | # Copy build configurations 32 | COPY tsconfig.json ./ 33 | 34 | # Copy types 35 | COPY types ./types 36 | 37 | # Copy Yarn release 38 | COPY .yarn/releases ./.yarn/releases 39 | 40 | # Copy source 41 | COPY src ./src 42 | 43 | # Build the project 44 | RUN yarn run build 45 | 46 | ### BOT STAGE ### 47 | FROM node:16.14.2-alpine AS bot 48 | 49 | LABEL maintainer 'Jonah Snider (jonah.pw)' 50 | 51 | WORKDIR /usr/src/dice 52 | 53 | ENV NODE_ENV=production 54 | 55 | # Top.gg webhook port 56 | EXPOSE 5000 57 | 58 | # Install dependencies 59 | COPY --from=installer /usr/src/installer/node_modules ./node_modules 60 | COPY --from=installer /usr/src/installer/yarn.lock ./yarn.lock 61 | COPY --from=installer /usr/src/installer/.yarn/releases ./.yarn/releases 62 | COPY --from=installer /usr/src/installer/.yarnrc.yml ./.yarnrc.yml 63 | COPY --from=installer /usr/src/installer/schema.prisma ./schema.prisma 64 | 65 | # Copy other required files 66 | COPY package.json package.json 67 | 68 | # Copy compiled TypeScript 69 | COPY --from=builder /usr/src/builder/tsc_output ./tsc_output 70 | 71 | ENTRYPOINT ["yarn", "run", "start"] 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dice 2 | 3 | [![Build Status](https://github.com/dice-discord/bot/workflows/CI/badge.svg)](https://github.com/dice-discord/bot/actions) 4 | [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) 5 | [![codecov](https://codecov.io/gh/dice-discord/bot/branch/master/graph/badge.svg)](https://codecov.io/gh/dice-discord/bot) 6 | 7 | A total rewrite of the [Dice Discord bot](https://github.com/dice-discord/bot) in TypeScript using the [Akairo framework](https://discord-akairo.github.io/#/). 8 | 9 | ## Developers 10 | 11 | ### File naming scheme 12 | 13 | #### Commands 14 | 15 | Commands should be under their category's subfolder (ex. `commands/admin` for the `admin` category). 16 | 17 | Command filenames should exactly match their ID. 18 | 19 | #### Listeners 20 | 21 | Commands should be under their category's subfolder (ex. `listeners/client` for the `client` category). 22 | The category should exactly match their emitter name. 23 | 24 | Listener filenames should exactly match their event name (ex. `commandStarted.ts` for the `commandStarted` event). 25 | 26 | #### Inhibitors 27 | 28 | Inhibitor filenames should exactly match their ID. 29 | 30 | ### Prequisites 31 | 32 | This project uses [Node.js](https://nodejs.org) 12 to run. 33 | 34 | This project uses [Yarn](https://yarnpkg.com) to install dependencies, although you can use another package manager like [npm](https://www.npmjs.com) or [pnpm](https://pnpm.js.org). 35 | 36 | ```sh 37 | yarn install 38 | # or `npm install` 39 | # or `pnpm install` 40 | ``` 41 | 42 | ### Building 43 | 44 | Run the `build` script to compile the TypeScript into the `tsc_output` folder. 45 | This will compile the `src` and the `test` directory, so be careful not to upload the whole folder as a published package. 46 | 47 | ### Style 48 | 49 | This project uses [Prettier](https://prettier.io) and [XO](https://github.com/xojs/xo). 50 | 51 | You can run Prettier in the project with this command: 52 | 53 | ```sh 54 | yarn run style 55 | ``` 56 | 57 | You can run XO with this command: 58 | 59 | ```sh 60 | yarn run lint 61 | ``` 62 | 63 | Note that XO will also error if you have TypeScript errors, not just if your formatting is incorrect. 64 | 65 | ### Linting 66 | 67 | This project uses [XO](https://github.com/xojs/xo) (which uses [ESLint](https://eslint.org) and some plugins internally) to perform static analysis on the TypeScript. 68 | It reports things like unused variables or not following code conventions. 69 | 70 | ```sh 71 | yarn run lint 72 | ``` 73 | 74 | Note that XO will also error if you have incorrect formatting, not just if your TypeScript code has errors. 75 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | docker-compose pull 2 | docker-compose up -d 3 | -------------------------------------------------------------------------------- /docs/influx/setup.md: -------------------------------------------------------------------------------- 1 | # InfluxDB setup 2 | 3 | These instructions are written for a fresh install of InfluxDB 1.8. 4 | 5 | Any of the password fields should have their values copied over to the respective `.env` files. 6 | 7 | - [`bot.env`](../../bot.env) `INFLUXDB_DSN` for the bot (user `dice`) 8 | - [`telegraf.env`](../../telegraf.env) `INFLUXDB_PASSWORD` for Telegraf (user `telegraf`) 9 | 10 | ```sql 11 | CREATE DATABASE dice 12 | 13 | USE dice 14 | 15 | -- This is used by the bot to write metrics 16 | CREATE USER dice WITH PASSWORD 'password' 17 | 18 | GRANT WRITE ON 'dice' TO 'dice' 19 | 20 | -- This is used by Grafana to read data and display it 21 | CREATE USER grafana WITH PASSWORD 'grafana_password' 22 | 23 | GRANT READ ON 'dice' TO 'grafana' 24 | 25 | CREATE DATABASE telegraf 26 | -- This is used by Telegraf to record system metrics 27 | CREATE USER telegraf WITH PASSWORD 'telegraf_password' 28 | GRANT WRITE ON 'telegraf' TO 'telegraf' 29 | ``` 30 | -------------------------------------------------------------------------------- /migrations/20210216193600_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "NotificationType" AS ENUM ('BAN_UNBAN', 'GUILD_MEMBER_JOIN_LEAVE', 'VOICE_CHANNEL', 'GUILD_MEMBER_UPDATE', 'USER_ACCOUNT_BIRTHDAY', 'MESSAGE_DELETE', 'MESSAGE_UPDATE'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "User" ( 6 | "balance" DECIMAL(65,30) NOT NULL DEFAULT 1000, 7 | "blacklistReason" TEXT, 8 | "dailyUsed" TIMESTAMP(3), 9 | "id" TEXT NOT NULL, 10 | 11 | PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateTable 15 | CREATE TABLE "Guild" ( 16 | "id" TEXT NOT NULL, 17 | "prefix" TEXT, 18 | "selfRoles" TEXT[], 19 | 20 | PRIMARY KEY ("id") 21 | ); 22 | 23 | -- CreateTable 24 | CREATE TABLE "Tag" ( 25 | "author" TEXT NOT NULL, 26 | "content" TEXT NOT NULL, 27 | "guildId" TEXT NOT NULL, 28 | "id" TEXT NOT NULL, 29 | 30 | PRIMARY KEY ("id","guildId") 31 | ); 32 | 33 | -- CreateTable 34 | CREATE TABLE "NotificationSettings" ( 35 | "channels" TEXT[], 36 | "guildId" TEXT NOT NULL, 37 | "id" "NotificationType" NOT NULL, 38 | 39 | PRIMARY KEY ("id","guildId") 40 | ); 41 | 42 | -- AddForeignKey 43 | ALTER TABLE "Tag" ADD FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; 44 | 45 | -- AddForeignKey 46 | ALTER TABLE "NotificationSettings" ADD FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; 47 | -------------------------------------------------------------------------------- /migrations/20210216193736_user_balance_float/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to alter the column `balance` on the `User` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `DoublePrecision`. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "User" ALTER COLUMN "balance" SET DATA TYPE DOUBLE PRECISION; 9 | -------------------------------------------------------------------------------- /migrations/20210910101532_fkey_updates/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "NotificationSettings" DROP CONSTRAINT "NotificationSettings_guildId_fkey"; 3 | 4 | -- DropForeignKey 5 | ALTER TABLE "Tag" DROP CONSTRAINT "Tag_guildId_fkey"; 6 | 7 | -- AddForeignKey 8 | ALTER TABLE "Tag" ADD CONSTRAINT "Tag_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE "NotificationSettings" ADD CONSTRAINT "NotificationSettings_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 12 | -------------------------------------------------------------------------------- /migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dice", 3 | "version": "4.21.0", 4 | "private": true, 5 | "bugs": { 6 | "url": "https://github.com/dice-discord/bot/issues" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/dice-discord/bot.git" 11 | }, 12 | "license": "Apache-2.0", 13 | "author": { 14 | "name": "Jonah Snider", 15 | "email": "jonah@jonahsnider.com", 16 | "url": "https://jonahsnider.com" 17 | }, 18 | "main": "./tsc_output/src/index.js", 19 | "scripts": { 20 | "prebuild": "rimraf tsc_output/**.js", 21 | "build": "tsc", 22 | "deploy": "semantic-release", 23 | "predocs": "rimraf tsc_output/command_docs", 24 | "docs": "node tsc_output/src/docs", 25 | "postinstall": "prisma generate", 26 | "lint": "xo", 27 | "start": "node tsc_output/src/index.js", 28 | "style": "prettier --check .", 29 | "test": "jest" 30 | }, 31 | "xo": { 32 | "extends": [ 33 | "plugin:jest/recommended" 34 | ], 35 | "plugins": [ 36 | "jest" 37 | ], 38 | "prettier": true, 39 | "rules": { 40 | "camelcase": [ 41 | "error", 42 | { 43 | "allow": [ 44 | "id_guildId" 45 | ] 46 | } 47 | ], 48 | "node/prefer-global/url": "off", 49 | "node/prefer-global/url-search-params": "off", 50 | "unicorn/filename-case": [ 51 | "error", 52 | { 53 | "cases": { 54 | "camelCase": true, 55 | "kebabCase": true, 56 | "pascalCase": true 57 | } 58 | } 59 | ] 60 | } 61 | }, 62 | "dependencies": { 63 | "@discoin/scambio": "2.2.0", 64 | "@google-cloud/debug-agent": "5.2.8", 65 | "@google-cloud/profiler": "4.1.7", 66 | "@jonahsnider/util": "9.0.0", 67 | "@prisma/client": "3.12.0", 68 | "@sentry/node": "6.19.6", 69 | "bufferutil": "4.0.6", 70 | "convert": "4.5.0", 71 | "cron": "1.8.2", 72 | "date-fns": "2.28.0", 73 | "delay": "5.0.0", 74 | "discord-akairo": "8.1.0", 75 | "discord-md-tags": "1.0.0", 76 | "discord.js": "12.5.3", 77 | "dotenv": "16.0.0", 78 | "escape-string-regexp": "4.0.0", 79 | "got": "11.8.3", 80 | "influx": "5.9.3", 81 | "kurasuta": "2.2.3", 82 | "meilisearch": "0.23.0", 83 | "micri": "4.5.0", 84 | "parse-color": "1.0.0", 85 | "pretty-ms": "7.0.1", 86 | "raw-body": "2.5.1", 87 | "roll": "1.3.1", 88 | "semantic-release-docker": "2.2.0", 89 | "signale": "1.4.0", 90 | "sqreen": "1.64.2", 91 | "utf-8-validate": "5.0.9", 92 | "zlib-sync": "0.1.7" 93 | }, 94 | "devDependencies": { 95 | "@eclass/semantic-release-sentry-releases": "3.0.0", 96 | "@semantic-release/exec": "6.0.3", 97 | "@tsconfig/node14": "1.0.1", 98 | "@types/cron": "1.7.3", 99 | "@types/jest": "27.4.1", 100 | "@types/node": "16.11.27", 101 | "@types/parse-color": "1.0.1", 102 | "@types/roll": "1.2.0", 103 | "@types/signale": "1.4.4", 104 | "@types/ws": "8.5.3", 105 | "eslint-plugin-jest": "26.1.4", 106 | "eslint-plugin-prettier": "3.4.1", 107 | "jest": "27.5.1", 108 | "nyc": "15.1.0", 109 | "prettier": "2.3.2", 110 | "prisma": "3.12.0", 111 | "rimraf": "3.0.2", 112 | "semantic-release": "19.0.2", 113 | "source-map-support": "0.5.21", 114 | "ts-jest": "27.1.4", 115 | "ts-node": "10.7.0", 116 | "type-fest": "2.12.2", 117 | "typescript": "4.2.4", 118 | "xo": "0.39.1" 119 | }, 120 | "engines": { 121 | "node": "14 || 15 || 16" 122 | }, 123 | "packageManager": "yarn@3.2.0" 124 | } 125 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["@jonahsnider"] 4 | } 5 | -------------------------------------------------------------------------------- /schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource postgres { 6 | provider = "postgresql" 7 | url = env("POSTGRES_URI") 8 | } 9 | 10 | model User { 11 | balance Float @default(1000) 12 | blacklistReason String? 13 | dailyUsed DateTime? 14 | id String @id 15 | } 16 | 17 | model Guild { 18 | id String @id 19 | prefix String? 20 | selfRoles String[] 21 | notifications NotificationSettings[] 22 | tags Tag[] 23 | } 24 | 25 | model Tag { 26 | author String 27 | content String 28 | guildId String 29 | id String 30 | guild Guild @relation(fields: [guildId], references: [id]) 31 | 32 | @@id([id, guildId]) 33 | } 34 | 35 | model NotificationSettings { 36 | channels String[] 37 | guildId String 38 | id NotificationType 39 | guild Guild @relation(fields: [guildId], references: [id]) 40 | 41 | @@id([id, guildId]) 42 | } 43 | 44 | enum NotificationType { 45 | BAN_UNBAN 46 | GUILD_MEMBER_JOIN_LEAVE 47 | VOICE_CHANNEL 48 | GUILD_MEMBER_UPDATE 49 | USER_ACCOUNT_BIRTHDAY 50 | MESSAGE_DELETE 51 | MESSAGE_UPDATE 52 | } 53 | -------------------------------------------------------------------------------- /src/commands/admin/load.ts: -------------------------------------------------------------------------------- 1 | import {Stopwatch} from '@jonahsnider/util'; 2 | import {AkairoHandler} from 'discord-akairo'; 3 | import {code, codeblock} from 'discord-md-tags'; 4 | import {Message} from 'discord.js'; 5 | import path from 'path'; 6 | import {runningInProduction} from '../../config'; 7 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 8 | import ms = require('pretty-ms'); 9 | 10 | type ModuleType = 'commands' | 'inhibitors' | 'listeners'; 11 | 12 | export default class LoadCommand extends DiceCommand { 13 | constructor() { 14 | super('load', { 15 | aliases: [ 16 | 'load-module', 17 | 'load-command', 18 | 'load-listener', 19 | 'load-inhibitor', 20 | 'register-module', 21 | 'register-command', 22 | 'register-listener', 23 | 'register-inhibitor' 24 | ], 25 | description: {content: 'Load a module (command, listener, or inhibitor).', usage: ' [type=]', examples: ['ping', 'blacklist']}, 26 | category: DiceCommandCategories.Admin, 27 | ownerOnly: true, 28 | args: [ 29 | { 30 | id: 'module', 31 | type: AkairoArgumentType.String, 32 | match: 'rest', 33 | prompt: {start: 'What module do you want to load?'} 34 | }, 35 | { 36 | id: 'type', 37 | type: [ 38 | ['commands', 'command', 'c', 'cmd'], 39 | ['inhibitors', 'inhibitor', 'i'], 40 | ['listeners', 'listener', 'l'] 41 | ], 42 | flag: ['type='], 43 | default: 'commands', 44 | match: 'option', 45 | prompt: {optional: true, start: 'What type of module do you want to load?', ended: 'Invalid type selected'} 46 | } 47 | ] 48 | }); 49 | } 50 | 51 | public async exec(message: Message, args: {module: string; type: ModuleType}): Promise { 52 | const handlers: Record = { 53 | commands: this.handler, 54 | listeners: this.client.listenerHandler, 55 | inhibitors: this.client.inhibitorHandler 56 | }; 57 | 58 | const type = args.type.slice(0, -1); 59 | 60 | const filePath = `${path.join(__dirname, '..', '..', args.type, args.module)}.${runningInProduction ? 'j' : 't'}s`; 61 | 62 | try { 63 | const stopwatch = new Stopwatch(); 64 | 65 | stopwatch.start(); 66 | const loaded = handlers[args.type].load(filePath); 67 | 68 | const duration = Number(stopwatch.end()); 69 | 70 | return await message.util?.send(`Loaded ${code`${loaded.id}`} in ${ms(duration)}`); 71 | } catch (error: unknown) { 72 | // eslint-disable-next-line no-return-await 73 | return await message.util?.send([`Failed to load ${type}`, 'Error:', codeblock`${error}`].join('\n')); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/commands/admin/reload.ts: -------------------------------------------------------------------------------- 1 | import {Stopwatch} from '@jonahsnider/util'; 2 | import {AkairoModule, Argument, Command, Inhibitor, Listener} from 'discord-akairo'; 3 | import {codeblock} from 'discord-md-tags'; 4 | import {Message} from 'discord.js'; 5 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 6 | import {DiceListener} from '../../structures/DiceListener'; 7 | import ms = require('pretty-ms'); 8 | 9 | type DiceModule = AkairoModule | DiceCommand | Inhibitor | DiceListener; 10 | 11 | export default class ReloadCommand extends DiceCommand { 12 | constructor() { 13 | super('reload', { 14 | aliases: ['reload-module', 'reload-command', 'reload-listener', 'reload-inhibitor'], 15 | description: {content: 'Reload a module (command, listener, or inhibitor).', usage: '', examples: ['ping']}, 16 | category: DiceCommandCategories.Admin, 17 | ownerOnly: true, 18 | args: [ 19 | { 20 | id: 'module', 21 | type: Argument.union( 22 | // Commands have their ID as an alias, so no need to add the command type in here 23 | AkairoArgumentType.CommandAlias, 24 | AkairoArgumentType.Listener, 25 | AkairoArgumentType.Inhibitor 26 | ), 27 | match: 'content', 28 | prompt: {start: 'Which module do you want to reload?', retry: 'Invalid module provided, try again'} 29 | } 30 | ] 31 | }); 32 | } 33 | 34 | public async exec(message: Message, args: {module: DiceModule}): Promise { 35 | const stopwatch = new Stopwatch(); 36 | 37 | let reloaded: DiceModule; 38 | 39 | stopwatch.start(); 40 | try { 41 | reloaded = args.module.reload(); 42 | } catch (error: unknown) { 43 | // eslint-disable-next-line no-return-await 44 | return await message.util?.send(['An error occurred while reloading', codeblock`${error}`].join('\n')); 45 | } 46 | 47 | const elapsed = Number(stopwatch.end()); 48 | 49 | let type = 'module'; 50 | 51 | if (reloaded instanceof Command) { 52 | type = 'command'; 53 | } else if (reloaded instanceof Listener) { 54 | type = 'listener'; 55 | } else if (reloaded instanceof Inhibitor) { 56 | type = 'inhibitor'; 57 | } 58 | 59 | return message.util?.send(`Reloaded ${type} \`${reloaded.categoryID}:${reloaded.id}\` in ${ms(elapsed)}`); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/commands/admin/unload.ts: -------------------------------------------------------------------------------- 1 | import {Stopwatch} from '@jonahsnider/util'; 2 | import {Argument, Command, Inhibitor, Listener} from 'discord-akairo'; 3 | import {Message} from 'discord.js'; 4 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {DiceListener} from '../../structures/DiceListener'; 6 | import ms = require('pretty-ms'); 7 | 8 | export default class UnloadCommand extends DiceCommand { 9 | constructor() { 10 | super('unload', { 11 | aliases: [ 12 | 'unload-module', 13 | 'unload-command', 14 | 'unload-listener', 15 | 'unload-inhibitor', 16 | 'remove-module', 17 | 'remove-command', 18 | 'remove-listener', 19 | 'remove-inhibitor' 20 | ], 21 | description: {content: 'Unload a module (command, listener, or inhibitor).', examples: ['help', 'clientError'], usage: ''}, 22 | category: DiceCommandCategories.Admin, 23 | ownerOnly: true, 24 | args: [ 25 | { 26 | id: 'module', 27 | type: Argument.union( 28 | // Commands have their ID as an alias, so no need to add the command type in here 29 | AkairoArgumentType.CommandAlias, 30 | AkairoArgumentType.Listener, 31 | AkairoArgumentType.Inhibitor 32 | ), 33 | match: 'content', 34 | prompt: {start: 'Which module do you want to unload?', retry: 'Invalid module provided, try again'} 35 | } 36 | ] 37 | }); 38 | } 39 | 40 | public async exec(message: Message, args: {module: DiceCommand | Inhibitor | DiceListener}): Promise { 41 | const stopwatch = new Stopwatch(); 42 | 43 | stopwatch.start(); 44 | const unloaded = args.module.remove(); 45 | const elapsed = Number(stopwatch.end()); 46 | 47 | let type = 'module'; 48 | 49 | if (unloaded instanceof Command) { 50 | type = 'command'; 51 | } else if (unloaded instanceof Listener) { 52 | type = 'listener'; 53 | } else if (unloaded instanceof Inhibitor) { 54 | type = 'inhibitor'; 55 | } 56 | 57 | return message.util?.send(`Unloaded ${type} \`${unloaded.categoryID}:${unloaded.id}\` in ${ms(elapsed)}`); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/developer/base-64.ts: -------------------------------------------------------------------------------- 1 | import {Message} from 'discord.js'; 2 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | 4 | export default class Base64Command extends DiceCommand { 5 | constructor() { 6 | super('base-64', { 7 | aliases: ['base-64-encode', 'base-64-decode'], 8 | category: DiceCommandCategories.Developer, 9 | description: { 10 | content: 'Encode or decode a string with Base64 encoding', 11 | usage: '--mode ', 12 | examples: ['--mode encode hello', '--mode decode aGVsbG8='] 13 | }, 14 | args: [ 15 | { 16 | id: 'content', 17 | type: AkairoArgumentType.String, 18 | match: 'rest', 19 | prompt: {start: 'What text would you like to decode or encode?'} 20 | }, 21 | { 22 | id: 'mode', 23 | match: 'option', 24 | type: [ 25 | ['encode', 'to'], 26 | ['decode', 'from'] 27 | ], 28 | default: 'encode', 29 | flag: '--mode', 30 | prompt: {start: 'Do you want to convert from Base64 or to Base64?', retry: 'Invalid mode, please try again'} 31 | } 32 | ] 33 | }); 34 | } 35 | 36 | async exec(message: Message, args: {content: string; mode: 'encode' | 'decode'}): Promise { 37 | const content = Buffer.from(args.content, args.mode === 'decode' ? 'base64' : 'utf-8'); 38 | 39 | return message.util?.send(content.toString(args.mode === 'encode' ? 'base64' : 'utf-8'), {code: true}); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/developer/color.ts: -------------------------------------------------------------------------------- 1 | import {Argument} from 'discord-akairo'; 2 | import {Message, MessageEmbed, Permissions, Util} from 'discord.js'; 3 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 4 | import parseColor from 'parse-color'; 5 | 6 | export default class ColorCommand extends DiceCommand { 7 | constructor() { 8 | super('color', { 9 | aliases: ['rgb', 'cmyk', 'hsv', 'hsl', 'hex', 'hexadecimal', 'colors'], 10 | category: DiceCommandCategories.Developer, 11 | description: { 12 | content: 'Display and convert a color.', 13 | usage: '', 14 | examples: ['blue', '#4caf50', 'hsl(210,50,50)'] 15 | }, 16 | clientPermissions: [Permissions.FLAGS.EMBED_LINKS], 17 | args: [ 18 | { 19 | id: 'color', 20 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => { 21 | if (!phrase.startsWith('#') && phrase.length === 6) { 22 | // Hexadecimal missing the pound sign 23 | const testResult = parseColor(`#${phrase}`); 24 | if (!testResult.cmyk || !testResult.rgb || !testResult.hsv || !testResult.hsl || !testResult.hex) { 25 | // Invalid hexadecimal 26 | return false; 27 | } 28 | } else { 29 | // Other color type 30 | const parsedColor = parseColor(phrase); 31 | if (!parsedColor.cmyk || !parsedColor.rgb || !parsedColor.hsv || !parsedColor.hsl || !parsedColor.hex) { 32 | return false; 33 | } 34 | } 35 | 36 | return true; 37 | }), 38 | match: 'content', 39 | prompt: {start: 'What color do you want to get information on?', retry: 'Invalid color provided, please try again'} 40 | } 41 | ] 42 | }); 43 | } 44 | 45 | async exec(message: Message, args: {color: string}): Promise { 46 | let parsedColor: parseColor.Color; 47 | 48 | if (!args.color.startsWith('#') && args.color.length === 6) { 49 | // Hexadecimal missing the pound sign 50 | const testResult = parseColor(`#${args.color}`); 51 | 52 | // Valid hexadecimal, missing pound sign `#` 53 | parsedColor = testResult; 54 | } else { 55 | // Other color type 56 | parsedColor = parseColor(args.color); 57 | } 58 | 59 | return message.util?.send( 60 | new MessageEmbed({ 61 | color: Util.resolveColor(parsedColor.rgb), 62 | thumbnail: { 63 | url: `http://www.thecolorapi.com/id?format=svg&named=false&hex=${parsedColor.hex.slice(1)}` 64 | }, 65 | fields: [ 66 | { 67 | name: 'CSS Keyword', 68 | value: parsedColor.keyword ?? 'None' 69 | }, 70 | { 71 | name: 'Hexadecimal', 72 | value: parsedColor.hex.toString() 73 | }, 74 | { 75 | name: 'CMYK', 76 | value: parsedColor.cmyk.join(', ') 77 | }, 78 | { 79 | name: 'HSL', 80 | value: parsedColor.hsl.join(', ') 81 | }, 82 | { 83 | name: 'HSV', 84 | value: parsedColor.hsv.join(', ') 85 | }, 86 | { 87 | name: 'RGB', 88 | value: parsedColor.rgb.join(', ') 89 | } 90 | ] 91 | }) 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/commands/developer/dev-help.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {code} from 'discord-md-tags'; 3 | import {Message, MessageEmbed} from 'discord.js'; 4 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {clusterID, findShardIDByGuildID, getClusterCount, getResponsibleShards} from '../../util/shard'; 6 | 7 | export default class DevHelpCommand extends DiceCommand { 8 | constructor() { 9 | super('dev-help', { 10 | aliases: ['shard', 'developer-help', 'debug'], 11 | description: {content: 'Get info to help developers fix bugs.', examples: [''], usage: ''}, 12 | category: DiceCommandCategories.Developer 13 | }); 14 | } 15 | 16 | async exec(message: Message): Promise { 17 | const {shard} = this.client; 18 | 19 | assert(shard); 20 | 21 | /** This shard's ID. This is a mostly meaningless number since Kurasuta has no real concept of shards, only the clusters that manage them. */ 22 | const shardID = message.guild ? findShardIDByGuildID(message.guild.id, BigInt(shard.shardCount)) : 0; 23 | 24 | const clusterCount = this.client.shard === null ? 1 : getClusterCount(this.client.shard); 25 | const responsibleShards: string = this.client.shard === null ? '0' : getResponsibleShards(this.client.shard).join(', '); 26 | 27 | return message.util?.send( 28 | new MessageEmbed({ 29 | fields: [ 30 | { 31 | name: 'Shard', 32 | value: `${shardID}/${shard?.shardCount ?? 0} (cluster ${clusterID}/${clusterCount}, handling ${responsibleShards})` 33 | }, 34 | { 35 | name: 'IDs', 36 | value: [ 37 | `Guild: \`${message.guild ? message.guild.id : 'dm'}\``, 38 | `Channel: \`${message.channel.id}\``, 39 | `User: \`${message.author.id}\``, 40 | `Message: \`${message.id}\`` 41 | ].join('\n') 42 | }, 43 | { 44 | name: 'Timestamp', 45 | value: [`Message: ${code`${message.createdTimestamp}`}`, `Current: \`${Date.now()}\``].join('\n') 46 | } 47 | ] 48 | }) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/commands/developer/generate-token.ts: -------------------------------------------------------------------------------- 1 | import {Message, User} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {typeName as anyUser} from '../../types/anyUser'; 4 | 5 | export default class GenerateTokenCommand extends DiceCommand { 6 | constructor() { 7 | super('generate-token', { 8 | aliases: ['gen-token', 'token', 'create-token', 'hack-bot-token', 'hack-bot'], 9 | category: DiceCommandCategories.Developer, 10 | description: { 11 | content: 'Generate the Discord token for an account.', 12 | usage: '', 13 | examples: ['Dice', '@Dice', '388191157869477888'] 14 | }, 15 | args: [ 16 | { 17 | id: 'user', 18 | type: anyUser, 19 | match: 'content', 20 | prompt: {start: 'Whose token would you like to generate?', retry: 'Invalid user, please try again'} 21 | } 22 | ] 23 | }); 24 | } 25 | 26 | async exec(message: Message, args: {user: User}): Promise { 27 | return message.util?.send(`${Buffer.from(args.user.id).toString('base64')}.${'X'.repeat(6)}.${'X'.repeat(27)}`, {code: true}); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/developer/hello-js.ts: -------------------------------------------------------------------------------- 1 | import {codeblock} from 'discord-md-tags'; 2 | import {Message, User} from 'discord.js'; 3 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 4 | import {typeName as anyUser} from '../../types/anyUser'; 5 | 6 | export default class HelloJsCommand extends DiceCommand { 7 | constructor() { 8 | super('hello-js', { 9 | aliases: ['hello-script', 'hello-javascript', 'js-hello', 'javascript-hello'], 10 | category: DiceCommandCategories.Developer, 11 | description: { 12 | content: 'Generate a JavaScript program to say hello to someone.', 13 | usage: '[user]', 14 | examples: ['', 'Dice', '@Dice', '388191157869477888'] 15 | }, 16 | args: [ 17 | { 18 | id: 'user', 19 | match: 'content', 20 | type: anyUser, 21 | prompt: { 22 | start: 'Who would you like to generate the script for?', 23 | retry: 'Invalid user, please try again' 24 | } 25 | } 26 | ] 27 | }); 28 | } 29 | 30 | async exec(message: Message, {user}: {user: User}): Promise { 31 | return message.util?.send(codeblock('javascript')` 32 | const ${user.username} = client.users.cache.get("${user.id}"); 33 | const filter = m => m.author.id === "${user.id}"; 34 | ${user.username}.send("how are you"); 35 | message.channel.awaitMessages(filter, { max: 3, time: 200000}).then(collected => { 36 | if(collected.first().content.toLowerCase() === "can i help you?") { 37 | msg.channel.send("i just asked").then(() => { 38 | const options = [0, 1]; 39 | const option = options[Math.floor(Math.random() * options.length)]; 40 | 41 | my.luck( if option === 1 ? "${message.author.username}.noBlock() : "${message.author.username}.block()) 42 | }) 43 | } 44 | })`); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/developer/shard-sankey.ts: -------------------------------------------------------------------------------- 1 | import {Message} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {sum} from '@jonahsnider/util'; 4 | 5 | export default class ShardSankeyCommand extends DiceCommand { 6 | constructor() { 7 | super('shard-sankey', { 8 | aliases: ['sankey', 'sankey-shard', 'cluster-sankey', 'sankey-cluster'], 9 | description: { 10 | content: 'Generate SankeyMatic compatible notation to show a breakdown of what clusters are managing which shards.', 11 | examples: [''], 12 | usage: '' 13 | }, 14 | category: DiceCommandCategories.Developer 15 | }); 16 | } 17 | 18 | async exec(message: Message): Promise { 19 | const {shard} = this.client; 20 | 21 | /** 22 | * @example [{ '0': 999 }] 23 | */ 24 | const clusters: Array> = (await shard?.broadcastEval(`this.responsibleGuildCount()`)) ?? [{'0': this.client.guilds.cache.size}]; 25 | 26 | // eslint-disable-next-line unicorn/no-array-reduce, unicorn/no-array-callback-reference 27 | const totalServerCount = clusters.flatMap(cluster => Object.values(cluster)).reduce(sum); 28 | 29 | const lines: string[] = [ 30 | `Dice [${totalServerCount}] Clusters`, 31 | ...clusters.map((cluster, clusterID) => { 32 | // eslint-disable-next-line unicorn/no-array-reduce, unicorn/no-array-callback-reference 33 | const clusterServerCount = Object.values(cluster).reduce(sum, 0); 34 | 35 | const clusterLines = [`Clusters [${clusterServerCount}] Cluster ${clusterID}`]; 36 | 37 | for (const [shardID, shardGuildCount] of Object.entries(cluster)) { 38 | clusterLines.push(`Cluster ${clusterID} [${shardGuildCount}] Shard ${shardID}`); 39 | } 40 | 41 | return clusterLines.join('\n'); 42 | }) 43 | ]; 44 | 45 | return message.util?.send(lines.join('\n'), {code: true}); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/economy/balance.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {bold} from 'discord-md-tags'; 3 | import {Message, User} from 'discord.js'; 4 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {DiceUser} from '../../structures/DiceUser'; 6 | import {typeName as anyUser} from '../../types/anyUser'; 7 | 8 | export default class BalanceCommand extends DiceCommand { 9 | constructor() { 10 | super('balance', { 11 | aliases: ['bal', 'user-balance'], 12 | category: DiceCommandCategories.Economy, 13 | description: { 14 | content: 'Check the balance of yourself or another user.', 15 | usage: '[user]', 16 | examples: ['', 'Dice', '@Dice', '388191157869477888'] 17 | }, 18 | args: [ 19 | { 20 | id: 'user', 21 | match: 'content', 22 | type: anyUser, 23 | prompt: {optional: true, retry: 'Invalid user, please try again'} 24 | } 25 | ] 26 | }); 27 | } 28 | 29 | async exec(message: Message, args: {user?: User}): Promise { 30 | assert(this.client.user); 31 | 32 | if (args.user?.bot && args.user?.id !== this.client.user.id) { 33 | return message.util?.send("You can't check the balance of bots"); 34 | } 35 | 36 | const user = new DiceUser(args.user ?? message.author); 37 | const balance = await user.getBalance(); 38 | 39 | return message.util?.send( 40 | `${args.user?.tag ? `${args.user.tag}'s` : 'Your'} account has a balance of ${bold`${balance.toLocaleString()}`} oat${balance === 1 ? '' : 's'}` 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/economy/daily.ts: -------------------------------------------------------------------------------- 1 | import {convert} from 'convert'; 2 | import {formatDistance} from 'date-fns'; 3 | import {bold} from 'discord-md-tags'; 4 | import {Message} from 'discord.js'; 5 | import {dailyAmount, defaults} from '../../constants'; 6 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 7 | 8 | const cooldown = convert(22, 'hours').to('milliseconds'); 9 | 10 | export default class DailyCommand extends DiceCommand { 11 | constructor() { 12 | super('daily', { 13 | aliases: ['dailies'], 14 | category: DiceCommandCategories.Economy, 15 | description: { 16 | content: 'Collect daily oats.', 17 | usage: '', 18 | examples: [''] 19 | }, 20 | typing: true 21 | }); 22 | } 23 | 24 | async exec(message: Message): Promise { 25 | const user = (await this.client.prisma.user.findUnique({where: {id: message.author.id}, select: {dailyUsed: true}})) ?? {dailyUsed: null}; 26 | const now = message.editedAt ?? message.createdAt; 27 | 28 | if (user.dailyUsed === null || user.dailyUsed.getTime() + cooldown < now.getTime()) { 29 | if (this.client.user === null) { 30 | throw new TypeError('Expected client.user to be defined'); 31 | } 32 | 33 | this.logger.debug({now, type: typeof now}); 34 | 35 | const [{balance: updatedBalance}] = await this.client.prisma.$transaction([ 36 | this.client.prisma.user.upsert({ 37 | where: {id: message.author.id}, 38 | update: {balance: {increment: dailyAmount}, dailyUsed: now}, 39 | create: {balance: defaults.startingBalance.users + dailyAmount, id: message.author.id}, 40 | select: {balance: true} 41 | }), 42 | this.client.prisma.user.upsert({ 43 | where: {id: this.client.user.id}, 44 | update: {balance: {increment: dailyAmount}}, 45 | create: {balance: defaults.startingBalance.bot + dailyAmount, id: this.client.user.id}, 46 | select: {id: true} 47 | }) 48 | ]); 49 | 50 | return message.util?.send( 51 | [`You were paid ${bold`${dailyAmount.toLocaleString()}`} oats`, `Your balance is now ${bold`${updatedBalance.toLocaleString()} oats`}`].join('\n') 52 | ); 53 | } 54 | 55 | const waitDuration = formatDistance(user.dailyUsed.getTime() + cooldown, now); 56 | 57 | return message.util?.send(`You must wait ${bold`${waitDuration}`} before collecting your daily oats`); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/economy/dice-game.test.ts: -------------------------------------------------------------------------------- 1 | import {winPercentage} from './dice-game'; 2 | 3 | test('winPercentage', () => { 4 | expect(winPercentage(2)).toBe(0.495); 5 | expect(winPercentage(100)).toBe(0.0099); 6 | }); 7 | -------------------------------------------------------------------------------- /src/commands/economy/leaderboard.ts: -------------------------------------------------------------------------------- 1 | import {Stopwatch} from '@jonahsnider/util'; 2 | import {Argument} from 'discord-akairo'; 3 | import {codeblock} from 'discord-md-tags'; 4 | import {Message, MessageEmbed, Util} from 'discord.js'; 5 | import {maxEmbedFields} from '../../constants'; 6 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 7 | import ms = require('pretty-ms'); 8 | 9 | /** 10 | * The number of fields allowed before the output will be in codeblock form. 11 | * Must be less than or equal to 25. 12 | */ 13 | const maxFieldsBeforeCodeblock = 10; 14 | 15 | export default class LeaderboardCommand extends DiceCommand { 16 | constructor() { 17 | super('leaderboard', { 18 | aliases: ['top'], 19 | category: DiceCommandCategories.Economy, 20 | description: { 21 | content: 'Check the wealthiest users in the economy.', 22 | usage: '[amount]', 23 | examples: ['', '20'] 24 | }, 25 | args: [ 26 | { 27 | id: 'amount', 28 | match: 'content', 29 | type: Argument.range(AkairoArgumentType.Integer, 1, maxEmbedFields, true), 30 | default: 10, 31 | prompt: {optional: true, retry: 'Invalid amount, please try again'} 32 | } 33 | ] 34 | }); 35 | } 36 | 37 | async exec(message: Message, args: {amount: number}): Promise { 38 | const stopwatch = new Stopwatch(); 39 | 40 | stopwatch.start(); 41 | const top = await this.client.prisma.user.findMany({orderBy: {balance: 'desc'}, take: args.amount}); 42 | 43 | const embed = new MessageEmbed({title: `Top ${top.length.toLocaleString()} leaderboard`}); 44 | 45 | const users = await Promise.all(top.map(async user => this.client.users.fetch(user.id))); 46 | 47 | if (top.length <= maxFieldsBeforeCodeblock) { 48 | for (const [index, user] of top.entries()) { 49 | embed.addField(`#${index + 1} ${users[index].tag}`, user.balance.toLocaleString()); 50 | } 51 | } else { 52 | const leaderboard: string = top 53 | .map((user, index) => { 54 | const balance = user.balance.toLocaleString(); 55 | const userTag = Util.escapeMarkdown(users[index].tag); 56 | const paddedNumber = `${index + 1}. `.padEnd(args.amount.toString().length + '. '.length); 57 | return `${paddedNumber}${userTag} - ${balance}`; 58 | }) 59 | .join('\n'); 60 | 61 | embed.setDescription(codeblock('markdown')`${leaderboard}`); 62 | } 63 | 64 | const duration = Number(stopwatch.end()); 65 | 66 | embed.setFooter(`Took ${ms(duration)}`); 67 | 68 | return message.util?.send(embed); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/economy/rates.ts: -------------------------------------------------------------------------------- 1 | import {Client as Discoin} from '@discoin/scambio'; 2 | import {Currency} from '@discoin/scambio/tsc_output/src/types/discoin'; 3 | import {formatTable, maxColumnLength} from '@jonahsnider/util'; 4 | import assert from 'assert'; 5 | import {Message, Permissions} from 'discord.js'; 6 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 7 | 8 | /** 9 | * Sorts currencies by value descending, but puts oats before everything else. 10 | * @param a First currency to compare 11 | * @param b Second currency to compare 12 | * @returns The order to move elements around, compatible with Array.prototype#sort 13 | */ 14 | function narcissisticSort(a: Currency, b: Currency): -1 | 1 | number { 15 | if (a.id === 'OAT') { 16 | return -1; 17 | } 18 | 19 | if (b.id === 'OAT') { 20 | return 1; 21 | } 22 | 23 | return b.value - a.value; 24 | } 25 | 26 | export default class RatesCommand extends DiceCommand { 27 | constructor() { 28 | super('rates', { 29 | aliases: ['discoin-rates', 'conversion-rates', 'convert-rates'], 30 | description: {content: 'Lists the conversion rates for Discoin currencies.', examples: [''], usage: ''}, 31 | category: DiceCommandCategories.Economy, 32 | clientPermissions: [Permissions.FLAGS.EMBED_LINKS], 33 | typing: true 34 | }); 35 | } 36 | 37 | async exec(message: Message): Promise { 38 | const currencies: Currency[] = []; 39 | 40 | try { 41 | const bots = await Discoin.bots.getMany(); 42 | 43 | for (const bot of Array.isArray(bots) ? bots : bots.data) { 44 | for (const currency of bot.currencies) { 45 | currency.name = `${bot.name} ${currency.name}`; 46 | currencies.push(currency); 47 | } 48 | } 49 | } catch (error: unknown) { 50 | this.logger.error(error); 51 | 52 | // eslint-disable-next-line no-return-await 53 | return await message.util?.send( 54 | ['An error occurred while fetching currencies', 'Please try again later, and if the error keeps occurring report this to a developer'].join('\n') 55 | ); 56 | } 57 | 58 | const oatsCurrency = currencies.find(currency => currency.id === 'OAT'); 59 | 60 | assert(oatsCurrency); 61 | 62 | currencies.sort(narcissisticSort); 63 | 64 | const header = ['#', 'Name', '', 'ID', '', 'Discoin value', '', '', 'OAT value', '']; 65 | 66 | const data = currencies.map((currency, index): string[] => [ 67 | `${(index + 1).toLocaleString()}.`, 68 | currency.name, 69 | '1', 70 | `${currency.id}`, 71 | '=', 72 | `${currency.value.toLocaleString()}`, 73 | 'D$', 74 | '=', 75 | `${(currency.value / oatsCurrency.value).toLocaleString()}`, 76 | 'OAT' 77 | ]); 78 | 79 | const maxLengths = maxColumnLength([...data, header]); 80 | 81 | const divider = header.map((heading, index) => (heading.length === 0 ? '' : '-'.repeat(maxLengths[index]))); 82 | 83 | const table = [header, divider, ...data]; 84 | 85 | return message.util?.send(formatTable(table), {code: 'markdown'}); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/commands/economy/transfer.ts: -------------------------------------------------------------------------------- 1 | import {toDigits} from '@jonahsnider/util'; 2 | import assert from 'assert'; 3 | import {Argument} from 'discord-akairo'; 4 | import {bold} from 'discord-md-tags'; 5 | import {Message, User, Util} from 'discord.js'; 6 | import {defaults} from '../../constants'; 7 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 8 | import {DiceUser} from '../../structures/DiceUser'; 9 | import {typeName as anyUser} from '../../types/anyUser'; 10 | 11 | export default class TransferCommand extends DiceCommand { 12 | constructor() { 13 | super('transfer', { 14 | aliases: ['send', 'pay', 'pay-user'], 15 | category: DiceCommandCategories.Economy, 16 | description: { 17 | content: 'Send money to a user.', 18 | usage: ' ', 19 | examples: ['Dice 100', '@Dice 100', '388191157869477888 100'] 20 | }, 21 | args: [ 22 | { 23 | id: 'user', 24 | type: anyUser, 25 | match: 'rest', 26 | prompt: {start: 'Who would you like to transfer oats to?', retry: 'Invalid user, please try again'}, 27 | unordered: true 28 | }, 29 | { 30 | id: 'amount', 31 | match: 'phrase', 32 | type: Argument.range(AkairoArgumentType.Number, 1, Number.MAX_SAFE_INTEGER), 33 | prompt: {start: 'How many oats would you like to transfer?', retry: 'Invalid amount, please try again'}, 34 | unordered: true 35 | } 36 | ] 37 | }); 38 | } 39 | 40 | async exec(message: Message, args: {user: User; amount: number}): Promise { 41 | assert(this.client.user); 42 | 43 | if (args.user?.bot && args.user?.id !== this.client.user.id) { 44 | return message.util?.send("You can't send oats to bots"); 45 | } 46 | 47 | if (message.author.id === args.user.id) { 48 | return message.util?.send("You can't send oats to yourself"); 49 | } 50 | 51 | args.amount = toDigits(args.amount, 2); 52 | 53 | const authorUser = new DiceUser(message.author); 54 | const authorBal = await authorUser.getBalance(); 55 | 56 | const queries = { 57 | author: {id: message.author.id}, 58 | recipient: {id: args.user.id} 59 | }; 60 | 61 | if (authorBal < args.amount) { 62 | return message.util?.send( 63 | [ 64 | 'You do not have enough oats to make the transfer', 65 | `Your current balance is ${bold`${authorBal.toLocaleString()}`} oat${authorBal === 1 ? '' : 's'}` 66 | ].join('\n') 67 | ); 68 | } 69 | 70 | const [{balance: updatedAuthorBalance}] = await this.client.prisma.$transaction([ 71 | this.client.prisma.user.upsert({ 72 | where: queries.author, 73 | update: {balance: {decrement: args.amount}}, 74 | create: {...queries.author, balance: defaults.startingBalance.users - args.amount}, 75 | select: {balance: true} 76 | }), 77 | this.client.prisma.user.upsert({ 78 | where: queries.recipient, 79 | update: {balance: {increment: args.amount}}, 80 | create: {...queries.recipient, balance: defaults.startingBalance[queries.recipient.id === this.client.user.id ? 'bot' : 'users'] + args.amount}, 81 | select: {balance: true} 82 | }) 83 | ]); 84 | 85 | return message.util?.send( 86 | [ 87 | `Paid ${Util.escapeMarkdown(args.user.tag)} ${bold`${args.amount.toLocaleString()}`} oat${args.amount === 1 ? '' : 's'}`, 88 | `Your balance is now ${bold`${updatedAuthorBalance.toLocaleString()}`} oat${updatedAuthorBalance === 1 ? '' : 's'}` 89 | ].join('\n') 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/commands/fun/cat.ts: -------------------------------------------------------------------------------- 1 | import {Message, MessageEmbed, Permissions} from 'discord.js'; 2 | import got, {Response} from 'got'; 3 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 4 | 5 | const genericErrorMessage = 'There was an error with the service we use (https://random.cat/)'; 6 | 7 | interface RandomCatResponse { 8 | file: string; 9 | } 10 | 11 | export default class CatCommand extends DiceCommand { 12 | constructor() { 13 | super('cat', { 14 | aliases: ['random-cat-image', 'random-cat', 'cat-image'], 15 | description: {content: 'Get a picture of a random cat.', examples: [''], usage: ''}, 16 | category: DiceCommandCategories.Fun, 17 | clientPermissions: [Permissions.FLAGS.EMBED_LINKS], 18 | typing: true 19 | }); 20 | } 21 | 22 | async exec(message: Message): Promise { 23 | let url: string; 24 | 25 | try { 26 | const response: Response = await got('https://aws.random.cat/meow', {responseType: 'json'}); 27 | 28 | url = response.body.file; 29 | } catch (error: unknown) { 30 | this.logger.error(error); 31 | 32 | // eslint-disable-next-line no-return-await 33 | return await message.util?.send(genericErrorMessage); 34 | } 35 | 36 | if (url) { 37 | return message.util?.send( 38 | new MessageEmbed({ 39 | author: { 40 | name: 'random.cat', 41 | iconURL: 'https://i.imgur.com/Ik0Gf0r.png', 42 | url: 'https://random.cat' 43 | }, 44 | image: {url} 45 | }) 46 | ); 47 | } 48 | 49 | return message.util?.send([genericErrorMessage, 'No image was returned'].join('\n')); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/commands/fun/clap.ts: -------------------------------------------------------------------------------- 1 | import {Message} from 'discord.js'; 2 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {clean} from '../../util/format'; 4 | 5 | export default class ClapCommand extends DiceCommand { 6 | constructor() { 7 | super('clap', { 8 | aliases: ['clapify'], 9 | description: {content: 'Talk👏like👏this.', examples: ['i am annoying'], usage: ''}, 10 | category: DiceCommandCategories.Fun, 11 | args: [ 12 | { 13 | id: 'content', 14 | match: 'content', 15 | type: AkairoArgumentType.String, 16 | prompt: {start: 'What do you want to clapify?'} 17 | } 18 | ] 19 | }); 20 | } 21 | 22 | async exec(message: Message, {content}: {content: string}): Promise { 23 | return message.util?.send(clean(content.replace(/\s+/g, '👏'), message)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/commands/fun/dog.ts: -------------------------------------------------------------------------------- 1 | import {Message, MessageEmbed, Permissions} from 'discord.js'; 2 | import got, {Response} from 'got'; 3 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 4 | 5 | const genericErrorMessage = 'There was an error with the service we use (https://dog.ceo/)'; 6 | 7 | interface DogCEOResponse { 8 | message: string; 9 | status: 'success' | string; 10 | } 11 | 12 | export default class DogCommand extends DiceCommand { 13 | constructor() { 14 | super('dog', { 15 | aliases: ['random-dog-image', 'random-dog', 'dog-image'], 16 | description: {content: 'Get a picture of a random dog.', examples: [''], usage: ''}, 17 | category: DiceCommandCategories.Fun, 18 | clientPermissions: [Permissions.FLAGS.EMBED_LINKS], 19 | typing: true 20 | }); 21 | } 22 | 23 | async exec(message: Message): Promise { 24 | let url; 25 | 26 | try { 27 | const response: Response = await got('https://dog.ceo/api/breeds/image/random', {responseType: 'json'}); 28 | 29 | url = response.body.message; 30 | } catch (error: unknown) { 31 | this.logger.error(error); 32 | 33 | // eslint-disable-next-line no-return-await 34 | return await message.util?.send(genericErrorMessage); 35 | } 36 | 37 | if (url) { 38 | return message.util?.send( 39 | new MessageEmbed({ 40 | author: { 41 | name: 'dog.ceo', 42 | iconURL: 'https://dog.ceo/img/favicon.png', 43 | url: 'https://dog.ceo/dog-api/' 44 | }, 45 | image: {url} 46 | }) 47 | ); 48 | } 49 | 50 | return message.util?.send([genericErrorMessage, 'No image was returned'].join('\n')); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/commands/fun/roll.ts: -------------------------------------------------------------------------------- 1 | import {Message} from 'discord.js'; 2 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import Roll = require('roll'); 4 | import {Argument} from 'discord-akairo'; 5 | import {bold} from 'discord-md-tags'; 6 | 7 | export default class RollCommand extends DiceCommand { 8 | constructor() { 9 | super('roll', { 10 | aliases: ['roll-dice', 'die', 'dice', 'roll-die'], 11 | category: DiceCommandCategories.Fun, 12 | description: { 13 | content: 'Roll dice using dice notation.', 14 | usage: '[roll]', 15 | examples: ['', 'd6', '4d6', '2d20+1d12', 'd%', '2d6+2'] 16 | }, 17 | args: [ 18 | { 19 | id: 'roll', 20 | match: 'content', 21 | default: 'd6', 22 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => { 23 | if (phrase.length > 300) { 24 | return false; 25 | } 26 | 27 | return Roll.prototype.validate(phrase); 28 | }), 29 | prompt: {optional: true, retry: 'Invalid dice notation, please try again', start: 'What do you want to roll?'} 30 | } 31 | ] 32 | }); 33 | } 34 | 35 | async exec(message: Message, {roll}: {roll: string}): Promise { 36 | const rolled = new Roll().roll(roll); 37 | 38 | if (rolled.rolled.length > 200 || rolled.result > 1_000_000) { 39 | // If the roll had a large number of dice or was very large, don't display all the info 40 | return message.util?.send(rolled.result.toLocaleString(), {split: true}); 41 | } 42 | 43 | return message.util?.send( 44 | [bold`${rolled.result.toLocaleString()}`, rolled.rolled.length > 1 ? ` (${rolled.rolled.map(value => value.toLocaleString()).join(', ')})` : ''].join(''), 45 | {split: true} 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/commands/fun/say.ts: -------------------------------------------------------------------------------- 1 | import {captureException} from '@sentry/node'; 2 | import {Message} from 'discord.js'; 3 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 4 | 5 | export default class SayCommand extends DiceCommand { 6 | constructor() { 7 | super('say', { 8 | aliases: ['echo'], 9 | description: {content: 'Have the bot say a message.', examples: ['hello'], usage: ''}, 10 | category: DiceCommandCategories.Fun, 11 | ownerOnly: true, 12 | args: [ 13 | { 14 | id: 'content', 15 | match: 'content', 16 | type: AkairoArgumentType.String, 17 | prompt: {start: 'What do you want me to say?'} 18 | } 19 | ] 20 | }); 21 | } 22 | 23 | async exec(message: Message, {content}: {content: string}): Promise { 24 | if (message.deletable) { 25 | // eslint-disable-next-line promise/prefer-await-to-then 26 | message.delete().catch(error => { 27 | this.logger.error(`An error occurred while deleting the message ${message.id} that triggered this command`, error); 28 | return captureException(error); 29 | }); 30 | } 31 | 32 | return message.util?.send(content); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/commands/fun/spoiler.ts: -------------------------------------------------------------------------------- 1 | import {Message, Util} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories, AkairoArgumentType} from '../../structures/DiceCommand'; 3 | 4 | export default class SpoilerCommand extends DiceCommand { 5 | constructor() { 6 | super('spoiler', { 7 | aliases: ['spoilerify'], 8 | description: { 9 | content: 'Make every character in a text a ||spoiler||.', 10 | examples: ['hello', 'hello --codeblock', 'hello -c', 'hello'], 11 | usage: ' [--codeblock]' 12 | }, 13 | category: DiceCommandCategories.Fun, 14 | args: [ 15 | { 16 | id: 'content', 17 | match: 'rest', 18 | type: AkairoArgumentType.String, 19 | prompt: {start: 'What do you want to turn into a spoiler?'} 20 | }, 21 | { 22 | id: 'codeblock', 23 | match: 'flag', 24 | flag: ['--codeblock', '-c'] 25 | } 26 | ] 27 | }); 28 | } 29 | 30 | async exec(message: Message, {content, codeblock}: {content: string; codeblock: boolean}): Promise { 31 | return message.util?.send( 32 | Util.cleanContent( 33 | Util.escapeSpoiler(content) 34 | .split('') 35 | .map(char => `||${char}||`) 36 | .join(''), 37 | message 38 | ), 39 | {code: codeblock ? 'markdown' : false} 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/fun/xkcd.ts: -------------------------------------------------------------------------------- 1 | import {Message, Permissions, MessageEmbed} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories, AkairoArgumentType} from '../../structures/DiceCommand'; 3 | import {Argument} from 'discord-akairo'; 4 | import {truncate} from '@jonahsnider/util'; 5 | import got from 'got'; 6 | 7 | interface XKCDComic { 8 | month: string; 9 | num: number; 10 | link: string; 11 | year: string; 12 | news: string; 13 | // eslint-disable-next-line camelcase 14 | safe_title: string; 15 | transcript: string; 16 | alt: string; 17 | img: string; 18 | title: string; 19 | day: string; 20 | } 21 | 22 | export default class XKCDCommand extends DiceCommand { 23 | constructor() { 24 | super('xkcd', { 25 | aliases: ['herandom-xkcd", "xkcd-comic", "random-xkcd-comicartbeat'], 26 | description: {content: 'Get the latest XKCD comic or a specific issue.', examples: ['', '614'], usage: '[issue]'}, 27 | category: DiceCommandCategories.Fun, 28 | clientPermissions: [Permissions.FLAGS.EMBED_LINKS], 29 | typing: true, 30 | args: [ 31 | { 32 | id: 'issue', 33 | type: Argument.range(AkairoArgumentType.Integer, 1, 5_000_000), 34 | match: 'content', 35 | default: 'latest', 36 | prompt: {optional: true, retry: 'Invalid issue number, please try again'} 37 | } 38 | ] 39 | }); 40 | } 41 | 42 | async exec(message: Message, {issue}: {issue: number | 'latest'}): Promise { 43 | const uri = `https://xkcd.com/${issue === 'latest' ? '' : issue}/info.0.json`; 44 | 45 | let body: XKCDComic; 46 | 47 | try { 48 | const response = await got(uri, {responseType: 'json'}); 49 | body = response.body; 50 | } catch (error: unknown) { 51 | this.logger.error(error); 52 | // eslint-disable-next-line no-return-await 53 | return await message.util?.send('An error occurred while retrieving the comic from XKCD'); 54 | } 55 | 56 | // Result embed 57 | const embed = new MessageEmbed({ 58 | title: `${body.safe_title} (#${body.num})`, 59 | author: { 60 | name: 'XKCD', 61 | iconURL: 'https://i.imgur.com/AP0vVy5.png', 62 | url: 'https://xkcd.com' 63 | } 64 | }); 65 | 66 | // Check if comic exists 67 | if (body.img) { 68 | embed.setImage(body.img); 69 | } else { 70 | return message.util?.send("Couldn't find that comic"); 71 | } 72 | 73 | // Alt text 74 | if (body.alt) { 75 | embed.addField('Alt', truncate(body.alt, 1024)); 76 | } 77 | 78 | // Transcript 79 | if (body.transcript) { 80 | embed.addField('Transcript', truncate(body.transcript, 1024)); 81 | } 82 | 83 | // Check if there's a link 84 | embed.setURL(body.link || `https://xkcd.com/${body.num}`); 85 | 86 | // Creation date 87 | if ([body.day, body.month, body.year].every(value => Boolean(value))) { 88 | embed.setTimestamp(new Date(Number(body.year), Number(body.month) - 1, Number(body.day))); 89 | } 90 | 91 | return message.util?.send(embed); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/commands/minecraft/avatar.ts: -------------------------------------------------------------------------------- 1 | import {Message, Permissions} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | // eslint-disable-next-line import/extensions 4 | import type {Size} from '../../types/crafatar'; 5 | import {crafatarArgs, downloadImage, genericErrorMessage} from '../../util/crafatar'; 6 | import {MinecraftAccount} from '../../util/player-db'; 7 | 8 | export default class AvatarCommand extends DiceCommand { 9 | constructor() { 10 | super('avatar', { 11 | aliases: ['get-face', 'get-mc-face', 'get-minecraft-face', 'mc-avatar', 'minecraft-avatar'], 12 | description: { 13 | content: 'Get an avatar image of a Minecraft user (via Crafatar).', 14 | usage: ' [size:] [--overlay]', 15 | examples: ['notch', 'notch size:200', 'notch --overlay', 'notch size:32 --overlay'] 16 | }, 17 | category: DiceCommandCategories.Minecraft, 18 | clientPermissions: [Permissions.FLAGS.ATTACH_FILES], 19 | typing: true, 20 | args: [crafatarArgs.player, crafatarArgs.size, crafatarArgs.overlay] 21 | }); 22 | } 23 | 24 | async exec(message: Message, args: {player: MinecraftAccount; overlay: boolean | null; size: Size | null}): Promise { 25 | let image: Buffer; 26 | 27 | try { 28 | image = await downloadImage({imageType: 'avatar', playerUUID: args.player.id, size: args.size ?? undefined, overlay: args.overlay ?? false}); 29 | } catch (error: unknown) { 30 | this.logger.error(error); 31 | 32 | // eslint-disable-next-line no-return-await 33 | return await message.util?.send(genericErrorMessage); 34 | } 35 | 36 | if (image) { 37 | return message.util?.send({files: [image]}); 38 | } 39 | 40 | return message.util?.send([genericErrorMessage, 'No image was found'].join('\n')); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/minecraft/body.ts: -------------------------------------------------------------------------------- 1 | import {Message, Permissions} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | // eslint-disable-next-line import/extensions 4 | import type {Scale} from '../../types/crafatar'; 5 | import {crafatarArgs, downloadImage, genericErrorMessage} from '../../util/crafatar'; 6 | import {MinecraftAccount} from '../../util/player-db'; 7 | 8 | export default class BodyCommand extends DiceCommand { 9 | constructor() { 10 | super('body', { 11 | aliases: ['get-body', 'get-mc-body', 'get-minecraft-body', 'mc-body', 'minecraft-body'], 12 | description: { 13 | content: "Get an image of a Minecraft user's body (via Crafatar).", 14 | usage: ' [scale:] [--overlay]', 15 | examples: ['notch', 'notch scale:5', 'notch --overlay', 'notch scale:10 --overlay'] 16 | }, 17 | category: DiceCommandCategories.Minecraft, 18 | clientPermissions: [Permissions.FLAGS.ATTACH_FILES], 19 | typing: true, 20 | args: [crafatarArgs.player, crafatarArgs.scale, crafatarArgs.overlay] 21 | }); 22 | } 23 | 24 | async exec(message: Message, args: {player: MinecraftAccount; scale: Scale | null; overlay: boolean | null}): Promise { 25 | let image: Buffer; 26 | 27 | try { 28 | image = await downloadImage({imageType: 'body', playerUUID: args.player.id, overlay: args.overlay ?? false}); 29 | } catch (error: unknown) { 30 | this.logger.error(error); 31 | 32 | // eslint-disable-next-line no-return-await 33 | return await message.util?.send(genericErrorMessage); 34 | } 35 | 36 | if (image) { 37 | return message.util?.send({files: [image]}); 38 | } 39 | 40 | return message.util?.send([genericErrorMessage, 'No image was found'].join('\n')); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/minecraft/cape.ts: -------------------------------------------------------------------------------- 1 | import {Message, Permissions} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {crafatarArgs, downloadImage, genericErrorMessage} from '../../util/crafatar'; 4 | import {MinecraftAccount} from '../../util/player-db'; 5 | 6 | export default class capeCommand extends DiceCommand { 7 | constructor() { 8 | super('cape', { 9 | aliases: ['get-cape', 'get-mc-cape', 'get-minecraft-cape', 'mc-cape', 'minecraft-cape'], 10 | description: { 11 | content: "Get a Minecraft user's cape (via Crafatar).", 12 | usage: '', 13 | examples: ['notch'] 14 | }, 15 | category: DiceCommandCategories.Minecraft, 16 | clientPermissions: [Permissions.FLAGS.ATTACH_FILES], 17 | typing: true, 18 | args: [crafatarArgs.player] 19 | }); 20 | } 21 | 22 | async exec(message: Message, args: {player: MinecraftAccount}): Promise { 23 | let image: Buffer; 24 | 25 | try { 26 | image = await downloadImage({imageType: 'cape', playerUUID: args.player.id}); 27 | } catch (error: unknown) { 28 | this.logger.error(error); 29 | 30 | // eslint-disable-next-line no-return-await 31 | return await message.util?.send(genericErrorMessage); 32 | } 33 | 34 | if (image) { 35 | return message.util?.send({files: [image]}); 36 | } 37 | 38 | return message.util?.send([genericErrorMessage, 'No image was found'].join('\n')); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/minecraft/head.ts: -------------------------------------------------------------------------------- 1 | import {Message, Permissions} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | // eslint-disable-next-line import/extensions 4 | import type {Scale} from '../../types/crafatar'; 5 | import {crafatarArgs, downloadImage, genericErrorMessage} from '../../util/crafatar'; 6 | import {MinecraftAccount} from '../../util/player-db'; 7 | 8 | export default class HeadCommand extends DiceCommand { 9 | constructor() { 10 | super('head', { 11 | aliases: ['get-head', 'get-mc-head', 'get-minecraft-head', 'mc-head', 'minecraft-head'], 12 | description: { 13 | content: "Get an image of a Minecraft user's head (via Crafatar).", 14 | usage: ' [scale:] [--overlay]', 15 | examples: ['notch', 'notch scale:5', 'notch --overlay', 'notch scale:10 --overlay'] 16 | }, 17 | category: DiceCommandCategories.Minecraft, 18 | clientPermissions: [Permissions.FLAGS.ATTACH_FILES], 19 | typing: true, 20 | args: [crafatarArgs.player, crafatarArgs.scale, crafatarArgs.overlay] 21 | }); 22 | } 23 | 24 | async exec(message: Message, args: {player: MinecraftAccount; scale: Scale | null; overlay: boolean | null}): Promise { 25 | let image: Buffer; 26 | 27 | try { 28 | image = await downloadImage({imageType: 'head', playerUUID: args.player.id, overlay: args.overlay ?? false}); 29 | } catch (error: unknown) { 30 | this.logger.error(error); 31 | 32 | // eslint-disable-next-line no-return-await 33 | return await message.util?.send(genericErrorMessage); 34 | } 35 | 36 | if (image) { 37 | return message.util?.send({files: [image]}); 38 | } 39 | 40 | return message.util?.send([genericErrorMessage, 'No image was found'].join('\n')); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/minecraft/skin.ts: -------------------------------------------------------------------------------- 1 | import {Message, Permissions} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {crafatarArgs, downloadImage, genericErrorMessage} from '../../util/crafatar'; 4 | import {MinecraftAccount} from '../../util/player-db'; 5 | 6 | export default class SkinCommand extends DiceCommand { 7 | constructor() { 8 | super('skin', { 9 | aliases: ['get-skin', 'get-mc-skin', 'get-minecraft-skin', 'mc-skin', 'minecraft-skin'], 10 | description: { 11 | content: "Get a Minecraft user's skin (via Crafatar).", 12 | usage: '', 13 | examples: ['notch'] 14 | }, 15 | category: DiceCommandCategories.Minecraft, 16 | clientPermissions: [Permissions.FLAGS.ATTACH_FILES], 17 | typing: true, 18 | args: [crafatarArgs.player] 19 | }); 20 | } 21 | 22 | async exec(message: Message, args: {player: MinecraftAccount}): Promise { 23 | let image: Buffer; 24 | 25 | try { 26 | image = await downloadImage({imageType: 'skin', playerUUID: args.player.id}); 27 | } catch (error: unknown) { 28 | this.logger.error(error); 29 | 30 | // eslint-disable-next-line no-return-await 31 | return await message.util?.send(genericErrorMessage); 32 | } 33 | 34 | if (image) { 35 | return message.util?.send({files: [image]}); 36 | } 37 | 38 | return message.util?.send([genericErrorMessage, 'No image was found'].join('\n')); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/moderation/ban.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Argument} from 'discord-akairo'; 3 | import {bold} from 'discord-md-tags'; 4 | import {Message, Permissions, User} from 'discord.js'; 5 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 6 | import {typeName as anyUser} from '../../types/anyUser'; 7 | import {clean} from '../../util/format'; 8 | import {notManageable} from '../../util/permissions'; 9 | 10 | export default class BanCommand extends DiceCommand { 11 | constructor() { 12 | super('ban', { 13 | aliases: ['ban-member', 'ban-user', 'hackban-user', 'hackban-member', 'hackban'], 14 | category: DiceCommandCategories.Moderation, 15 | description: { 16 | content: 'Ban any user from your server or prevent them from ever joining.', 17 | usage: ' [reason]', 18 | examples: ['@Dice', 'Dice', '388191157869477888', '@Dice Spamming', 'Dice Spamming', '388191157869477888 Spamming'] 19 | }, 20 | userPermissions: [Permissions.FLAGS.BAN_MEMBERS], 21 | clientPermissions: [Permissions.FLAGS.BAN_MEMBERS], 22 | channel: 'guild', 23 | args: [ 24 | { 25 | id: 'user', 26 | type: anyUser, 27 | prompt: { 28 | start: 'Who would you like to ban?', 29 | retry: 'Invalid user provided, please try again' 30 | } 31 | }, 32 | { 33 | id: 'reason', 34 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 400), 35 | match: 'rest', 36 | prompt: { 37 | optional: true, 38 | retry: 'Invalid reason provided, please keep it below 400 characters' 39 | } 40 | } 41 | ] 42 | }); 43 | } 44 | 45 | async exec(message: Message, args: {user: User; reason: string | null}): Promise { 46 | assert(message.guild); 47 | assert(message.member); 48 | 49 | args.reason = args.reason ? `${args.reason} - Requested by ${message.author.tag}` : `Requested by ${message.author.tag}`; 50 | 51 | const bans = await message.guild.fetchBans(); 52 | 53 | if (bans.has(args.user.id)) { 54 | return message.util?.send('That user is already banned'); 55 | } 56 | 57 | const guildMember = message.guild.members.resolve(args.user); 58 | 59 | if (guildMember) { 60 | if (!guildMember.bannable) { 61 | // User is on this server and is not bannable 62 | return message.util?.send("I can't ban that member"); 63 | } 64 | 65 | // Taken from discord.js `GuildMember.manageable https://github.com/discordjs/discord.js/blob/4ec01ddef56272f6bed23dd0eced8ea9851127b7/src/structures/GuildMember.js#L216-L222` 66 | if (notManageable(message.member, guildMember)) { 67 | return message.util?.send("You don't have permissions to ban that member"); 68 | } 69 | } 70 | 71 | try { 72 | await message.guild.members.ban(args.user, {reason: args.reason}); 73 | } catch { 74 | // eslint-disable-next-line no-return-await 75 | return await message.util?.send('Unable to ban that user'); 76 | } 77 | 78 | return message.util?.send(`${bold`${clean(args.user.tag, message)}`} was banned`); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/commands/moderation/kick.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Argument} from 'discord-akairo'; 3 | import {bold} from 'discord-md-tags'; 4 | import {GuildMember, Message, Permissions} from 'discord.js'; 5 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 6 | import {clean} from '../../util/format'; 7 | import {notManageable} from '../../util/permissions'; 8 | 9 | export default class KickCommand extends DiceCommand { 10 | constructor() { 11 | super('kick', { 12 | aliases: ['kick-member', 'kick-user'], 13 | category: DiceCommandCategories.Moderation, 14 | description: { 15 | content: 'Kick a member from your server.', 16 | usage: ' [reason]', 17 | examples: ['@Dice', 'Dice', '388191157869477888', '@Dice Spamming', 'Dice Spamming', '388191157869477888 Spamming'] 18 | }, 19 | userPermissions: [Permissions.FLAGS.KICK_MEMBERS], 20 | clientPermissions: [Permissions.FLAGS.KICK_MEMBERS], 21 | channel: 'guild', 22 | args: [ 23 | { 24 | id: 'member', 25 | type: AkairoArgumentType.Member, 26 | prompt: { 27 | start: 'Who would you like to kick?', 28 | retry: `Invalid member provided, please try again` 29 | } 30 | }, 31 | { 32 | id: 'reason', 33 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 400), 34 | match: 'rest', 35 | prompt: { 36 | optional: true, 37 | retry: `Invalid reason provided, please keep it below 400 characters` 38 | } 39 | } 40 | ] 41 | }); 42 | } 43 | 44 | async exec(message: Message, args: {member: GuildMember; reason: string | null}): Promise { 45 | assert(message.member); 46 | 47 | args.reason = args.reason ? `${args.reason} - Requested by ${message.author.tag}` : `Requested by ${message.author.tag}`; 48 | 49 | if (!notManageable(message.member, args.member)) { 50 | return message.util?.send("You don't have permissions to kick that member"); 51 | } 52 | 53 | if (args.member.kickable) { 54 | try { 55 | await args.member.kick(args.reason); 56 | } catch { 57 | // eslint-disable-next-line no-return-await 58 | return await message.util?.send('Unable to kick that member'); 59 | } 60 | 61 | return message.util?.send(`${bold`${clean(args.member.user.tag, message)}`} was kicked`); 62 | } 63 | 64 | // Member not kickable 65 | return message.util?.send("I can't kick that member"); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/moderation/prune.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Argument} from 'discord-akairo'; 3 | import {bold} from 'discord-md-tags'; 4 | import {Collection, Message, Permissions, TextChannel} from 'discord.js'; 5 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 6 | 7 | export default class PruneCommand extends DiceCommand { 8 | constructor() { 9 | super('prune', { 10 | aliases: [ 11 | 'bulk-delete-messages', 12 | 'message-prune', 13 | 'message-bulk-delete', 14 | 'delete-messages', 15 | 'messages-prune', 16 | 'messages-bulk-delete', 17 | 'bulk-delete', 18 | 'prune-messages' 19 | ], 20 | category: DiceCommandCategories.Moderation, 21 | description: { 22 | content: 'Delete several messages in bulk.', 23 | usage: '', 24 | examples: ['5', '100'] 25 | }, 26 | userPermissions: [Permissions.FLAGS.MANAGE_MESSAGES], 27 | channel: 'guild', 28 | args: [ 29 | { 30 | id: 'amount', 31 | type: Argument.range(AkairoArgumentType.Integer, 1, 100, true), 32 | prompt: { 33 | start: 'How many messages do you want to delete?', 34 | retry: `Invalid amount provided, please provide a number from 1-100` 35 | } 36 | } 37 | ] 38 | }); 39 | } 40 | 41 | async exec(message: Message, args: {amount: number}): Promise { 42 | assert(message.member); 43 | 44 | if (message.member.permissionsIn(message.channel).has(Permissions.FLAGS.MANAGE_MESSAGES)) { 45 | const reason = `Requested by ${message.author.tag}`; 46 | 47 | if (message.deletable) { 48 | await message.delete({reason}); 49 | } 50 | 51 | const channelMessages = await message.channel.messages.fetch({limit: args.amount}); 52 | 53 | let deletedMessages: Collection; 54 | try { 55 | deletedMessages = await (message.channel as TextChannel).bulkDelete(channelMessages); 56 | } catch { 57 | // eslint-disable-next-line no-return-await 58 | return await message.util?.send('Unable to delete those messages'); 59 | } 60 | 61 | return message.util?.send(`${bold`${deletedMessages.size}`} messages were deleted`); 62 | } 63 | 64 | return message.util?.send("You don't have permissions to delete messages in this channel"); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/commands/moderation/toggle-nsfw.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {bold} from 'discord-md-tags'; 3 | import {Message, Permissions, TextChannel} from 'discord.js'; 4 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {clean} from '../../util/format'; 6 | 7 | export default class ToggleNSFWCommand extends DiceCommand { 8 | constructor() { 9 | super('toggle-nsfw', { 10 | aliases: ['nsfw', 'nsfw-toggle', 'toggle-channel-nsfw', 'toggle-nsfw-channel'], 11 | category: DiceCommandCategories.Moderation, 12 | description: { 13 | content: "Toggle a channel's NSFW setting.", 14 | usage: '', 15 | examples: ['#pics', 'pics', '452599740274573312'] 16 | }, 17 | channel: 'guild', 18 | args: [ 19 | { 20 | id: 'channel', 21 | type: AkairoArgumentType.TextChannel, 22 | prompt: { 23 | start: 'Which channel do you want to modify?', 24 | retry: `Invalid channel provided, please provide a valid channel on this server` 25 | } 26 | } 27 | ] 28 | }); 29 | } 30 | 31 | async exec(message: Message, args: {channel: TextChannel}): Promise { 32 | const channelName = bold`${clean(args.channel.name, message)}`; 33 | 34 | if (!args.channel.manageable) { 35 | return message.util?.send(`I don't have permissions to manage ${channelName}`); 36 | } 37 | 38 | assert(message.member); 39 | 40 | if (!args.channel.permissionsFor(message.member)?.has(Permissions.FLAGS.MANAGE_CHANNELS)) { 41 | return message.util?.send(`You don't have permissions to manage ${channelName}`); 42 | } 43 | 44 | try { 45 | await args.channel.setNSFW(!args.channel.nsfw); 46 | } catch (error: unknown) { 47 | this.logger.error(error); 48 | // eslint-disable-next-line no-return-await 49 | return await message.util?.send(`An error occurred while changing ${channelName}'s NSFW setting`); 50 | } 51 | 52 | return message.util?.send(`${channelName} was set to ${args.channel.nsfw ? '' : 'not '}NSFW`); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/commands/moderation/unban.ts: -------------------------------------------------------------------------------- 1 | import {Argument} from 'discord-akairo'; 2 | import {bold} from 'discord-md-tags'; 3 | import {Message, Permissions, User} from 'discord.js'; 4 | import assert from 'assert'; 5 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 6 | import {typeName as anyUser} from '../../types/anyUser'; 7 | import {clean} from '../../util/format'; 8 | 9 | export default class BanCommand extends DiceCommand { 10 | constructor() { 11 | super('unban', { 12 | aliases: ['unban-member', 'unban-user', 'unhackban-user', 'unhackban-member', 'unhackban'], 13 | category: DiceCommandCategories.Moderation, 14 | description: { 15 | content: 'Unban any user from your server.', 16 | usage: ' [reason}', 17 | examples: [ 18 | '@Dice', 19 | 'Dice', 20 | '388191157869477888', 21 | '@Dice No longer acting stupid', 22 | 'Dice No longer acting stupid', 23 | '388191157869477888 No longer acting stupid' 24 | ] 25 | }, 26 | userPermissions: [Permissions.FLAGS.BAN_MEMBERS], 27 | clientPermissions: [Permissions.FLAGS.BAN_MEMBERS], 28 | channel: 'guild', 29 | args: [ 30 | { 31 | id: 'user', 32 | type: anyUser, 33 | prompt: { 34 | start: 'Who would you like to unban?', 35 | retry: `Invalid user provided, please try again` 36 | } 37 | }, 38 | { 39 | id: 'reason', 40 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 400), 41 | match: 'rest', 42 | prompt: { 43 | optional: true, 44 | retry: `Invalid reason provided, please keep it below 400 characters` 45 | } 46 | } 47 | ] 48 | }); 49 | } 50 | 51 | async exec(message: Message, args: {user: User; reason: string | null}): Promise { 52 | assert(message.guild); 53 | 54 | args.reason = args.reason ? `${args.reason} - Requested by ${message.author.tag}` : `Requested by ${message.author.tag}`; 55 | 56 | const bans = await message.guild.fetchBans(); 57 | 58 | if (bans.has(args.user.id)) { 59 | try { 60 | await message.guild.members.unban(args.user, args.reason); 61 | } catch { 62 | // eslint-disable-next-line no-return-await 63 | return await message.util?.send('Unable to unban that user'); 64 | } 65 | 66 | return message.util?.send(`${bold`${clean(args.user.tag, message)}`} was unbanned`); 67 | } 68 | 69 | return message.util?.send("That user isn't banned"); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/selfroles/add-self-role.ts: -------------------------------------------------------------------------------- 1 | import {bold} from 'discord-md-tags'; 2 | import {Message, Permissions, Role, Util} from 'discord.js'; 3 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 4 | import {clean} from '../../util/format'; 5 | import assert from 'assert'; 6 | 7 | export default class AddSelfroleCommand extends DiceCommand { 8 | constructor() { 9 | super('add-self-role', { 10 | aliases: ['self-role-add', 'self-roles-add', 'add-self-roles'], 11 | description: {content: "Add a role to a server's selfroles.", usage: '', examples: ['@Updates', 'Artists']}, 12 | category: DiceCommandCategories.Selfroles, 13 | channel: 'guild', 14 | userPermissions: [Permissions.FLAGS.MANAGE_ROLES], 15 | args: [ 16 | {id: 'role', type: AkairoArgumentType.Role, prompt: {retry: 'Invalid role provided, please try again', start: 'What selfrole would you like to add?'}} 17 | ] 18 | }); 19 | } 20 | 21 | async exec(message: Message, args: {role: Role}): Promise { 22 | assert(message.guild); 23 | assert(message.guild.me); 24 | assert(message.member); 25 | 26 | if (message.guild.id === args.role.id) { 27 | return message.util?.send(`You can't add ${Util.cleanContent('@everyone', message)} as a selfrole`); 28 | } 29 | 30 | const guild = await this.client.prisma.guild.findUnique({where: {id: message.guild.id}, select: {selfRoles: true}}); 31 | 32 | const selfRoles = new Set(guild?.selfRoles); 33 | 34 | // Check if the role is already a self role 35 | if (selfRoles.has(args.role.id)) { 36 | return message.util?.send('That role is already a selfrole'); 37 | } 38 | 39 | // Check if the author is able to add the role 40 | if (args.role.comparePositionTo(message.member.roles.highest) >= 0 && !message.member.hasPermission(Permissions.FLAGS.ADMINISTRATOR)) { 41 | return message.reply("You don't have the permissions to add that role"); 42 | } 43 | 44 | // Check if bot is able to add that role 45 | if (args.role.comparePositionTo(message.guild.me.roles.highest) >= 0) { 46 | return message.reply("I don't have the permissions to add that role"); 47 | } 48 | 49 | // Check if role is managed by an integration 50 | if (args.role.managed) { 51 | return message.reply('An integration is managing that role'); 52 | } 53 | 54 | await this.client.prisma.guild.upsert({ 55 | where: {id: message.guild.id}, 56 | create: {id: message.guild.id, selfRoles: {set: [args.role.id]}}, 57 | update: {selfRoles: {set: [...selfRoles, args.role.id]}} 58 | }); 59 | 60 | return message.util?.send(`Added ${bold`${clean(args.role.name, message)}`} to the selfroles`); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/commands/selfroles/delete-self-role.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {bold} from 'discord-md-tags'; 3 | import {Message, Permissions, Role} from 'discord.js'; 4 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {clean} from '../../util/format'; 6 | 7 | export default class DeleteSelfRoleCommand extends DiceCommand { 8 | constructor() { 9 | super('delete-self-role', { 10 | aliases: ['self-role-delete', 'self-roles-delete', 'delete-self-roles', 'del-self-roles', 'self-role-del', 'self-roles-del', 'del-self-role'], 11 | description: {content: "Remove a role from this server's selfroles.", usage: '', examples: ['@Updates', 'Artists']}, 12 | category: DiceCommandCategories.Selfroles, 13 | channel: 'guild', 14 | userPermissions: [Permissions.FLAGS.MANAGE_ROLES], 15 | args: [ 16 | { 17 | id: 'role', 18 | type: AkairoArgumentType.Role, 19 | prompt: {retry: 'Invalid role provided, please try again', start: 'What selfrole would you like to remove?'} 20 | } 21 | ] 22 | }); 23 | } 24 | 25 | async exec(message: Message, args: {role: Role}): Promise { 26 | assert(message.member); 27 | assert(message.guild); 28 | 29 | const guild = await this.client.prisma.guild.findUnique({where: {id: message.guild.id}, select: {selfRoles: true}}); 30 | const selfRoles = new Set(guild?.selfRoles); 31 | 32 | if (!selfRoles.has(args.role.id)) { 33 | return message.util?.send("That role isn't a selfrole"); 34 | } 35 | 36 | if (message.member.roles.highest.comparePositionTo(args.role) <= 0 && !message.member.hasPermission(Permissions.FLAGS.ADMINISTRATOR)) { 37 | return message.reply("You don't have the permissions to delete that role"); 38 | } 39 | 40 | selfRoles.delete(args.role.id); 41 | 42 | await this.client.prisma.guild.update({where: {id: message.guild.id}, data: {selfRoles: {set: [...selfRoles]}}}); 43 | 44 | return message.util?.send(`Removed ${bold`${clean(args.role.name, message)}`} from the selfroles`); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/selfroles/get-self-role.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {bold} from 'discord-md-tags'; 3 | import {Message, Permissions, Role} from 'discord.js'; 4 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {clean} from '../../util/format'; 6 | import {cleanDeletedSelfRoles} from '../../util/self-roles'; 7 | 8 | export default class GetSelfRoleCommand extends DiceCommand { 9 | constructor() { 10 | super('get-self-role', { 11 | aliases: ['self-role-get', 'self-roles-get', 'get-self-roles'], 12 | description: {content: 'Give yourself a selfrole.', usage: '', examples: ['@Updates', 'Artists']}, 13 | category: DiceCommandCategories.Selfroles, 14 | channel: 'guild', 15 | clientPermissions: [Permissions.FLAGS.MANAGE_ROLES], 16 | args: [ 17 | {id: 'role', type: AkairoArgumentType.Role, prompt: {retry: 'Invalid role provided, please try again', start: 'What selfrole would you like to get?'}} 18 | ] 19 | }); 20 | } 21 | 22 | async exec(message: Message, args: {role: Role}): Promise { 23 | assert(message.member); 24 | assert(message.guild); 25 | 26 | if (message.member.roles.cache.has(args.role.id)) { 27 | return message.util?.send('You already have that role'); 28 | } 29 | 30 | const guild = await this.client.prisma.guild.findUnique({where: {id: message.guild.id}, select: {selfRoles: true}}); 31 | const selfRoles = new Set(guild?.selfRoles); 32 | 33 | if (guild && selfRoles.size > 0) { 34 | const validatedSelfroles = await cleanDeletedSelfRoles(this.client.prisma, [...selfRoles], message.guild); 35 | 36 | if (validatedSelfroles.length === 0) { 37 | // The selfroles list from the DB consisted entirely of invalid roles 38 | return message.util?.send('No selfroles'); 39 | } 40 | 41 | if (selfRoles.has(args.role.id)) { 42 | try { 43 | await message.member.roles.add(args.role.id, 'Selfrole'); 44 | } catch { 45 | // eslint-disable-next-line no-return-await 46 | return await message.util?.send('Unable to give you that role'); 47 | } 48 | 49 | return message.util?.send(`You were given the ${bold`${clean(args.role.name, message)}`} role`); 50 | } 51 | 52 | return message.util?.send("That role isn't a selfrole"); 53 | } 54 | 55 | return message.util?.send('No selfroles'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/commands/selfroles/list-self-roles.ts: -------------------------------------------------------------------------------- 1 | import {Message, Util} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {cleanDeletedSelfRoles} from '../../util/self-roles'; 4 | import assert from 'assert'; 5 | 6 | export default class ListSelfRolesCommand extends DiceCommand { 7 | constructor() { 8 | super('list-self-roles', { 9 | aliases: ['self-roles-list'], 10 | description: {content: 'List all self-assigned roles from this server.', examples: [''], usage: ''}, 11 | category: DiceCommandCategories.Selfroles, 12 | channel: 'guild' 13 | }); 14 | } 15 | 16 | async exec(message: Message): Promise { 17 | assert(message.guild); 18 | assert(message.member); 19 | 20 | const guild = await this.client.prisma.guild.findUnique({where: {id: message.guild.id}, select: {selfRoles: true}}); 21 | 22 | if (guild && guild.selfRoles.length > 0) { 23 | const validatedSelfroles = await cleanDeletedSelfRoles(this.client.prisma, guild.selfRoles, message.guild); 24 | 25 | if (validatedSelfroles.length === 0) { 26 | // The selfroles list from the DB consisted entirely of invalid roles 27 | return message.util?.send('No selfroles'); 28 | } 29 | 30 | return message.util?.send( 31 | [ 32 | 'A ▫ indicates a role you currently have', 33 | Util.escapeMarkdown( 34 | Util.cleanContent( 35 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 36 | validatedSelfroles.map(id => `${message.guild!.roles.cache.get(id)!.name}${message.member!.roles.cache.has(id) ? '▫' : ''}`).join('\n'), 37 | message 38 | ) 39 | ) 40 | ].join('\n') 41 | ); 42 | } 43 | 44 | return message.util?.send('No selfroles'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/selfroles/remove-self-role.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {bold} from 'discord-md-tags'; 3 | import {Message, Permissions, Role} from 'discord.js'; 4 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {clean} from '../../util/format'; 6 | 7 | export default class GetSelfRoleCommand extends DiceCommand { 8 | constructor() { 9 | super('remove-self-role', { 10 | aliases: ['self-role-remove', 'self-roles-remove', 'get-self-remove'], 11 | description: {content: 'Remove a selfrole from yourself.', usage: '', examples: ['@Updates", "Artists']}, 12 | category: DiceCommandCategories.Selfroles, 13 | channel: 'guild', 14 | clientPermissions: [Permissions.FLAGS.MANAGE_ROLES], 15 | args: [ 16 | { 17 | id: 'role', 18 | type: AkairoArgumentType.Role, 19 | prompt: {retry: 'Invalid role provided, please try again', start: 'What selfrole would you like to remove from yourself?'} 20 | } 21 | ] 22 | }); 23 | } 24 | 25 | async exec(message: Message, args: {role: Role}): Promise { 26 | assert(message.guild); 27 | assert(message.member); 28 | 29 | if (!message.member.roles.cache.has(args.role.id)) { 30 | return message.util?.send("You don't have that role"); 31 | } 32 | 33 | const guild = await this.client.prisma.guild.findUnique({where: {id: message.guild.id}, select: {selfRoles: true}}); 34 | const selfRoles = new Set(guild?.selfRoles); 35 | 36 | if (!selfRoles.has(args.role.id)) { 37 | return message.util?.send("That role isn't a selfrole"); 38 | } 39 | 40 | try { 41 | await message.member.roles.remove(args.role.id, 'Selfrole'); 42 | 43 | return await message.util?.send(`You no longer have the ${bold`${clean(args.role.name, message)}`} role`); 44 | } catch { 45 | // eslint-disable-next-line no-return-await 46 | return await message.util?.send('Unable to remove that role from you'); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/commands/selfroles/self-role.ts: -------------------------------------------------------------------------------- 1 | import {Flag, ArgumentOptions} from 'discord-akairo'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | 4 | export default class SelfRoleCommand extends DiceCommand { 5 | public constructor() { 6 | super('self-role', { 7 | aliases: ['self-roles'], 8 | description: { 9 | content: 'Commands for selfroles on servers', 10 | usage: ' <…arguments>', 11 | examples: ['add Artists', 'delete Artists', 'get Artists', 'list', 'remove Artists'] 12 | }, 13 | category: DiceCommandCategories.Selfroles, 14 | channel: 'guild' 15 | }); 16 | } 17 | 18 | public *args(): Generator< 19 | ArgumentOptions, 20 | Flag & { 21 | command: string; 22 | ignore: boolean; 23 | rest: string; 24 | }, 25 | string 26 | > { 27 | const arg: ArgumentOptions = { 28 | type: [ 29 | ['add-self-role', 'add'], 30 | ['delete-self-role', 'delete', 'del'], 31 | ['get-self-role', 'get'], 32 | ['list-self-roles', 'list', 'ls'], 33 | ['remove-self-role', 'remove', 'rm'] 34 | ], 35 | default: 'list-self-roles', 36 | prompt: {optional: true, retry: 'Invalid subcommand provided, please try again'} 37 | }; 38 | 39 | const method = yield arg; 40 | 41 | return Flag.continue(method); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/single/invite.ts: -------------------------------------------------------------------------------- 1 | import SingleResponseCommand from '../../structures/SingleResponseCommand'; 2 | 3 | export default class InviteCommand extends SingleResponseCommand { 4 | constructor() { 5 | super('invite', { 6 | description: 'An invite link to add Dice to a server.', 7 | response: 'https://dice.js.org/invite' 8 | }); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/commands/single/nitro.ts: -------------------------------------------------------------------------------- 1 | import SingleResponseCommand from '../../structures/SingleResponseCommand'; 2 | import {MessageEmbed} from 'discord.js'; 3 | 4 | const response = new MessageEmbed({ 5 | author: { 6 | name: 'Discord Nitro', 7 | iconURL: 'https://cdn.discordapp.com/emojis/314068430611415041.png', 8 | url: 'https://discordapp.com/nitro' 9 | }, 10 | thumbnail: { 11 | url: 'https://cdn.discordapp.com/emojis/314068430611415041.png' 12 | }, 13 | color: 0x8395d3, 14 | description: ['This message can only be viewed by users with Discord Nitro.', '[Lift off with Discord Nitro today](https://discordapp.com/nitro).'].join('\n') 15 | }); 16 | 17 | export default class NitroCommand extends SingleResponseCommand { 18 | constructor() { 19 | super('nitro', { 20 | aliases: ['discord-nitro', 'nitro-message', 'nitro-msg'], 21 | description: 'This message can only be viewed by users with Discord Nitro.', 22 | response 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/commands/single/support.ts: -------------------------------------------------------------------------------- 1 | import SingleResponseCommand from '../../structures/SingleResponseCommand'; 2 | 3 | export default class SupportCommand extends SingleResponseCommand { 4 | constructor() { 5 | super('support', { 6 | aliases: ['home', 'report', 'bug'], 7 | description: 'An invite to the Dice server.', 8 | response: 'https://dice.js.org/server' 9 | }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/commands/single/vote.ts: -------------------------------------------------------------------------------- 1 | import SingleResponseCommand from '../../structures/SingleResponseCommand'; 2 | 3 | export default class VoteCommand extends SingleResponseCommand { 4 | constructor() { 5 | super('vote', { 6 | aliases: ['voting'], 7 | description: 'Get a link to vote on Top.gg and get extra oats.', 8 | response: 'https://top.gg/bot/388191157869477888/vote' 9 | }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/commands/tags/create-tag.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Argument} from 'discord-akairo'; 3 | import {bold} from 'discord-md-tags'; 4 | import {Message} from 'discord.js'; 5 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 6 | import {clean} from '../../util/format'; 7 | 8 | export default class CreateTagCommand extends DiceCommand { 9 | constructor() { 10 | super('create-tag', { 11 | aliases: ['add-tag', 'tag-create', 'tag-add', 'make-tag', 'tag-make', 'new-tag', 'tag-new'], 12 | description: { 13 | content: "Add a tag to a server's tags.", 14 | usage: ' ', 15 | examples: ['help If you need help, look for someone with a purple name'] 16 | }, 17 | category: DiceCommandCategories.Tags, 18 | channel: 'guild', 19 | args: [ 20 | { 21 | id: 'id', 22 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 50), 23 | prompt: {start: 'What ID should the new tag have?', retry: "Invalid ID provided, please provide an ID that's less than 50 characters"}, 24 | match: 'phrase' 25 | }, 26 | { 27 | id: 'content', 28 | match: 'rest', 29 | prompt: {start: 'What content should the new tag have?', retry: 'Please keep your tag content below 1,800 characters'}, 30 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 1800) 31 | } 32 | ] 33 | }); 34 | } 35 | 36 | async exec(message: Message, args: {id: string; content: string}): Promise { 37 | assert(message.guild); 38 | 39 | const tag = await this.client.prisma.tag.findUnique({where: {id_guildId: {guildId: message.guild.id, id: args.id}}}); 40 | 41 | if (tag) { 42 | // Tag exists 43 | return message.util?.send('That tag already exists'); 44 | } 45 | 46 | // Upsert the guild since the tag and/or the guild don't exist 47 | await this.client.prisma.guild.upsert({ 48 | where: {id: message.guild.id}, 49 | create: {id: message.guild.id, tags: {create: {author: message.author.id, id: args.id, content: args.content}}}, 50 | update: {tags: {create: {author: message.author.id, id: args.id, content: args.content}}} 51 | }); 52 | 53 | return message.util?.send(`Added ${bold`${clean(args.id, message)}`} to the tags`); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/tags/delete-tag.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Argument} from 'discord-akairo'; 3 | import {bold} from 'discord-md-tags'; 4 | import {Message, Permissions} from 'discord.js'; 5 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 6 | import {clean} from '../../util/format'; 7 | 8 | export default class DeleteTagCommand extends DiceCommand { 9 | constructor() { 10 | super('delete-tag', { 11 | aliases: ['tag-delete', 'tags-delete', 'delete-tags', 'del-tags', 'tag-del', 'tags-del', 'del-tag', 'rm-tag', 'rm-tags', 'tags-rm', 'tag-rm'], 12 | description: {content: "Remove a role from this server's selfroles.", usage: '', examples: ['help']}, 13 | category: DiceCommandCategories.Tags, 14 | channel: 'guild', 15 | args: [ 16 | { 17 | id: 'id', 18 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 50), 19 | prompt: {retry: 'Invalid tag provided, please try again', start: 'Which tag do you like to delete?'} 20 | } 21 | ] 22 | }); 23 | } 24 | 25 | async exec(message: Message, args: {id: string}): Promise { 26 | assert(message.guild); 27 | assert(message.member); 28 | 29 | const tag = await this.client.prisma.tag.findUnique({where: {id_guildId: {guildId: message.guild.id, id: args.id}}}); 30 | 31 | if (tag) { 32 | if (tag.author === message.author.id || message.member.permissions.has(Permissions.FLAGS.MANAGE_MESSAGES)) { 33 | // You created this tag or you have manage messages permissions 34 | await this.client.prisma.tag.delete({where: {id_guildId: {guildId: message.guild.id, id: args.id}}, select: {id: true}}); 35 | return message.util?.send(`Deleted ${bold`${clean(args.id, message)}`} from this server's tags`); 36 | } 37 | 38 | return message.util?.send( 39 | [ 40 | "You don't have permission to delete that tag", 41 | `Only its author (${clean((await this.client.users.fetch(tag.author)).tag, message)}) or someone with manage messages permissions can delete it` 42 | ].join('\n') 43 | ); 44 | } 45 | 46 | return message.util?.send("That tag doesn't exist"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/commands/tags/edit-tag.ts: -------------------------------------------------------------------------------- 1 | import {Argument} from 'discord-akairo'; 2 | import {bold} from 'discord-md-tags'; 3 | import {Message, Permissions} from 'discord.js'; 4 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {clean} from '../../util/format'; 6 | import assert from 'assert'; 7 | 8 | export default class EditTagCommand extends DiceCommand { 9 | constructor() { 10 | super('edit-tag', { 11 | aliases: [ 12 | 'edit-tags', 13 | 'modify-tag', 14 | 'modify-tags', 15 | 'tag-edit', 16 | 'tags-edit', 17 | 'tag-change', 18 | 'change-tag', 19 | 'tags-change', 20 | 'change-tags', 21 | 'update-tags', 22 | 'update-tag', 23 | 'tag-update', 24 | 'tags-update' 25 | ], 26 | description: {content: "Edit an existing tag from a server's tags.", usage: ' ', examples: ['help New content']}, 27 | category: DiceCommandCategories.Tags, 28 | channel: 'guild', 29 | args: [ 30 | { 31 | id: 'id', 32 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 50), 33 | prompt: {start: 'Which tag do you want to edit?', retry: "Invalid ID provided, please provide an ID that's less than 50 characters"}, 34 | match: 'phrase' 35 | }, 36 | { 37 | id: 'content', 38 | match: 'rest', 39 | prompt: {start: 'What content should the updated tag have?', retry: 'Please keep your tag content below 1,800 characters'}, 40 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 1800) 41 | } 42 | ] 43 | }); 44 | } 45 | 46 | async exec(message: Message, args: {id: string; content: string}): Promise { 47 | assert(message.guild); 48 | assert(message.member); 49 | 50 | const tag = await this.client.prisma.tag.findUnique({where: {id_guildId: {guildId: message.guild.id, id: args.id}}, select: {author: true}}); 51 | 52 | if (tag) { 53 | if (tag.author === message.author.id || message.member.permissions.has(Permissions.FLAGS.MANAGE_MESSAGES)) { 54 | // You created this tag or you have manage messages permissions 55 | 56 | const updatedTag = await this.client.prisma.tag.update({ 57 | where: {id_guildId: {guildId: message.guild.id, id: args.id}}, 58 | data: {content: args.content}, 59 | select: {id: true} 60 | }); 61 | return message.util?.send(`Edited tag ${bold`${clean(updatedTag.id, message)}`}`); 62 | } 63 | 64 | return message.util?.send( 65 | [ 66 | "You don't have permission to edit that tag", 67 | `Only its author (${clean((await this.client.users.fetch(tag.author)).tag, message)}) or someone with manage messages permissions can edit it` 68 | ].join('\n') 69 | ); 70 | } 71 | 72 | return message.util?.send("That tag doesn't exist"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/tags/get-tag.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Argument} from 'discord-akairo'; 3 | import {Message} from 'discord.js'; 4 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {clean} from '../../util/format'; 6 | 7 | export interface GetTagCommandArgs { 8 | id?: string; 9 | noError?: boolean; 10 | } 11 | 12 | export default class GetTagCommand extends DiceCommand { 13 | constructor() { 14 | super('get-tag', { 15 | aliases: ['read-tag', 'tag-get', 'tag'], 16 | description: { 17 | content: "Get a tag from a server's tags.", 18 | usage: '', 19 | examples: ['help'] 20 | }, 21 | category: DiceCommandCategories.Tags, 22 | channel: 'guild', 23 | args: [ 24 | { 25 | id: 'id', 26 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 50), 27 | prompt: {start: 'What is the ID of the tag you want to get?', retry: "Invalid ID provided, please provide an ID that's less than 50 characters"}, 28 | match: 'content' 29 | } 30 | ] 31 | }); 32 | } 33 | 34 | // `noError` is intentionally not listed in the args array in the constructor 35 | async exec(message: Message, args: GetTagCommandArgs): Promise { 36 | if (args.id === undefined) { 37 | return; 38 | } 39 | 40 | assert(message.guild); 41 | 42 | const tag = await this.client.prisma.tag.findUnique({where: {id_guildId: {guildId: message.guild.id, id: args.id}}, select: {content: true}}); 43 | 44 | if (tag) { 45 | return message.util?.send(clean(tag.content, message)); 46 | } 47 | 48 | // This is defined when the command is triggered by a `messageInvalid` event 49 | // ex. `$$tag-name` would provide the args {id: 'tag-name', noError: true} 50 | if (!args.noError) { 51 | return message.util?.send("That tag doesn't exist"); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/commands/tags/list-tags.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {code} from 'discord-md-tags'; 3 | import {Message, Util} from 'discord.js'; 4 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | 6 | export default class ListTagsCommand extends DiceCommand { 7 | constructor() { 8 | super('list-tags', { 9 | aliases: ['tags-list', 'all-tags', 'tags-all', 'tags-ls', 'ls-tags'], 10 | description: {content: 'List all tags from this server.', examples: [''], usage: ''}, 11 | category: DiceCommandCategories.Tags, 12 | channel: 'guild' 13 | }); 14 | } 15 | 16 | async exec(message: Message): Promise { 17 | assert(message.guild); 18 | 19 | const guild = await this.client.prisma.guild.findUnique({where: {id: message.guild.id}, select: {tags: true}}); 20 | 21 | if (guild && guild.tags.length > 0) { 22 | return message.util?.send(guild.tags.map(tag => code`${Util.escapeMarkdown(Util.cleanContent(tag.id, message), {inlineCodeContent: true})}`).join(', ')); 23 | } 24 | 25 | return message.util?.send('No tags'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/tags/tags.ts: -------------------------------------------------------------------------------- 1 | import {Flag, ArgumentOptions} from 'discord-akairo'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | 4 | export default class TagsCommand extends DiceCommand { 5 | public constructor() { 6 | super('tags', { 7 | description: { 8 | content: 'Commands for tags on servers.', 9 | usage: ' <…arguments>', 10 | examples: ['add ip 127.0.0.1', 'delete ip', 'get ip', 'list'] 11 | }, 12 | category: DiceCommandCategories.Tags, 13 | channel: 'guild' 14 | }); 15 | } 16 | 17 | public *args(): Generator< 18 | ArgumentOptions, 19 | Flag & { 20 | command: string; 21 | ignore: boolean; 22 | rest: string; 23 | }, 24 | string 25 | > { 26 | const arg: ArgumentOptions = { 27 | type: [ 28 | ['create-tag', 'create', 'new', 'add'], 29 | ['delete-tag', 'delete', 'del', 'remove', 'rm'], 30 | ['get-tag', 'get', 'view'], 31 | ['list-tags', 'list', 'ls', 'all'], 32 | ['edit-tag', 'edit', 'modify', 'change', 'update'] 33 | ], 34 | default: 'list-tags', 35 | prompt: {optional: true, retry: 'Invalid subcommand provided, please try again'} 36 | }; 37 | 38 | const method = yield arg; 39 | 40 | return Flag.continue(method); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/utility/age.ts: -------------------------------------------------------------------------------- 1 | import {capitalize} from '@jonahsnider/util'; 2 | import {formatDistance, formatRelative} from 'date-fns'; 3 | import {Message, User} from 'discord.js'; 4 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 5 | import {typeName as anyUser} from '../../types/anyUser'; 6 | 7 | export default class AgeCommand extends DiceCommand { 8 | constructor() { 9 | super('age', { 10 | aliases: ['account-age', 'user-age', 'user-creation', 'account-creation'], 11 | category: DiceCommandCategories.Util, 12 | description: { 13 | content: 'See how old a user account is.', 14 | usage: '[user]', 15 | examples: ['', 'Dice', '@Dice', '388191157869477888'] 16 | }, 17 | args: [ 18 | { 19 | id: 'user', 20 | match: 'content', 21 | type: anyUser, 22 | prompt: {optional: true, retry: 'Invalid user, please try again'} 23 | } 24 | ] 25 | }); 26 | } 27 | 28 | async exec(message: Message, {user}: {user?: User}): Promise { 29 | const {createdAt} = user ?? message.author; 30 | const now = message.editedAt ?? message.createdAt; 31 | 32 | return message.util?.send([`${capitalize(formatDistance(createdAt, now))} old`, `Created on ${formatRelative(createdAt, now)}`].join('\n')); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/commands/utility/aqi.ts: -------------------------------------------------------------------------------- 1 | import ms = require('pretty-ms'); 2 | import {Stopwatch} from '@jonahsnider/util'; 3 | import {Argument} from 'discord-akairo'; 4 | import {Message, MessageEmbed} from 'discord.js'; 5 | import got from 'got'; 6 | // eslint-disable-next-line import/extensions 7 | import type {Forecast, ForecastInputZipCode} from '../../../types/airnow'; 8 | import {airNowApiToken} from '../../config'; 9 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 10 | 11 | const aqiColors = ['#48E400', '#FEFE00', '#F67D00', '#F40000', '#8B3F99', '#780021']; 12 | 13 | export default class AqiCommand extends DiceCommand { 14 | constructor() { 15 | super('aqi', { 16 | aliases: ['get-aqi', 'zipcode-aqi'], 17 | description: {content: 'Get the AQI (air quality index) for a ZIP code.', examples: ['94124'], usage: ''}, 18 | category: DiceCommandCategories.Util, 19 | args: [ 20 | { 21 | id: 'zip', 22 | type: Argument.validate(AkairoArgumentType.Integer, (message, phrase, value: number) => value.toString().length <= 10), 23 | match: 'content', 24 | prompt: {start: 'What ZIP code would you like to look up?', retry: 'Invalid ZIP code, please try again'} 25 | } 26 | ] 27 | }); 28 | } 29 | 30 | async exec(message: Message, args: {zip: number}): Promise { 31 | if (airNowApiToken === undefined) { 32 | this.logger.error('No AirNow API token was found'); 33 | 34 | return message.util?.send("Sorry, the developers haven't configured this command for use"); 35 | } 36 | 37 | const data: ForecastInputZipCode = { 38 | // eslint-disable-next-line camelcase 39 | api_key: airNowApiToken, 40 | format: 'application/json', 41 | zipCode: args.zip.toString() 42 | }; 43 | 44 | const stopwatch = new Stopwatch(); 45 | 46 | let aqi; 47 | stopwatch.start(); 48 | try { 49 | const request = got('aq/forecast/zipCode', { 50 | prefixUrl: 'https://www.airnowapi.org', 51 | searchParams: data as unknown as Record, 52 | responseType: 'json' 53 | }); 54 | 55 | const response = await request; 56 | 57 | if (response.body.length === 0) { 58 | return await message.util?.send('There were no results'); 59 | } 60 | 61 | aqi = response.body[0]; 62 | } catch (error: unknown) { 63 | this.logger.error(error); 64 | 65 | // eslint-disable-next-line no-return-await 66 | return await message.util?.send('An error occurred while getting the AQI'); 67 | } 68 | 69 | const elapsed = Number(stopwatch.end()); 70 | 71 | const embed = new MessageEmbed() 72 | .setTimestamp(new Date(aqi.DateForecast)) 73 | .setFooter(`Took ${ms(elapsed)}`) 74 | .setTitle(`${aqi.Latitude}, ${aqi.Longitude} (${aqi.ReportingArea})`) 75 | .setDescription([`${aqi.AQI} (${aqi.ParameterName})`, aqi.Discussion].join('\n')) 76 | .setColor(aqiColors[aqi.Category.Number - 1] ?? undefined); 77 | 78 | return message.util?.send(embed); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/commands/utility/average.ts: -------------------------------------------------------------------------------- 1 | import {mean} from '@jonahsnider/util'; 2 | import {Message} from 'discord.js'; 3 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 4 | 5 | export default class AverageCommand extends DiceCommand { 6 | constructor() { 7 | super('average', { 8 | aliases: ['average-numbers', 'avg-numbers', 'avg', 'mean'], 9 | description: {content: 'Get the mean of a set of numbers.', examples: ['192 168 1 1'], usage: '<...numbers>'}, 10 | category: DiceCommandCategories.Util, 11 | args: [ 12 | { 13 | id: 'numbers', 14 | match: 'separate', 15 | type: AkairoArgumentType.Number, 16 | prompt: {start: 'What numbers should be averaged?', retry: 'Invalid numbers provided, please try again'} 17 | } 18 | ] 19 | }); 20 | } 21 | 22 | async exec(message: Message, {numbers}: {numbers: number[]}): Promise { 23 | if (numbers.length > 0) { 24 | return message.util?.send(mean(numbers).toLocaleString()); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/utility/blacklist.ts: -------------------------------------------------------------------------------- 1 | import {Message, User} from 'discord.js'; 2 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {typeName as anyUser} from '../../types/anyUser'; 4 | 5 | // eslint-disable-next-line unicorn/prevent-abbreviations 6 | export default class BlacklistCommand extends DiceCommand { 7 | constructor() { 8 | super('blacklist', { 9 | aliases: ['blacklist-user', 'unblacklist', 'unblacklist-user'], 10 | category: DiceCommandCategories.Util, 11 | description: { 12 | content: 'Blacklist a user or see why they were blacklisted.', 13 | usage: ' ', 14 | examples: ['@Dice', '@Dice abusing a bug', '@Dice remove'] 15 | }, 16 | ownerOnly: true, 17 | args: [ 18 | { 19 | id: 'user', 20 | match: 'phrase', 21 | type: anyUser, 22 | prompt: {start: 'Who do you want to blacklist?', retry: 'Invalid user, please try again'} 23 | }, 24 | { 25 | id: 'reason', 26 | match: 'rest', 27 | type: AkairoArgumentType.String, 28 | prompt: {optional: true, start: 'Why do you want to blacklist them?'} 29 | } 30 | ] 31 | }); 32 | } 33 | 34 | async exec(message: Message, args: {user: User; reason?: 'remove' | string}): Promise { 35 | const nflUser = await this.client.nfl?.fetch(args.user.id); 36 | 37 | if (nflUser) { 38 | return message.util?.send(`${args.user.tag} is already blacklisted`); 39 | } 40 | 41 | const user = await this.client.prisma.user.findUnique({where: {id: args.user.id}, select: {blacklistReason: true}}); 42 | 43 | if (typeof user?.blacklistReason === 'string') { 44 | // User is blacklisted 45 | if (args.reason === 'remove') { 46 | // Remove the user from the blacklist 47 | await this.client.prisma.user.update({where: {id: args.user.id}, data: {blacklistReason: null}}); 48 | return message.util?.send(`${args.user.tag} was removed from the blacklist`); 49 | } 50 | 51 | return message.util?.send( 52 | [`${args.user.tag} is ${typeof args.reason === 'string' ? 'already ' : ''}blacklisted for`, `> ${user.blacklistReason}`].join('\n') 53 | ); 54 | } 55 | 56 | // User does not exist or is not blacklisted 57 | if (typeof args.reason === 'string' && args.reason !== 'remove') { 58 | await this.client.prisma.user.upsert({ 59 | where: {id: args.user.id}, 60 | create: {id: args.user.id, blacklistReason: args.reason}, 61 | update: {blacklistReason: args.reason} 62 | }); 63 | return message.util?.send(`Blacklisted ${args.user.tag}`); 64 | } 65 | 66 | return message.util?.send(`${args.user.tag} is not blacklisted`); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/commands/utility/bot-info.ts: -------------------------------------------------------------------------------- 1 | import {sum} from '@jonahsnider/util'; 2 | import {convert} from 'convert'; 3 | import {formatDistanceToNow, subMilliseconds} from 'date-fns'; 4 | import {Message, MessageEmbed, Permissions} from 'discord.js'; 5 | import assert from 'assert'; 6 | import * as pkg from '../../../package.json'; 7 | import {Colors} from '../../constants'; 8 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 9 | 10 | export default class BotInfoCommand extends DiceCommand { 11 | constructor() { 12 | super('bot-info', { 13 | aliases: ['uptime', 'version', 'bot', 'memory', 'ram', 'memory-usage', 'ram-usage', 'patrons', 'supporters'], 14 | description: {content: 'Information about the bot.', examples: [''], usage: ''}, 15 | category: DiceCommandCategories.Util, 16 | clientPermissions: [Permissions.FLAGS.EMBED_LINKS] 17 | }); 18 | } 19 | 20 | async exec(message: Message): Promise { 21 | assert(this.client.uptime); 22 | 23 | let heapUsed = 0; 24 | 25 | if (this.client.shard) { 26 | const allHeapUsed = (await this.client.shard.broadcastEval('process.memoryUsage().heapUsed')) as number[]; 27 | 28 | // eslint-disable-next-line unicorn/no-array-reduce, unicorn/no-array-callback-reference 29 | heapUsed = allHeapUsed.reduce(sum); 30 | } else { 31 | heapUsed = process.memoryUsage().heapUsed; 32 | } 33 | 34 | return message.util?.send( 35 | new MessageEmbed({ 36 | title: 'Dice', 37 | url: 'https://dice.js.org', 38 | color: Colors.Primary, 39 | description: 'Dice is a multipurpose, general-use, utility bot', 40 | thumbnail: {url: this.client.user?.displayAvatarURL({format: 'webp', size: 512})}, 41 | fields: [ 42 | {name: 'Uptime', value: formatDistanceToNow(subMilliseconds(new Date(), this.client.uptime)), inline: true}, 43 | { 44 | name: 'Version', 45 | value: `v${pkg.version}`, 46 | inline: true 47 | }, 48 | { 49 | name: 'RAM usage', 50 | value: `${convert(heapUsed, 'bytes').to('megabytes').toFixed(2)} megabytes`, 51 | inline: true 52 | }, 53 | { 54 | name: 'Support team', 55 | value: 'Jonah#6905', 56 | inline: true 57 | } 58 | ] 59 | }) 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/commands/utility/choose.ts: -------------------------------------------------------------------------------- 1 | import {Message} from 'discord.js'; 2 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {clean} from '../../util/format'; 4 | 5 | export default class ChooseCommand extends DiceCommand { 6 | constructor() { 7 | super('choose', { 8 | aliases: ['pick', 'select'], 9 | description: {content: 'Choose an item from a list you provide.', examples: ['red green blue'], usage: '<...items>'}, 10 | category: DiceCommandCategories.Util, 11 | args: [ 12 | { 13 | id: 'items', 14 | match: 'separate', 15 | type: AkairoArgumentType.Lowercase, 16 | prompt: {start: 'What items should I choose from?'} 17 | } 18 | ] 19 | }); 20 | } 21 | 22 | async exec(message: Message, {items}: {items: string[]}): Promise { 23 | const randomNumber = Math.floor(Math.random() * items.length); 24 | 25 | return message.util?.send([`I pick #${randomNumber + 1}: "${clean(items[randomNumber], message)}"`]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/utility/db-ping.ts: -------------------------------------------------------------------------------- 1 | import ms = require('pretty-ms'); 2 | import {Stopwatch} from '@jonahsnider/util'; 3 | import assert from 'assert'; 4 | import {bold} from 'discord-md-tags'; 5 | import {Message} from 'discord.js'; 6 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 7 | 8 | export default class DBPingCommand extends DiceCommand { 9 | constructor() { 10 | super('db-ping', { 11 | aliases: ['database-ping'], 12 | description: {content: 'Checks how long it takes to perform a database query.', examples: [''], usage: ''}, 13 | category: DiceCommandCategories.Util 14 | }); 15 | } 16 | 17 | async exec(message: Message): Promise { 18 | assert(this.client.user); 19 | 20 | const stopwatch = Stopwatch.start(); 21 | await this.client.prisma.user.findUnique({where: {id: this.client.user.id}}); 22 | 23 | const duration = Number(stopwatch.end()); 24 | 25 | return message.util?.send(`Query took ${bold`${ms(duration, {formatSubMilliseconds: true})}`}`); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/utility/emoji.ts: -------------------------------------------------------------------------------- 1 | import {GuildEmoji, Message} from 'discord.js'; 2 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {clean} from '../../util/format'; 4 | import {bold, code} from 'discord-md-tags'; 5 | 6 | export default class EmojiCommand extends DiceCommand { 7 | constructor() { 8 | super('emoji', { 9 | aliases: ['custom-emoji'], 10 | description: {content: 'View info about a custom emoji.', usage: '', examples: ['thonk']}, 11 | category: DiceCommandCategories.Util, 12 | channel: 'guild', 13 | args: [ 14 | { 15 | id: 'emoji', 16 | type: AkairoArgumentType.Emoji, 17 | match: 'content', 18 | prompt: {start: 'Which custom emoji do you want to view?', retry: 'Invalid custom emoji provided, please try again'} 19 | } 20 | ] 21 | }); 22 | } 23 | 24 | async exec(message: Message, {emoji}: {emoji: GuildEmoji}): Promise { 25 | return message.util?.send([`${bold`:${clean(emoji.name, message)}:`} - ${code`${emoji.id}`}`, emoji.url].join('\n')); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/utility/help.ts: -------------------------------------------------------------------------------- 1 | import {capitalize} from '@jonahsnider/util'; 2 | import {PrefixSupplier} from 'discord-akairo'; 3 | import {code, codeblock} from 'discord-md-tags'; 4 | import {Message, MessageEmbed, Permissions} from 'discord.js'; 5 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 6 | 7 | export default class HelpCommand extends DiceCommand { 8 | constructor() { 9 | super('help', { 10 | category: DiceCommandCategories.Util, 11 | description: { 12 | content: 'Displays a list of available commands, or detailed information for a specified command.', 13 | usage: '[command]', 14 | examples: ['', 'ping'] 15 | }, 16 | clientPermissions: [Permissions.FLAGS.EMBED_LINKS], 17 | args: [ 18 | { 19 | id: 'command', 20 | type: AkairoArgumentType.CommandAlias, 21 | prompt: {optional: true, retry: 'Invalid command provided, please try again'} 22 | } 23 | ] 24 | }); 25 | } 26 | 27 | async exec(message: Message, {command}: {command?: DiceCommand}): Promise { 28 | const embed = new MessageEmbed(); 29 | 30 | if (command) { 31 | const [primaryCommandAlias] = command.aliases; 32 | 33 | embed 34 | .setTitle(primaryCommandAlias) 35 | .addField('Description', command.description.content) 36 | .addField('Usage', code`${primaryCommandAlias}${command.description.usage ? ` ${command.description.usage}` : ''}`); 37 | 38 | if (command.aliases.length > 1) { 39 | embed.addField('Aliases', command.aliases.map(alias => code`${alias}`).join(', ')); 40 | } 41 | 42 | if (command.description.examples?.length) { 43 | embed.addField( 44 | 'Examples', 45 | codeblock`${command.description.examples.map(example => `${primaryCommandAlias}${example ? ` ${example}` : ''}`).join('\n')}` 46 | ); 47 | } 48 | 49 | return message.util?.send(embed); 50 | } 51 | 52 | const prefix = (this.handler.prefix as PrefixSupplier)(message); 53 | embed.setTitle('Commands'); 54 | embed.setDescription(`For additional help for a command use ${code`${await prefix}help `}`); 55 | 56 | const authorIsOwner = this.client.isOwner(message.author); 57 | 58 | for (const [id, category] of this.handler.categories) { 59 | // Only show categories if any of the following are true 60 | // 1. The message author is an owner 61 | // 2. Some commands are not owner-only 62 | if (authorIsOwner || category.some(cmd => !cmd.ownerOnly)) { 63 | embed.addField( 64 | capitalize(id), 65 | category 66 | // Remove owner-only commands if you are not an owner 67 | .filter(cmd => (authorIsOwner ? true : !cmd.ownerOnly)) 68 | .map(cmd => `\`${cmd.aliases[0]}\``) 69 | .join(', ') 70 | ); 71 | } 72 | } 73 | 74 | return message.util?.send(embed); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/commands/utility/ping.ts: -------------------------------------------------------------------------------- 1 | import ms = require('pretty-ms'); 2 | import {Message} from 'discord.js'; 3 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 4 | import {bold} from 'discord-md-tags'; 5 | 6 | export default class PingCommand extends DiceCommand { 7 | constructor() { 8 | super('ping', { 9 | aliases: ['heartbeat'], 10 | description: {content: "Checks the bot's ping to the Discord server.", examples: [''], usage: ''}, 11 | category: DiceCommandCategories.Util 12 | }); 13 | } 14 | 15 | async exec(message: Message): Promise { 16 | const response = await message.util?.send('Pinging…'); 17 | 18 | if (response) { 19 | const timestamps = { 20 | response: response.editedTimestamp ?? response.createdTimestamp, 21 | original: message.editedTimestamp ?? message.createdTimestamp 22 | }; 23 | 24 | return message.util?.edit( 25 | [ 26 | `The message round-trip took ${bold`${ms(timestamps.response - timestamps.original, {formatSubMilliseconds: true})}`}`, 27 | `The heartbeat ping to Discord is ${bold`${ms(this.client.ws.ping, {formatSubMilliseconds: true})}`}` 28 | ].join('\n') 29 | ); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/commands/utility/prefix.ts: -------------------------------------------------------------------------------- 1 | import {captureException} from '@sentry/node'; 2 | import assert from 'assert'; 3 | import {Argument} from 'discord-akairo'; 4 | import {Message, Permissions, Util} from 'discord.js'; 5 | import {defaultPrefix} from '../../config'; 6 | import {AkairoArgumentType, DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 7 | 8 | export default class PrefixCommand extends DiceCommand { 9 | constructor() { 10 | super('prefix', { 11 | category: DiceCommandCategories.Util, 12 | description: { 13 | content: 'View or update the prefix used for triggering commands.', 14 | usage: '[prefix] [--reset]', 15 | examples: ['', '!', '--reset'] 16 | }, 17 | channel: 'guild', 18 | args: [ 19 | { 20 | id: 'prefix', 21 | match: 'text', 22 | type: Argument.validate(AkairoArgumentType.String, (message, phrase) => phrase.length <= 10), 23 | // An Akairo bug will display the `otherwise` message every time if prefix arg is not provided 24 | // https://discordapp.com/channels/305153029567676426/387777801412935691/677012630531080192 25 | // otherwise: 'The provided prefix was too long', 26 | prompt: {optional: true, retry: 'The provided prefix was more than 10 characters long'} 27 | }, 28 | { 29 | id: 'reset', 30 | match: 'flag', 31 | flag: '--reset', 32 | prompt: {optional: true} 33 | } 34 | ] 35 | }); 36 | } 37 | 38 | /** 39 | * Handle an error that occurred while refreshing a cached guild's settings. 40 | * @param error Error to log and report to Sentry 41 | * @returns The generated Sentry `eventId`. 42 | */ 43 | handleRefreshError(error: unknown) { 44 | this.logger.error("An error occurred while attempting to refresh a guild's settings", error); 45 | return captureException(error); 46 | } 47 | 48 | async exec(message: Message, {prefix, reset}: {prefix?: string; reset: boolean}): Promise { 49 | assert(message.guild); 50 | const {id} = message.guild; 51 | 52 | // If you are modifying the prefix and you don't have `MANAGE_GUILD` and you aren't an owner 53 | if ((prefix ?? reset) && !message.member?.permissions.has(Permissions.FLAGS.MANAGE_GUILD) && !this.client.isOwner(message.author.id)) { 54 | return message.util?.send('You must have manage server permissions to modify the command prefix'); 55 | } 56 | 57 | if (prefix) { 58 | await this.client.prisma.guild.upsert({where: {id}, create: {id, prefix}, update: {prefix}}); 59 | 60 | // eslint-disable-next-line promise/prefer-await-to-then 61 | this.client.guildSettingsCache.refresh(id).catch(this.handleRefreshError); 62 | 63 | return message.util?.send(`Command prefix changed to \`${Util.escapeMarkdown(prefix, {inlineCodeContent: true})}\``); 64 | } 65 | 66 | if (reset) { 67 | const guild = await this.client.guildSettingsCache.get(id); 68 | 69 | if (guild?.prefix) { 70 | await this.client.prisma.guild.update({where: {id}, data: {prefix: null}}); 71 | // eslint-disable-next-line promise/prefer-await-to-then 72 | this.client.guildSettingsCache.refresh(id).catch(this.handleRefreshError); 73 | 74 | return message.util?.send(`The command prefix was reset to the default (\`${Util.escapeMarkdown(defaultPrefix, {inlineCodeContent: true})}\`)`); 75 | } 76 | 77 | return message.util?.send('The command prefix is already the default.'); 78 | } 79 | 80 | const guild = await this.client.guildSettingsCache.get(id); 81 | const currentPrefix = guild?.prefix ?? defaultPrefix; 82 | 83 | return message.util?.send(`Command prefix is \`${Util.escapeMarkdown(currentPrefix, {inlineCodeContent: true})}\``); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/commands/utility/stats.ts: -------------------------------------------------------------------------------- 1 | import {Message, MessageEmbed} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories} from '../../structures/DiceCommand'; 3 | import {getClusterCount} from '../../util/shard'; 4 | import {sum} from '@jonahsnider/util'; 5 | 6 | export default class StatsCommand extends DiceCommand { 7 | constructor() { 8 | super('stats', { 9 | aliases: ['statistics'], 10 | description: {content: 'See bot statistics', examples: [''], usage: ''}, 11 | category: DiceCommandCategories.Util, 12 | typing: true 13 | }); 14 | } 15 | 16 | async exec(message: Message): Promise { 17 | let serverCount = this.client.guilds.cache.size; 18 | 19 | if (this.client.shard) { 20 | const shardServerCounts = await (this.client.shard.broadcastEval('this.guilds.cache.size') as Promise); 21 | 22 | const clusterCount = getClusterCount(this.client.shard); 23 | 24 | const actual = shardServerCounts.length; 25 | 26 | // eslint-disable-next-line unicorn/no-array-reduce, unicorn/no-array-callback-reference 27 | serverCount = shardServerCounts.reduce(sum, 0); 28 | 29 | if (actual !== clusterCount) { 30 | /** The number of clusters that didn't respond. */ 31 | const unresponsiveClusterCount = Math.abs(clusterCount - actual); 32 | 33 | return message.util?.send( 34 | [ 35 | `It looks like ${unresponsiveClusterCount.toLocaleString()} clusters didn't respond`, 36 | `The approximate server count is ${serverCount + unresponsiveClusterCount * 1000}`, 37 | 'To get the exact server count, please try this again in a bit once some more clusters have come online' 38 | ].join('\n') 39 | ); 40 | } 41 | } 42 | 43 | return message.util?.send( 44 | new MessageEmbed({ 45 | title: 'Stats', 46 | fields: [ 47 | { 48 | name: 'Servers', 49 | value: serverCount.toLocaleString() 50 | }, 51 | { 52 | name: 'Users (who have used Dice before)', 53 | value: (await this.client.prisma.user.count()).toLocaleString() 54 | } 55 | ] 56 | }) 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import {PresenceData} from 'discord.js'; 2 | import {PackageJson} from 'type-fest'; 3 | import * as pkg from '../package.json'; 4 | 5 | /** Utility structure to organize admin user IDs. */ 6 | export enum Admins { 7 | /** `Jonah#6905` on Discord. */ 8 | Jonah = '210024244766179329', 9 | /** `fizza pox#0594` on Discord. */ 10 | FizzaPox = '405208438101245952' 11 | } 12 | 13 | /** Colors in numberic format, mostly for use in message embeds. */ 14 | export const enum Colors { 15 | Primary = 0x4caf50, 16 | // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member 17 | Success = Primary, 18 | Error = 0xf44336, 19 | Warning = 0xff9800 20 | } 21 | 22 | /** The amount of oats a user should receive when they use the daily command. */ 23 | export const dailyAmount = 1000; 24 | 25 | /** Prompts to give a user while using a command. */ 26 | export const commandArgumentPrompts = { 27 | modify: { 28 | start: (string?: string) => [string ?? 'No argument was provided, please specify it now', 'Type `cancel` to cancel the command'].join('\n'), 29 | retry: (string?: string) => [string ?? 'The provided argument was invalid, please try again', 'Type `cancel` to cancel the command'].join('\n') 30 | }, 31 | timeout: 'The command was automatically cancelled because it timed out', 32 | ended: 'The command was automatically cancelled', 33 | cancel: 'The command has been cancelled' 34 | }; 35 | 36 | /** Default values, mostly for the database. */ 37 | export const defaults = { 38 | startingBalance: { 39 | bot: 750_000, 40 | users: 1_000 41 | }, 42 | /** Default rewards for voting for the bot on a listing. */ 43 | vote: { 44 | /** Reward when a user votes when it is not the weekend. */ 45 | base: 1_000, 46 | // Voting on the weekend gives double the normal ranking points on top.gg 47 | /** Reward when a user votes during the weekend. */ 48 | weekend: 2_000 49 | } 50 | }; 51 | 52 | /** 53 | * Notifications server moderators can enable. 54 | * Key is the ID of the event, value is the label for the event. 55 | */ 56 | export const notifications: Record = { 57 | BAN_UNBAN: 'ban and unban', 58 | GUILD_MEMBER_JOIN_LEAVE: 'member join and leave', 59 | VOICE_CHANNEL: 'voice channel', 60 | GUILD_MEMBER_UPDATE: 'nickname change', 61 | USER_ACCOUNT_BIRTHDAY: 'user account birthday', 62 | MESSAGE_DELETE: 'message delete', 63 | MESSAGE_UPDATE: 'message update/edit' 64 | }; 65 | 66 | /** 67 | * Map notification IDs from the database to nice values for developers. 68 | * This should be in sync with the `NotificationSettings` id enum in the Prisma schema. 69 | */ 70 | export const enum Notifications { 71 | BanUnban = 'BAN_UNBAN', 72 | GuildMemberJoinLeave = 'GUILD_MEMBER_JOIN_LEAVE', 73 | VoiceChannel = 'VOICE_CHANNEL', 74 | GuildMemberUpdate = 'GUILD_MEMBER_UPDATE', 75 | UserAccountBirthday = 'USER_ACCOUNT_BIRTHDAY', 76 | MessageDelete = 'MESSAGE_DELETE', 77 | MessageUpdate = 'MESSAGE_UPDATE' 78 | } 79 | 80 | /** Number of nanoseconds in a millisecond. */ 81 | export const nsInMs = 1_000_000; 82 | 83 | /** 84 | * The maximum number of field elements allowed in an embed. 85 | * @see https://discordapp.com/developers/docs/resources/channel#embed-limits-limits 86 | */ 87 | export const maxEmbedFields = 25; 88 | 89 | /** Port to listen for top.gg webhooks on. */ 90 | export const topGGWebhookPort = 5000; 91 | 92 | /** 93 | * Exit codes used for the process. 94 | */ 95 | export const enum ExitCodes { 96 | Success, 97 | Error, 98 | LoginError 99 | } 100 | 101 | /** Presence data to use with the client on login. */ 102 | export const presence: PresenceData = {activity: {name: 'for @Dice help', type: 'WATCHING'}}; 103 | 104 | const {version} = pkg as PackageJson; 105 | 106 | /** User agent to use in HTTP requests. */ 107 | export const userAgent = `Dice Discord bot / v${version ?? '0.0.0-development'} dice.js.org`; 108 | -------------------------------------------------------------------------------- /src/docs.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {AkairoClient, CommandHandler} from 'discord-akairo'; 4 | import {generateDocs} from './docs/generate'; 5 | import {baseLogger} from './logging/logger'; 6 | import {options as commandHandlerOptions} from './util/commandHandler'; 7 | import {promises} from 'fs'; 8 | import path from 'path'; 9 | const {writeFile, mkdir} = promises; 10 | 11 | const logger = baseLogger.scope('docs'); 12 | const commandHandlerLogger = baseLogger.scope('docs', 'command handler'); 13 | 14 | /** An exit code for the CLI. */ 15 | enum ExitCode { 16 | Success, 17 | Error, 18 | CommandHandlerLoadError, 19 | FSWriteError, 20 | FSMkdirError 21 | } 22 | 23 | function crash(exitCode: ExitCode) { 24 | logger.fatal(`Exiting with code ${exitCode} - ${ExitCode[exitCode]}`); 25 | process.exit(exitCode); 26 | } 27 | 28 | process.on('uncaughtException', () => { 29 | crash(ExitCode.Error); 30 | }); 31 | process.on('unhandledRejection', () => { 32 | crash(ExitCode.Error); 33 | }); 34 | 35 | logger.start('Generating docs...'); 36 | 37 | const client = new AkairoClient(); 38 | const commandHandler = new CommandHandler(client, commandHandlerOptions); 39 | 40 | commandHandlerLogger.pending('Loading commands...'); 41 | 42 | try { 43 | commandHandler.loadAll(); 44 | } catch (error: unknown) { 45 | commandHandlerLogger.error(error); 46 | crash(ExitCode.CommandHandlerLoadError); 47 | } 48 | 49 | commandHandlerLogger.success('Loaded commands'); 50 | 51 | const docs = generateDocs(commandHandler); 52 | 53 | const baseDirectory = path.join(__dirname, '..', 'command_docs'); 54 | mkdir(baseDirectory) 55 | .then(async () => { 56 | /** Promises for creating the documentation folders before writing the doc files. */ 57 | const createFolders = docs.keyArray().map(async categoryID => mkdir(path.join(baseDirectory, categoryID))); 58 | 59 | try { 60 | await Promise.all(createFolders); 61 | } catch (error: unknown) { 62 | logger.fatal(error); 63 | crash(ExitCode.FSMkdirError); 64 | return; 65 | } 66 | 67 | const writeOperations: Array> = docs 68 | .mapValues((category, categoryID) => 69 | category.mapValues(async (commandDocs, commandID) => writeFile(path.join(baseDirectory, categoryID, `${commandID}.md`), commandDocs, {})).array() 70 | ) 71 | .array() 72 | .flat(); 73 | 74 | try { 75 | await Promise.all(writeOperations); 76 | } catch (error: unknown) { 77 | logger.fatal(error); 78 | crash(ExitCode.FSWriteError); 79 | return; 80 | } 81 | 82 | logger.success(`Generated ${writeOperations.length.toLocaleString()} documentation files`); 83 | return process.exit(ExitCode.Success); 84 | }) 85 | .catch(error => { 86 | logger.fatal(error); 87 | logger.info('Try deleting the documentation folder'); 88 | crash(ExitCode.FSMkdirError); 89 | }); 90 | -------------------------------------------------------------------------------- /src/docs/generate.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Category} from 'discord-akairo'; 3 | import {DiceCommand} from '../structures/DiceCommand'; 4 | import {generateDocs} from './generate'; 5 | 6 | const categories: Category> = new Category('cat id'); 7 | 8 | const commands: Category = new Category('cmd id'); 9 | 10 | categories.set('category', commands); 11 | 12 | // @ts-expect-error 13 | commands.set('id', { 14 | id: 'id', 15 | categoryID: 'category', 16 | description: { 17 | content: 'description.content', 18 | examples: ['', 'description.examples[1]'], 19 | usage: 'description.usage' 20 | } 21 | } as DiceCommand); 22 | 23 | const commandHandler = {categories}; 24 | 25 | test('generateDocs', () => { 26 | // @ts-expect-error 27 | const docs = generateDocs(commandHandler); 28 | 29 | const category = docs.get('category'); 30 | 31 | expect(category).toBeDefined(); 32 | 33 | assert(category); 34 | 35 | const command = category.get('id'); 36 | expect(command).toBeDefined(); 37 | 38 | assert(command); 39 | 40 | expect(command).toEqual( 41 | [ 42 | `title: Id`, 43 | `description: description.content`, 44 | `path: tree/master/src/commands/category`, 45 | 'source: id.ts', 46 | '', 47 | '# Id', 48 | '', 49 | '## Description', 50 | '', 51 | 'description.content', 52 | '', 53 | '## Usage', 54 | '', 55 | '### Format', 56 | '', 57 | '`id description.usage`', 58 | '', 59 | '### Examples', 60 | '', 61 | '- `id`', 62 | '- `id description.examples[1]`' 63 | ].join('\n') 64 | ); 65 | }); 66 | -------------------------------------------------------------------------------- /src/docs/generate.ts: -------------------------------------------------------------------------------- 1 | import {CommandHandler, Category} from 'discord-akairo'; 2 | import {Collection} from 'discord.js'; 3 | import {DiceCommand} from '../structures/DiceCommand'; 4 | import {capitalize} from '@jonahsnider/util'; 5 | import {code} from 'discord-md-tags'; 6 | 7 | /** 8 | * Generate documentation for all commands in a command handler, organized by category. 9 | * @param commandHandler Command handler to generate docs for 10 | * 11 | */ 12 | export function generateDocs(commandHandler: CommandHandler): Collection> { 13 | // The reason we do this weird business of creating a new `Collection` instead of `Collection#mapValues` is because we are working with Akairo `Category`s, which have an `id` property that leaks into our docs 14 | const docs: Collection> = new Collection(); 15 | 16 | for (const [id, category] of commandHandler.categories) { 17 | docs.set(id, generateDocsForCategory(category as Category)); 18 | } 19 | 20 | return docs; 21 | } 22 | 23 | /** 24 | * Generate docs for all commands in a category. 25 | * @param category Category to generate docs for 26 | * @returns A collection keyed by command ID, the value is the documentation for the respective command 27 | */ 28 | export function generateDocsForCategory(category: Category): Collection { 29 | const docs = new Collection(); 30 | 31 | for (const [id, command] of category) { 32 | docs.set(id, generateDocsForCommand(command)); 33 | } 34 | 35 | return docs; 36 | } 37 | 38 | /** 39 | * Generate documentation for a command. 40 | * @param command Command to generate docs for 41 | */ 42 | export function generateDocsForCommand(command: DiceCommand): string { 43 | return [ 44 | `title: ${capitalize(command.id)}`, 45 | `description: ${command.description.content}`, 46 | `path: tree/master/src/commands/${command.categoryID}`, 47 | `source: ${command.id}.ts`, 48 | '', 49 | `# ${capitalize(command.id)}`, 50 | '', 51 | `## Description`, 52 | ``, 53 | `${command.description.content}`, 54 | ``, 55 | `## Usage`, 56 | ``, 57 | `### Format`, 58 | ``, 59 | command.description.usage.length === 0 ? code`${command.id}` : code`${command.id} ${command.description.usage}`, 60 | ``, 61 | `### Examples`, 62 | ``, 63 | command.description.examples.map(example => `- \`${command.id}${example.length === 0 ? '' : ` ${example}`}\``).join('\n') 64 | ].join('\n'); 65 | } 66 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {start as startDebugAgent} from '@google-cloud/debug-agent'; 2 | import {captureException} from '@sentry/node'; 3 | import {Util} from 'discord.js'; 4 | import {ShardingManager} from 'kurasuta'; 5 | import path from 'path'; 6 | import {discordToken, googleAppCredentials, googleBaseConfig, runningInProduction} from './config'; 7 | import {DiceClient} from './structures/DiceClient'; 8 | import {baseLogger} from './logging/logger'; 9 | import {registerSharderEvents} from './util/register-sharder-events'; 10 | 11 | const logger = baseLogger.scope('sharder'); 12 | 13 | if (googleAppCredentials) { 14 | const googleConfig = Util.mergeDefault(googleBaseConfig, {serviceContext: {service: 'bot'}}); 15 | 16 | try { 17 | startDebugAgent(googleConfig); 18 | logger.success('Started Google Cloud Debug Agent'); 19 | } catch (error: unknown) { 20 | logger.error('Failed to initialize Google Cloud Debug Agent', error); 21 | captureException(error); 22 | } 23 | } 24 | 25 | const sharder = new ShardingManager(path.join(__dirname, 'structures', 'DiceCluster'), { 26 | client: DiceClient, 27 | development: !runningInProduction, 28 | // Only restart in production 29 | respawn: runningInProduction, 30 | retry: runningInProduction, 31 | // Used to automatically determine recommended shard count from the Discord API 32 | token: discordToken 33 | }); 34 | 35 | registerSharderEvents(sharder, logger); 36 | 37 | sharder.spawn().catch(error => { 38 | logger.fatal(error); 39 | throw error; 40 | }); 41 | -------------------------------------------------------------------------------- /src/inhibitors/blacklist.ts: -------------------------------------------------------------------------------- 1 | import {Message} from 'discord.js'; 2 | import {DiceInhibitor} from '../structures/DiceInhibitor'; 3 | import {baseLogger} from '../logging/logger'; 4 | 5 | const logger = baseLogger.scope('inhibitor', 'blacklist'); 6 | 7 | // eslint-disable-next-line unicorn/prevent-abbreviations 8 | export default class BlacklistInhibitor extends DiceInhibitor { 9 | constructor() { 10 | super('blacklist', { 11 | reason: 'blacklist' 12 | }); 13 | } 14 | 15 | async exec({author: {id}}: Message): Promise { 16 | if (this.client.isOwner(id)) { 17 | return false; 18 | } 19 | 20 | if (this.client.nfl?.cache.has(id)) { 21 | return true; 22 | } 23 | 24 | try { 25 | const user = await this.client.prisma.user.findUnique({ 26 | where: {id}, 27 | select: {blacklistReason: true} 28 | }); 29 | 30 | // We don't do a regular check here in case the blacklistReason is a falsy string (ex. '', since that is a falsy value, but the user is still blacklisted) 31 | return typeof user?.blacklistReason === 'string'; 32 | } catch (error: unknown) { 33 | logger.error(error); 34 | return false; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/listeners/client/debug.ts: -------------------------------------------------------------------------------- 1 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 2 | import {baseLogger} from '../../logging/logger'; 3 | 4 | const excludedEvents = /(sending a heartbeat|latency of)/i; 5 | 6 | export default class DebugListener extends DiceListener { 7 | logger: typeof baseLogger; 8 | private scopedWithClusterID = false; 9 | 10 | constructor() { 11 | super('debug', { 12 | emitter: 'client', 13 | event: 'debug', 14 | category: DiceListenerCategories.Client 15 | }); 16 | 17 | this.logger = baseLogger.scope('discord.js'); 18 | } 19 | 20 | /** 21 | * @param info The debug information 22 | */ 23 | exec(info: string): void { 24 | if (excludedEvents.test(info)) { 25 | return; 26 | } 27 | 28 | if (!this.scopedWithClusterID && this.client?.shard?.id !== undefined) { 29 | this.logger = this.logger.scope('discord.js', `cluster ${this.client.shard.id}`); 30 | this.scopedWithClusterID = true; 31 | } 32 | 33 | this.logger.debug(info); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/listeners/client/error.ts: -------------------------------------------------------------------------------- 1 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 2 | import {baseLogger} from '../../logging/logger'; 3 | 4 | export default class ErrorListener extends DiceListener { 5 | logger: typeof baseLogger; 6 | private scopedWithClusterID = false; 7 | 8 | constructor() { 9 | super('clientError', { 10 | emitter: 'client', 11 | event: 'error', 12 | category: DiceListenerCategories.Client 13 | }); 14 | 15 | this.logger = baseLogger.scope('discord.js'); 16 | } 17 | 18 | /** 19 | * @param error The error encountered 20 | */ 21 | exec(error: Error): void { 22 | if (!this.scopedWithClusterID && this.client?.shard?.id !== undefined) { 23 | this.logger = this.logger.scope('discord.js', `cluster ${this.client.shard.id}`); 24 | this.scopedWithClusterID = true; 25 | } 26 | 27 | this.logger.error(error); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/listeners/client/guildBanAdd.ts: -------------------------------------------------------------------------------- 1 | import delay from 'delay'; 2 | import {Guild, MessageEmbed, TextChannel, User} from 'discord.js'; 3 | import {Colors, Notifications} from '../../constants'; 4 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 5 | import {channelCanBeNotified} from '../../util/notifications'; 6 | 7 | export default class GuildBanAddListener extends DiceListener { 8 | public constructor() { 9 | super('guildBanAdd', { 10 | emitter: 'client', 11 | event: 'guildBanAdd', 12 | category: DiceListenerCategories.Client 13 | }); 14 | } 15 | 16 | public static async generateNotification(guild: Guild, user: User): Promise { 17 | const embed = new MessageEmbed({ 18 | title: `${user.tag} was banned`, 19 | author: { 20 | name: `${user.tag} (${user.id})`, 21 | iconURL: user.displayAvatarURL({size: 128}) 22 | }, 23 | color: Colors.Error, 24 | thumbnail: {url: 'https://dice.js.org/images/statuses/banUnban/ban.png'} 25 | }); 26 | 27 | if (guild.me?.hasPermission('VIEW_AUDIT_LOG')) { 28 | // Hope that Discord has updated the audit log with the ban event 29 | // Sometimes it takes a bit, and if we have the wrong value it will say someone else was banned 30 | await delay(1000); 31 | 32 | const auditLogs = await guild.fetchAuditLogs({ 33 | type: 'MEMBER_BAN_ADD' 34 | }); 35 | const auditEntry = auditLogs.entries.first(); 36 | 37 | if (auditEntry) { 38 | if (auditEntry.reason !== null) { 39 | embed.addField('Reason', auditEntry.reason); 40 | } 41 | 42 | embed.setTimestamp(auditEntry.createdAt); 43 | embed.setFooter(`Banned by ${auditEntry.executor.tag} (${auditEntry.executor.id})`, auditEntry.executor.displayAvatarURL({size: 128})); 44 | } 45 | } else { 46 | embed.setFooter('Give me permissions to view the audit log and more information will appear'); 47 | embed.setTimestamp(new Date()); 48 | } 49 | 50 | return embed; 51 | } 52 | 53 | public async exec(guild: Guild, user: User): Promise { 54 | const guildSettings = await this.client.prisma.guild.findUnique({ 55 | where: {id: guild.id}, 56 | select: {notifications: {select: {channels: true}, where: {id: Notifications.BanUnban}}} 57 | }); 58 | 59 | if (guildSettings?.notifications?.length) { 60 | // This array will be a single element since we are filtering by notification ID above 61 | const [setting] = guildSettings.notifications; 62 | 63 | const embed = await GuildBanAddListener.generateNotification(guild, user); 64 | 65 | await Promise.all( 66 | setting.channels.map(async channelID => { 67 | if (await channelCanBeNotified(Notifications.BanUnban, guild, channelID)) { 68 | const channel = this.client.channels.cache.get(channelID) as TextChannel; 69 | 70 | await channel.send(embed); 71 | } 72 | }) 73 | ); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/listeners/client/guildBanRemove.ts: -------------------------------------------------------------------------------- 1 | import delay from 'delay'; 2 | import {Guild, MessageEmbed, TextChannel, User} from 'discord.js'; 3 | import {Colors, Notifications} from '../../constants'; 4 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 5 | import {channelCanBeNotified} from '../../util/notifications'; 6 | 7 | export default class GuildBanRemoveListener extends DiceListener { 8 | public constructor() { 9 | super('guildBanRemove', { 10 | emitter: 'client', 11 | event: 'guildBanRemove', 12 | category: DiceListenerCategories.Client 13 | }); 14 | } 15 | 16 | public static async generateNotification(guild: Guild, user: User): Promise { 17 | const embed = new MessageEmbed({ 18 | title: `${user.tag} was unbanned`, 19 | author: { 20 | name: `${user.tag} (${user.id})`, 21 | iconURL: user.displayAvatarURL({size: 128}) 22 | }, 23 | color: Colors.Success, 24 | thumbnail: {url: 'https://dice.js.org/images/statuses/banUnban/unban.png'} 25 | }); 26 | 27 | if (guild.me?.hasPermission('VIEW_AUDIT_LOG')) { 28 | // Hope that Discord has updated the audit log 29 | await delay(1000); 30 | 31 | const auditLogs = await guild.fetchAuditLogs({ 32 | type: 'MEMBER_BAN_REMOVE' 33 | }); 34 | const auditEntry = auditLogs.entries.first(); 35 | 36 | if (auditEntry) { 37 | if (auditEntry.reason !== null) { 38 | embed.addField('Reason', auditEntry.reason); 39 | } 40 | 41 | embed.setTimestamp(auditEntry.createdAt); 42 | embed.setFooter(`Unbanned by ${auditEntry.executor.tag} (${auditEntry.executor.id})`, auditEntry.executor.displayAvatarURL({size: 128})); 43 | } 44 | } else { 45 | embed.setFooter('Give me permissions to view the audit log and more information will appear'); 46 | embed.setTimestamp(new Date()); 47 | } 48 | 49 | return embed; 50 | } 51 | 52 | public async exec(guild: Guild, user: User): Promise { 53 | const guildSettings = await this.client.prisma.guild.findUnique({ 54 | where: {id: guild.id}, 55 | select: {notifications: {select: {channels: true}, where: {id: Notifications.BanUnban}}} 56 | }); 57 | 58 | if (guildSettings?.notifications?.length) { 59 | // This array will be a single element since we are filtering by notification ID above 60 | const [setting] = guildSettings.notifications; 61 | 62 | const embed = await GuildBanRemoveListener.generateNotification(guild, user); 63 | 64 | await Promise.all( 65 | setting.channels.map(async channelID => { 66 | if (await channelCanBeNotified(Notifications.BanUnban, guild, channelID)) { 67 | const channel = this.client.channels.cache.get(channelID) as TextChannel; 68 | 69 | await channel.send(embed); 70 | } 71 | }) 72 | ); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/listeners/client/guildCreate.ts: -------------------------------------------------------------------------------- 1 | import {Guild} from 'discord.js'; 2 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 3 | 4 | export default class GuildCreateListener extends DiceListener { 5 | constructor() { 6 | super('guildCreate', { 7 | emitter: 'client', 8 | event: 'guildCreate', 9 | category: DiceListenerCategories.Client 10 | }); 11 | } 12 | 13 | /** 14 | * @param _guild The created guild 15 | */ 16 | async exec(_guild: Guild): Promise { 17 | return this.client.influxUtil?.recordDiscordStats(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/listeners/client/guildDelete.ts: -------------------------------------------------------------------------------- 1 | import {Guild} from 'discord.js'; 2 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 3 | 4 | export default class GuildDeleteListener extends DiceListener { 5 | constructor() { 6 | super('guildDelete', { 7 | emitter: 'client', 8 | event: 'guildDelete', 9 | category: DiceListenerCategories.Client 10 | }); 11 | } 12 | 13 | /** 14 | * @param _guild The guild that was deleted 15 | */ 16 | async exec(_guild: Guild): Promise { 17 | return this.client.influxUtil?.recordDiscordStats(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/listeners/client/guildMemberAdd.ts: -------------------------------------------------------------------------------- 1 | import {bold} from 'discord-md-tags'; 2 | import {GuildMember, MessageEmbed, TextChannel} from 'discord.js'; 3 | import {Colors, Notifications} from '../../constants'; 4 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 5 | import {channelCanBeNotified} from '../../util/notifications'; 6 | 7 | export default class GuildMemberAddListener extends DiceListener { 8 | public constructor() { 9 | super('guildMemberAdd', { 10 | emitter: 'client', 11 | event: 'guildMemberAdd', 12 | category: DiceListenerCategories.Client 13 | }); 14 | } 15 | 16 | public static generateNotification(member: GuildMember): MessageEmbed { 17 | const embed = new MessageEmbed({ 18 | title: 'New Member', 19 | timestamp: member.joinedAt ?? new Date(), 20 | thumbnail: { 21 | url: 'https://dice.js.org/images/statuses/guildMember/join.png' 22 | }, 23 | color: Colors.Success, 24 | author: { 25 | name: `${member.user.tag} (${member.user.id})`, 26 | iconURL: member.user.displayAvatarURL({size: 128}) 27 | }, 28 | fields: [ 29 | { 30 | name: 'Number of Server Members', 31 | value: `${bold`${member.guild.memberCount.toLocaleString()}`} members` 32 | } 33 | ] 34 | }); 35 | 36 | return embed; 37 | } 38 | 39 | public async exec(member: GuildMember): Promise { 40 | const guildSettings = await this.client.prisma.guild.findUnique({ 41 | where: {id: member.guild.id}, 42 | select: {notifications: {select: {channels: true}, where: {id: Notifications.GuildMemberJoinLeave}}} 43 | }); 44 | 45 | if (guildSettings?.notifications?.length) { 46 | // This array will be a single element since we are filtering by notification ID above 47 | const [setting] = guildSettings.notifications; 48 | 49 | const embed = GuildMemberAddListener.generateNotification(member); 50 | 51 | await Promise.all( 52 | setting.channels.map(async channelID => { 53 | if (await channelCanBeNotified(Notifications.GuildMemberJoinLeave, member.guild, channelID)) { 54 | const channel = this.client.channels.cache.get(channelID) as TextChannel; 55 | 56 | await channel.send(embed); 57 | } 58 | }) 59 | ); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/listeners/client/guildMemberRemove.ts: -------------------------------------------------------------------------------- 1 | import {formatDistanceToNow} from 'date-fns'; 2 | import {bold} from 'discord-md-tags'; 3 | import {GuildMember, MessageEmbed, TextChannel} from 'discord.js'; 4 | import {Colors, Notifications} from '../../constants'; 5 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 6 | import {channelCanBeNotified} from '../../util/notifications'; 7 | 8 | export default class GuildMemberRemoveListener extends DiceListener { 9 | public constructor() { 10 | super('guildMemberRemove', { 11 | emitter: 'client', 12 | event: 'guildMemberRemove', 13 | category: DiceListenerCategories.Client 14 | }); 15 | } 16 | 17 | public static generateNotification(member: GuildMember): MessageEmbed { 18 | const embed = new MessageEmbed({ 19 | title: 'Member Left', 20 | timestamp: new Date(), 21 | color: Colors.Error, 22 | thumbnail: { 23 | url: 'https://dice.js.org/images/statuses/guildMember/leave.png' 24 | }, 25 | author: { 26 | name: `${member.user.tag} (${member.user.id})`, 27 | iconURL: member.user.displayAvatarURL({size: 128}) 28 | }, 29 | fields: [ 30 | { 31 | name: 'Number of Server Members', 32 | value: `${bold`${member.guild.memberCount.toLocaleString()}`} members` 33 | } 34 | ] 35 | }); 36 | 37 | if (member.joinedAt) { 38 | embed.setFooter(`Member for ${formatDistanceToNow(member.joinedAt)}`); 39 | } 40 | 41 | return embed; 42 | } 43 | 44 | public async exec(member: GuildMember): Promise { 45 | const guildSettings = await this.client.prisma.guild.findUnique({ 46 | where: {id: member.guild.id}, 47 | select: {notifications: {select: {channels: true}, where: {id: Notifications.GuildMemberJoinLeave}}} 48 | }); 49 | 50 | if (guildSettings?.notifications?.length) { 51 | // This array will be a single element since we are filtering by notification ID above 52 | const [setting] = guildSettings.notifications; 53 | 54 | const embed = GuildMemberRemoveListener.generateNotification(member); 55 | 56 | await Promise.all( 57 | setting.channels.map(async channelID => { 58 | if (await channelCanBeNotified(Notifications.GuildMemberJoinLeave, member.guild, channelID)) { 59 | const channel = this.client.channels.cache.get(channelID) as TextChannel; 60 | 61 | await channel.send(embed); 62 | } 63 | }) 64 | ); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/listeners/client/guildMemberUpdate.ts: -------------------------------------------------------------------------------- 1 | import {GuildMember, MessageEmbed, TextChannel, Util} from 'discord.js'; 2 | import {Colors, Notifications} from '../../constants'; 3 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 4 | import {channelCanBeNotified} from '../../util/notifications'; 5 | 6 | export default class GuildMemberUpdateListener extends DiceListener { 7 | public constructor() { 8 | super('guildMemberUpdate', { 9 | emitter: 'client', 10 | event: 'guildMemberUpdate', 11 | category: DiceListenerCategories.Client 12 | }); 13 | } 14 | 15 | public static generateNotification(oldMember: GuildMember, newMember: GuildMember): MessageEmbed | null { 16 | const embed = new MessageEmbed({ 17 | timestamp: new Date(), 18 | author: { 19 | name: `${newMember.user.tag} (${newMember.user.id})`, 20 | iconURL: newMember.user.displayAvatarURL({size: 128}) 21 | } 22 | }); 23 | 24 | if (oldMember.nickname === null && newMember.nickname !== null) { 25 | // New nickname, no old nickname 26 | embed 27 | .setTitle('New Member Nickname') 28 | .addField('New nickname', Util.escapeMarkdown(newMember.nickname)) 29 | .setColor(Colors.Success) 30 | .setThumbnail('https://dice.js.org/images/statuses/guildMemberUpdate/new.png'); 31 | 32 | return embed; 33 | } 34 | 35 | if (oldMember.nickname !== null && newMember.nickname === null) { 36 | // Reset nickname 37 | embed 38 | .setTitle('Member Nickname Removed') 39 | .addField('Previous nickname', Util.escapeMarkdown(oldMember.nickname)) 40 | .setColor(Colors.Error) 41 | .setThumbnail('https://dice.js.org/images/statuses/guildMemberUpdate/removed.png'); 42 | 43 | return embed; 44 | } 45 | 46 | if (oldMember.nickname !== null && newMember.nickname !== null && oldMember.nickname !== newMember.nickname) { 47 | // Nickname change 48 | embed 49 | .setTitle('Changed Member Nickname') 50 | .addField('New nickname', Util.escapeMarkdown(newMember.nickname), true) 51 | .addField('Previous nickname', Util.escapeMarkdown(oldMember.nickname), true) 52 | .setColor(Colors.Warning) 53 | // For some godforsaken reason this image is not using the correct color 54 | // TODO: Recreate image with proper background color 55 | .setThumbnail('https://dice.js.org/images/statuses/guildMemberUpdate/changed.png'); 56 | 57 | return embed; 58 | } 59 | 60 | return null; 61 | } 62 | 63 | public async exec(oldMember: GuildMember, newMember: GuildMember): Promise { 64 | const guildSettings = await this.client.prisma.guild.findUnique({ 65 | where: {id: newMember.guild.id}, 66 | select: {notifications: {select: {channels: true}, where: {id: Notifications.GuildMemberUpdate}}} 67 | }); 68 | 69 | if (guildSettings?.notifications?.length) { 70 | // This array will be a single element since we are filtering by notification ID above 71 | const [setting] = guildSettings.notifications; 72 | 73 | const embed = GuildMemberUpdateListener.generateNotification(oldMember, newMember); 74 | 75 | if (!embed) { 76 | return; 77 | } 78 | 79 | await Promise.all( 80 | setting.channels.map(async channelID => { 81 | if (await channelCanBeNotified(Notifications.GuildMemberUpdate, newMember.guild, channelID)) { 82 | const channel = this.client.channels.cache.get(channelID) as TextChannel; 83 | 84 | await channel.send(embed); 85 | } 86 | }) 87 | ); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/listeners/client/message.ts: -------------------------------------------------------------------------------- 1 | import {Message} from 'discord.js'; 2 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 3 | import {Indexes, IndexNames} from '../../util/meili-search'; 4 | 5 | export default class MessageListener extends DiceListener { 6 | public constructor() { 7 | super('message', { 8 | emitter: 'client', 9 | event: 'message', 10 | category: DiceListenerCategories.Client 11 | }); 12 | } 13 | 14 | public async exec(message: Message): Promise { 15 | const index = this.client.meiliSearch.index(IndexNames.Users); 16 | 17 | await index.addDocuments([{id: message.author.id, tag: message.author.tag}]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/listeners/client/messageDelete.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Message, MessageEmbed, TextChannel} from 'discord.js'; 3 | import {Colors, Notifications} from '../../constants'; 4 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 5 | import {channelCanBeNotified} from '../../util/notifications'; 6 | 7 | export default class MessageDeleteListener extends DiceListener { 8 | public constructor() { 9 | super('messageDelete', { 10 | emitter: 'client', 11 | event: 'messageDelete', 12 | category: DiceListenerCategories.Client 13 | }); 14 | } 15 | 16 | public static generateNotification(message: Message): MessageEmbed { 17 | const embed = new MessageEmbed({ 18 | title: 'Message Deleted', 19 | color: Colors.Error, 20 | timestamp: new Date(), 21 | footer: { 22 | text: `Message content is hidden to protect ${message.author.tag}'s privacy` 23 | }, 24 | author: { 25 | name: `${message.author.tag} (${message.author.id})`, 26 | iconURL: message.author.displayAvatarURL({size: 128}) 27 | }, 28 | fields: [ 29 | { 30 | name: 'Channel', 31 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 32 | value: message.channel.toString() 33 | } 34 | ] 35 | }); 36 | 37 | return embed; 38 | } 39 | 40 | public async exec(message: Message): Promise { 41 | if (message.channel.type === 'text') { 42 | assert(message.guild); 43 | 44 | const guildSettings = await this.client.prisma.guild.findUnique({ 45 | where: {id: message.guild.id}, 46 | select: {notifications: {select: {channels: true}, where: {id: Notifications.MessageDelete}}} 47 | }); 48 | 49 | if (guildSettings?.notifications?.length) { 50 | // This array will be a single element since we are filtering by notification ID above 51 | const [setting] = guildSettings.notifications; 52 | 53 | const embed = MessageDeleteListener.generateNotification(message); 54 | 55 | await Promise.all( 56 | setting.channels.map(async channelID => { 57 | assert(message.guild); 58 | 59 | if (await channelCanBeNotified(Notifications.MessageDelete, message.guild, channelID)) { 60 | const channel = this.client.channels.cache.get(channelID) as TextChannel; 61 | 62 | await channel.send(embed); 63 | } 64 | }) 65 | ); 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/listeners/client/messageUpdate.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Message, MessageEmbed, TextChannel} from 'discord.js'; 3 | import {Notifications} from '../../constants'; 4 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 5 | import {channelCanBeNotified} from '../../util/notifications'; 6 | 7 | export default class MessageUpdateListener extends DiceListener { 8 | public constructor() { 9 | super('messageUpdate', { 10 | emitter: 'client', 11 | event: 'messageUpdate', 12 | category: DiceListenerCategories.Client 13 | }); 14 | } 15 | 16 | public static generateNotification(message: Message): MessageEmbed | null { 17 | assert(message.editedAt); 18 | 19 | if (message.guild) { 20 | const embed = new MessageEmbed({ 21 | title: `Message edited (${message.id})`, 22 | color: 0xff9800, 23 | timestamp: message.editedAt, 24 | footer: { 25 | text: `Message history is hidden to protect ${message.author.tag}'s privacy` 26 | }, 27 | author: { 28 | name: `${message.author.tag} (${message.author.id})`, 29 | iconURL: message.author.displayAvatarURL({size: 128}) 30 | }, 31 | fields: [ 32 | { 33 | name: 'Channel', 34 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 35 | value: message.channel.toString(), 36 | inline: true 37 | }, 38 | { 39 | name: 'Message', 40 | value: `[Jump to](https://discordapp.com/channels/${message.guild.id}/${message.channel.id}/${message.id})`, 41 | inline: true 42 | } 43 | ] 44 | }); 45 | 46 | return embed; 47 | } 48 | 49 | return null; 50 | } 51 | 52 | public async exec(oldMessage: Message, newMessage: Message): Promise { 53 | if (newMessage.guild && newMessage.editedAt && (oldMessage.content !== newMessage.content || oldMessage.embeds.length !== newMessage.embeds.length)) { 54 | assert(oldMessage.guild); 55 | 56 | const guildSettings = await this.client.prisma.guild.findUnique({ 57 | where: {id: oldMessage.guild.id}, 58 | select: {notifications: {select: {channels: true}, where: {id: Notifications.MessageUpdate}}} 59 | }); 60 | 61 | if (guildSettings?.notifications?.length) { 62 | // This array will be a single element since we are filtering by notification ID above 63 | const [setting] = guildSettings.notifications; 64 | 65 | const embed = MessageUpdateListener.generateNotification(oldMessage); 66 | 67 | if (!embed) { 68 | return; 69 | } 70 | 71 | await Promise.all( 72 | setting.channels.map(async channelID => { 73 | assert(oldMessage.guild); 74 | 75 | if (await channelCanBeNotified(Notifications.MessageUpdate, oldMessage.guild, channelID)) { 76 | const channel = this.client.channels.cache.get(channelID) as TextChannel; 77 | 78 | await channel.send(embed); 79 | } 80 | }) 81 | ); 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/listeners/client/ready.ts: -------------------------------------------------------------------------------- 1 | import {captureException} from '@sentry/node'; 2 | import assert from 'assert'; 3 | import {code} from 'discord-md-tags'; 4 | import {MessageEmbed, WebhookClient} from 'discord.js'; 5 | import * as pkg from '../../../package.json'; 6 | import {readyWebhook, runningInCI, runningInProduction} from '../../config'; 7 | import {ExitCodes} from '../../constants'; 8 | import {baseLogger} from '../../logging/logger'; 9 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 10 | import {Indexes, IndexNames} from '../../util/meili-search'; 11 | 12 | const embed = new MessageEmbed({ 13 | title: 'Ready', 14 | fields: [ 15 | { 16 | name: 'Version', 17 | value: code`${pkg.version}` 18 | } 19 | ] 20 | }); 21 | 22 | export default class ReadyListener extends DiceListener { 23 | logger: typeof baseLogger; 24 | private scopedWithClusterID = false; 25 | 26 | constructor() { 27 | super('ready', { 28 | emitter: 'client', 29 | event: 'ready', 30 | category: DiceListenerCategories.Client 31 | }); 32 | 33 | this.logger = baseLogger.scope('client'); 34 | } 35 | 36 | async exec(): Promise { 37 | assert(this.client.user); 38 | 39 | if (!this.scopedWithClusterID && this.client.shard?.id !== undefined) { 40 | this.logger = baseLogger.scope('client', `cluster ${this.client.shard.id}`); 41 | this.scopedWithClusterID = true; 42 | } 43 | 44 | // eslint-disable-next-line promise/prefer-await-to-then 45 | this.client.influxUtil?.recordDiscordStats().catch(error => { 46 | this.logger.error('Failed to report InfluxDB Discord stats', error); 47 | }); 48 | 49 | if (!runningInCI) { 50 | const index = await this.client.meiliSearch.getOrCreateIndex(IndexNames.Users); 51 | const users = [...this.client.users.cache.values()]; 52 | 53 | const documents = users.map(user => ({id: user.id, tag: user.tag})); 54 | await index.addDocumentsInBatches(documents, 500); 55 | } 56 | 57 | if (runningInProduction && this.client.shard?.id === 0) { 58 | if (readyWebhook.id !== undefined && readyWebhook.token !== undefined) { 59 | const webhookClient = new WebhookClient(readyWebhook.id, readyWebhook.token); 60 | 61 | embed.setTimestamp(this.client.readyAt ?? new Date()); 62 | 63 | // eslint-disable-next-line promise/prefer-await-to-then 64 | webhookClient.send(embed).catch(error => { 65 | this.logger.error('An error occurred while sending the ready webhook', error); 66 | return captureException(error); 67 | }); 68 | } else { 69 | this.logger.warn('Running in production, but the ready webhook credentials are invalid'); 70 | 71 | if (readyWebhook.id === undefined && readyWebhook.token === undefined) { 72 | this.logger.warn('Ready webhook ID and token not provided'); 73 | } else if (readyWebhook.id === undefined) { 74 | this.logger.warn('Ready webhook ID not provided'); 75 | } else if (readyWebhook.token === undefined) { 76 | this.logger.warn('Ready webhook token not provided'); 77 | } 78 | } 79 | } 80 | 81 | this.logger.start('Ready'); 82 | 83 | if (runningInCI) { 84 | this.logger.complete('CI environment detected, gracefully exiting as part of test'); 85 | this.logger.info('This behavior is triggered because the `CI` environment variable was defined'); 86 | // eslint-disable-next-line unicorn/no-process-exit 87 | return process.exit(ExitCodes.Success); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/listeners/client/userUpdate.ts: -------------------------------------------------------------------------------- 1 | import {User} from 'discord.js'; 2 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 3 | import {Indexes, IndexNames} from '../../util/meili-search'; 4 | 5 | export default class UserUpdateListener extends DiceListener { 6 | public constructor() { 7 | super('userUpdate', { 8 | emitter: 'client', 9 | event: 'userUpdate', 10 | category: DiceListenerCategories.Client 11 | }); 12 | } 13 | 14 | public async exec(oldUser: User, newUser: User): Promise { 15 | const index = this.client.meiliSearch.index(IndexNames.Users); 16 | 17 | await index.addDocuments([{id: newUser.id, tag: newUser.tag}]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/listeners/client/voiceStateUpdate.ts: -------------------------------------------------------------------------------- 1 | import {MessageEmbed, TextChannel, Util, VoiceState} from 'discord.js'; 2 | import {Colors, Notifications} from '../../constants'; 3 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 4 | import {channelCanBeNotified} from '../../util/notifications'; 5 | 6 | export default class VoiceStateUpdateListener extends DiceListener { 7 | public constructor() { 8 | super('voiceStateUpdate', { 9 | emitter: 'client', 10 | event: 'voiceStateUpdate', 11 | category: DiceListenerCategories.Client 12 | }); 13 | } 14 | 15 | public static async generateNotification(oldState: VoiceState, newState: VoiceState): Promise { 16 | // Fetch the member if they aren't in the cache 17 | const member = newState.member ?? (await newState.guild.members.fetch(newState.id)); 18 | 19 | const {user} = member; 20 | 21 | const embed = new MessageEmbed({ 22 | timestamp: new Date(), 23 | author: { 24 | name: `${user.tag} (${newState.id})`, 25 | iconURL: user.displayAvatarURL({size: 128}) 26 | } 27 | }); 28 | 29 | if (oldState.channel && newState.channel && oldState.channel !== newState.channel) { 30 | // Moving from one voice channel to another 31 | embed 32 | .setTitle('Switched voice channels') 33 | .setColor(Colors.Warning) 34 | .addField('Old voice channel', Util.escapeMarkdown(oldState.channel.name), true) 35 | .addField('New voice channel', Util.escapeMarkdown(newState.channel.name), true) 36 | .setThumbnail('https://dice.js.org/images/statuses/voiceChannel/transfer.png'); 37 | return embed; 38 | } 39 | 40 | if (newState.channel && newState.channel !== oldState.channel) { 41 | // Connected to a voice channel 42 | embed 43 | .setTitle('Connected to a voice channel') 44 | .setColor(Colors.Success) 45 | .addField('Voice channel', Util.escapeMarkdown(newState.channel.name)) 46 | .setThumbnail('https://dice.js.org/images/statuses/voiceChannel/join.png'); 47 | return embed; 48 | } 49 | 50 | if (oldState.channel && newState.channel !== oldState.channel) { 51 | // Disconnected from a voice channel 52 | embed 53 | .setTitle('Disconnected from a voice channel') 54 | .setColor(Colors.Error) 55 | .addField('Voice channel', Util.escapeMarkdown(oldState.channel.name)) 56 | .setThumbnail('https://dice.js.org/images/statuses/voiceChannel/leave.png'); 57 | 58 | return embed; 59 | } 60 | 61 | // Why do we individually return an embed in each branch and return null here? 62 | // There is a scenario where someone is dragged from channel A -> channel A 63 | // This behavior is intentionally not handled here, hence returning null 64 | return null; 65 | } 66 | 67 | public async exec(oldState: VoiceState, newState: VoiceState): Promise { 68 | const guildSettings = await this.client.prisma.guild.findUnique({ 69 | where: {id: newState.guild.id}, 70 | select: {notifications: {select: {channels: true}, where: {id: Notifications.VoiceChannel}}} 71 | }); 72 | 73 | if (guildSettings?.notifications?.length) { 74 | // This array will be a single element since we are filtering by notification ID above 75 | const [setting] = guildSettings.notifications; 76 | 77 | const embed = await VoiceStateUpdateListener.generateNotification(oldState, newState); 78 | 79 | if (!embed) { 80 | return; 81 | } 82 | 83 | await Promise.all( 84 | setting.channels.map(async channelID => { 85 | if (await channelCanBeNotified(Notifications.VoiceChannel, newState.guild, channelID)) { 86 | const channel = this.client.channels.cache.get(channelID) as TextChannel; 87 | 88 | await channel.send(embed); 89 | } 90 | }) 91 | ); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/listeners/client/warn.ts: -------------------------------------------------------------------------------- 1 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 2 | import {baseLogger} from '../../logging/logger'; 3 | 4 | export default class WarnListener extends DiceListener { 5 | logger: typeof baseLogger; 6 | private scopedWithClusterID = false; 7 | 8 | constructor() { 9 | super('warn', { 10 | emitter: 'client', 11 | event: 'warn', 12 | category: DiceListenerCategories.Client 13 | }); 14 | 15 | this.logger = baseLogger.scope('discord.js'); 16 | } 17 | 18 | /** 19 | * @param info The warning 20 | */ 21 | exec(info: string): void { 22 | if (!this.scopedWithClusterID && this.client?.shard?.id !== undefined) { 23 | this.logger = this.logger.scope('discord.js', `cluster ${this.client.shard.id}`); 24 | this.scopedWithClusterID = true; 25 | } 26 | 27 | this.logger.warn(info); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/listeners/commandHandler/commandStarted.ts: -------------------------------------------------------------------------------- 1 | import {inspect} from 'util'; 2 | import {Message} from 'discord.js'; 3 | import {baseLogger} from '../../logging/logger'; 4 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 5 | import {DiceCommand} from '../../structures/DiceCommand'; 6 | 7 | export default class CommandStartedListener extends DiceListener { 8 | constructor() { 9 | super('commandStarted', { 10 | emitter: 'commandHandler', 11 | event: 'commandStarted', 12 | category: DiceListenerCategories.CommandHandler 13 | }); 14 | } 15 | 16 | exec(message: Message, command: DiceCommand, args: Record): void { 17 | const logger = baseLogger.scope('commands', command.id); 18 | 19 | logger.command({ 20 | prefix: `${message.author.tag} (${message.author.id})`, 21 | // Use inspect with a depth of `0` here to avoid a giant object of args (ex. every property in a Command class from the reload command) 22 | message: Object.keys(args).length === 0 ? undefined : inspect(args, {depth: 0}) 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/listeners/commandHandler/error.ts: -------------------------------------------------------------------------------- 1 | import {addBreadcrumb, captureException, configureScope, setContext, Severity} from '@sentry/node'; 2 | import {code} from 'discord-md-tags'; 3 | import {Message} from 'discord.js'; 4 | import {DiceCommand} from '../../structures/DiceCommand'; 5 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 6 | import {baseLogger} from '../../logging/logger'; 7 | 8 | export default class ErrorListener extends DiceListener { 9 | logger: typeof baseLogger = baseLogger.scope('commands'); 10 | 11 | constructor() { 12 | super('commandError', { 13 | emitter: 'commandHandler', 14 | event: 'error', 15 | category: DiceListenerCategories.CommandHandler 16 | }); 17 | } 18 | 19 | exec(error: Error, message: Message, command?: DiceCommand): Promise | undefined { 20 | if (command) { 21 | this.logger = command.logger; 22 | } 23 | 24 | configureScope(scope => { 25 | scope.setUser({id: message.author.id, username: message.author.tag}); 26 | }); 27 | 28 | addBreadcrumb({ 29 | message: 'command.error', 30 | category: command?.category.id ?? 'inhibitor', 31 | level: Severity.Error, 32 | data: { 33 | guild: message.guild 34 | ? { 35 | id: message.guild.id, 36 | name: message.guild.name 37 | } 38 | : null, 39 | command: command 40 | ? { 41 | id: command.id, 42 | aliases: command.aliases, 43 | category: command.category.id 44 | } 45 | : null, 46 | message: { 47 | id: message.id, 48 | content: message.content 49 | } 50 | } 51 | }); 52 | if (command) { 53 | setContext('command.start', { 54 | extra: { 55 | guild: message.guild 56 | ? { 57 | id: message.guild.id, 58 | name: message.guild.name 59 | } 60 | : null, 61 | command: { 62 | id: command.id, 63 | aliases: command.aliases, 64 | category: command.category.id 65 | }, 66 | message: { 67 | id: message.id, 68 | content: message.content 69 | } 70 | } 71 | }); 72 | } 73 | 74 | const exceptionID = captureException(error); 75 | 76 | this.logger.error('Error while running command:', error); 77 | 78 | if (message.channel.type === 'dm' || message.guild?.me?.permissionsIn(message.channel).has('SEND_MESSAGES')) { 79 | // Send the message if we have permissions 80 | return message.util?.send( 81 | [ 82 | 'An unexpected error occurred while running this command', 83 | 'An error report about this incident was recorded', 84 | `To report this to a developer give them the code ${code`${message.id}-${exceptionID}`}` 85 | ].join('\n') 86 | ); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/listeners/commandHandler/messageInvalid.ts: -------------------------------------------------------------------------------- 1 | import {Message} from 'discord.js'; 2 | import assert from 'assert'; 3 | import {GetTagCommandArgs} from '../../commands/tags/get-tag'; 4 | import {DiceListener, DiceListenerCategories} from '../../structures/DiceListener'; 5 | 6 | export default class MessageInvalidListener extends DiceListener { 7 | public constructor() { 8 | super('messageInvalid', { 9 | emitter: 'commandHandler', 10 | event: 'messageInvalid', 11 | category: DiceListenerCategories.CommandHandler 12 | }); 13 | } 14 | 15 | public async exec(message: Message): Promise { 16 | // Someone attempted to run a regular command but it didn't exist 17 | 18 | assert(message.client.user); 19 | 20 | if ( 21 | message.guild && 22 | typeof message.util?.parsed?.prefix === 'string' && 23 | message.util?.parsed?.prefix !== message.client.user.toString() && 24 | typeof message.util?.parsed?.afterPrefix === 'string' 25 | ) { 26 | // They are on a guild and specified a prefix that wasn't an @mention @$$evaluate message.util.parsed.alias 27 | // The command had something after the prefix (ex. `$$this-is-afterPrefix`) 28 | 29 | const command = this.client.commandHandler.modules.get('get-tag'); 30 | 31 | assert(command); 32 | 33 | return this.client.commandHandler.runCommand(message, command, { 34 | noError: true, 35 | ...(await command.parse(message, message.util?.parsed?.afterPrefix)) 36 | } as GetTagCommandArgs); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/lite.ts: -------------------------------------------------------------------------------- 1 | import {DiceClient} from './structures/DiceClient'; 2 | import {discordToken, runningInCI} from './config'; 3 | import {baseLogger} from './logging/logger'; 4 | import {ExitCodes} from './constants'; 5 | 6 | const logger = baseLogger.scope('lite'); 7 | 8 | const client = new DiceClient(); 9 | 10 | client.init().catch(error => { 11 | logger.fatal('Failed to initialize client', error); 12 | 13 | if (runningInCI) { 14 | // eslint-disable-next-line unicorn/no-process-exit 15 | process.exit(ExitCodes.Error); 16 | } 17 | }); 18 | client.login(discordToken).catch(error => { 19 | logger.fatal('Failed to login client', error); 20 | 21 | if (runningInCI) { 22 | // eslint-disable-next-line unicorn/no-process-exit 23 | process.exit(ExitCodes.LoginError); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /src/logging/logger.test.ts: -------------------------------------------------------------------------------- 1 | import {baseLogger} from './logger'; 2 | 3 | test('baseLogger', () => { 4 | expect(baseLogger).toHaveProperty('command' as keyof typeof baseLogger); 5 | }); 6 | -------------------------------------------------------------------------------- /src/logging/logger.ts: -------------------------------------------------------------------------------- 1 | import * as signale from 'signale'; 2 | import {secrets} from '../config'; 3 | 4 | const options: signale.SignaleOptions<'command'> | undefined = { 5 | secrets, 6 | config: {displayDate: true, displayTimestamp: true}, 7 | types: { 8 | command: { 9 | badge: '💬', 10 | color: 'gray', 11 | label: 'command', 12 | logLevel: 'debug' 13 | } 14 | } 15 | }; 16 | 17 | const logger: signale.Signale = new signale.Signale(options); 18 | 19 | export const baseLogger = logger; 20 | -------------------------------------------------------------------------------- /src/structures/DiceCluster.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | import 'sqreen'; 3 | import {BaseCluster, ShardingManager} from 'kurasuta'; 4 | import {discordToken} from '../config'; 5 | import {baseLogger} from '../logging/logger'; 6 | import {DiceClient} from './DiceClient'; 7 | 8 | export class DiceCluster extends BaseCluster { 9 | // Client is defined in BaseCluster constructor 10 | public client!: DiceClient; 11 | logger: typeof baseLogger; 12 | 13 | constructor(...args: [ShardingManager]) { 14 | super(...args); 15 | 16 | this.logger = baseLogger.scope('cluster', this.id.toString()); 17 | this.client.cluster = this; 18 | } 19 | 20 | async launch(): Promise { 21 | // eslint-disable-next-line promise/prefer-await-to-then 22 | this.client.init().catch(error => { 23 | this.logger.fatal('Failed to initialize client', error); 24 | throw error; 25 | }); 26 | 27 | // eslint-disable-next-line promise/prefer-await-to-then 28 | this.client.login(discordToken).catch(error => { 29 | this.logger.fatal('Failed to login client', error); 30 | throw error; 31 | }); 32 | } 33 | } 34 | 35 | // Must provide a default export for Kurasuta 36 | export default DiceCluster; 37 | -------------------------------------------------------------------------------- /src/structures/DiceInhibitor.ts: -------------------------------------------------------------------------------- 1 | import {Inhibitor} from 'discord-akairo'; 2 | import {DiceClient} from './DiceClient'; 3 | 4 | export class DiceInhibitor extends Inhibitor { 5 | client!: DiceClient; 6 | } 7 | -------------------------------------------------------------------------------- /src/structures/DiceListener.ts: -------------------------------------------------------------------------------- 1 | import {Listener, ListenerOptions} from 'discord-akairo'; 2 | import {DiceClient} from './DiceClient'; 3 | 4 | export const enum DiceListenerCategories { 5 | Client = 'client', 6 | CommandHandler = 'commandHandler', 7 | Prisma = 'prisma' 8 | } 9 | 10 | interface DiceListenerOptions extends ListenerOptions { 11 | category: DiceListenerCategories; 12 | } 13 | 14 | export class DiceListener extends Listener { 15 | client!: DiceClient; 16 | 17 | // eslint-disable-next-line @typescript-eslint/no-useless-constructor 18 | constructor(id: string, options: DiceListenerOptions) { 19 | super(id, options); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/structures/DiceUser.ts: -------------------------------------------------------------------------------- 1 | import {PrismaClient} from '@prisma/client'; 2 | import {Snowflake, UserResolvable} from 'discord.js'; 3 | import {defaults} from '../constants'; 4 | import {DiceClient} from './DiceClient'; 5 | 6 | /** 7 | * Provides helper functions for handling users in the database. 8 | */ 9 | export class DiceUser { 10 | private readonly id: Snowflake; 11 | private readonly prisma: PrismaClient; 12 | private readonly client: DiceClient; 13 | 14 | /** 15 | * Provide a user resolvable for an instance. 16 | * @param user The user resolvable 17 | * @param client The client to use. If `undefined` the `user.client` property will be used 18 | */ 19 | constructor(user: UserResolvable, client?: DiceClient) { 20 | if (!client) { 21 | if (typeof user === 'string') { 22 | throw new TypeError('You must specify a client argument if you are providing a user snowflake'); 23 | } else { 24 | client = user.client as DiceClient; 25 | } 26 | } 27 | 28 | const id = client.users.resolveID(user); 29 | 30 | if (id) { 31 | this.id = id; 32 | this.client = client; 33 | this.prisma = this.client.prisma; 34 | } else { 35 | throw new Error('No ID could be resolved from the provided user resolvable'); 36 | } 37 | } 38 | 39 | /** 40 | * Get the balance of this user. 41 | * If the user does not have a balance in the database the default balance will be provided. 42 | * @returns The balance of this user 43 | */ 44 | async getBalance(): Promise { 45 | const user = await this.prisma.user.findUnique({where: {id: this.id}, select: {balance: true}}); 46 | 47 | return user?.balance ?? defaults.startingBalance[this.id === this.client.user?.id ? 'bot' : 'users']; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/structures/DiscordInfluxUtil.ts: -------------------------------------------------------------------------------- 1 | import {AkairoClient} from 'discord-akairo'; 2 | import {FieldType, InfluxDB} from 'influx'; 3 | // eslint-disable-next-line import/extensions 4 | import type {Integer} from '../../types/opaque'; 5 | import {clusterID} from '../util/shard'; 6 | 7 | /* eslint-disable camelcase */ 8 | 9 | /** A type representing the options used to record a measurement of the `discord` schema. */ 10 | type DiscordSchema = { 11 | guild_count: Integer; 12 | user_count: Integer; 13 | channel_count: Integer; 14 | }; 15 | 16 | /** A type representing the schema options passed to add a schema to InfluxDB. */ 17 | type SchemaOptions = Required>; 18 | 19 | /** Tags used for the `discord` schema. */ 20 | type DiscordSchemaTags = 'cluster_id'; 21 | 22 | /** 23 | * A class to help Discord clients periodically record statistics to InfluxDB. 24 | */ 25 | export class DiscordInfluxUtil { 26 | /** The InfluxDB client for this isntance. */ 27 | public influx: InfluxDB; 28 | client: AkairoClient; 29 | 30 | /** 31 | * @param dsn The InfluxDB DSN to use to connect 32 | * @param client The Discord client to use 33 | */ 34 | constructor(dsn: string, client: AkairoClient) { 35 | this.influx = new InfluxDB(dsn); 36 | this.client = client; 37 | 38 | this.influx.addSchema({ 39 | measurement: 'discord', 40 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 41 | fields: { 42 | guild_count: FieldType.INTEGER, 43 | user_count: FieldType.INTEGER, 44 | channel_count: FieldType.INTEGER 45 | } as SchemaOptions, 46 | tags: ['cluster_id'] as DiscordSchemaTags[] 47 | }); 48 | } 49 | 50 | /** 51 | * Records a measurement to the `discord` schema 52 | */ 53 | async recordDiscordStats(): Promise { 54 | const fields: DiscordSchema = { 55 | guild_count: this.client.guilds.cache.size as Integer, 56 | user_count: this.client.users.cache.size as Integer, 57 | channel_count: this.client.channels.cache.size as Integer 58 | }; 59 | 60 | const tags: Record = {cluster_id: clusterID.toString()}; 61 | 62 | return this.influx.writeMeasurement('discord', [{tags, fields}]); 63 | } 64 | } 65 | 66 | /* eslint-enable camelcase */ 67 | -------------------------------------------------------------------------------- /src/structures/GuildSettingsCache.ts: -------------------------------------------------------------------------------- 1 | import {Guild, PrismaClient} from '@prisma/client'; 2 | import {Guild as DiscordGuild, GuildResolvable, Snowflake} from 'discord.js'; 3 | 4 | type CachedGuild = Pick; 5 | 6 | /** 7 | * A read-only cache for guild settings. 8 | * Primarily used for quickly retrieving custom prefixes. 9 | */ 10 | export class GuildSettingsCache { 11 | private readonly _cache: Map = new Map(); 12 | 13 | constructor(private readonly prisma: PrismaClient) {} 14 | 15 | static getGuildID(resolvable: GuildResolvable): Snowflake { 16 | if (typeof resolvable === 'string') { 17 | // Resolvable is a guild ID 18 | return resolvable; 19 | } 20 | 21 | if (resolvable instanceof DiscordGuild) { 22 | // Resolvable is a guild 23 | return resolvable.id; 24 | } 25 | 26 | if (resolvable.guild) { 27 | // Resolvable is something that is present in a guild (role, channel, or member) 28 | return resolvable.guild.id; 29 | } 30 | 31 | throw new RangeError('No guild could be resolved from the provided resolvable'); 32 | } 33 | 34 | /** 35 | * Retrieve settings for a guild. 36 | * @param guild The Discord guild to retrieve 37 | * 38 | * @returns The retrieved guild, if it exists 39 | */ 40 | async get(guild: GuildResolvable): Promise { 41 | const id = GuildSettingsCache.getGuildID(guild); 42 | 43 | if (this._cache.has(id)) { 44 | return this._cache.get(id); 45 | } 46 | 47 | return this.cache(id); 48 | } 49 | 50 | /** 51 | * Add a guild from the database to the cache. 52 | * @param guild The Discord guild to cache 53 | * 54 | * @return The retrieved guild, if it exists 55 | */ 56 | async cache(guild: GuildResolvable): Promise { 57 | const id = GuildSettingsCache.getGuildID(guild); 58 | 59 | const fetchedGuild = await this.prisma.guild.findUnique({where: {id}, select: {prefix: true}}); 60 | 61 | if (fetchedGuild) { 62 | this._cache.set(id, fetchedGuild); 63 | return fetchedGuild; 64 | } 65 | } 66 | 67 | async refresh(guild: GuildResolvable): Promise { 68 | const id = GuildSettingsCache.getGuildID(guild); 69 | 70 | // Delete the old item 71 | this._cache.delete(id); 72 | 73 | // Update the item 74 | return this.cache(id); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/structures/NoFlyList.ts: -------------------------------------------------------------------------------- 1 | import {CronJob} from 'cron'; 2 | import {Snowflake} from 'discord.js'; 3 | import got, {Headers} from 'got'; 4 | import {URLSearchParams} from 'url'; 5 | import {userAgent} from '../constants'; 6 | 7 | interface APINFLUser { 8 | discordId: string; 9 | reason: string; 10 | email: string | null; 11 | /** Date string. */ 12 | dateBlacklisted: string; 13 | } 14 | 15 | export interface NFLUser { 16 | id: Snowflake; 17 | } 18 | 19 | export class NoFlyList { 20 | /** API URL. */ 21 | public static apiURL = 'https://dice.jonah.pw/nfl'; 22 | /** A cached blacklist of user IDs. */ 23 | public cache = new Set(); 24 | /** 25 | * A job to refresh the cache of blacklisted users 26 | */ 27 | private readonly refreshJob: CronJob; 28 | /** Headers to use in requests. */ 29 | private readonly headers: Headers; 30 | 31 | constructor(apiToken: string) { 32 | this.headers = {'User-Agent': userAgent, Authorization: `Bearer ${apiToken}`}; 33 | 34 | this.refreshJob = new CronJob('*/15 * * * *', async () => this.refresh()); 35 | 36 | this.refreshJob.start(); 37 | // eslint-disable-next-line promise/prefer-await-to-then 38 | this.refresh().catch(error => { 39 | throw error; 40 | }); 41 | } 42 | 43 | destroy(): void { 44 | this.refreshJob.stop(); 45 | } 46 | 47 | /** 48 | * Fetch a user directly from the API, without using the local cache. 49 | * @param user User to fetch 50 | */ 51 | async fetch(user: Snowflake): Promise { 52 | const searchParameters = new URLSearchParams({fields: 'discordId'}); 53 | 54 | try { 55 | const response = await got>(`${NoFlyList.apiURL}/blacklist/${encodeURIComponent(user)}`, { 56 | headers: this.headers, 57 | searchParams: searchParameters 58 | }); 59 | 60 | this.cache.add(response.body.discordId); 61 | return true; 62 | } catch { 63 | return false; 64 | } 65 | } 66 | 67 | /** 68 | * Refresh the cache with data from the API. 69 | */ 70 | private async refresh(): Promise { 71 | const searchParameters = new URLSearchParams({fields: 'discordId'}); 72 | const denyList = await got>>(`${NoFlyList.apiURL}/blacklist`, { 73 | headers: this.headers, 74 | searchParams: searchParameters, 75 | responseType: 'json' 76 | }); 77 | this.cache = new Set(denyList.body.map(user => user.discordId)); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/structures/SingleResponseCommand.test.ts: -------------------------------------------------------------------------------- 1 | import SingleResponseCommand from './SingleResponseCommand'; 2 | 3 | const response = 'response'; 4 | const description = 'description'; 5 | const aliases = ['alias']; 6 | 7 | class TestCommand extends SingleResponseCommand { 8 | constructor() { 9 | super('id', { 10 | response, 11 | description, 12 | aliases 13 | }); 14 | } 15 | } 16 | 17 | test('SingleResponseCommand', () => { 18 | const testCommand = new TestCommand(); 19 | const mockResponseFn = jest.fn(); 20 | 21 | /* eslint-disable @typescript-eslint/no-floating-promises */ 22 | // @ts-expect-error 23 | testCommand.exec({util: {send: mockResponseFn}}); 24 | /* eslint-enable @typescript-eslint/no-floating-promises */ 25 | expect(mockResponseFn).toBeCalledWith(response); 26 | 27 | expect(testCommand.aliases).toEqual(expect.arrayContaining(aliases)); 28 | 29 | expect(testCommand.description.content).toBe(description); 30 | }); 31 | -------------------------------------------------------------------------------- /src/structures/SingleResponseCommand.ts: -------------------------------------------------------------------------------- 1 | import {Message, MessageEmbed, Permissions} from 'discord.js'; 2 | import {DiceCommand, DiceCommandCategories, DiceCommandOptions} from './DiceCommand'; 3 | 4 | type Response = string | MessageEmbed; 5 | 6 | interface SingleResponseCommandsOptions extends Pick { 7 | description: string; 8 | response: Response; 9 | } 10 | 11 | export default class SingleResponseCommand extends DiceCommand { 12 | response: Response; 13 | constructor(id: string, options: SingleResponseCommandsOptions) { 14 | super(id, { 15 | aliases: options.aliases, 16 | category: DiceCommandCategories.Single, 17 | description: {content: options.description, examples: [''], usage: ''}, 18 | clientPermissions: options.response instanceof MessageEmbed ? [Permissions.FLAGS.EMBED_LINKS] : undefined 19 | }); 20 | 21 | this.response = options.response; 22 | } 23 | 24 | async exec(message: Message): Promise { 25 | return message.util?.send(this.response); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/structures/TopGgVoteWebhookHandler.ts: -------------------------------------------------------------------------------- 1 | import {TypedEventEmitter} from '@jonahsnider/util'; 2 | import {Client, Snowflake} from 'discord.js'; 3 | import {EventEmitter} from 'events'; 4 | import {IncomingMessage, ServerResponse} from 'http'; 5 | import {json, send, serve, Server} from 'micri'; 6 | import {topGGWebhookPassword} from '../config'; 7 | import {baseLogger} from '../logging/logger'; 8 | 9 | type EventType = 'upvote' | 'test'; 10 | 11 | const eventTypes = new Set(['test', 'upvote']); 12 | 13 | /** 14 | * The format of the data your webhook URL will receive in a POST request. 15 | */ 16 | interface TopGGVoteWebhook { 17 | /** ID of the bot that received a vote. */ 18 | bot: Snowflake; 19 | /** ID of the user who voted. */ 20 | user: Snowflake; 21 | /** The type of the vote (should always be `upvote` except when using the test button it's `test`). */ 22 | type: EventType; 23 | /** Whether the weekend multiplier is in effect, meaning users votes count as two. */ 24 | isWeekend: boolean; 25 | /** 26 | * Query string params found on the `/bot/:ID/vote` page. 27 | * @example '?a=1&b=2' 28 | */ 29 | query?: string; 30 | } 31 | 32 | interface Config { 33 | /** Client to use. */ 34 | client: Client; 35 | } 36 | 37 | export interface TopGGVote { 38 | /** ID of the user who voted */ 39 | user: Snowflake; 40 | /** Whether the weekend multiplier is in effect, meaning users votes count as two */ 41 | weekend: boolean; 42 | } 43 | 44 | /** 45 | * Handle top.gg webhook events for when users upvote the bot. 46 | */ 47 | export class TopGGVoteWebhookHandler extends (EventEmitter as new () => TypedEventEmitter<{vote: (data: TopGGVote) => void}>) { 48 | public server: Server; 49 | private readonly _config: Config; 50 | private readonly logger = baseLogger.scope('top.gg vote webhook handler'); 51 | 52 | constructor(config: Config) { 53 | // eslint-disable-next-line constructor-super 54 | super(); 55 | this._config = config; 56 | 57 | if (!this._config.client.user) { 58 | this.logger.warn('Client object that was provided to config does not have a `user` property'); 59 | } 60 | 61 | this.server = serve(async (request: IncomingMessage, response: ServerResponse) => this.handle(request, response)); 62 | } 63 | 64 | /** Handle a request. */ 65 | async handle(request: IncomingMessage, response: ServerResponse): Promise { 66 | if (!this._config.client.user) { 67 | throw new TypeError('Client object that was provided to config does not have a `user` property'); 68 | } 69 | 70 | /** Response status code to use. */ 71 | let statusCode = 202; 72 | 73 | /** Request body. */ 74 | const body: TopGGVoteWebhook = await json(request); 75 | 76 | if (request.headers.authorization === topGGWebhookPassword) { 77 | if (eventTypes.has(body.type)) { 78 | if (body.bot === this._config.client.user?.id) { 79 | const data: TopGGVote = { 80 | user: body.user, 81 | weekend: body.isWeekend 82 | }; 83 | 84 | if (body.type === 'upvote') { 85 | this.emit('vote', data); 86 | } else { 87 | statusCode = 204; 88 | this.logger.debug('Test vote event from top.gg received:', data); 89 | } 90 | } else { 91 | this.logger.warn(`Vote event for a different bot was received, has the webhook URL been leaked? Got ${body.bot}`); 92 | statusCode = 422; 93 | } 94 | } else { 95 | this.logger.warn(`Unknown webhook event type of ${body.type} was received`); 96 | statusCode = 422; 97 | } 98 | } else { 99 | statusCode = 403; 100 | } 101 | 102 | send(response, statusCode); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/types/anyUser.ts: -------------------------------------------------------------------------------- 1 | import {Message, User} from 'discord.js'; 2 | import {DiceClient} from '../structures/DiceClient'; 3 | import {Indexes, IndexNames} from '../util/meili-search'; 4 | 5 | /** The name of this type in Akairo. */ 6 | export const typeName = 'anyUser'; 7 | 8 | /** 9 | * Finds any user on Discord, first by resolving them from a cache and then by fetching them from Discord. 10 | * @param message The message to use as context 11 | * @param phrase The phrase to be used to resolve a user 12 | * @returns The resolved user, if found 13 | */ 14 | export async function resolver(message: Message, phrase: string | null): Promise { 15 | if (phrase === null) { 16 | return null; 17 | } 18 | 19 | const client = message.client as DiceClient; 20 | const index = client.meiliSearch.index(IndexNames.Users); 21 | const { 22 | hits: [searched] 23 | } = await index.search(phrase, {limit: 1}); 24 | 25 | let fetched: User | null = null; 26 | 27 | try { 28 | fetched = await client.users.fetch(phrase); 29 | } catch {} 30 | 31 | if (fetched) { 32 | return fetched; 33 | } 34 | 35 | if (searched) { 36 | try { 37 | fetched = await client.users.fetch(searched.id); 38 | } catch {} 39 | } 40 | 41 | return fetched ?? null; 42 | } 43 | -------------------------------------------------------------------------------- /src/types/minecraftUser.ts: -------------------------------------------------------------------------------- 1 | import {Message} from 'discord.js'; 2 | import {fetchMinecraftAccount, MinecraftAccount} from '../util/player-db'; 3 | 4 | /** The name of this type in Akairo. */ 5 | export const typeName = 'minecraftUser'; 6 | 7 | /** 8 | * Resolves a phrase to a Minecraft user. 9 | * @param message The message to use as context 10 | * @param phrase The phrase to be used to resolve a user 11 | * @returns The resolved user, if found 12 | */ 13 | export async function resolver(message: Message, phrase: string | null): Promise { 14 | if (phrase === null) { 15 | return null; 16 | } 17 | 18 | let account: MinecraftAccount | null = null; 19 | 20 | try { 21 | account = await fetchMinecraftAccount(phrase); 22 | } catch {} 23 | 24 | return account; 25 | } 26 | -------------------------------------------------------------------------------- /src/util/commandHandler.test.ts: -------------------------------------------------------------------------------- 1 | import {loadFilter} from './commandHandler'; 2 | 3 | test('loadFilter', () => { 4 | expect(loadFilter('command.test.ts')).toBe(false); 5 | expect(loadFilter('command.test.js')).toBe(false); 6 | expect(loadFilter('command.ts')).toBe(true); 7 | expect(loadFilter('command.js')).toBe(true); 8 | }); 9 | -------------------------------------------------------------------------------- /src/util/commandHandler.ts: -------------------------------------------------------------------------------- 1 | import {CommandHandlerOptions} from 'discord-akairo'; 2 | import path from 'path'; 3 | 4 | /** 5 | * Determines whether or not a file path to a command should be loaded. 6 | * @param filePath File path to check 7 | * @returns `true` when the file should be loaded 8 | * @see https://discord-akairo.github.io/#/docs/main/master/typedef/LoadPredicate 9 | */ 10 | export function loadFilter(filePath: string): boolean { 11 | return !/\.test\.[jt]s$/.test(filePath); 12 | } 13 | 14 | export const options: CommandHandlerOptions = { 15 | directory: path.join(__dirname, '..', 'commands'), 16 | loadFilter, 17 | aliasReplacement: /-/g, 18 | handleEdits: true, 19 | commandUtil: true 20 | }; 21 | -------------------------------------------------------------------------------- /src/util/format.ts: -------------------------------------------------------------------------------- 1 | import {Message, Util} from 'discord.js'; 2 | 3 | /** 4 | * Removes any Discord Markdown or mentions to make a string safe for display. 5 | * @param string String to clean 6 | * @param message The message to provide context for cleaning 7 | * @returns The cleaned string 8 | */ 9 | export function clean(string: string, message: Message): string { 10 | return Util.cleanContent(Util.escapeMarkdown(string), message); 11 | } 12 | -------------------------------------------------------------------------------- /src/util/meili-search.ts: -------------------------------------------------------------------------------- 1 | import {Snowflake} from 'discord.js'; 2 | 3 | export enum IndexNames { 4 | Users = 'users' 5 | } 6 | 7 | export interface Indexes { 8 | [IndexNames.Users]: { 9 | id: Snowflake; 10 | tag: string; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/util/permissions.ts: -------------------------------------------------------------------------------- 1 | import {GuildMember} from 'discord.js'; 2 | 3 | /** 4 | * Check if `user` can manage the `target`. 5 | * 6 | * @param user User who will be performing a moderation action 7 | * @param target The member who is being checked as manageable 8 | * @see https://discord.js.org/#/docs/main/master/class/GuildMember?scrollTo=manageable Discord.js docs on `GuildMember.manageable` 9 | */ 10 | export function notManageable(user: GuildMember, target: GuildMember): boolean { 11 | return ( 12 | target.user.id === user.guild.ownerID || 13 | target.user.id === user.user.id || 14 | user.user.id !== user.guild.ownerID || 15 | user.roles.highest.comparePositionTo(target.roles.highest) > 0 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/util/player-db.ts: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | 3 | /** Base URL to use for the API. */ 4 | const baseURL = 'https://playerdb.co/api/player'; 5 | 6 | /** 7 | * A base interface for a response from PlayerDB. 8 | */ 9 | interface BasePlayerDBResponse { 10 | code: string; 11 | message: string; 12 | } 13 | 14 | /** 15 | * A failed response from PlayerDB. 16 | */ 17 | interface FailedPlayerDBResponse extends BasePlayerDBResponse { 18 | error: true; 19 | data: Record; 20 | } 21 | 22 | /** 23 | * A successful response from PlayerDB. 24 | */ 25 | interface SuccessfulPlayerDBResponse extends BasePlayerDBResponse { 26 | code: 'player.found'; 27 | success: true; 28 | data: Record; 29 | } 30 | 31 | /** 32 | * A response from PlayerDB. 33 | */ 34 | type PlayerDBResponse = FailedPlayerDBResponse | SuccessfulPlayerDBResponse; 35 | 36 | /** 37 | * Check if a response from player DB was successful. 38 | * @param response Response to check 39 | */ 40 | function playerDBResponseWasSuccessful(response: PlayerDBResponse): response is SuccessfulPlayerDBResponse { 41 | return Object.prototype.hasOwnProperty.call(response, 'success'); 42 | } 43 | 44 | /** 45 | * A username that the user had at one point in the past. 46 | */ 47 | interface HistoricUsername { 48 | /** The username. */ 49 | name: string; 50 | /** The timestamp of when this name was changed to. */ 51 | changeToAt?: number; 52 | } 53 | 54 | interface SuccessfulMinecraftResponse extends SuccessfulPlayerDBResponse { 55 | code: 'player.found'; 56 | data: { 57 | player: { 58 | // eslint-disable-next-line camelcase 59 | meta: {name_history: HistoricUsername[]}; 60 | username: string; 61 | /** @example '8f54a6f1-9b02-45e5-b210-205dafc80fe4' */ 62 | id: string; 63 | /** @example '8f54a6f19b0245e5b210205dafc80fe4' */ 64 | // eslint-disable-next-line camelcase 65 | raw_id: string; 66 | avatar: string; 67 | }; 68 | }; 69 | } 70 | 71 | interface FailedMinecraftResponse extends FailedPlayerDBResponse { 72 | code: 'minecraft.api_failure'; 73 | } 74 | 75 | type MinecraftResponse = SuccessfulMinecraftResponse | FailedMinecraftResponse; 76 | 77 | /** 78 | * A Minecraft account. 79 | */ 80 | export interface MinecraftAccount { 81 | id: string; 82 | username: string; 83 | } 84 | 85 | /** 86 | * Resolve a Minecraft account from a username or user ID 87 | * @param account The Minecraft account that was resolved 88 | */ 89 | export async function fetchMinecraftAccount(account: string): Promise { 90 | const response = await got(`${baseURL}/minecraft/${encodeURIComponent(account)}`, {responseType: 'json'}); 91 | 92 | if (playerDBResponseWasSuccessful(response.body)) { 93 | const {id, username} = response.body.data.player; 94 | return {id, username}; 95 | } 96 | 97 | throw new Error(`No account could be found for ${account}`); 98 | } 99 | -------------------------------------------------------------------------------- /src/util/register-sharder-events.ts: -------------------------------------------------------------------------------- 1 | import {ShardingManager, SharderEvents} from 'kurasuta'; 2 | import {baseLogger} from '../logging/logger'; 3 | 4 | export function registerSharderEvents(sharder: ShardingManager, logger?: typeof baseLogger): ShardingManager { 5 | const usedLogger = logger ?? baseLogger.scope('sharder'); 6 | 7 | // #region sharder event listeners 8 | sharder.on(SharderEvents.DEBUG, message => { 9 | usedLogger.debug({prefix: 'debug', message}); 10 | }); 11 | sharder.on(SharderEvents.MESSAGE, message => { 12 | usedLogger.debug({prefix: 'message', message}); 13 | }); 14 | sharder.on(SharderEvents.READY, cluster => { 15 | usedLogger.success({prefix: `cluster ${cluster.id}`, message: 'Ready'}); 16 | }); 17 | sharder.on(SharderEvents.SPAWN, cluster => { 18 | usedLogger.start({prefix: `cluster ${cluster.id}`, message: 'Spawned'}); 19 | }); 20 | sharder.on(SharderEvents.SHARD_READY, shardID => { 21 | usedLogger.success({prefix: `shard ${shardID}`, message: 'Ready'}); 22 | }); 23 | sharder.on(SharderEvents.SHARD_RECONNECT, shardID => { 24 | usedLogger.start({prefix: `shard ${shardID}`, message: 'Reconnected'}); 25 | }); 26 | sharder.on(SharderEvents.SHARD_RESUME, (replayed, shardID) => { 27 | usedLogger.start({prefix: `shard ${shardID}`, message: `Resumed, replayed 1 ${replayed}`}); 28 | }); 29 | sharder.on(SharderEvents.SHARD_DISCONNECT, (closeEvent, shardID) => { 30 | usedLogger.warn({ 31 | prefix: `shard ${shardID}`, 32 | message: `Disconnected (${closeEvent.code}, ${closeEvent.wasClean ? '' : 'not '}clean): ${closeEvent.reason}` 33 | }); 34 | }); 35 | // #endregion 36 | 37 | return sharder; 38 | } 39 | -------------------------------------------------------------------------------- /src/util/self-roles.ts: -------------------------------------------------------------------------------- 1 | import {Snowflake, Guild} from 'discord.js'; 2 | import {PrismaClient} from '@prisma/client'; 3 | import {pull} from '@jonahsnider/util'; 4 | 5 | /** 6 | * Removes self roles that are for roles that have been deleted on a Discord guild. 7 | * 8 | * @param selfRoles Array of role IDs that are self roles 9 | * @param guild The guild for these self roles 10 | * @returns An array of IDs for self roles that stll exist in the guild 11 | */ 12 | export async function cleanDeletedSelfRoles(prisma: PrismaClient, selfRoles: Snowflake[], guild: Guild): Promise { 13 | const copy = [...selfRoles]; 14 | 15 | /** Selfroles that have been deleted from Discord. */ 16 | const deletedRoles = selfRoles.filter(id => !guild.roles.cache.has(id)); 17 | 18 | // Delete the old roles from the validated list 19 | for (const deletedRole of deletedRoles) { 20 | pull(copy, deletedRole); 21 | } 22 | 23 | if (copy.length !== selfRoles.length) { 24 | // If something changed during the validation phase 25 | await prisma.guild.update({where: {id: guild.id}, data: {selfRoles: {set: copy}}}); 26 | } 27 | 28 | return copy; 29 | } 30 | -------------------------------------------------------------------------------- /src/util/shard.ts: -------------------------------------------------------------------------------- 1 | import {Snowflake} from 'discord.js'; 2 | import {ShardClientUtil} from 'kurasuta'; 3 | 4 | /** 5 | * Find the shard ID that is handling a guild by the guild ID. 6 | * Uses the formula provided by Discord. 7 | * @param guildID The string form of the guild ID 8 | * @param shardCount The total number of shards running 9 | * @returns The shard ID responsible for the provided guild ID 10 | * @example findShardIDByGuildID('388366947689168897', 6); 11 | * @see https://discord.com/developers/docs/topics/gateway#sharding-sharding-formula Formula for determining shard ID 12 | */ 13 | export function findShardIDByGuildID(guildID: Snowflake, shardCount: bigint): number { 14 | const calculated = (BigInt(guildID) >> BigInt(22)) % shardCount; 15 | 16 | // `calculated` should fit within [0, shardCount) 17 | if (calculated >= 0 && calculated < shardCount) { 18 | return Number(calculated); 19 | } 20 | 21 | throw new Error(`Shard count is incorrect or the guild ID ${guildID} is invalid, calculated ${calculated}`); 22 | } 23 | 24 | const responsibleShards = process.env.CLUSTER_SHARDS?.split(',').map(shard => Number.parseInt(shard, 10)); 25 | 26 | /** The shards this cluster is responsible for managing. */ 27 | export function getResponsibleShards(shard: ShardClientUtil): number[] { 28 | return responsibleShards ?? [shard.id]; 29 | } 30 | 31 | /** The total number of clusters. */ 32 | export function getClusterCount(shard: ShardClientUtil): number { 33 | return shard?.clusterCount ?? 0; 34 | } 35 | 36 | /** This cluster's ID. */ 37 | export const clusterID = process.env.CLUSTER_ID === undefined ? 0 : Number.parseInt(process.env.CLUSTER_ID, 10); 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "downlevelIteration": true, 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "moduleResolution": "node", 8 | "noImplicitAny": true, 9 | "outDir": "tsc_output", 10 | "resolveJsonModule": true 11 | }, 12 | "exclude": ["node_modules", "./src/**/*.test.*"], 13 | "extends": "@tsconfig/node14/tsconfig.json", 14 | "include": ["src", "types"] 15 | } 16 | -------------------------------------------------------------------------------- /types/airnow.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Forecasted AQI category number. 3 | */ 4 | export enum CategoryNumber { 5 | Good = 1, 6 | Moderate, 7 | UnhealthyForSensitiveGroups, 8 | Unhealthy, 9 | VeryUnhealthy, 10 | Hazardous, 11 | Unavailable 12 | } 13 | 14 | /** 15 | * @private 16 | */ 17 | interface ForecastInput { 18 | /** 19 | * Date of forecast. If date is omitted, the current forecast is returned. 20 | * @example '2012-02-01' 21 | */ 22 | date?: string; 23 | /** 24 | * Format of the payload file returned. 25 | */ 26 | format: 'text/csv' | 'application/json' | 'application/xml'; 27 | /** 28 | * @example 150 29 | */ 30 | distance?: number; 31 | /** 32 | * Unique API key, associated with the AirNow user account. 33 | */ 34 | // eslint-disable-next-line camelcase 35 | api_key: string; 36 | } 37 | 38 | export interface ForecastInputZipCode extends ForecastInput { 39 | /** 40 | * Zip code. 41 | * @example '94954' 42 | */ 43 | zipCode: string; 44 | /** 45 | * If no reporting area is associated with the specified Zip Code, return a forecast from a nearby reporting area within this distance (in miles). 46 | */ 47 | distance?: number; 48 | } 49 | 50 | export interface Forecast { 51 | /** 52 | * Date the forecast was issued. 53 | * @example '2012-02-01' 54 | */ 55 | DateIssue: string; 56 | /** 57 | * Date for which the forecast applies. 58 | * @example '2012-02-02' 59 | */ 60 | DateForecast: string; 61 | /** 62 | * Two-character state abbreviation. 63 | * @example 'CA' 64 | */ 65 | ReportingArea: string; 66 | /** 67 | * Latitude in decimal degrees. 68 | * @example 38.33 69 | */ 70 | Latitude: number; 71 | /** 72 | * Longitude in decimal degrees. 73 | * @example -122.28 74 | */ 75 | Longitude: number; 76 | /** 77 | * Forecasted parameter name. 78 | * @example 'Ozone' 79 | */ 80 | ParameterName: string; 81 | /** 82 | * Numerical AQI value forecasted. When a numerical AQI value is not available, such as when only a categorical forecast has been submitted, a `-1` will be returned. 83 | * @example 45 84 | */ 85 | AQI: number | -1; 86 | 87 | Category: { 88 | /** 89 | * Forecasted AQI category number. 90 | * @see {CategoryNumber} Category number enum 91 | */ 92 | Number: CategoryNumber; 93 | 94 | /** 95 | * Name of the AQI category. 96 | * @example 'Good' 97 | */ 98 | Name: 'Good' | 'Moderate' | 'Unhealthy for Sensitive Groups' | 'Unhealthy' | 'Very Unhealthy' | 'Hazardous' | 'Unavailable'; 99 | }; 100 | /** 101 | * Action day status (true or false). 102 | * @example false 103 | */ 104 | ActionDay: boolean; 105 | /** 106 | * Forecast discussion narrative. 107 | * @example 'Today, scattered light rain showers and the associated high relative humidity will support particle production, keeping particle levels Moderate.' 108 | */ 109 | Discussion?: string; 110 | } 111 | -------------------------------------------------------------------------------- /types/discord.d.ts: -------------------------------------------------------------------------------- 1 | import {Snowflake} from 'discord.js'; 2 | 3 | /** 4 | * A datastructure for holding a webhook token and ID. 5 | */ 6 | export interface WebhookConfig { 7 | /** The token of the webhook. */ 8 | token: string; 9 | /** The ID of the webhook. */ 10 | id: Snowflake; 11 | } 12 | -------------------------------------------------------------------------------- /types/ghActions.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface ProcessEnv { 3 | /** Always set to `true`. */ 4 | CI: 'true'; 5 | /** 6 | * The path to the GitHub home directory used to store user data. 7 | * @example '/github/home' 8 | */ 9 | HOME: string; 10 | /** The name of the workflow. */ 11 | GITHUB_WORKFLOW: string; 12 | /** 13 | * A unique number for each run within a repository. 14 | * This number does not change if you re-run the workflow run. 15 | */ 16 | GITHUB_RUN_ID: string; 17 | /** 18 | * A unique number for each run of a particular workflow in a repository. 19 | * This number begins at 1 for the workflow's first run, and increments with each new run. 20 | * This number does not change if you re-run the workflow run. 21 | */ 22 | GITHUB_RUN_NUMBER: string; 23 | /** The unique identifier (id) of the action. */ 24 | GITHUB_ACTION: string; 25 | /** 26 | * Always set to true when GitHub Actions is running the workflow. 27 | * You can use this variable to differentiate when tests are being run locally or by GitHub Actions. 28 | */ 29 | GITHUB_ACTIONS: string; 30 | /** 31 | * The name of the person or app that initiated the workflow. 32 | * @example 'octocat' 33 | */ 34 | GITHUB_ACTOR: string; 35 | /** 36 | * The owner and repository name. 37 | * @example 'octocat/Hello-World' 38 | */ 39 | GITHUB_REPOSITORY: string; 40 | /** 41 | * The name of the webhook event that triggered the workflow. 42 | * @see https://help.github.com/en/actions/reference/events-that-trigger-workflows#webhook-events 43 | */ 44 | GITHUB_EVENT_NAME: 45 | | 'check_run' 46 | | 'check_suite' 47 | | 'create' 48 | | 'delete' 49 | | 'deployment' 50 | | 'deployment_status' 51 | | 'fork' 52 | | 'gollum' 53 | | 'issue_comment' 54 | | 'issues' 55 | | 'label' 56 | | 'milestone' 57 | | 'page_build' 58 | | 'project' 59 | | 'project_card' 60 | | 'project_column' 61 | | 'public' 62 | | 'pull_request' 63 | | 'pull_request_review' 64 | | 'pull_request_review_comment' 65 | | 'push' 66 | | 'registry_package' 67 | | 'release' 68 | | 'status' 69 | | 'watch' 70 | | 'schedule' 71 | | 'repository_dispatch'; 72 | /** 73 | * The path of the file with the complete webhook event payload. 74 | * @example '/github/workflow/event.json' 75 | */ 76 | GITHUB_EVENT_PATH: string; 77 | /** 78 | * The GitHub workspace directory path. 79 | * The workspace directory contains a subdirectory with a copy of your repository if your workflow uses the actions/checkout action. 80 | * If you don't use the actions/checkout action, the directory will be empty. 81 | * @example '/home/runner/work/my-repo-name/my-repo-name' 82 | */ 83 | GITHUB_WORKSPACE: string; 84 | /** The commit SHA that triggered the workflow. 85 | * @example 'ffac537e6cbbf934b08745a378932722df287a53' 86 | */ 87 | GITHUB_SHA: string; 88 | /** 89 | * The branch or tag ref that triggered the workflow. 90 | * If neither a branch or tag is available for the event type, the variable will not exist. 91 | * @example 'refs/heads/feature-branch-1' 92 | */ 93 | GITHUB_REF?: string; 94 | /** 95 | * Only set for forked repositories. 96 | * The branch of the head repository. 97 | */ 98 | GITHUB_HEAD_REF?: string; 99 | /** 100 | * Only set for forked repositories. 101 | * The branch of the base repository. 102 | */ 103 | GITHUB_BASE_REF?: string; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /types/google.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Google Cloud Platform service account. 3 | */ 4 | /* eslint-disable camelcase */ 5 | export interface GoogleServiceAccount { 6 | type: string; 7 | project_id: string; 8 | private_key_id: string; 9 | private_key: string; 10 | client_email: string; 11 | client_id: string; 12 | auth_uri: string; 13 | token_uri: string; 14 | auth_provider_x509_cert_url: string; 15 | client_x509_cert_url: string; 16 | } 17 | /* eslint-enable camelcase */ 18 | -------------------------------------------------------------------------------- /types/node.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface ProcessEnv { 3 | DISCORD_TOKEN?: string; 4 | SENTRY_DSN?: string; 5 | DISCOIN_TOKEN?: string; 6 | READY_WEBHOOK_ID?: string; 7 | READY_WEBHOOK_TOKEN?: string; 8 | TOP_GG_WEBHOOK_PASSWORD?: string; 9 | POSTGRES_URI?: string; 10 | INFLUXDB_DSN?: string; 11 | GOOGLE_APPLICATION_CREDENTIALS?: string; 12 | NFL_API_TOKEN?: string; 13 | AIR_NOW_API_TOKEN?: string; 14 | MEILISEARCH_HOST?: string; 15 | MEILISEARCH_API_KEY?: string; 16 | /** Always set to `true` on GitHub Actions. */ 17 | // CI?: 'true'; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /types/opaque.d.ts: -------------------------------------------------------------------------------- 1 | import {Opaque} from 'type-fest'; 2 | 3 | export type Integer = Opaque; 4 | --------------------------------------------------------------------------------