├── .env.example
├── .github
├── CODEOWNERS
├── FUNDING.yml
└── workflows
│ └── continuous-integration.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.mjs
├── .vscode
└── settings.json
├── .yarn
├── plugins
│ └── @yarnpkg
│ │ └── plugin-nolyfill.cjs
└── releases
│ └── yarn-4.8.1.cjs
├── .yarnrc.yml
├── LICENSE
├── README.md
├── eslint.config.js
├── package.json
├── prisma
├── migrations
│ ├── 20240510235633_initial_schema
│ │ └── migration.sql
│ ├── 20250331073400_drop_words_and_move_to_always_regex
│ │ └── migration.sql
│ └── migration_lock.toml
└── schema.prisma
├── renovate.json
├── src.old
├── commands
│ └── Highlight
│ │ ├── highlight.ts
│ │ ├── regex.ts
│ │ └── words.ts
└── events
│ └── wslProcessHighlight.mjs
├── src
├── Highlight.ts
├── commands
│ ├── Admin
│ │ └── bot-parsing.ts
│ ├── Highlight
│ │ ├── globally-ignored-users.ts
│ │ └── server-ignore-list.ts
│ └── Miscellaneous
│ │ ├── help.ts
│ │ ├── invite.ts
│ │ ├── statistics.ts
│ │ └── support.ts
├── interaction-handlers
│ └── commands
│ │ ├── globally-ignored-users
│ │ └── clear.ts
│ │ └── server-ignore-list
│ │ └── clear.ts
├── lib
│ ├── setup.ts
│ ├── structures
│ │ ├── HighlightClient.ts
│ │ ├── HighlightManager.ts
│ │ └── customIds
│ │ │ ├── globally-ignored-users.ts
│ │ │ └── server-ignore-list.ts
│ ├── types
│ │ └── WorkerTypes.ts
│ ├── utils
│ │ ├── customIds.ts
│ │ ├── db.ts
│ │ ├── embeds.ts
│ │ ├── hooks
│ │ │ ├── useDevelopmentGuildIds.ts
│ │ │ ├── useErrorWebhook.ts
│ │ │ ├── useGuildJoinLeaveWebhook.ts
│ │ │ └── withDeprecationWarningForMessageCommands.ts
│ │ ├── misc.ts
│ │ └── userTags.ts
│ └── workers
│ │ ├── Worker.ts
│ │ ├── WorkerCache.ts
│ │ └── common.ts
├── listeners
│ ├── errorRelated
│ │ ├── commandApplicationCommandRegistryError.ts
│ │ ├── errorHandling.ts
│ │ └── listenerError.ts
│ ├── highlightRelated
│ │ ├── activityUpdater.ts
│ │ └── parseMessage.ts
│ └── miscEvents
│ │ ├── dbCleaners
│ │ ├── channelWithBotParsingCleaner.ts
│ │ └── userActivityCleaner.ts
│ │ ├── debug.ts
│ │ ├── guildCreate.ts
│ │ ├── guildDelete.ts
│ │ ├── mentionPrefix.ts
│ │ ├── preconditionDeniedHandling.ts
│ │ └── ready.ts
└── preconditions
│ ├── ApplicationOwnerOnly.ts
│ ├── GuildStaff.ts
│ └── UserNotOptedOut.ts
├── tests
├── __shared__
│ └── constants.ts
├── hooks
│ └── withDeprecationWarningForMessageCommands.test.ts
├── tsconfig.json
├── utils
│ └── customIds.test.ts
└── workers
│ └── WorkerCache.test.ts
├── tsconfig.eslint.json
├── tsconfig.json
├── vitest.config.ts
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 | # Database related options
2 | POSTGRES_HOST = localhost
3 | POSTGRES_PORT = 5432
4 | POSTGRES_DB = highlight
5 | POSTGRES_USERNAME = postgres
6 | POSTGRES_PASSWORD = root
7 |
8 | # Discord token for the bot application
9 | DISCORD_TOKEN=owo
10 |
11 | # Discord webhook URL for guild joins/leaves
12 | GUILD_JOIN_LEAVE_WEBHOOK_URL=https://owo.uwu
13 |
14 | # Discord webhook URL for errors
15 | ERROR_WEBHOOK_URL=https://owo.uwu
16 |
17 | # Discord server for support
18 | SUPPORT_SERVER_INVITE=https://discord.gg/C6D9bge
19 |
20 | # Comma-separated list of development ids
21 | DEVELOPMENT_GUILD_IDS=
22 |
23 | # Ignore anything after this
24 | POSTGRES_URL = postgresql://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public
25 |
26 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @vladfrangu
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [vladfrangu]
2 | patreon: vladfrangu
3 |
--------------------------------------------------------------------------------
/.github/workflows/continuous-integration.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on:
4 | push:
5 | branches:
6 | - sapphire/rewrite-to-sapphire-and-application-commands
7 | pull_request:
8 |
9 | jobs:
10 | Linting:
11 | name: Linting
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout Project
15 | uses: actions/checkout@v4
16 | - name: Use Node.js v20
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: 20
20 | cache: yarn
21 | registry-url: https://registry.yarnpkg.com/
22 | - name: Install Dependencies
23 | run: yarn --immutable
24 | - name: Run ESLint & Prettier
25 | run: yarn lint
26 |
27 | UnitTests:
28 | name: Unit tests
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout Project
32 | uses: actions/checkout@v4
33 | - name: Use Node.js v20
34 | uses: actions/setup-node@v4
35 | with:
36 | node-version: 20
37 | cache: yarn
38 | registry-url: https://registry.yarnpkg.com/
39 | - name: Install Dependencies
40 | run: yarn --immutable
41 | - name: Run unit tests
42 | run: yarn test
43 |
44 | Building:
45 | name: Build code
46 | runs-on: ubuntu-latest
47 | steps:
48 | - name: Checkout Project
49 | uses: actions/checkout@v4
50 | - name: Use Node.js v20
51 | uses: actions/setup-node@v4
52 | with:
53 | node-version: 20
54 | cache: yarn
55 | registry-url: https://registry.yarnpkg.com/
56 | - name: Install Dependencies
57 | run: yarn --immutable
58 | - name: Build Code
59 | run: yarn build
60 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore a blackhole and the folder for development
2 | node_modules/
3 | .vs/
4 | .idea/
5 | *.iml
6 | /rethinkdb_data/
7 | coverage/
8 |
9 | # Environment variables
10 | .DS_Store
11 | .env.local
12 | .env.development.local
13 | .env.test.local
14 | .env.production.local
15 |
16 | # Ignore bwd folders
17 | dist/
18 | bwd/{pending,open,closed}Tickets
19 |
20 | # Ignore JavaScript files
21 | **/*.js
22 | **/*.js.map
23 | **/*.d.ts
24 | !src/**/*.d.ts
25 | !eslint.config.js
26 | **/*.tsbuildinfo
27 |
28 | # Ignore the config file (contains sensitive information such as tokens)
29 | config.ts
30 |
31 | # Ignore heapsnapshot and log files
32 | *.heapsnapshot
33 | *.log
34 |
35 | # Ignore the GH cli downloaded by workflows
36 | gh
37 |
38 | # Ignore the "wiki" folder so we can checkout the wiki inside the same folder
39 | wiki/
40 |
41 | bwd/
42 | .env
43 |
44 | # Yarn files
45 | .yarn/install-state.gz
46 | .yarn/build-state.yml
47 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | coverage
2 | dist
3 | node_modules
4 | .yarn
5 | src.old
6 |
--------------------------------------------------------------------------------
/.prettierrc.mjs:
--------------------------------------------------------------------------------
1 | import sapphirePrettierConfig from '@sapphire/prettier-config';
2 |
3 | /** @type {import('prettier').Config} */
4 | export default {
5 | ...sapphirePrettierConfig,
6 | trailingComma: 'all',
7 | printWidth: 120,
8 | experimentalTernaries: true,
9 | };
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
3 | "eslint.experimental.useFlatConfig": true,
4 | "eslint.workingDirectories": [{ "directory": "${workspaceFolder}" }],
5 | "editor.defaultFormatter": "esbenp.prettier-vscode",
6 | "editor.formatOnSave": true,
7 | "editor.codeActionsOnSave": {
8 | "source.organizeImports": "never",
9 | "source.fixAll.eslint": "explicit",
10 | "source.fixAll": "explicit"
11 | },
12 | "editor.trimAutoWhitespace": false,
13 | "testing.automaticallyOpenPeekView": "never",
14 | "typescript.tsdk": "node_modules/typescript/lib",
15 | "typescript.enablePromptUseWorkspaceTsdk": true
16 | }
17 |
--------------------------------------------------------------------------------
/.yarn/plugins/@yarnpkg/plugin-nolyfill.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | //prettier-ignore
3 | module.exports = {
4 | name: "@yarnpkg/plugin-nolyfill",
5 | factory: function (require) {
6 | "use strict";var plugin=(()=>{var p=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var n=Object.getOwnPropertyNames;var y=Object.prototype.hasOwnProperty;var l=(t=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(t,{get:(r,e)=>(typeof require<"u"?require:r)[e]}):t)(function(t){if(typeof require<"u")return require.apply(this,arguments);throw new Error('Dynamic require of "'+t+'" is not supported')});var c=(t,r)=>{for(var e in r)p(t,e,{get:r[e],enumerable:!0})},g=(t,r,e,s)=>{if(r&&typeof r=="object"||typeof r=="function")for(let a of n(r))!y.call(t,a)&&a!==e&&p(t,a,{get:()=>r[a],enumerable:!(s=i(r,a))||s.enumerable});return t};var f=t=>g(p({},"__esModule",{value:!0}),t);var m={};c(m,{default:()=>h});var o=l("@yarnpkg/core"),d=["abab","array-buffer-byte-length","array-includes","array.from","array.of","array.prototype.at","array.prototype.every","array.prototype.find","array.prototype.findlast","array.prototype.findlastindex","array.prototype.flat","array.prototype.flatmap","array.prototype.flatmap","array.prototype.foreach","array.prototype.reduce","array.prototype.toreversed","array.prototype.tosorted","arraybuffer.prototype.slice","assert","asynciterator.prototype","available-typed-arrays","deep-equal","deep-equal-json","define-properties","es-aggregate-error","es-iterator-helpers","es-set-tostringtag","es6-object-assign","function-bind","function.prototype.name","get-symbol-description","globalthis","gopd","harmony-reflect","has","has-property-descriptors","has-proto","has-symbols","has-tostringtag","hasown","internal-slot","is-arguments","is-array-buffer","is-core-module","is-date-object","is-generator-function","is-nan","is-regex","is-shared-array-buffer","is-string","is-symbol","is-typed-array","is-weakref","isarray","iterator.prototype","json-stable-stringify","jsonify","object-is","object-keys","object.assign","object.entries","object.fromentries","object.getownpropertydescriptors","object.groupby","object.hasown","object.values","promise.allsettled","promise.any","reflect.getprototypeof","reflect.ownkeys","regexp.prototype.flags","safe-array-concat","safe-regex-test","set-function-length","side-channel","string.prototype.at","string.prototype.codepointat","string.prototype.includes","string.prototype.matchall","string.prototype.padend","string.prototype.padstart","string.prototype.repeat","string.prototype.replaceall","string.prototype.split","string.prototype.startswith","string.prototype.trim","string.prototype.trimend","string.prototype.trimleft","string.prototype.trimright","string.prototype.trimstart","typed-array-buffer","typed-array-byte-length","typed-array-byte-offset","typed-array-length","typedarray","unbox-primitive","util.promisify","which-boxed-primitive","which-typed-array"],u=new Map(d.map(t=>[o.structUtils.makeIdent(null,t).identHash,o.structUtils.makeIdent("nolyfill",t)])),b={hooks:{reduceDependency:async t=>{let r=u.get(t.identHash);if(r){let e=o.structUtils.makeDescriptor(r,"latest"),s=o.structUtils.makeRange({protocol:"npm:",source:null,selector:o.structUtils.stringifyDescriptor(e),params:null});return o.structUtils.makeDescriptor(t,s)}return t}}},h=b;return f(m);})();
7 | return plugin;
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | compressionLevel: mixed
2 |
3 | enableGlobalCache: true
4 |
5 | nodeLinker: node-modules
6 |
7 | plugins:
8 | - checksum: 9b6f8a34bda80f025c0b223fa80836f5e931cf5c8dd83e10ccfa9e677856cf1508b063d027060f74e3ce66ee1c8a936542e85db18a30584f9b88a50379b3f514
9 | path: .yarn/plugins/@yarnpkg/plugin-nolyfill.cjs
10 | spec: 'https://raw.githubusercontent.com/wojtekmaj/yarn-plugin-nolyfill/v1.0.1/bundles/@yarnpkg/plugin-nolyfill.js'
11 |
12 | yarnPath: .yarn/releases/yarn-4.8.1.cjs
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Highlight
2 |
3 |
13 |
14 | A simple to use highlight bot made using klasa and discord.js.
15 |
16 | ## Installation
17 |
18 | Clone this repository, make sure you have git installed and run
19 |
20 | ```bash
21 | yarn
22 | ```
23 |
24 | Copy `.env.example` to `.env`, fill in the values, then run:
25 |
26 | ```bash
27 | yarn build
28 | node .
29 | ```
30 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import { setup } from '@skyra/env-utilities';
3 | import safeql from '@ts-safeql/eslint-plugin/config';
4 | import common from 'eslint-config-neon/flat/common.js';
5 | import node from 'eslint-config-neon/flat/node.js';
6 | import prettier from 'eslint-config-neon/flat/prettier.js';
7 | import typescript from 'eslint-config-neon/flat/typescript.js';
8 | import isCI from 'is-ci';
9 | import merge from 'lodash.merge';
10 |
11 | setup({ path: new URL('.env', import.meta.url) });
12 |
13 | const commonFiles = '{js,mjs,cjs,ts,mts,cts,jsx,tsx}';
14 |
15 | const commonRuleset = merge(...common, { files: [`**/*${commonFiles}`] });
16 |
17 | const nodeRuleset = merge(...node, { files: [`**/*${commonFiles}`] });
18 |
19 | const typeScriptRuleset = merge(...typescript, {
20 | files: [`**/*${commonFiles}`],
21 | languageOptions: {
22 | parserOptions: {
23 | warnOnUnsupportedTypeScriptVersion: false,
24 | allowAutomaticSingleRunInference: true,
25 | project: ['tsconfig.eslint.json'],
26 | },
27 | },
28 | rules: {
29 | '@typescript-eslint/consistent-type-definitions': [2, 'interface'],
30 | '@typescript-eslint/dot-notation': 0,
31 | 'import/order': [
32 | 1,
33 | {
34 | alphabetize: { caseInsensitive: false, order: 'asc' },
35 | groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
36 | 'newlines-between': 'never',
37 | },
38 | ],
39 | 'tsdoc/syntax': 0,
40 | },
41 | settings: { 'import/resolver': { typescript: { project: ['tsconfig.eslint.json', 'tests/tsconfig.json'] } } },
42 | });
43 |
44 | const prettierRuleset = merge(...prettier, { files: [`**/*${commonFiles}`] });
45 |
46 | const url = new URL(process.env.POSTGRES_URL);
47 | url.searchParams.delete('schema');
48 |
49 | /** @type {import('eslint').Linter.FlatConfig} */
50 | const safeqlRuleset = {
51 | ...safeql.configs.connections({
52 | connectionUrl: url.toString(),
53 | migrationsDir: './prisma/migrations',
54 | targets: [{ tag: '**prisma.+($queryRaw|$executeRaw)', transform: '{type}[]' }],
55 | overrides: {},
56 | }),
57 | files: [`**/*${commonFiles}`],
58 | languageOptions: { parserOptions: { project: ['tsconfig.eslint.json'] } },
59 | };
60 |
61 | /** @type {import('eslint').Linter.FlatConfig[]} */
62 | const rules = [
63 | { ignores: ['**/node_modules/', '.git/', '**/dist/', '**/coverage/'] },
64 | commonRuleset,
65 | nodeRuleset,
66 | typeScriptRuleset,
67 | { files: ['**/*{ts,mts,cts,tsx}'], rules: { 'jsdoc/no-undefined-types': 0 } },
68 | prettierRuleset,
69 | ];
70 |
71 | if (!isCI) {
72 | rules.push(safeqlRuleset);
73 | }
74 |
75 | export default rules;
76 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "highlight-bot",
3 | "version": "3.0.0",
4 | "description": "An utility bot written in Sapphire to get highlighted when a word, or regular expression is said",
5 | "private": true,
6 | "main": "dist/Highlight.js",
7 | "type": "module",
8 | "imports": {
9 | "#internals/*": "./dist/lib/internals/*.js",
10 | "#hooks/*": "./dist/lib/utils/hooks/*.js",
11 | "#setup": "./dist/lib/setup.js",
12 | "#customIds/*": "./dist/lib/structures/customIds/*.js",
13 | "#structures/*": "./dist/lib/structures/*.js",
14 | "#types/*": "./dist/lib/types/*.js",
15 | "#utils/*": "./dist/lib/utils/*.js",
16 | "#workers/*": "./dist/lib/workers/*.js"
17 | },
18 | "scripts": {
19 | "build": "tsc",
20 | "clean": "rimraf dist",
21 | "cleanbuild": "yarn clean && tsc",
22 | "coverage": "vitest run --coverage",
23 | "lint": "prettier --check . && cross-env TIMING=1 eslint --format=pretty src tests",
24 | "format": "prettier --write . && cross-env TIMING=1 eslint --fix --format=pretty src tests",
25 | "start": "yarn cleanbuild && node .",
26 | "test": "vitest run",
27 | "watch": "tsc -w"
28 | },
29 | "dependencies": {
30 | "@discordjs/builders": "^1.11.2",
31 | "@mikro-orm/sql-highlighter": "^1.0.1",
32 | "@prisma/client": "^6.7.0",
33 | "@sapphire/decorators": "^6.1.1",
34 | "@sapphire/discord-utilities": "^3.5.0",
35 | "@sapphire/discord.js-utilities": "^7.3.3",
36 | "@sapphire/framework": "^5.3.4",
37 | "@sapphire/plugin-logger": "^4.0.2",
38 | "@sapphire/plugin-subcommands": "^6.0.3",
39 | "@sapphire/stopwatch": "^1.5.4",
40 | "@sapphire/time-utilities": "^1.7.14",
41 | "@sapphire/timestamp": "^1.0.5",
42 | "@sapphire/utilities": "^3.18.2",
43 | "@skyra/env-utilities": "^2.0.0",
44 | "@skyra/jaro-winkler": "^1.1.1",
45 | "colorette": "^2.0.20",
46 | "confusables": "^1.1.1",
47 | "discord-api-types": "^0.38.4",
48 | "discord.js": "^14.19.3",
49 | "lru-cache": "^11.1.0",
50 | "re2": "^1.21.4",
51 | "tslib": "^2.8.1"
52 | },
53 | "devDependencies": {
54 | "@sapphire/prettier-config": "^2.0.0",
55 | "@sapphire/ts-config": "^5.0.1",
56 | "@ts-safeql/eslint-plugin": "^3.6.12",
57 | "@types/is-ci": "^3.0.4",
58 | "@types/lodash.merge": "^4.6.9",
59 | "@types/node": "^22.15.17",
60 | "@typescript-eslint/eslint-plugin": "^8.32.0",
61 | "@typescript-eslint/parser": "^8.32.0",
62 | "@vitest/coverage-v8": "^3.1.3",
63 | "cross-env": "^7.0.3",
64 | "eslint": "^8.57.1",
65 | "eslint-config-neon": "^0.1.62",
66 | "eslint-formatter-pretty": "^6.0.1",
67 | "is-ci": "^4.1.0",
68 | "libpg-query": "^16.3.0",
69 | "lodash.merge": "^4.6.2",
70 | "prettier": "^3.5.3",
71 | "prisma": "^6.7.0",
72 | "rimraf": "^6.0.1",
73 | "typescript": "^5.8.3",
74 | "vitest": "^3.1.3"
75 | },
76 | "author": {
77 | "name": "Vlad Frangu",
78 | "email": "me@vladfrangu.dev"
79 | },
80 | "engines": {
81 | "node": ">=20.0.0"
82 | },
83 | "packageManager": "yarn@4.8.1",
84 | "volta": {
85 | "node": "22.14.0",
86 | "yarn": "4.8.1"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/prisma/migrations/20240510235633_initial_schema/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "GuildNotificationStyle" AS ENUM ('WITH_CONTEXT_AND_AUTHOR', 'WITH_CONTEXT_BUT_NO_AUTHOR', 'WITHOUT_CONTEXT_BUT_WITH_AUTHOR', 'WITHOUT_CONTEXT_OR_AUTHOR');
3 |
4 | -- CreateTable
5 | CREATE TABLE "users" (
6 | "id" TEXT NOT NULL,
7 | "opted_out" BOOLEAN NOT NULL DEFAULT false,
8 | "opted_out_at" TIMESTAMP(3),
9 | "grace_period" INTEGER,
10 | "adult_channel_highlights" BOOLEAN NOT NULL DEFAULT false,
11 | "direct_message_failed_attempts" INTEGER NOT NULL DEFAULT 0,
12 | "direct_message_cooldown_expires_at" TIMESTAMP(3),
13 |
14 | CONSTRAINT "users_pkey" PRIMARY KEY ("id")
15 | );
16 |
17 | -- CreateTable
18 | CREATE TABLE "global_ignored_users" (
19 | "user_id" TEXT NOT NULL,
20 | "ignored_user_id" TEXT NOT NULL,
21 |
22 | CONSTRAINT "global_ignored_users_pkey" PRIMARY KEY ("user_id","ignored_user_id")
23 | );
24 |
25 | -- CreateTable
26 | CREATE TABLE "guilds" (
27 | "guild_id" TEXT NOT NULL,
28 | "notification_style" "GuildNotificationStyle" NOT NULL DEFAULT 'WITH_CONTEXT_AND_AUTHOR',
29 | "channel_with_bot_parsings_allowed" INTEGER NOT NULL DEFAULT 3,
30 |
31 | CONSTRAINT "guilds_pkey" PRIMARY KEY ("guild_id")
32 | );
33 |
34 | -- CreateTable
35 | CREATE TABLE "channels_with_bot_parsing" (
36 | "channel_id" TEXT NOT NULL,
37 | "guild_id" TEXT NOT NULL,
38 | "user_id" TEXT NOT NULL,
39 | "added_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
40 |
41 | CONSTRAINT "channels_with_bot_parsing_pkey" PRIMARY KEY ("channel_id")
42 | );
43 |
44 | -- CreateTable
45 | CREATE TABLE "members" (
46 | "guild_id" TEXT NOT NULL,
47 | "user_id" TEXT NOT NULL,
48 | "words" TEXT[],
49 | "regular_expressions" TEXT[],
50 |
51 | CONSTRAINT "members_pkey" PRIMARY KEY ("guild_id","user_id")
52 | );
53 |
54 | -- CreateTable
55 | CREATE TABLE "user_activities" (
56 | "user_id" TEXT NOT NULL,
57 | "channel_id" TEXT NOT NULL,
58 | "guild_id" TEXT NOT NULL,
59 | "last_active_at" TIMESTAMP NOT NULL,
60 |
61 | CONSTRAINT "user_activities_pkey" PRIMARY KEY ("user_id","channel_id")
62 | );
63 |
64 | -- CreateTable
65 | CREATE TABLE "guild_ignored_channels" (
66 | "ignored_channel_id" TEXT NOT NULL,
67 | "user_id" TEXT NOT NULL,
68 | "guild_id" TEXT NOT NULL,
69 |
70 | CONSTRAINT "guild_ignored_channels_pkey" PRIMARY KEY ("guild_id","user_id","ignored_channel_id")
71 | );
72 |
73 | -- CreateTable
74 | CREATE TABLE "guild_ignored_users" (
75 | "ignored_user_id" TEXT NOT NULL,
76 | "user_id" TEXT NOT NULL,
77 | "guild_id" TEXT NOT NULL,
78 |
79 | CONSTRAINT "guild_ignored_users_pkey" PRIMARY KEY ("guild_id","user_id","ignored_user_id")
80 | );
81 |
82 | -- CreateIndex
83 | CREATE INDEX "global_ignored_users_user_id_idx" ON "global_ignored_users"("user_id");
84 |
85 | -- CreateIndex
86 | CREATE INDEX "members_guild_id_idx" ON "members"("guild_id");
87 |
88 | -- CreateIndex
89 | CREATE INDEX "guild_ignored_channels_user_id_guild_id_idx" ON "guild_ignored_channels"("user_id", "guild_id");
90 |
91 | -- CreateIndex
92 | CREATE INDEX "guild_ignored_channels_guild_id_idx" ON "guild_ignored_channels"("guild_id");
93 |
94 | -- CreateIndex
95 | CREATE INDEX "guild_ignored_users_user_id_guild_id_idx" ON "guild_ignored_users"("user_id", "guild_id");
96 |
97 | -- CreateIndex
98 | CREATE INDEX "guild_ignored_users_guild_id_idx" ON "guild_ignored_users"("guild_id");
99 |
100 | -- AddForeignKey
101 | ALTER TABLE "global_ignored_users" ADD CONSTRAINT "global_ignored_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
102 |
103 | -- AddForeignKey
104 | ALTER TABLE "channels_with_bot_parsing" ADD CONSTRAINT "channels_with_bot_parsing_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "guilds"("guild_id") ON DELETE CASCADE ON UPDATE CASCADE;
105 |
106 | -- AddForeignKey
107 | ALTER TABLE "members" ADD CONSTRAINT "members_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "guilds"("guild_id") ON DELETE CASCADE ON UPDATE CASCADE;
108 |
109 | -- AddForeignKey
110 | ALTER TABLE "members" ADD CONSTRAINT "members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
111 |
112 | -- AddForeignKey
113 | ALTER TABLE "user_activities" ADD CONSTRAINT "user_activities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
114 |
115 | -- AddForeignKey
116 | ALTER TABLE "user_activities" ADD CONSTRAINT "user_activities_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "guilds"("guild_id") ON DELETE CASCADE ON UPDATE CASCADE;
117 |
118 | -- AddForeignKey
119 | ALTER TABLE "guild_ignored_channels" ADD CONSTRAINT "guild_ignored_channels_guild_id_user_id_fkey" FOREIGN KEY ("guild_id", "user_id") REFERENCES "members"("guild_id", "user_id") ON DELETE CASCADE ON UPDATE CASCADE;
120 |
121 | -- AddForeignKey
122 | ALTER TABLE "guild_ignored_users" ADD CONSTRAINT "guild_ignored_users_guild_id_user_id_fkey" FOREIGN KEY ("guild_id", "user_id") REFERENCES "members"("guild_id", "user_id") ON DELETE CASCADE ON UPDATE CASCADE;
123 |
--------------------------------------------------------------------------------
/prisma/migrations/20250331073400_drop_words_and_move_to_always_regex/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `words` on the `members` table. All the data in the column will be lost.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "members" DROP COLUMN "words";
9 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("POSTGRES_URL")
8 | }
9 |
10 | model User {
11 | id String @id @db.Text
12 |
13 | /// /highlight opt-in/opt-out
14 | optedOut Boolean @default(false) @map("opted_out")
15 | optedOutAt DateTime? @map("opted_out_at")
16 |
17 | /// Used to say "only send me highlights after I've been away from the channel for x seconds"
18 | ///
19 | /// `null` means always send messages
20 | ///
21 | /// /highlight grace-period set/clear/show
22 | gracePeriod Int? @map("grace_period")
23 |
24 | /// /highlight adult-channel-highlights enable/disable
25 | adultChannelHighlights Boolean @default(false) @map("adult_channel_highlights")
26 |
27 | /// Support globally ignoring users
28 | ///
29 | /// /globally-ignored-users add/remove/list/clear [user_id]
30 | globallyIgnoredUsers GlobalIgnoredUser[]
31 |
32 | /// All members that point to this user
33 | members Member[]
34 | /// All user activities for this user
35 | userActivity UserActivity[]
36 |
37 | /// Represents the last time the user was unable to be DM'd.
38 | /// This property is used to give people who add words and then block the bot a cooldown on messages
39 | /// to prevent abuse.
40 | ///
41 | /// Users can check this via `/highlight direct-message-cooldown`, and can reset it by pressing the button attached to the message
42 | directMessageFailedAttempts Int @default(0) @map("direct_message_failed_attempts")
43 | directMessageCooldownExpiresAt DateTime? @map("direct_message_cooldown_expires_at")
44 |
45 | @@map("users")
46 | }
47 |
48 | model GlobalIgnoredUser {
49 | userId String @map("user_id") @db.Text
50 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
51 | ignoredUserId String @map("ignored_user_id") @db.Text
52 |
53 | @@id([userId, ignoredUserId])
54 | @@index([userId])
55 | @@map("global_ignored_users")
56 | }
57 |
58 | model Guild {
59 | guildId String @id @map("guild_id") @db.Text
60 | /// Keep default in sync with src/listeners/highlightRelated/parseMessage.ts
61 | notificationStyle GuildNotificationStyle @default(WithContextAndAuthor) @map("notification_style")
62 | // Make this customizable per server, incentivize paid for more than 3
63 | channelWithBotParsingsAllowed Int @default(3) @map("channel_with_bot_parsings_allowed")
64 | /// All channels that have bot parsing enabled for this guild
65 | channelsWithBotParsing ChannelWithBotParsing[]
66 | /// All user activities for this guild
67 | userActivities UserActivity[]
68 | /// All members that point to this guild
69 | members Member[]
70 |
71 | @@map("guilds")
72 | }
73 |
74 | /// Some people have requested that highlight parses bot messages too :shrug:
75 | /// There can be legit use cases I suppose, so for now this is added as an option
76 | model ChannelWithBotParsing {
77 | channelId String @id @map("channel_id") @db.Text
78 | guildId String @map("guild_id") @db.Text
79 | guild Guild @relation(fields: [guildId], references: [guildId], onDelete: Cascade)
80 | userId String @map("user_id") @db.Text
81 | addedAt DateTime @default(now()) @map("added_at")
82 |
83 | @@map("channels_with_bot_parsing")
84 | }
85 |
86 | /// Privacy levels in DMs. All will still get a message link to the original message, but this will
87 | /// hopefully prevent certain abuse vectors by leaving it at the guild's choice how the embed should look
88 | enum GuildNotificationStyle {
89 | /// User#0000/@User highlighted you in #channel (+ optional message history is possible with who wrote the messages)
90 | WithContextAndAuthor @map("WITH_CONTEXT_AND_AUTHOR")
91 | /// A user highlighted you in #channel (+ optional message history but no author data)
92 | WithContextButNoAuthor @map("WITH_CONTEXT_BUT_NO_AUTHOR")
93 | /// User#0000/@User highlighted you in #channel (without message history)
94 | WithoutContextButWithAuthor @map("WITHOUT_CONTEXT_BUT_WITH_AUTHOR")
95 | /// A user highlighted you in #channel (without message history and author data)
96 | WithoutContextOrAuthor @map("WITHOUT_CONTEXT_OR_AUTHOR")
97 | }
98 |
99 | model Member {
100 | guildId String @map("guild_id") @db.Text
101 | guild Guild @relation(fields: [guildId], references: [guildId], onDelete: Cascade)
102 | userId String @map("user_id") @db.Text
103 | user User @relation(fields: [userId], references: [id], onDelete: Restrict)
104 |
105 | /// Patterns that will trigger a highlight
106 | regularExpressions String[] @map("regular_expressions") @db.Text
107 | /// Ignored users that will not trigger a highlight
108 | ///
109 | /// /server-ignore-list add/remove/list/clear [user_id|channel_id]
110 | ignoredUsers GuildIgnoredUser[]
111 | /// Ignored channels that will not trigger a highlight
112 | ///
113 | /// /server-ignore-list add/remove/list/clear [user_id|channel_id]
114 | ignoredChannels GuildIgnoredChannel[]
115 |
116 | @@id([guildId, userId])
117 | @@index([guildId])
118 | @@map("members")
119 | }
120 |
121 | // Used to power the grace period (but only for users who opted into having a grace period)
122 | model UserActivity {
123 | userId String @map("user_id") @db.Text
124 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
125 | channelId String @map("channel_id") @db.Text
126 | guildId String @map("guild_id") @db.Text
127 | guild Guild @relation(fields: [guildId], references: [guildId], onDelete: Cascade)
128 | /// The last time the user was active
129 | lastActiveAt DateTime @map("last_active_at") @db.Timestamp()
130 |
131 | @@id([userId, channelId])
132 | @@map("user_activities")
133 | }
134 |
135 | model GuildIgnoredChannel {
136 | ignoredChannelId String @map("ignored_channel_id") @db.Text
137 | userId String @map("user_id") @db.Text
138 | guildId String @map("guild_id") @db.Text
139 |
140 | member Member @relation(fields: [guildId, userId], references: [guildId, userId], onDelete: Cascade)
141 |
142 | @@id([guildId, userId, ignoredChannelId])
143 | @@index([userId, guildId])
144 | @@index([guildId])
145 | @@map("guild_ignored_channels")
146 | }
147 |
148 | model GuildIgnoredUser {
149 | ignoredUserId String @map("ignored_user_id") @db.Text
150 | userId String @map("user_id") @db.Text
151 | guildId String @map("guild_id") @db.Text
152 |
153 | member Member @relation(fields: [guildId, userId], references: [guildId, userId], onDelete: Cascade)
154 |
155 | @@id([guildId, userId, ignoredUserId])
156 | @@index([userId, guildId])
157 | @@index([guildId])
158 | @@map("guild_ignored_users")
159 | }
160 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
4 | "semanticCommits": "enabled",
5 | "lockFileMaintenance": {
6 | "enabled": true,
7 | "schedule": ["before 1pm every friday"]
8 | },
9 | "ignorePaths": ["website/"],
10 | "packageRules": [
11 | {
12 | "matchUpdateTypes": ["patch", "minor"],
13 | "matchCurrentVersion": "!/^0/",
14 | "groupName": "patch/minor dependencies",
15 | "groupSlug": "all-non-major"
16 | }
17 | ],
18 | "schedule": ["before 1pm every friday"],
19 | "ignoreDeps": ["discord-api-types"]
20 | }
21 |
--------------------------------------------------------------------------------
/src.old/commands/Highlight/highlight.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vladfrangu/highlight/d13b2656715262a59ca8a53ea229ed16e9f76381/src.old/commands/Highlight/highlight.ts
--------------------------------------------------------------------------------
/src.old/commands/Highlight/regex.ts:
--------------------------------------------------------------------------------
1 | import { Command, CommandOptions, KlasaMessage } from 'klasa';
2 | import { ApplyOptions } from '@skyra/decorators';
3 | import { MessageEmbed, Util } from 'discord.js';
4 | import { pluralize, tryRegex } from '../../lib/utils/Util';
5 |
6 | const NEEDS_REGEX = ['list', 'clear'];
7 |
8 | @ApplyOptions({
9 | aliases: ['regexes', 'rr', 'regularexpressions', 'regularexpression'],
10 | description: 'Control what regular expressions will highlight you',
11 | permissionLevel: 2,
12 | runIn: ['text'],
13 | subcommands: true,
14 | usage: ' (regularExpression:string)',
15 | usageDelim: ' ',
16 | extendedHelp: [
17 | "→ If you want to see a list of all regular expressions you have",
18 | '`{prefix}regularexpressions [list]` → Specifying `list` is optional as it is the default subcommand',
19 | "",
20 | "→ Adding, or removing a regular expression from your highlighting list",
21 | "`{prefix}regularexpressions add .*` → Adds the specified regular expression, if it isn't added already.",
22 | "`{prefix}regularexpressions remove .*` → Removes the specified regular expression, if it was added",
23 | "",
24 | "→ Clearing the regular expressions list, if you want to start from scratch",
25 | "`{prefix}regularexpressions clear`",
26 | "",
27 | "*If you like your DMs, don't add* `.*` *or anything similar...*",
28 | ].join('\n'),
29 | })
30 | export default class extends Command {
31 | needsMember = true;
32 |
33 | async list(message: KlasaMessage) {
34 | if (!message.guild || !message.member) throw new Error('Unreachable');
35 |
36 | const regularExpressions = message.member.settings.get('regularExpressions') as string[];
37 |
38 | const embed = new MessageEmbed()
39 | .setColor(0x3669FA)
40 | .setDescription("There are no regular expressions you'd want to be highlighted for!");
41 |
42 | if (regularExpressions.length) {
43 | embed
44 | .setTitle(`You have __${regularExpressions.length}__ ${pluralize(regularExpressions.length, 'regular expression', 'regular expressions')} added`)
45 | .setDescription(`- ${regularExpressions.map((r) => Util.escapeMarkdown(r)).join('\n- ')}`);
46 | }
47 |
48 | return message.send(embed);
49 | }
50 | async add(message: KlasaMessage, [regularExpression]: [string]) {
51 | regularExpression = regularExpression.toLowerCase();
52 | if (!message.guild || !message.member) throw new Error('Unreachable');
53 |
54 | const previousExpressions = message.member.settings.get('regularExpressions') as string[];
55 |
56 | let added = false;
57 |
58 | const [valid] = tryRegex(regularExpression);
59 | if (!valid) {
60 | return message.send(
61 | new MessageEmbed()
62 | .setColor(0xCC0F16)
63 | .setDescription(`Your regular expression (\`${Util.escapeMarkdown(regularExpression, {
64 | bold: false,
65 | italic: false,
66 | spoiler: false,
67 | strikethrough: false,
68 | underline: false,
69 | })}\`) is not valid!
70 | Use a site like [regexr](https://regexr.com/) to validate it and try again!`),
71 | );
72 | }
73 |
74 | if (!previousExpressions.includes(regularExpression)) added = true;
75 |
76 | if (added) {
77 | await message.member.settings.update('regularExpressions', regularExpression, { arrayAction: 'add' });
78 | message.guild.addRegularExpression(regularExpression, message.author.id);
79 | }
80 |
81 | const embed = new MessageEmbed()
82 | .setColor(0x43B581)
83 | .setDescription('No new regular expression was added..');
84 |
85 | if (added) {
86 | embed
87 | // eslint-disable-next-line @typescript-eslint/require-array-sort-compare
88 | .setDescription(`Your regular expression was added to the list: \`${Util.escapeMarkdown(regularExpression, {
89 | bold: false,
90 | italic: false,
91 | spoiler: false,
92 | strikethrough: false,
93 | underline: false,
94 | })}\``)
95 | .setFooter('Remember; regular expressions are case insensitive!');
96 | }
97 |
98 | return message.send(embed);
99 | }
100 |
101 | async remove(message: KlasaMessage, [regularExpression]: [string]) {
102 | regularExpression = regularExpression.toLowerCase();
103 | if (!message.guild || !message.member) throw new Error('Unreachable');
104 |
105 | const previousExpressions = [...message.member.settings.get('regularExpressions') as string[]];
106 |
107 | let removed = false;
108 | if (previousExpressions.includes(regularExpression)) {
109 | removed = true;
110 | previousExpressions.splice(previousExpressions.indexOf(regularExpression), 1);
111 | }
112 |
113 | if (removed) {
114 | await message.member.settings.update('regularExpressions', previousExpressions, { arrayAction: 'overwrite' });
115 | message.guild.removeRegularExpression(regularExpression, message.author.id);
116 | }
117 |
118 | const embed = new MessageEmbed()
119 | .setColor(0x43B581)
120 | .setDescription('No regular expressions have been removed..');
121 |
122 | if (removed) {
123 | embed
124 | // eslint-disable-next-line @typescript-eslint/require-array-sort-compare
125 | .setDescription(`Your regular expression was removed from the list: \`${Util.escapeMarkdown(regularExpression, {
126 | bold: false,
127 | italic: false,
128 | spoiler: false,
129 | strikethrough: false,
130 | underline: false,
131 | })}\``);
132 | }
133 |
134 | return message.send(embed);
135 | }
136 |
137 | async clear(message: KlasaMessage) {
138 | if (!message.guild || !message.member) throw new Error('Unreachable');
139 |
140 | const oldExpressions = message.member.settings.get('regularExpressions') as string[];
141 |
142 | await message.member.settings.reset('regularExpressions');
143 | message.guild.removeRegularExpressions(oldExpressions, message.author.id);
144 |
145 | return message.send(
146 | new MessageEmbed()
147 | .setColor(0x43B581)
148 | .setDescription('Your regular expression list was reset'),
149 | );
150 | }
151 |
152 | async init() {
153 | this.createCustomResolver('string', async(arg, possible, message, params) => {
154 | if (NEEDS_REGEX.includes(params[0])) return undefined;
155 | return this.client.arguments.get('string')!.run(arg, possible, message);
156 | });
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/src.old/commands/Highlight/words.ts:
--------------------------------------------------------------------------------
1 | import { Command, CommandOptions, KlasaMessage } from 'klasa';
2 | import { ApplyOptions } from '@skyra/decorators';
3 | import { MessageEmbed, Util } from 'discord.js';
4 | import { pluralize } from '../../lib/utils/Util';
5 |
6 | const NEEDS_WORD = ['list', 'clear'];
7 |
8 | @ApplyOptions({
9 | aliases: ['word', 'w'],
10 | description: 'Control what words will highlight you',
11 | permissionLevel: 2,
12 | runIn: ['text'],
13 | subcommands: true,
14 | usage: ' (words:string) [...]',
15 | usageDelim: ' ',
16 | extendedHelp: [
17 | "→ If you want to see a list of all words you have",
18 | '`{prefix}words [list]` → Specifying `list` is optional as it is the default subcommand',
19 | "",
20 | "→ Adding, or removing a word (or multiple words) from your highlighting list",
21 | "`{prefix}words add hi vlad` → Adds the specified words, if they aren't added already.",
22 | "`{prefix}words remove vlad` → Removes the specified words, if they were added",
23 | "",
24 | "→ Clearing the word list, if you want to start from scratch",
25 | "`{prefix}words clear`",
26 | ].join('\n'),
27 | })
28 | export default class extends Command {
29 | needsMember = true;
30 |
31 | async list(message: KlasaMessage) {
32 | if (!message.guild || !message.member) throw new Error('Unreachable');
33 |
34 | const words = message.member.settings.get('words') as string[];
35 |
36 | const embed = new MessageEmbed()
37 | .setColor(0x3669FA)
38 | .setDescription("There are no words you'd want to be highlighted for!");
39 |
40 | if (words.length) {
41 | embed
42 | .setTitle(`You have __${words.length}__ ${pluralize(words.length, 'word', 'words')} added`)
43 | .setDescription(`- ${words.map((word) => Util.escapeMarkdown(word)).join('\n- ')}`);
44 | }
45 |
46 | return message.send(embed);
47 | }
48 | async add(message: KlasaMessage, words: string[]) {
49 | if (!message.guild || !message.member) throw new Error('Unreachable');
50 |
51 | const previousWords = message.member.settings.get('words') as string[];
52 | // Make sure there are no duplicates
53 | const wordSet = new Set(words.map((it) => it.toLowerCase()));
54 |
55 | const addedWords = [];
56 |
57 | for (const word of wordSet)
58 | if (!previousWords.includes(word)) addedWords.push(word);
59 |
60 | await message.member.settings.update('words', addedWords, { arrayAction: 'add' });
61 | message.guild.addWords(addedWords, message.author.id);
62 |
63 | const embed = new MessageEmbed()
64 | .setColor(0x43B581)
65 | .setDescription('No new words have been added..');
66 |
67 | if (addedWords.length) {
68 | embed.setTitle(`The following ${pluralize(addedWords.length, 'word', 'words')} have been added to your list`)
69 | // eslint-disable-next-line @typescript-eslint/require-array-sort-compare
70 | .setDescription(`- ${addedWords.sort().map((word) => Util.escapeMarkdown(word)).join('\n- ')}`);
71 | }
72 |
73 | return message.send(embed);
74 | }
75 |
76 | async remove(message: KlasaMessage, words: string[]) {
77 | if (!message.guild || !message.member) throw new Error('Unreachable');
78 |
79 | const previousWords = [...message.member.settings.get('words') as string[]];
80 | // Make sure there are no duplicates
81 | const wordSet = new Set(words.map((it) => it.toLowerCase()));
82 |
83 | const removed = new Set();
84 |
85 | for (const word of wordSet) {
86 | const index = previousWords.indexOf(word);
87 | if (index === -1) continue;
88 | removed.add(word);
89 | previousWords.splice(index, 1);
90 | }
91 |
92 | await message.member.settings.update('words', previousWords, { arrayAction: 'overwrite' });
93 | message.guild.removeWords([...removed], message.author.id);
94 |
95 | const embed = new MessageEmbed()
96 | .setColor(0x43B581)
97 | .setDescription('No words have been removed..');
98 |
99 | if (removed.size) {
100 | embed.setTitle(`The following ${pluralize(removed.size, 'word', 'words')} have been removed from your list`)
101 | // eslint-disable-next-line @typescript-eslint/require-array-sort-compare
102 | .setDescription(`- ${[...removed].sort().map((word) => Util.escapeMarkdown(word)).join('\n- ')}`);
103 | }
104 |
105 | return message.send(embed);
106 | }
107 |
108 | async set(message: KlasaMessage, words: string[]) {
109 | if (!message.guild || !message.member) throw new Error('Unreachable');
110 |
111 | const old = message.member.settings.get('words') as string[];
112 | // Make sure there are no duplicates
113 | const wordSet = new Set(words.map((it) => it.toLowerCase()));
114 |
115 | await message.member.settings.update('words', [...wordSet], { arrayAction: 'overwrite' });
116 | message.guild.removeWords(old, message.author.id);
117 | message.guild.addWords([...wordSet], message.author.id);
118 |
119 | const embed = new MessageEmbed()
120 | .setColor(0x43B581)
121 | .setTitle(`The following ${pluralize(wordSet.size, 'word', 'words')} have been set in your list`)
122 | // eslint-disable-next-line @typescript-eslint/require-array-sort-compare
123 | .setDescription(`- ${[...wordSet].sort().map((word) => Util.escapeMarkdown(word)).join('\n- ')}`);
124 |
125 | return message.send(embed);
126 | }
127 |
128 | async clear(message: KlasaMessage) {
129 | if (!message.guild || !message.member) throw new Error('Unreachable');
130 |
131 | const oldWords = message.member.settings.get('words') as string[];
132 |
133 | await message.member.settings.reset('words');
134 | message.guild.removeWords(oldWords, message.author.id);
135 |
136 | return message.send(
137 | new MessageEmbed()
138 | .setColor(0x43B581)
139 | .setDescription('Your word list was reset'),
140 | );
141 | }
142 |
143 | async init() {
144 | this.createCustomResolver('string', async(arg, possible, message, params) => {
145 | if (NEEDS_WORD.includes(params[0])) return undefined;
146 | return this.client.arguments.get('string')!.run(arg, possible, message);
147 | });
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/src.old/events/wslProcessHighlight.mjs:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | var __importDefault =
3 | (this && this.__importDefault) ||
4 | function (mod) {
5 | return mod && mod.__esModule ? mod : { default: mod };
6 | };
7 | Object.defineProperty(exports, '__esModule', { value: true });
8 | const klasa_1 = require('klasa');
9 | const discord_js_1 = require('discord.js');
10 | const moment_timezone_1 = __importDefault(require('moment-timezone'));
11 |
12 | const excludeFromParsing = ['130175406673231873'];
13 |
14 | class default_1 extends klasa_1.Monitor {
15 | constructor() {
16 | super(...arguments);
17 | this.ignoreBots = false;
18 | this.ignoreEdits = true;
19 | this.ignoreSelf = true;
20 | this.ignoreOthers = false;
21 | this.ignoreWebhooks = false;
22 | }
23 | async run(message) {
24 | if (!message.guild) return null;
25 | if (message.content.length === 0) return null;
26 | if (excludeFromParsing.includes(message.author.id)) return null;
27 | if (
28 | (message.author.bot || message.webhookID) &&
29 | ![
30 | // TURBO #0420
31 | '751500024348934164',
32 | '837096413665296424',
33 | '837096621610369044',
34 | // g3rd#9268
35 | '830179100114157608',
36 | '828255477506244628',
37 | '827114559486951514',
38 | '825052409323061298',
39 | '880373442921234492',
40 | '880553941547487232',
41 | '880557173950808125',
42 | // rudolfthered#9268
43 | '917658171244445706',
44 | '917658224986042378',
45 | '917658640779993108',
46 | '917658296121442344',
47 | '917658333505273926',
48 | '917658378489200662',
49 | '917658428120399993',
50 | ].includes(message.channel.id)
51 | )
52 | return null;
53 | const { regularExpressions, words } = message.guild;
54 | if (words.size === 0 && regularExpressions.size === 0) return null;
55 | const sent = new Set();
56 | const scheduledActions = [];
57 | const [parsedRegularExpressions, parsedWords] = await this.client.workers.parseHighlight(message);
58 | if (parsedRegularExpressions.results.length === 0 && parsedWords.results.length === 0) return null;
59 | const previousChannelMessages = [];
60 | if (message.channel.permissionsFor(message.guild.me).has('READ_MESSAGE_HISTORY'))
61 | previousChannelMessages.push(...(await this._fetchPreviousMessages(message)));
62 | for (const data of parsedRegularExpressions.results) {
63 | if (sent.has(data.memberID)) continue;
64 | sent.add(data.memberID);
65 | scheduledActions.push(
66 | this._handleHighlight(message, previousChannelMessages.slice(), data, 'regularExpressions'),
67 | );
68 | }
69 | for (const data of parsedWords.results) {
70 | if (sent.has(data.memberID)) continue;
71 | sent.add(data.memberID);
72 | scheduledActions.push(this._handleHighlight(message, previousChannelMessages.slice(), data, 'words'));
73 | }
74 | await Promise.all(scheduledActions);
75 | return true;
76 | }
77 | async _handleHighlight(message, previous, { memberID, parsedContent, trigger }, type) {
78 | var _a;
79 | if (!message.guild) throw new Error('Unreachable');
80 | const member = await message.guild.members.fetch(memberID).catch(() => null);
81 | if (!member) {
82 | message.guild[type === 'regularExpressions' ? 'removeRegularExpression' : 'removeWord'](trigger, memberID);
83 | return;
84 | }
85 | if (!((_a = message.channel.permissionsFor(member)) === null || _a === void 0 ? void 0 : _a.has('VIEW_CHANNEL')))
86 | return;
87 | if (message.mentions.users.has(memberID)) return;
88 | const [blacklistedUsers, blacklistedChannels] = member.settings.pluck('blacklist.users', 'blacklist.channels');
89 | if (blacklistedUsers.includes(message.author.id) || blacklistedChannels.includes(message.channel.id)) return;
90 | const embed = this._prepareEmbed(previous, message, parsedContent);
91 | try {
92 | await member.send(
93 | [
94 | 'Your highlight',
95 | type === 'regularExpressions' ? 'regular expression' : 'word',
96 | `**${discord_js_1.Util.escapeMarkdown(trigger)}** was mentioned by **${discord_js_1.Util.escapeMarkdown(
97 | message.author.tag,
98 | )}**`,
99 | `in ${message.channel} of ${message.guild}`,
100 | ].join(' '),
101 | embed,
102 | );
103 | } catch {
104 | this.client.emit(
105 | 'wtf',
106 | `Failed to DM ${memberID} in ${message.guild.id} for trigger ${trigger} by ${message.author.id}`,
107 | );
108 | }
109 | }
110 | async _fetchPreviousMessages(message) {
111 | const returnData = [];
112 | const messages = await message.channel.messages.fetch({ limit: 5, before: message.id });
113 | for (const data of messages.sort((a, b) => a.createdTimestamp - b.createdTimestamp).values()) {
114 | returnData.push([
115 | `[${moment_timezone_1
116 | .default(data.createdTimestamp)
117 | .tz('Europe/London')
118 | .format('HH[:]mm')} UTC] ${discord_js_1.Util.escapeMarkdown(data.author.tag)}`,
119 | data.content.length === 0
120 | ? data.attachments.size === 0
121 | ? `*Message has an embed*\n*Click **[here](${data.url})** to see the message*`
122 | : `*Message has an attachment*\n*Click **[here](${data.url})** to see the message*`
123 | : data.content.length >= 600
124 | ? `*This message's content was too large.*\n*Click **[here](${data.url})** to see the message*`
125 | : data.content,
126 | ]);
127 | }
128 | return returnData;
129 | }
130 | _prepareEmbed(previous, message, parsedContent) {
131 | const embed = new discord_js_1.MessageEmbed()
132 | .setColor(0x3669fa)
133 | .setAuthor(
134 | discord_js_1.Util.escapeMarkdown(message.author.tag) + ` (${message.author.id})`,
135 | message.author.displayAvatarURL({ size: 128, format: 'png', dynamic: true }),
136 | )
137 | .setTimestamp()
138 | .setDescription(`**[Click here to jump to the highlight message](${message.url})**`)
139 | .setFooter('Highlighted');
140 | for (const [name, value] of previous) embed.addField(name, value);
141 | embed.addField(
142 | `__**[${moment_timezone_1
143 | .default(message.createdTimestamp)
144 | .tz('Europe/London')
145 | .format('HH[:]mm')} UTC] ${discord_js_1.Util.escapeMarkdown(message.author.tag)}**__`,
146 | parsedContent,
147 | );
148 | return embed;
149 | }
150 | }
151 | exports.default = default_1;
152 | //# sourceMappingURL=highlight.js.map
153 |
--------------------------------------------------------------------------------
/src/Highlight.ts:
--------------------------------------------------------------------------------
1 | import '#setup';
2 |
3 | import process from 'node:process';
4 | import { container, LogLevel } from '@sapphire/framework';
5 | import { Time } from '@sapphire/time-utilities';
6 | import type { GuildMember, User } from 'discord.js';
7 | import { ActivityType, GatewayIntentBits, IntentsBitField, Options } from 'discord.js';
8 | import { HighlightClient } from '#structures/HighlightClient';
9 |
10 | function clientUserFilter(member: GuildMember | User) {
11 | return member.id !== member.client.user!.id;
12 | }
13 |
14 | const client = new HighlightClient({
15 | presence: {
16 | activities: [
17 | {
18 | name: 'messages fly by!',
19 | type: ActivityType.Watching,
20 | },
21 | ],
22 | },
23 | defaultPrefix: [
24 | // Common prefixes from DB dump. I cannot wait to drop these in v4
25 | ',',
26 | '!',
27 | '?.',
28 | '?',
29 | '.',
30 | '^',
31 | '=',
32 | '>',
33 | 'h!',
34 | 'h.',
35 | ],
36 | intents: new IntentsBitField([
37 | GatewayIntentBits.Guilds,
38 | GatewayIntentBits.GuildMessages,
39 | GatewayIntentBits.GuildMessageReactions,
40 | // TODO: lets see how many people will notice the lack of this
41 | // GatewayIntentBits.GuildMessageTyping,
42 | GatewayIntentBits.DirectMessages,
43 | GatewayIntentBits.MessageContent,
44 | ]),
45 | makeCache: Options.cacheWithLimits({
46 | MessageManager: {
47 | maxSize: 50,
48 | },
49 | UserManager: {
50 | maxSize: 200,
51 | keepOverLimit: (user) => user.id === user.client.user!.id,
52 | },
53 | GuildMemberManager: {
54 | maxSize: 200,
55 | keepOverLimit: (member) => member.user.id === member.client.user!.id,
56 | },
57 | // Useless props for the bot
58 | GuildEmojiManager: { maxSize: 0 },
59 | GuildStickerManager: { maxSize: 0 },
60 | }),
61 | sweepers: {
62 | // Members, users and messages are needed for the bot to function
63 | guildMembers: {
64 | interval: (Time.Minute * 15) / 1_000,
65 | // Sweep all members except the bot member
66 | filter: () => clientUserFilter,
67 | },
68 | users: {
69 | interval: (Time.Minute * 15) / 1_000,
70 | // Sweep all users except the bot user
71 | filter: () => clientUserFilter,
72 | },
73 | messages: {
74 | interval: (Time.Minute * 5) / 1_000,
75 | lifetime: (Time.Minute * 15) / 1_000,
76 | },
77 | },
78 | caseInsensitiveCommands: true,
79 | logger: {
80 | depth: 2,
81 | level: Reflect.has(process.env, 'PM2_HOME') ? LogLevel.Info : LogLevel.Debug,
82 | },
83 | loadMessageCommandListeners: true,
84 | loadDefaultErrorListeners: false,
85 | });
86 |
87 | // Graceful shutdown
88 | for (const event of [
89 | 'SIGTERM', //
90 | 'SIGINT',
91 | ] as const)
92 | process.on(event, async () => {
93 | container.logger.info(`${event} signal received, shutting down...`);
94 | await client.destroy();
95 | });
96 |
97 | try {
98 | await client.login();
99 | } catch (error) {
100 | container.logger.fatal(container.colors.redBright('Failed to start Highlight'));
101 | container.logger.error((error as Error).message);
102 | await client.destroy();
103 | }
104 |
--------------------------------------------------------------------------------
/src/commands/Admin/bot-parsing.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { Subcommand, type SubcommandMappingArray } from '@sapphire/plugin-subcommands';
3 | import {
4 | ActionRowBuilder,
5 | ButtonBuilder,
6 | ButtonStyle,
7 | ChannelType,
8 | PermissionFlagsBits,
9 | bold,
10 | channelMention,
11 | inlineCode,
12 | italic,
13 | quote,
14 | type ForumChannel,
15 | type GuildTextBasedChannel,
16 | } from 'discord.js';
17 | import { withDeprecationWarningForMessageCommands } from '#hooks/withDeprecationWarningForMessageCommands';
18 | import { createInfoEmbed } from '#utils/embeds';
19 | import { Emojis, SupportServerButton, pluralize, resolveUserIdFromMessageOrInteraction } from '#utils/misc';
20 |
21 | const allowedChannelTypes = [
22 | ChannelType.GuildText,
23 | ChannelType.GuildVoice,
24 | ChannelType.GuildAnnouncement,
25 | ChannelType.PublicThread,
26 | ChannelType.PrivateThread,
27 | ChannelType.GuildStageVoice,
28 | ChannelType.GuildForum,
29 | ] as const;
30 |
31 | type AllowedChannel = ForumChannel | GuildTextBasedChannel;
32 |
33 | @ApplyOptions({
34 | description: 'Control what channels should trigger highlights if a bot message is sent',
35 | detailedDescription: [
36 | quote("Here's how you can add a channel to the list that should trigger highlights if a bot message is sent:"),
37 | '',
38 | `For ${Emojis.ChatInputCommands} chat input commands: ${bold(
39 | inlineCode(`/bot-parsing add channel:`),
40 | )}`,
41 | '',
42 | italic(
43 | `You should replace ${inlineCode(
44 | '',
45 | )} with the channel you want to add, either by mentioning it or by providing its ID.`,
46 | ),
47 | '',
48 | quote(
49 | 'You can also remove channels from the list by using the same command, but with the `remove` subcommand instead.',
50 | ),
51 | '',
52 | `For ${Emojis.ChatInputCommands} chat input commands: ${bold(
53 | inlineCode(`/bot-parsing remove channel:`),
54 | )}`,
55 | '',
56 | quote('You can also list all channels that are currently in the list by using the `list` subcommand.'),
57 | '',
58 | `For ${Emojis.ChatInputCommands} chat input commands: ${bold(inlineCode(`/bot-parsing list`))}`,
59 | ].join('\n'),
60 | preconditions: ['GuildStaff'],
61 | })
62 | export class BotParsingCommand extends Subcommand {
63 | public subcommandMappings: SubcommandMappingArray = [
64 | {
65 | name: 'add',
66 | chatInputRun: async (interaction: Subcommand.ChatInputCommandInteraction<'cached'>) => {
67 | const channel = interaction.options.getChannel('channel', true, allowedChannelTypes);
68 |
69 | return this.addSubcommand(interaction, channel);
70 | },
71 | },
72 | {
73 | name: 'remove',
74 | chatInputRun: async (interaction: Subcommand.ChatInputCommandInteraction<'cached'>) => {
75 | const channel = interaction.options.getChannel('channel', true, allowedChannelTypes);
76 |
77 | return this.removeSubcommand(interaction, channel);
78 | },
79 | },
80 | {
81 | name: 'list',
82 | chatInputRun: async (interaction: Subcommand.ChatInputCommandInteraction<'cached'>) => {
83 | return this.listSubcommand(interaction);
84 | },
85 | },
86 | ];
87 |
88 | public async addSubcommand(interaction: Subcommand.ChatInputCommandInteraction<'cached'>, channel: AllowedChannel) {
89 | const guildSettings = await this.container.prisma.guild.findFirstOrThrow({
90 | where: { guildId: interaction.guildId },
91 | include: { channelsWithBotParsing: true },
92 | });
93 |
94 | // Incentivize users to donate if they want more channels.
95 | if (guildSettings.channelsWithBotParsing.length >= guildSettings.channelWithBotParsingsAllowed) {
96 | await interaction.reply(
97 | withDeprecationWarningForMessageCommands({
98 | commandName: this.name,
99 | guildId: interaction.guildId,
100 | receivedFromMessage: false,
101 | options: {
102 | embeds: [
103 | createInfoEmbed(
104 | `You have reached the maximum amount of channels that bots can trigger highlights for!\n\nConsider donating to the project to receive more channels! Join the support server if you have more questions, we'll gladly help you out with this!`,
105 | ),
106 | ],
107 | ephemeral: true,
108 | components: [
109 | new ActionRowBuilder().setComponents(
110 | new ButtonBuilder()
111 | .setStyle(ButtonStyle.Link)
112 | .setURL('https://github.com/sponsors/vladfrangu')
113 | .setLabel('Donate')
114 | .setEmoji({
115 | name: '💙',
116 | }),
117 | SupportServerButton,
118 | ),
119 | ],
120 | },
121 | }),
122 | );
123 | return;
124 | }
125 |
126 | const existingEntry = guildSettings.channelsWithBotParsing.find((entry) => entry.channelId === channel.id);
127 |
128 | if (existingEntry) {
129 | await interaction.reply(
130 | withDeprecationWarningForMessageCommands({
131 | commandName: this.name,
132 | guildId: interaction.guildId,
133 | receivedFromMessage: false,
134 | options: {
135 | embeds: [
136 | createInfoEmbed(
137 | `The channel you provided is already in the list of channels that bots can trigger highlights for!`,
138 | ),
139 | ],
140 | ephemeral: true,
141 | },
142 | }),
143 | );
144 | return;
145 | }
146 |
147 | await this.container.prisma.channelWithBotParsing.create({
148 | data: {
149 | channelId: channel.id,
150 | userId: resolveUserIdFromMessageOrInteraction(interaction),
151 | guildId: interaction.guildId,
152 | },
153 | });
154 |
155 | await interaction.reply(
156 | withDeprecationWarningForMessageCommands({
157 | commandName: this.name,
158 | guildId: interaction.guildId,
159 | receivedFromMessage: false,
160 | options: {
161 | embeds: [
162 | createInfoEmbed(
163 | `The channel ${channel.name} (${channelMention(
164 | channel.id,
165 | )}) has been added to the list of channels that bots can trigger highlights for!`,
166 | ),
167 | ],
168 | ephemeral: true,
169 | },
170 | }),
171 | );
172 | }
173 |
174 | public async removeSubcommand(
175 | interaction: Subcommand.ChatInputCommandInteraction<'cached'>,
176 | channel: AllowedChannel,
177 | ) {
178 | const entry = await this.container.prisma.channelWithBotParsing.findFirst({
179 | where: { channelId: channel.id },
180 | });
181 |
182 | if (!entry) {
183 | await interaction.reply(
184 | withDeprecationWarningForMessageCommands({
185 | commandName: this.name,
186 | guildId: interaction.guildId,
187 | receivedFromMessage: false,
188 | options: {
189 | embeds: [
190 | createInfoEmbed(
191 | `The channel you provided is not in the list of channels that bots can trigger highlights for!`,
192 | ),
193 | ],
194 | ephemeral: true,
195 | },
196 | }),
197 | );
198 | return;
199 | }
200 |
201 | await this.container.prisma.channelWithBotParsing.delete({ where: { channelId: channel.id } });
202 |
203 | await interaction.reply(
204 | withDeprecationWarningForMessageCommands({
205 | commandName: this.name,
206 | guildId: interaction.guildId,
207 | receivedFromMessage: false,
208 | options: {
209 | embeds: [
210 | createInfoEmbed(
211 | `The channel ${channel.name} (${channelMention(
212 | channel.id,
213 | )}) has been removed from the list of channels that bots can trigger highlights for!`,
214 | ),
215 | ],
216 | ephemeral: true,
217 | },
218 | }),
219 | );
220 | }
221 |
222 | public async listSubcommand(interaction: Subcommand.ChatInputCommandInteraction<'cached'>) {
223 | const guildSettings = await this.container.prisma.guild.findFirstOrThrow({
224 | where: { guildId: interaction.guildId },
225 | include: { channelsWithBotParsing: true },
226 | });
227 |
228 | if (!guildSettings.channelsWithBotParsing.length) {
229 | await interaction.reply(
230 | withDeprecationWarningForMessageCommands({
231 | commandName: this.name,
232 | guildId: interaction.guildId,
233 | receivedFromMessage: false,
234 | options: {
235 | embeds: [
236 | createInfoEmbed(
237 | `This server has no channels that bots can trigger highlights for!\n\nAs a reminder, you can add up to ${bold(
238 | inlineCode(`${guildSettings.channelWithBotParsingsAllowed}`),
239 | )} channels to this list!`,
240 | ),
241 | ],
242 | ephemeral: true,
243 | },
244 | }),
245 | );
246 |
247 | return;
248 | }
249 |
250 | const channels: string[] = [];
251 |
252 | for (const channelData of guildSettings.channelsWithBotParsing) {
253 | const guildChannel = interaction.guild.channels.resolve(channelData.channelId);
254 |
255 | if (!guildChannel) {
256 | continue;
257 | }
258 |
259 | channels.push(`- ${channelMention(guildChannel.id)} (${guildChannel.name})`);
260 | }
261 |
262 | const availableSlots =
263 | guildSettings.channelWithBotParsingsAllowed - guildSettings.channelsWithBotParsing.length;
264 |
265 | await interaction.reply(
266 | withDeprecationWarningForMessageCommands({
267 | commandName: this.name,
268 | guildId: interaction.guildId,
269 | receivedFromMessage: false,
270 | options: {
271 | embeds: [
272 | createInfoEmbed(
273 | [
274 | `This server has ${bold(
275 | inlineCode(`${guildSettings.channelsWithBotParsing.length}`),
276 | )} ${pluralize(
277 | guildSettings.channelsWithBotParsing.length,
278 | 'channel',
279 | 'channels',
280 | )} that bots can trigger highlights for!`,
281 | '',
282 | `Here's the list of channels:`,
283 | '',
284 | channels.join('\n'),
285 | '',
286 | `There ${pluralize(availableSlots, 'is', 'are')} ${bold(
287 | inlineCode(`${availableSlots}`),
288 | )} more ${pluralize(availableSlots, 'slot', 'slots')} available!`,
289 | ].join('\n'),
290 | ),
291 | ],
292 | ephemeral: true,
293 | },
294 | }),
295 | );
296 | }
297 |
298 | public override registerApplicationCommands(registry: Subcommand.Registry) {
299 | registry.registerChatInputCommand((botParsing) =>
300 | botParsing
301 | .setName(this.name)
302 | .setDescription(this.description)
303 | .setDMPermission(false)
304 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
305 | .addSubcommand((add) =>
306 | add
307 | .setName('add')
308 | .setDescription('Add a channel to the list of channels that bots can trigger highlights for')
309 | .addChannelOption((channel) =>
310 | channel
311 | .setName('channel')
312 | .setDescription('The channel to add')
313 | .addChannelTypes(...allowedChannelTypes)
314 | .setRequired(true),
315 | ),
316 | )
317 | .addSubcommand((remove) =>
318 | remove
319 | .setName('remove')
320 | .setDescription(
321 | 'Remove a channel from the list of channels that bots can trigger highlights for',
322 | )
323 | .addChannelOption((channel) =>
324 | channel
325 | .setName('channel')
326 | .setDescription('The channel to remove')
327 | .addChannelTypes(...allowedChannelTypes)
328 | .setRequired(true),
329 | ),
330 | )
331 | .addSubcommand((list) =>
332 | list.setName('list').setDescription('List all channels that can trigger highlights'),
333 | ),
334 | );
335 | }
336 | }
337 |
--------------------------------------------------------------------------------
/src/commands/Highlight/globally-ignored-users.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import type { Args } from '@sapphire/framework';
3 | import { Subcommand, type SubcommandMappingArray } from '@sapphire/plugin-subcommands';
4 | import {
5 | ActionRowBuilder,
6 | ApplicationCommandType,
7 | ButtonBuilder,
8 | ButtonStyle,
9 | Message,
10 | User,
11 | bold,
12 | inlineCode,
13 | quote,
14 | userMention,
15 | } from 'discord.js';
16 | import {
17 | GloballyIgnoredUsersClearCustomIdActions,
18 | GloballyIgnoredUsersClearIdFactory,
19 | } from '#customIds/globally-ignored-users';
20 | import { withDeprecationWarningForMessageCommands } from '#hooks/withDeprecationWarningForMessageCommands';
21 | import { getDatabaseUser } from '#utils/db';
22 | import { createInfoEmbed } from '#utils/embeds';
23 | import { Emojis, HelpDetailedDescriptionReplacers, resolveUserIdFromMessageOrInteraction } from '#utils/misc';
24 | import type { HelpCommand } from '../Miscellaneous/help.js';
25 |
26 | @ApplyOptions({
27 | description: 'Control which users should not highlight you in any servers you share with them.',
28 | detailedDescription: [
29 | quote("Here's how you can add a user to your global block list:"),
30 | '',
31 | `For ${Emojis.ChatInputCommands} chat input commands: ${bold(
32 | inlineCode('/globally-ignored-users add user:'),
33 | )}`,
34 | `For message based commands: ${bold(
35 | inlineCode(`${HelpDetailedDescriptionReplacers.UserMention} globally-ignored-users add `),
36 | )}`,
37 | '',
38 | quote(
39 | 'You can also remove users from your global block list by using the same command, but with the `remove` subcommand instead.',
40 | ),
41 | '',
42 | `For ${Emojis.ChatInputCommands} chat input commands: ${bold(
43 | inlineCode('/globally-ignored-users remove user:'),
44 | )}`,
45 | `For message based commands: ${bold(
46 | inlineCode(`${HelpDetailedDescriptionReplacers.UserMention} globally-ignored-users remove `),
47 | )}`,
48 | '',
49 | quote(
50 | 'You can also list all the users that are currently in your global block list by using the `list` subcommand.',
51 | ),
52 | '',
53 | `For ${Emojis.ChatInputCommands} chat input commands: ${bold(inlineCode('/globally-ignored-users list'))}`,
54 | `For message based commands: ${bold(
55 | inlineCode(`${HelpDetailedDescriptionReplacers.UserMention} globally-ignored-users list`),
56 | )}`,
57 | '',
58 | quote('You can also clear your global block list by using the `clear` subcommand.'),
59 | '',
60 | `For ${Emojis.ChatInputCommands} chat input commands: ${bold(inlineCode('/globally-ignored-users clear'))}`,
61 | `For message based commands: ${bold(
62 | inlineCode(`${HelpDetailedDescriptionReplacers.UserMention} globally-ignored-users clear`),
63 | )}`,
64 | ].join('\n'),
65 | aliases: ['global_block', 'global_ignore', 'global-block'],
66 | generateUnderscoreLessAliases: true,
67 | })
68 | export class GlobalBlockCommand extends Subcommand {
69 | public subcommandMappings: SubcommandMappingArray = [
70 | {
71 | name: 'add',
72 | chatInputRun: async (interaction: Subcommand.ChatInputCommandInteraction<'cached'>) => {
73 | const user = interaction.options.getUser('user', true);
74 |
75 | return this.addSubcommand(interaction, [user], false);
76 | },
77 | messageRun: async (message: Message, args) => {
78 | const resolved = await this.resolveMessageArgs(args);
79 |
80 | if (resolved.length === 0) {
81 | await message.reply(
82 | withDeprecationWarningForMessageCommands({
83 | commandName: this.name,
84 | guildId: message.guildId!,
85 | receivedFromMessage: true,
86 | options: {
87 | embeds: [
88 | createInfoEmbed(
89 | 'You need to provide at least one user to add to your global ignore list',
90 | ),
91 | ],
92 | ephemeral: true,
93 | },
94 | }),
95 | );
96 |
97 | return;
98 | }
99 |
100 | return this.addSubcommand(message, resolved, true);
101 | },
102 | },
103 | {
104 | name: 'remove',
105 | chatInputRun: async (interaction: Subcommand.ChatInputCommandInteraction<'cached'>) => {
106 | const user = interaction.options.getUser('user', true);
107 |
108 | return this.removeSubcommand(interaction, [user], false);
109 | },
110 | messageRun: async (message: Message, args) => {
111 | const resolved = await this.resolveMessageArgs(args);
112 |
113 | if (resolved.length === 0) {
114 | await message.reply(
115 | withDeprecationWarningForMessageCommands({
116 | commandName: this.name,
117 | guildId: message.guildId!,
118 | receivedFromMessage: true,
119 | options: {
120 | embeds: [
121 | createInfoEmbed(
122 | 'You need to provide at least one user to remove from your global ignore list',
123 | ),
124 | ],
125 | ephemeral: true,
126 | },
127 | }),
128 | );
129 |
130 | return;
131 | }
132 |
133 | return this.removeSubcommand(message, resolved, true);
134 | },
135 | },
136 | {
137 | name: 'list',
138 | chatInputRun: async (interaction: Subcommand.ChatInputCommandInteraction<'cached'>) => {
139 | return this.listSubcommand(interaction, false);
140 | },
141 | messageRun: async (message: Message) => {
142 | return this.listSubcommand(message, true);
143 | },
144 | },
145 | {
146 | name: 'clear',
147 | chatInputRun: async (interaction: Subcommand.ChatInputCommandInteraction<'cached'>) => {
148 | return this.clearSubcommand(interaction, false);
149 | },
150 | messageRun: async (message: Message) => {
151 | return this.clearSubcommand(message, true);
152 | },
153 | },
154 | // Hidden subcommand to show the help menu by default
155 | {
156 | name: 'help',
157 | default: true,
158 | messageRun: async (message) => {
159 | return (this.container.stores.get('commands').get('help') as HelpCommand)['sendSingleCommandHelp'](
160 | message,
161 | this,
162 | true,
163 | );
164 | },
165 | },
166 | ];
167 |
168 | public override async contextMenuRun(interaction: Subcommand.ContextMenuCommandInteraction<'cached'>) {
169 | if (!interaction.isUserContextMenuCommand()) {
170 | throw new Error('unreachable.');
171 | }
172 |
173 | if (interaction.commandName.toLowerCase().includes('remove')) {
174 | return this.removeSubcommand(interaction, [interaction.targetUser], false);
175 | }
176 |
177 | return this.addSubcommand(interaction, [interaction.targetUser], false);
178 | }
179 |
180 | public override registerApplicationCommands(registry: Subcommand.Registry) {
181 | registry.registerChatInputCommand((serverIgnoreList) =>
182 | serverIgnoreList
183 | .setName(this.name)
184 | .setDescription(this.description)
185 | .setDMPermission(false)
186 | .addSubcommand((add) =>
187 | add
188 | .setName('add')
189 | .setDescription('Adds a user to your global ignore list')
190 | .addUserOption((user) =>
191 | user.setName('user').setDescription('The user to ignore').setRequired(true),
192 | ),
193 | )
194 | .addSubcommand((remove) =>
195 | remove
196 | .setName('remove')
197 | .setDescription('Removes a user from your global ignore list')
198 |
199 | .addUserOption((user) =>
200 | user.setName('user').setDescription('The user to stop ignoring').setRequired(true),
201 | ),
202 | )
203 | .addSubcommand((list) =>
204 | list.setName('list').setDescription('Lists all users that you are globally ignoring'),
205 | )
206 | .addSubcommand((clear) => clear.setName('clear').setDescription('Clears your global ignore list')),
207 | );
208 |
209 | registry.registerContextMenuCommand((ignoreUser) =>
210 | ignoreUser.setName('Globally ignore user').setType(ApplicationCommandType.User).setDMPermission(false),
211 | );
212 |
213 | registry.registerContextMenuCommand((ignoreUser) =>
214 | ignoreUser
215 | .setName('Remove globally ignored user')
216 | .setType(ApplicationCommandType.User)
217 | .setDMPermission(false),
218 | );
219 | }
220 |
221 | private async addSubcommand(
222 | messageOrInteraction:
223 | | Message
224 | | Subcommand.ChatInputCommandInteraction<'cached'>
225 | | Subcommand.ContextMenuCommandInteraction<'cached'>,
226 | args: User[],
227 | receivedFromMessage: boolean,
228 | ) {
229 | // Sanity check
230 | if (args.length === 0) {
231 | await messageOrInteraction.reply(
232 | withDeprecationWarningForMessageCommands({
233 | commandName: this.name,
234 | guildId: messageOrInteraction.guildId,
235 | receivedFromMessage,
236 | options: {
237 | embeds: [createInfoEmbed('You need to provide at least one user to add to your ignore list')],
238 | ephemeral: true,
239 | },
240 | }),
241 | );
242 |
243 | return;
244 | }
245 |
246 | const userId = resolveUserIdFromMessageOrInteraction(messageOrInteraction);
247 | const userData = await getDatabaseUser(userId);
248 | const added: string[] = [];
249 | const ignored: string[] = [];
250 | const seen = new Set();
251 |
252 | const userIdsToAdd: string[] = [];
253 |
254 | for (const user of args) {
255 | // ignore duplicate entries
256 | if (seen.has(user.id)) {
257 | continue;
258 | }
259 |
260 | seen.add(user.id);
261 |
262 | if (user.id === userId) {
263 | ignored.push(`- ${userMention(user.id)}\n└ You cannot ignore yourself`);
264 | continue;
265 | }
266 |
267 | // Check that the user isn't already ignored
268 | if (userData.globallyIgnoredUsers.some((_user) => _user.ignoredUserId === user.id)) {
269 | ignored.push(`- ${userMention(user.id)}\n└ This user is already ignored`);
270 | continue;
271 | }
272 |
273 | // Add them in the database
274 | added.push(`- ${userMention(user.id)}`);
275 | userIdsToAdd.push(user.id);
276 | }
277 |
278 | if (added.length === 0 && ignored.length === 0) {
279 | await messageOrInteraction.reply(
280 | withDeprecationWarningForMessageCommands({
281 | commandName: this.name,
282 | guildId: messageOrInteraction.guildId,
283 | receivedFromMessage,
284 | options: {
285 | embeds: [createInfoEmbed('There were no changes done to your global ignore list.')],
286 | ephemeral: true,
287 | },
288 | }),
289 | );
290 |
291 | return;
292 | }
293 |
294 | if (userIdsToAdd.length) {
295 | await this.container.prisma.globalIgnoredUser.createMany({
296 | data: userIdsToAdd.map((ignoredUserId) => ({
297 | userId,
298 | ignoredUserId,
299 | })),
300 | });
301 | }
302 |
303 | const embed = createInfoEmbed(['Here are the changes done to your global ignore list:'].join('\n'));
304 |
305 | if (added.length) {
306 | embed.addFields({ name: 'Added', value: added.join('\n') });
307 | }
308 |
309 | if (ignored.length) {
310 | embed.addFields({ name: 'Already added', value: ignored.join('\n') });
311 | }
312 |
313 | await messageOrInteraction.reply(
314 | withDeprecationWarningForMessageCommands({
315 | commandName: this.name,
316 | guildId: messageOrInteraction.guildId,
317 | receivedFromMessage,
318 | options: {
319 | embeds: [embed],
320 | ephemeral: true,
321 | },
322 | }),
323 | );
324 | }
325 |
326 | private async removeSubcommand(
327 | messageOrInteraction:
328 | | Message
329 | | Subcommand.ChatInputCommandInteraction<'cached'>
330 | | Subcommand.ContextMenuCommandInteraction<'cached'>,
331 | args: User[],
332 | receivedFromMessage: boolean,
333 | ) {
334 | // Sanity check
335 | if (args.length === 0) {
336 | await messageOrInteraction.reply(
337 | withDeprecationWarningForMessageCommands({
338 | commandName: this.name,
339 | guildId: messageOrInteraction.guildId,
340 | receivedFromMessage,
341 | options: {
342 | embeds: [
343 | createInfoEmbed(
344 | 'You need to provide at least one user to remove from your global ignore list',
345 | ),
346 | ],
347 | ephemeral: true,
348 | },
349 | }),
350 | );
351 |
352 | return;
353 | }
354 |
355 | const userId = resolveUserIdFromMessageOrInteraction(messageOrInteraction);
356 | const userData = await getDatabaseUser(userId);
357 | const removed: string[] = [];
358 | const ignored: string[] = [];
359 | const seen = new Set();
360 |
361 | const userIdsToRemove: string[] = [];
362 |
363 | for (const user of args) {
364 | // ignore duplicate entries
365 | if (seen.has(user.id)) {
366 | continue;
367 | }
368 |
369 | seen.add(user.id);
370 |
371 | if (user.id === userId) {
372 | ignored.push(`- ${userMention(user.id)}\n└ You cannot un-ignore yourself`);
373 | continue;
374 | }
375 |
376 | // Check that the user isn't already ignored
377 | if (!userData.globallyIgnoredUsers.some((_user) => _user.ignoredUserId === user.id)) {
378 | ignored.push(`- ${userMention(user.id)}\n└ This user is not ignored`);
379 | continue;
380 | }
381 |
382 | // Add them in the database
383 | removed.push(`- ${userMention(user.id)}`);
384 | userIdsToRemove.push(user.id);
385 | }
386 |
387 | if (removed.length === 0 && ignored.length === 0) {
388 | await messageOrInteraction.reply(
389 | withDeprecationWarningForMessageCommands({
390 | commandName: this.name,
391 | guildId: messageOrInteraction.guildId,
392 | receivedFromMessage,
393 | options: {
394 | embeds: [createInfoEmbed('There were no changes done to your global ignore list.')],
395 | ephemeral: true,
396 | },
397 | }),
398 | );
399 |
400 | return;
401 | }
402 |
403 | if (userIdsToRemove.length) {
404 | await this.container.prisma.globalIgnoredUser.deleteMany({
405 | where: {
406 | userId,
407 | ignoredUserId: { in: userIdsToRemove },
408 | },
409 | });
410 | }
411 |
412 | const embed = createInfoEmbed(['Here are the changes done to your global ignore list:'].join('\n'));
413 |
414 | if (removed.length) {
415 | embed.addFields({ name: 'Removed', value: removed.join('\n') });
416 | }
417 |
418 | if (ignored.length) {
419 | embed.addFields({ name: 'Not present', value: ignored.join('\n') });
420 | }
421 |
422 | await messageOrInteraction.reply(
423 | withDeprecationWarningForMessageCommands({
424 | commandName: this.name,
425 | guildId: messageOrInteraction.guildId,
426 | receivedFromMessage,
427 | options: {
428 | embeds: [embed],
429 | ephemeral: true,
430 | },
431 | }),
432 | );
433 | }
434 |
435 | private async clearSubcommand(
436 | messageOrInteraction: Message | Subcommand.ChatInputCommandInteraction<'cached'>,
437 | receivedFromMessage: boolean,
438 | ) {
439 | const id = resolveUserIdFromMessageOrInteraction(messageOrInteraction);
440 |
441 | await messageOrInteraction.reply(
442 | withDeprecationWarningForMessageCommands({
443 | commandName: this.name,
444 | guildId: messageOrInteraction.guildId,
445 | receivedFromMessage,
446 | options: {
447 | embeds: [createInfoEmbed('Are you sure you want to clear your ignore list?')],
448 | ephemeral: true,
449 | components: [
450 | new ActionRowBuilder().addComponents(
451 | new ButtonBuilder()
452 | .setLabel('Confirm')
453 | .setStyle(ButtonStyle.Danger)
454 | .setCustomId(
455 | GloballyIgnoredUsersClearIdFactory.encodeId({
456 | userId: id,
457 | action: GloballyIgnoredUsersClearCustomIdActions.Confirm,
458 | }).unwrap(),
459 | )
460 | .setEmoji('🧹'),
461 | new ButtonBuilder()
462 | .setLabel('Cancel')
463 | .setStyle(ButtonStyle.Secondary)
464 | .setCustomId(
465 | GloballyIgnoredUsersClearIdFactory.encodeId({
466 | userId: id,
467 | action: GloballyIgnoredUsersClearCustomIdActions.Reject,
468 | }).unwrap(),
469 | )
470 | .setEmoji('❌'),
471 | ),
472 | ],
473 | },
474 | }),
475 | );
476 | }
477 |
478 | private async listSubcommand(
479 | messageOrInteraction: Message | Subcommand.ChatInputCommandInteraction<'cached'>,
480 | receivedFromMessage: boolean,
481 | ) {
482 | const userId = resolveUserIdFromMessageOrInteraction(messageOrInteraction);
483 | const userData = await getDatabaseUser(userId);
484 |
485 | if (userData.globallyIgnoredUsers.length === 0) {
486 | await messageOrInteraction.reply(
487 | withDeprecationWarningForMessageCommands({
488 | commandName: this.name,
489 | guildId: messageOrInteraction.guildId,
490 | receivedFromMessage,
491 | options: {
492 | embeds: [createInfoEmbed('You are not ignoring any users globally.')],
493 | ephemeral: true,
494 | },
495 | }),
496 | );
497 |
498 | return;
499 | }
500 |
501 | 1;
502 | }
503 |
504 | private async resolveMessageArgs(args: Args) {
505 | return args.repeat('user');
506 | }
507 | }
508 |
--------------------------------------------------------------------------------
/src/commands/Miscellaneous/help.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { AutoCompleteLimits } from '@sapphire/discord-utilities';
3 | import { Command } from '@sapphire/framework';
4 | import type { Args, Result, ChatInputCommand, MessageCommand } from '@sapphire/framework';
5 | import type { Subcommand } from '@sapphire/plugin-subcommands';
6 | import { jaroWinkler } from '@skyra/jaro-winkler';
7 | import type { ApplicationCommandOptionChoiceData, Message } from 'discord.js';
8 | import { Collection, bold, inlineCode, italic, quote } from 'discord.js';
9 | import { withDeprecationWarningForMessageCommands } from '#hooks/withDeprecationWarningForMessageCommands';
10 | import { createErrorEmbed, createSuccessEmbed } from '#utils/embeds';
11 | import { Emojis, HelpDetailedDescriptionReplacers, orList } from '#utils/misc';
12 |
13 | const randomMissingPermissionMessages = [
14 | '🙈 This maze was not meant for you.',
15 | "🤔 I don't think you can run that command here.",
16 | '🔒 You do not have permission to run this command here.',
17 | "🧐 Something feels off...how do you know about this command? (You don't have permissions to run this command here)",
18 | '🤐 I cannot tell you about this command! You do not have permissions to run it here!',
19 | "😱 Woah, woah, woah! I don't know how you know about this command, but you cannot run it here!",
20 | ];
21 |
22 | @ApplyOptions({
23 | description: 'See what commands are available to you, and how to use them',
24 | detailedDescription: [
25 | quote("If you want to list all commands you have access to, here's how to run it:"),
26 | '',
27 | `For ${Emojis.ChatInputCommands} chat input commands: ${bold(inlineCode('/help'))}`,
28 | `For message based commands: ${bold(inlineCode(`${HelpDetailedDescriptionReplacers.UserMention} help`))}`,
29 | '',
30 | quote(`If you want to get help for a specific command, here are some examples that you can use.`),
31 | '',
32 | italic(
33 | `You should replace ${inlineCode('')} with a command name (like ${inlineCode(
34 | 'help',
35 | )} for example)`,
36 | ),
37 | '',
38 | `For ${Emojis.ChatInputCommands} chat input commands: ${bold(inlineCode(`/help command:`))}`,
39 | `For message based commands: ${bold(
40 | inlineCode(`${HelpDetailedDescriptionReplacers.UserMention} help `),
41 | )}`,
42 | ].join('\n'),
43 | })
44 | export class HelpCommand extends Command {
45 | public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
46 | const maybeCommand = interaction.options.getString('command');
47 |
48 | if (maybeCommand) {
49 | const actualCommandName = this.resolvePossibleCommandName(maybeCommand);
50 | const result = this.findCommandByName(actualCommandName);
51 |
52 | if (result.exact) {
53 | return this.sendSingleCommandHelp(interaction, result.match, false);
54 | }
55 |
56 | const almostExactMatch = result.maybeMatch.find((cmd) => jaroWinkler(cmd.name, actualCommandName) >= 0.9);
57 |
58 | if (almostExactMatch) {
59 | return this.sendSingleCommandHelp(interaction, almostExactMatch, false);
60 | }
61 |
62 | return this.replyWithPossibleCommandNames(interaction, actualCommandName, result.maybeMatch, false);
63 | }
64 |
65 | return this.replyWithAllCommandsTheUserCanRun(interaction, false);
66 | }
67 |
68 | public override async messageRun(message: Message, args: Args) {
69 | const maybeCommand = await args.rest('string').catch(() => null);
70 |
71 | if (maybeCommand) {
72 | const result = this.findCommandByName(maybeCommand);
73 |
74 | if (result.exact) {
75 | return this.sendSingleCommandHelp(message, result.match, true);
76 | }
77 |
78 | const almostExactMatch = result.maybeMatch.find((cmd) => jaroWinkler(cmd.name, maybeCommand) >= 0.9);
79 |
80 | if (almostExactMatch) {
81 | return this.sendSingleCommandHelp(message, almostExactMatch, true);
82 | }
83 |
84 | return this.replyWithPossibleCommandNames(message, maybeCommand, result.maybeMatch, true);
85 | }
86 |
87 | return this.replyWithAllCommandsTheUserCanRun(message, true);
88 | }
89 |
90 | public override async autocompleteRun(interaction: Command.AutocompleteInteraction) {
91 | const focusedOption = interaction.options.getFocused(true);
92 |
93 | if (focusedOption.name !== 'command') {
94 | await interaction.respond([]);
95 | return;
96 | }
97 |
98 | const allCommands = [...this.store.values()] as Command[];
99 | const providedAutocompleteName = focusedOption.value;
100 | const providedAutocompleteFilterName = providedAutocompleteName.toLowerCase();
101 |
102 | const startsWithChunk = allCommands
103 | .filter((cmd) => cmd.name.toLowerCase().startsWith(providedAutocompleteFilterName))
104 | .sort((a, b) => a.name.localeCompare(b.name));
105 |
106 | const includesChunk = allCommands
107 | .filter((cmd) => cmd.name.toLowerCase().includes(providedAutocompleteFilterName))
108 | .sort((a, b) => a.name.localeCompare(b.name));
109 |
110 | const alreadyProcessed: string[] = [];
111 | const options: ApplicationCommandOptionChoiceData[] = [];
112 |
113 | for (const command of startsWithChunk) {
114 | // If we already got all possible commands, exit early
115 | if (options.length === AutoCompleteLimits.MaximumAmountOfOptions) {
116 | break;
117 | }
118 |
119 | if (alreadyProcessed.includes(command.name)) {
120 | continue;
121 | }
122 |
123 | alreadyProcessed.push(command.name);
124 |
125 | const canRun = await this.canRunCommand(interaction as any, command, false);
126 |
127 | if (canRun) {
128 | options.push({
129 | name: `🌟 ${command.name} - ${command.category ?? 'Category-less'} command`,
130 | value: command.name,
131 | });
132 | }
133 | }
134 |
135 | for (const command of includesChunk) {
136 | // If we already got all possible commands, exit early
137 | if (options.length === AutoCompleteLimits.MaximumAmountOfOptions) {
138 | break;
139 | }
140 |
141 | if (alreadyProcessed.includes(command.name)) {
142 | continue;
143 | }
144 |
145 | alreadyProcessed.push(command.name);
146 |
147 | const canRun = await this.canRunCommand(interaction as any, command, false);
148 |
149 | if (canRun) {
150 | options.push({
151 | name: `${command.name} - ${command.category ?? 'Category-less'} command`,
152 | value: command.name,
153 | });
154 | }
155 | }
156 |
157 | await interaction.respond(options);
158 | }
159 |
160 | public override registerApplicationCommands(registry: Command.Registry) {
161 | registry.registerChatInputCommand((builder) =>
162 | builder
163 | .setName(this.name)
164 | .setDescription(this.description)
165 | .addStringOption((command) =>
166 | command
167 | .setName('command')
168 | .setDescription('The command to get help for')
169 | .setRequired(false)
170 | .setAutocomplete(true),
171 | ),
172 | );
173 | }
174 |
175 | private resolvePossibleCommandName(commandName: string) {
176 | const mightHaveAutocompleteNameInsteadOfValue = commandName.indexOf(' - ');
177 |
178 | // 🌟 exam-ple - Category-less command
179 | if (commandName.startsWith('🌟')) {
180 | return commandName.slice(2, mightHaveAutocompleteNameInsteadOfValue).trim();
181 | }
182 |
183 | // exam-ple - Category-less command
184 | if (mightHaveAutocompleteNameInsteadOfValue !== -1) {
185 | return commandName.slice(0, mightHaveAutocompleteNameInsteadOfValue);
186 | }
187 |
188 | return commandName;
189 | }
190 |
191 | private findCommandByName(commandName: string) {
192 | const storeEntry =
193 | this.store.get(commandName.toLowerCase()) ??
194 | this.store.find((cmd) => cmd.name.toLowerCase() === commandName.toLowerCase());
195 |
196 | if (storeEntry) {
197 | return { exact: true as const, match: storeEntry as Command } as const;
198 | }
199 |
200 | const maybeEntries = [...this.store.values()]
201 | .filter((cmd) => jaroWinkler(cmd.name.toLowerCase(), commandName.toLowerCase()) >= 0.85)
202 | .sort((a, b) => a.name.localeCompare(b.name)) as Command[];
203 |
204 | return { exact: false as const, maybeMatch: maybeEntries } as const;
205 | }
206 |
207 | private async replyWithPossibleCommandNames(
208 | messageOrInteraction: Command.ChatInputCommandInteraction | Message,
209 | input: string,
210 | matches: Command[],
211 | isMessage: boolean,
212 | ) {
213 | const empathyChance = Math.random() * 100 < 5;
214 |
215 | if (!matches.length) {
216 | await messageOrInteraction.reply(
217 | withDeprecationWarningForMessageCommands({
218 | commandName: this.name,
219 | guildId: messageOrInteraction.guildId,
220 | receivedFromMessage: isMessage,
221 | options: {
222 | embeds: [
223 | createErrorEmbed(
224 | `${
225 | empathyChance ?
226 | '🍌 Not even the empathy banana knows of a command called'
227 | : "👀 I don't know of a command called"
228 | } ${bold(inlineCode(input))}. Try running ${bold(
229 | inlineCode('/help'),
230 | )} to see all available commands!`,
231 | ),
232 | ],
233 | ephemeral: true,
234 | },
235 | }),
236 | );
237 |
238 | return;
239 | }
240 |
241 | const list = orList.format(matches.map((cmd) => bold(inlineCode(cmd.name))));
242 |
243 | await messageOrInteraction.reply(
244 | withDeprecationWarningForMessageCommands({
245 | commandName: this.name,
246 | guildId: messageOrInteraction.guildId,
247 | receivedFromMessage: isMessage,
248 | options: {
249 | embeds: [
250 | createErrorEmbed(
251 | `${
252 | empathyChance ?
253 | "🍌 The magnifier broke, but fear not! Empathy banana is here to let you know that I couldn't find a command called"
254 | : "🔎 I couldn't find a command called"
255 | } ${bold(inlineCode(input))}. Maybe one of these match your search: ${list}`,
256 | ),
257 | ],
258 | ephemeral: true,
259 | },
260 | }),
261 | );
262 | }
263 |
264 | private async canRunCommand(
265 | messageOrInteraction: Command.ChatInputCommandInteraction | Message,
266 | command: Command | Subcommand,
267 | isMessage = false,
268 | ) {
269 | const preconditionStore = this.container.stores.get('preconditions');
270 |
271 | // Run global preconditions
272 | let globalResult: Result;
273 |
274 | if (isMessage) {
275 | globalResult = await preconditionStore.messageRun(
276 | messageOrInteraction as Message,
277 | command as MessageCommand,
278 | {
279 | external: true,
280 | },
281 | );
282 | } else {
283 | globalResult = await preconditionStore.chatInputRun(
284 | messageOrInteraction as Command.ChatInputCommandInteraction,
285 | command as ChatInputCommand,
286 | {
287 | external: true,
288 | },
289 | );
290 | }
291 |
292 | if (!globalResult.isOk()) {
293 | return false;
294 | }
295 |
296 | // Run command preconditions
297 | let localResult: Result;
298 |
299 | if (isMessage) {
300 | localResult = await command.preconditions.messageRun(
301 | messageOrInteraction as Message,
302 | command as MessageCommand,
303 | {
304 | external: true,
305 | },
306 | );
307 | } else {
308 | localResult = await command.preconditions.chatInputRun(
309 | messageOrInteraction as Command.ChatInputCommandInteraction,
310 | command as ChatInputCommand,
311 | {
312 | external: true,
313 | },
314 | );
315 | }
316 |
317 | // If all checks pass, we're good to go
318 | return localResult.isOk();
319 | }
320 |
321 | private async sendSingleCommandHelp(
322 | messageOrInteraction: Command.ChatInputCommandInteraction | Message,
323 | command: Command | Subcommand,
324 | isMessage: boolean,
325 | ) {
326 | const canRun = await this.canRunCommand(messageOrInteraction, command, isMessage);
327 |
328 | if (!canRun) {
329 | const randomMessage =
330 | randomMissingPermissionMessages.at(
331 | Math.floor(Math.random() * randomMissingPermissionMessages.length),
332 | ) ?? randomMissingPermissionMessages[0];
333 |
334 | await messageOrInteraction.reply(
335 | withDeprecationWarningForMessageCommands({
336 | commandName: this.name,
337 | guildId: messageOrInteraction.guildId,
338 | receivedFromMessage: isMessage,
339 | options: {
340 | embeds: [createErrorEmbed(randomMessage)],
341 | ephemeral: true,
342 | },
343 | }),
344 | );
345 |
346 | return;
347 | }
348 |
349 | const description = [command.description];
350 |
351 | if (command.detailedDescription) {
352 | const final = (command.detailedDescription as string).replaceAll(
353 | HelpDetailedDescriptionReplacers.UserMention,
354 | `@${this.container.client.user!.username}`,
355 | );
356 |
357 | description.push('', bold('📝 | Usage examples'), '', final);
358 | }
359 |
360 | const responseEmbed = createSuccessEmbed(description.join('\n')).setTitle(
361 | `/${command.name} - ${command.category ?? 'Category-less'} Command`,
362 | );
363 |
364 | if (['-', '_'].some((char) => command.name.includes(char))) {
365 | responseEmbed.addFields({
366 | name: 'A note about message based commands and dashes/underscores',
367 | value: `Since the command name includes a ${bold(inlineCode('-'))} or ${bold(
368 | inlineCode('_'),
369 | )}, the dashes/underscores are optional in the command name when using message commands. (for example ${bold(
370 | inlineCode(command.name),
371 | )} can be used as ${bold(inlineCode(command.name.replaceAll(/[_-]/g, '')))} too)`,
372 | });
373 | }
374 |
375 | const supportsMessageCommands = command.supportsMessageCommands();
376 | const supportsChatInputCommands = command.supportsChatInputCommands();
377 |
378 | if (supportsMessageCommands && !supportsChatInputCommands) {
379 | responseEmbed.addFields({
380 | name: '⚠️ Warning ⚠️',
381 | value: `This command can only be ran via messages (invoke it by using ${bold(
382 | inlineCode(`@${this.container.client.user!.username} ${command.name}`),
383 | )})`,
384 | });
385 | } else if (!supportsMessageCommands && supportsChatInputCommands) {
386 | responseEmbed.addFields({
387 | name: '⚠️ Warning ⚠️',
388 | value: `This command can only be ran via ${
389 | Emojis.ChatInputCommands
390 | } chat input commands (invoke it by using ${bold(inlineCode(`/${command.name}`))})`,
391 | });
392 | }
393 |
394 | await messageOrInteraction.reply(
395 | withDeprecationWarningForMessageCommands({
396 | commandName: this.name,
397 | guildId: messageOrInteraction.guildId,
398 | receivedFromMessage: isMessage,
399 | options: {
400 | ephemeral: true,
401 | embeds: [responseEmbed],
402 | },
403 | }),
404 | );
405 | }
406 |
407 | private async replyWithAllCommandsTheUserCanRun(
408 | messageOrInteraction: Command.ChatInputCommandInteraction | Message,
409 | isMessage: boolean,
410 | ) {
411 | // Step 1. Get all commands the user can run
412 | const commandsTheUserCanRun = (
413 | await Promise.all(
414 | [...this.store.values()].map(async (command) => {
415 | const canRun = await this.canRunCommand(messageOrInteraction, command, isMessage);
416 |
417 | return canRun ? command : null;
418 | }),
419 | )
420 | ).filter((item): item is Command => item !== null);
421 |
422 | const categoryAndCommands = new Collection();
423 |
424 | for (const command of commandsTheUserCanRun) {
425 | const entry = categoryAndCommands.get(command.category ?? 'Category-less');
426 |
427 | if (entry) {
428 | categoryAndCommands.set(command.category ?? 'Category-less', [...entry, command]);
429 | } else {
430 | categoryAndCommands.set(command.category ?? 'Category-less', [command]);
431 | }
432 | }
433 |
434 | const sorted = categoryAndCommands.sorted((_, __, catA, catB) => catA.localeCompare(catB));
435 |
436 | const embed = createSuccessEmbed(
437 | `Here is a list of all commands you can run in this server! You can run ${bold(
438 | inlineCode('/help '),
439 | )} to find out more about each command. 🎉`,
440 | );
441 |
442 | for (const [category, commands] of sorted.entries()) {
443 | embed.addFields({
444 | name: `${category} Commands`,
445 | value: commands
446 | .sort((a, b) => a.name.localeCompare(b.name))
447 | .map((command) => `${bold(inlineCode(`/${command.name}`))} - ${command.description}`)
448 | .join('\n'),
449 | });
450 | }
451 |
452 | await messageOrInteraction.reply(
453 | withDeprecationWarningForMessageCommands({
454 | commandName: this.name,
455 | guildId: messageOrInteraction.guildId,
456 | receivedFromMessage: isMessage,
457 | options: {
458 | embeds: [embed],
459 | ephemeral: true,
460 | },
461 | }),
462 | );
463 | }
464 | }
465 |
--------------------------------------------------------------------------------
/src/commands/Miscellaneous/invite.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { Command } from '@sapphire/framework';
3 | import type { ButtonBuilder, Message } from 'discord.js';
4 | import { ActionRowBuilder, hyperlink } from 'discord.js';
5 | import { withDeprecationWarningForMessageCommands } from '#hooks/withDeprecationWarningForMessageCommands';
6 | import { createInfoEmbed } from '#utils/embeds';
7 | import { InviteButton } from '#utils/misc';
8 |
9 | @ApplyOptions({
10 | description: 'Get a link with which you can invite the application to your server',
11 | })
12 | export class InviteCommand extends Command {
13 | public override async messageRun(message: Message) {
14 | return this._sharedRun(message, true);
15 | }
16 |
17 | public override async chatInputRun(interaction: Command.ChatInputCommandInteraction<'cached'>) {
18 | return this._sharedRun(interaction, false);
19 | }
20 |
21 | public override registerApplicationCommands(registry: Command.Registry) {
22 | registry.registerChatInputCommand((invite) => invite.setName(this.name).setDescription(this.description));
23 | }
24 |
25 | protected async _sharedRun(
26 | messageOrInteraction: Command.ChatInputCommandInteraction<'cached'> | Message,
27 | isMessage: boolean,
28 | ) {
29 | const embed = createInfoEmbed(
30 | [
31 | 'Click the button below to add me to your server! 😄 🎉',
32 | '',
33 | `If that didn't work, try clicking ${hyperlink('here', this.container.clientInvite)} instead.`,
34 | ].join('\n'),
35 | );
36 |
37 | await messageOrInteraction.reply(
38 | withDeprecationWarningForMessageCommands({
39 | commandName: this.name,
40 | guildId: messageOrInteraction.guildId,
41 | receivedFromMessage: isMessage,
42 | options: {
43 | embeds: [embed],
44 | ephemeral: true,
45 | components: [new ActionRowBuilder().setComponents(InviteButton)],
46 | },
47 | }),
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/commands/Miscellaneous/statistics.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import { ApplyOptions } from '@sapphire/decorators';
3 | import { Command, version as sapphireVersion } from '@sapphire/framework';
4 | import type { Message } from 'discord.js';
5 | import {
6 | ActionRowBuilder,
7 | ButtonBuilder,
8 | ButtonStyle,
9 | bold,
10 | version as discordJsVersion,
11 | hyperlink,
12 | italic,
13 | } from 'discord.js';
14 | import ts from 'typescript';
15 | import { withDeprecationWarningForMessageCommands } from '#hooks/withDeprecationWarningForMessageCommands';
16 | import { createInfoEmbed } from '#utils/embeds';
17 | import { InviteButton, SupportServerButton, packageJsonFile } from '#utils/misc';
18 |
19 | const { version: typescriptVersion } = ts;
20 |
21 | @ApplyOptions({
22 | aliases: ['stats'],
23 | description: 'Find out some statistics about this application',
24 | })
25 | export class StatisticsCommand extends Command {
26 | public override async messageRun(message: Message) {
27 | return this._sharedRun(message, true);
28 | }
29 |
30 | public override async chatInputRun(interaction: Command.ChatInputCommandInteraction<'cached'>) {
31 | return this._sharedRun(interaction, false);
32 | }
33 |
34 | public override registerApplicationCommands(registry: Command.Registry) {
35 | registry.registerChatInputCommand((statistics) =>
36 | statistics.setName(this.name).setDescription(this.description),
37 | );
38 | }
39 |
40 | protected async _sharedRun(
41 | messageOrInteraction: Command.ChatInputCommandInteraction<'cached'> | Message,
42 | isMessage: boolean,
43 | ) {
44 | const embed = createInfoEmbed(
45 | [
46 | `Here is some of that ${italic('juicy')} data about Highlight ${bold(
47 | `v${packageJsonFile.version}`,
48 | )} - Sapphire Edition, built by ${hyperlink('@vladdy', 'https://github.com/vladfrangu')}!`,
49 | ].join('\n'),
50 | ).setFields(
51 | {
52 | name: 'Built using these amazing tools',
53 | value: [
54 | `• ${hyperlink('node.js', 'https://nodejs.org/')} ${bold(process.version)} & ${hyperlink(
55 | 'TypeScript',
56 | 'https://typescriptlang.org/',
57 | )} ${bold(`v${typescriptVersion}`)}`,
58 | `• ${hyperlink('discord.js', 'https://discord.js.org/')} ${bold(`v${discordJsVersion}`)}`,
59 | `• ${hyperlink('Sapphire Framework', 'https://sapphirejs.dev/')} ${bold(`v${sapphireVersion}`)}`,
60 | ].join('\n'),
61 | },
62 | {
63 | name: 'Handling highlights for the following',
64 | value: [
65 | `• Shards: ${bold((this.container.client.options.shardCount ?? 1).toLocaleString())}`,
66 | `• Servers: ${bold(this.container.client.guilds.cache.size.toLocaleString())}`,
67 | `• Users: ${bold(
68 | this.container.client.guilds.cache
69 | .reduce((acc, curr) => acc + curr.memberCount, 0)
70 | .toLocaleString(),
71 | )}`,
72 | ].join('\n'),
73 | },
74 | );
75 |
76 | await messageOrInteraction.reply(
77 | withDeprecationWarningForMessageCommands({
78 | commandName: this.name,
79 | guildId: messageOrInteraction.guildId,
80 | receivedFromMessage: isMessage,
81 | options: {
82 | embeds: [embed],
83 | ephemeral: true,
84 | components: [
85 | new ActionRowBuilder().setComponents(InviteButton, SupportServerButton),
86 | new ActionRowBuilder().setComponents(
87 | new ButtonBuilder()
88 | .setStyle(ButtonStyle.Link)
89 | .setURL('https://github.com/vladfrangu/highlight')
90 | .setLabel('GitHub Repository')
91 | .setEmoji({
92 | name: 'github',
93 | id: '950169270896197633',
94 | }),
95 | new ButtonBuilder()
96 | .setStyle(ButtonStyle.Link)
97 | .setURL('https://github.com/sponsors/vladfrangu')
98 | .setLabel('Donate')
99 | .setEmoji({
100 | name: '💙',
101 | }),
102 | ),
103 | ],
104 | },
105 | }),
106 | );
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/commands/Miscellaneous/support.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { Command } from '@sapphire/framework';
3 | import type { ButtonBuilder, Message } from 'discord.js';
4 | import { ActionRowBuilder, italic } from 'discord.js';
5 | import { withDeprecationWarningForMessageCommands } from '#hooks/withDeprecationWarningForMessageCommands';
6 | import { createInfoEmbed } from '#utils/embeds';
7 | import { SupportServerButton } from '#utils/misc';
8 |
9 | @ApplyOptions({
10 | description: 'Get a link to the support server for this application',
11 | })
12 | export class SupportCommand extends Command {
13 | public override async messageRun(message: Message) {
14 | return this._sharedRun(message, true);
15 | }
16 |
17 | public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
18 | return this._sharedRun(interaction, false);
19 | }
20 |
21 | public override registerApplicationCommands(registry: Command.Registry) {
22 | registry.registerChatInputCommand((support) => support.setName(this.name).setDescription(this.description));
23 | }
24 |
25 | protected async _sharedRun(
26 | messageOrInteraction: Command.ChatInputCommandInteraction | Message,
27 | isMessage: boolean,
28 | ) {
29 | const embed = createInfoEmbed(
30 | [
31 | italic("It's dangerous to go alone if you are lost..."),
32 | "We're here to help you if you need help using Highlight! Just click the button below to join the support server!",
33 | ].join('\n'),
34 | );
35 |
36 | await messageOrInteraction.reply(
37 | withDeprecationWarningForMessageCommands({
38 | commandName: this.name,
39 | guildId: messageOrInteraction.guildId,
40 | receivedFromMessage: isMessage,
41 | options: {
42 | embeds: [embed],
43 | ephemeral: true,
44 | components: [new ActionRowBuilder().setComponents(SupportServerButton)],
45 | },
46 | }),
47 | );
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/interaction-handlers/commands/globally-ignored-users/clear.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework';
3 | import type { ButtonInteraction } from 'discord.js';
4 | import {
5 | GloballyIgnoredUsersClearCustomIdActions,
6 | GloballyIgnoredUsersClearIdFactory,
7 | } from '#customIds/globally-ignored-users';
8 | import { createInfoEmbed } from '#utils/embeds';
9 |
10 | @ApplyOptions({
11 | name: 'globallyIgnoredUsersClear',
12 | interactionHandlerType: InteractionHandlerTypes.Button,
13 | })
14 | export class GloballyIgnoredUsersClearHandler extends InteractionHandler {
15 | public override async run(interaction: ButtonInteraction, parsedData: InteractionHandler.ParseResult) {
16 | if (!interaction.inGuild()) {
17 | await interaction.reply({
18 | embeds: [createInfoEmbed('These buttons can only be used in a server')],
19 | ephemeral: true,
20 | });
21 |
22 | return;
23 | }
24 |
25 | if (interaction.user.id !== parsedData.userId) {
26 | await interaction.reply({
27 | embeds: [createInfoEmbed("You cannot alter another user's ignore list")],
28 | ephemeral: true,
29 | });
30 |
31 | return;
32 | }
33 |
34 | switch (parsedData.action) {
35 | case GloballyIgnoredUsersClearCustomIdActions.Confirm: {
36 | await this.container.prisma.globalIgnoredUser.deleteMany({
37 | where: { userId: parsedData.userId },
38 | });
39 |
40 | await interaction.update({
41 | embeds: [createInfoEmbed('Your global ignore list has been cleared 🧹')],
42 | components: [],
43 | });
44 |
45 | break;
46 | }
47 |
48 | case GloballyIgnoredUsersClearCustomIdActions.Reject: {
49 | await interaction.update({
50 | embeds: [createInfoEmbed('Your global ignore list has not been cleared')],
51 | components: [],
52 | });
53 |
54 | break;
55 | }
56 | }
57 | }
58 |
59 | public override parse(interaction: ButtonInteraction) {
60 | return GloballyIgnoredUsersClearIdFactory.decodeId(interaction.customId);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/interaction-handlers/commands/server-ignore-list/clear.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework';
3 | import type { ButtonInteraction } from 'discord.js';
4 | import { ServerIgnoreListClearCustomIdActions, ServerIgnoreListClearIdFactory } from '#customIds/server-ignore-list';
5 | import { createInfoEmbed } from '#utils/embeds';
6 |
7 | @ApplyOptions({
8 | name: 'serverIgnoreListClear',
9 | interactionHandlerType: InteractionHandlerTypes.Button,
10 | })
11 | export class ServerIgnoreListClearHandler extends InteractionHandler {
12 | public override async run(interaction: ButtonInteraction, parsedData: InteractionHandler.ParseResult) {
13 | if (!interaction.inGuild()) {
14 | await interaction.reply({
15 | embeds: [createInfoEmbed('These buttons can only be used in a server')],
16 | ephemeral: true,
17 | });
18 |
19 | return;
20 | }
21 |
22 | if (interaction.user.id !== parsedData.userId) {
23 | await interaction.reply({
24 | embeds: [createInfoEmbed("You cannot alter another user's ignore list")],
25 | ephemeral: true,
26 | });
27 |
28 | return;
29 | }
30 |
31 | switch (parsedData.action) {
32 | case ServerIgnoreListClearCustomIdActions.Confirm: {
33 | await this.container.prisma.guildIgnoredUser.deleteMany({
34 | where: { userId: parsedData.userId, guildId: interaction.guildId },
35 | });
36 |
37 | await this.container.prisma.guildIgnoredChannel.deleteMany({
38 | where: { userId: parsedData.userId, guildId: interaction.guildId },
39 | });
40 |
41 | await interaction.update({
42 | embeds: [createInfoEmbed('Your ignore list has been cleared 🧹')],
43 | components: [],
44 | });
45 |
46 | break;
47 | }
48 |
49 | case ServerIgnoreListClearCustomIdActions.Reject: {
50 | await interaction.update({
51 | embeds: [createInfoEmbed('Your ignore list has not been cleared')],
52 | components: [],
53 | });
54 |
55 | break;
56 | }
57 | }
58 | }
59 |
60 | public override parse(interaction: ButtonInteraction) {
61 | return ServerIgnoreListClearIdFactory.decodeId(interaction.customId);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/lib/setup.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/order, import/first */
2 |
3 | // #region Env Setup
4 | import { rootDir } from '#utils/misc';
5 | import { setup, type ArrayString, type NumberString } from '@skyra/env-utilities';
6 |
7 | setup({ path: new URL('.env', rootDir) });
8 |
9 | declare module '@skyra/env-utilities' {
10 | interface Env {
11 | DEVELOPMENT_GUILD_IDS: ArrayString;
12 | DISCORD_TOKEN: string;
13 | ERROR_WEBHOOK_URL: string;
14 | GUILD_JOIN_LEAVE_WEBHOOK_URL: string;
15 | POSTGRES_DB: string;
16 | POSTGRES_HOST: string;
17 | POSTGRES_PASSWORD: string;
18 | POSTGRES_PORT: NumberString;
19 | POSTGRES_URL: string;
20 | POSTGRES_USERNAME: string;
21 | SUPPORT_SERVER_INVITE: string;
22 | }
23 | }
24 | // #endregion
25 |
26 | // #region Sapphire config
27 | import { ApplicationCommandRegistries, container, LogLevel, RegisterBehavior } from '@sapphire/framework';
28 | import '@sapphire/plugin-logger/register';
29 |
30 | const { useDevelopmentGuildIds } = await import('#hooks/useDevelopmentGuildIds');
31 | ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical(RegisterBehavior.BulkOverwrite);
32 | ApplicationCommandRegistries.setDefaultGuildIds(useDevelopmentGuildIds());
33 | // #endregion
34 |
35 | // #region NodeJS inspect settings
36 | import { inspect } from 'node:util';
37 |
38 | inspect.defaultOptions.depth = 4;
39 | // #endregion
40 |
41 | // #region Global color utility
42 | import { createColors, type Colorette } from 'colorette';
43 |
44 | container.colors = createColors({ useColor: true });
45 |
46 | declare module '@sapphire/pieces' {
47 | interface Container {
48 | colors: Colorette;
49 | }
50 | }
51 | // #endregion
52 |
53 | // #region Prisma
54 | import { SqlHighlighter } from '@mikro-orm/sql-highlighter';
55 | import { PrismaClient } from '@prisma/client';
56 |
57 | const highlighter = new SqlHighlighter();
58 |
59 | const prisma = new PrismaClient({
60 | errorFormat: 'pretty',
61 | log: [
62 | { emit: 'stdout', level: 'warn' },
63 | { emit: 'stdout', level: 'error' },
64 | ],
65 | }).$extends({
66 | name: 'performance_tracking',
67 | query: {
68 | async $allOperations({ args, operation, query, model }) {
69 | // If we're not in debug mode, just run the query and return
70 | if (!container.logger.has(LogLevel.Debug)) {
71 | return query(args);
72 | }
73 |
74 | const start = performance.now();
75 | const result = await query(args);
76 | const end = performance.now();
77 | const time = end - start;
78 |
79 | if (model) {
80 | const stringifiedArgs = JSON.stringify(args, null, 2)
81 | .split('\n')
82 | .map((line) => container.colors.gray(line))
83 | .join('\n');
84 |
85 | container.logger.debug(
86 | `${container.colors.cyanBright('prisma:query')} ${container.colors.bold(
87 | `${model}.${operation}(${stringifiedArgs}${container.colors.bold(')')}`,
88 | )} took ${container.colors.bold(`${container.colors.green(time.toFixed(4))}ms`)}`,
89 | );
90 | } else {
91 | // Most likely in $executeRaw/queryRaw
92 | const casted = args as { strings?: string[]; values?: unknown[] } | undefined;
93 |
94 | const consoleMessage = [
95 | `${container.colors.cyanBright('prisma:query')} `,
96 | container.colors.bold(`Prisma.${operation}(\``),
97 | ];
98 |
99 | const sqlString = [];
100 |
101 | if (casted?.strings) {
102 | if (casted.values) {
103 | for (const str of casted.strings) {
104 | sqlString.push(str);
105 |
106 | const value = casted.values.shift();
107 | if (value) {
108 | sqlString.push(JSON.stringify(value));
109 | }
110 | }
111 | } else {
112 | // just add all the strings
113 | sqlString.push(...casted.strings);
114 | }
115 |
116 | consoleMessage.push(highlighter.highlight(sqlString.join('')));
117 | } else if (Array.isArray(args)) {
118 | // Most likely in $executeRawUnsafe/queryRawUnsafe
119 | const sqlString = args.shift() as string | undefined;
120 |
121 | if (sqlString) {
122 | if (args.length) {
123 | for (let paramIndex = 1; paramIndex <= args.length; paramIndex++) {
124 | sqlString.replace(`$${paramIndex}`, JSON.stringify(args[paramIndex - 1]));
125 | }
126 |
127 | consoleMessage.push(highlighter.highlight(sqlString));
128 | } else {
129 | consoleMessage.push(highlighter.highlight(sqlString));
130 | }
131 | } else {
132 | consoleMessage.push(container.colors.gray(JSON.stringify(args)));
133 | }
134 | } else {
135 | // Who tf knows brother
136 | consoleMessage.push(container.colors.gray(JSON.stringify(args)));
137 | }
138 |
139 | consoleMessage.push(
140 | container.colors.bold('`) '),
141 | `took ${container.colors.bold(`${container.colors.green(time.toFixed(4))}ms`)}`,
142 | );
143 |
144 | container.logger.debug(consoleMessage.join(''));
145 | }
146 |
147 | return result;
148 | },
149 | },
150 | }) as PrismaClient<{ errorFormat: 'pretty' }>;
151 |
152 | container.prisma = prisma;
153 |
154 | declare module '@sapphire/pieces' {
155 | interface Container {
156 | prisma: typeof prisma;
157 | }
158 | }
159 | // #endregion
160 |
161 | // #region Highlight manager
162 | import { HighlightManager } from '#structures/HighlightManager';
163 |
164 | container.highlightManager = new HighlightManager();
165 |
166 | declare module '@sapphire/pieces' {
167 | interface Container {
168 | highlightManager: HighlightManager;
169 | }
170 | }
171 | // #endregion
172 |
--------------------------------------------------------------------------------
/src/lib/structures/HighlightClient.ts:
--------------------------------------------------------------------------------
1 | import { container, SapphireClient } from '@sapphire/framework';
2 |
3 | export class HighlightClient extends SapphireClient {
4 | public override async login(token?: string) {
5 | container.logger.info('Connecting to the database...');
6 | await container.prisma.$connect();
7 |
8 | this.logger.info('Starting the workers...');
9 | await container.highlightManager.start();
10 |
11 | this.logger.info('Logging in to Discord...');
12 | return super.login(token);
13 | }
14 |
15 | public override async destroy() {
16 | try {
17 | await container.prisma.$disconnect();
18 | } catch {}
19 |
20 | await container.highlightManager.destroy();
21 | return super.destroy();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/structures/HighlightManager.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout } from 'node:timers';
2 | import { Worker } from 'node:worker_threads';
3 | import { container } from '@sapphire/framework';
4 | import { remove } from 'confusables';
5 | import type { Message } from 'discord.js';
6 | import {
7 | WorkerCommands,
8 | WorkerResponseTypes,
9 | WorkerType,
10 | type DeleteInvalidRegularExpressionResponse,
11 | type HighlightResult,
12 | type WorkerCommandsUnion,
13 | type WorkerResponse,
14 | } from '#types/WorkerTypes';
15 |
16 | const WORKER_PATH = new URL('../workers/Worker.js', import.meta.url);
17 |
18 | type ResultsTuple = [words: HighlightResult, regularExpressions: HighlightResult];
19 |
20 | export class HighlightManager {
21 | /**
22 | * A tuple of the two workers that must be kept alive
23 | */
24 | private workers: [words: Worker, regularExpressions: Worker] = [] as any;
25 |
26 | /**
27 | * If the client was destroyed
28 | */
29 | private destroyed = false;
30 |
31 | #promiseMap = new Map<
32 | string,
33 | { promise: Promise; resolve(data: ResultsTuple): void; results: ResultsTuple }
34 | >();
35 |
36 | public async start() {
37 | this.initializeWorkers();
38 | await this.updateAllCaches();
39 | }
40 |
41 | public async destroy() {
42 | this.destroyed = true;
43 | await Promise.all(this.workers.map(async (item) => item.terminate()));
44 | }
45 |
46 | public async updateAllCaches() {
47 | const members = await container.prisma.member.findMany();
48 | this.broadcastCommand({ command: WorkerCommands.UpdateFullCache, data: { members } });
49 | }
50 |
51 | public async updateCacheForGuildID(guildId: string) {
52 | const members = await container.prisma.member.findMany({ where: { guildId } });
53 | this.broadcastCommand({ command: WorkerCommands.UpdateCacheForGuild, data: { guildId, members } });
54 | }
55 |
56 | public async validateRegularExpression(input: string) {
57 | const worker = this.workers[WorkerType.RegularExpression];
58 |
59 | return new Promise((resolve, reject) => {
60 | const listener = (payload: WorkerResponse) => {
61 | // eslint-disable-next-line @typescript-eslint/no-use-before-define
62 | timeout.refresh();
63 |
64 | if (
65 | payload.command === WorkerResponseTypes.ValidateRegularExpressionResult &&
66 | payload.data.input === input
67 | ) {
68 | worker.off('message', listener);
69 | resolve(payload.data.valid);
70 | }
71 | };
72 |
73 | const timeout = setTimeout(() => {
74 | worker.off('message', listener);
75 | // eslint-disable-next-line prefer-promise-reject-errors
76 | reject('Timed out after 30s');
77 | }, 30_000);
78 |
79 | worker.on('message', listener);
80 | worker.postMessage({
81 | command: WorkerCommands.ValidateRegularExpression,
82 | data: { regularExpression: input },
83 | } as WorkerCommandsUnion);
84 | });
85 | }
86 |
87 | public async parseHighlight(message: Message) {
88 | let pResolve: (data: ResultsTuple) => void;
89 | const promise = new Promise((resolve) => {
90 | pResolve = resolve;
91 | });
92 |
93 | const promiseObject = { promise, resolve: pResolve!, results: [] as any };
94 |
95 | this.#promiseMap.set(message.id, promiseObject);
96 |
97 | this.broadcastCommand({
98 | command: WorkerCommands.HandleHighlight,
99 | data: {
100 | authorId: message.author.id,
101 | content: remove(message.content),
102 | guildId: message.guild!.id,
103 | messageId: message.id,
104 | },
105 | });
106 |
107 | return promise;
108 | }
109 |
110 | public removeTriggerForUser(guildId: string, memberId: string, trigger: string) {
111 | this.broadcastCommand({ command: WorkerCommands.RemoveTriggerForUser, data: { guildId, memberId, trigger } });
112 | }
113 |
114 | private initializeWorkers() {
115 | this.createWorkerType(WorkerType.Word);
116 | this.createWorkerType(WorkerType.RegularExpression);
117 | }
118 |
119 | private createWorkerType(type: WorkerType) {
120 | // If the client was destroyed, stop early
121 | if (this.destroyed) {
122 | return;
123 | }
124 |
125 | // eslint-disable-next-line no-multi-assign
126 | const worker = (this.workers[type] = new Worker(WORKER_PATH, { workerData: { type } }));
127 | worker.once('exit', (exitCode) => {
128 | container.logger.info(
129 | `[${container.colors.cyan(`${WorkerType[type]} Worker`)}]: Exited with code ${exitCode}.${
130 | this.destroyed ? '' : ' Respawning...'
131 | }`,
132 | );
133 |
134 | worker.removeAllListeners();
135 | this.createWorkerType(type);
136 | });
137 | worker.on('message', (data: WorkerResponse) => this.onWorkerResponse(type, data));
138 | }
139 |
140 | /**
141 | * Sends a command to the workers
142 | */
143 | private broadcastCommand(command: WorkerCommandsUnion) {
144 | for (const worker of this.workers) worker.postMessage(command);
145 | }
146 |
147 | private onWorkerResponse(type: WorkerType, payload: WorkerResponse) {
148 | const colored = container.colors.cyan(`${WorkerType[type]} Worker`);
149 |
150 | switch (payload.command) {
151 | case WorkerResponseTypes.DeleteInvalidRegularExpression: {
152 | void this.deleteInvalidRegularExpression(payload.data);
153 | break;
154 | }
155 |
156 | case WorkerResponseTypes.HighlightResult: {
157 | const { messageId, result } = payload.data;
158 | const promiseData = this.#promiseMap.get(messageId);
159 |
160 | if (!promiseData) {
161 | container.logger.warn(
162 | //
163 | `Parsed highlight for message, but there was no promise in the promise map`,
164 | { messageId },
165 | );
166 | return;
167 | }
168 |
169 | const { type } = result;
170 | promiseData.results[type] = result;
171 |
172 | if (typeof promiseData.results[0] !== 'undefined' && typeof promiseData.results[1] !== 'undefined') {
173 | promiseData.resolve(promiseData.results);
174 | this.#promiseMap.delete(messageId);
175 | }
176 |
177 | break;
178 | }
179 |
180 | case WorkerResponseTypes.Ready: {
181 | container.logger.info(`[${colored}]: READY`);
182 | break;
183 | }
184 |
185 | case WorkerResponseTypes.ValidateRegularExpressionResult: {
186 | // Do a whole lot of nothing
187 | break;
188 | }
189 | }
190 | }
191 |
192 | private async deleteInvalidRegularExpression(data: DeleteInvalidRegularExpressionResponse['data']) {
193 | const memberData = await container.prisma.member.findFirst({
194 | where: { guildId: data.guildId, userId: data.memberId },
195 | });
196 |
197 | if (!memberData) {
198 | container.logger.warn(
199 | `Received invalid regular expression for member but no member data could be found`,
200 | data,
201 | );
202 | return;
203 | }
204 |
205 | const newRegularExpressions = memberData.regularExpressions.filter((regex) => regex !== data.value);
206 | await container.prisma.member.update({
207 | where: { guildId_userId: { guildId: data.guildId, userId: data.memberId } },
208 | data: { regularExpressions: newRegularExpressions },
209 | });
210 |
211 | // TODO: warn user in DMs
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/src/lib/structures/customIds/globally-ignored-users.ts:
--------------------------------------------------------------------------------
1 | import { none, some } from '@sapphire/framework';
2 | import { createCustomIdFactory } from '#utils/customIds';
3 |
4 | export const GloballyIgnoredUsersClearIdFactory = createCustomIdFactory({
5 | prefix: 'globally_ignored_users.clear:',
6 | decoder(id) {
7 | const split = id.split(':');
8 |
9 | if (split.length !== 2) {
10 | return none;
11 | }
12 |
13 | const [action, userId] = split;
14 |
15 | return some({ action: Number.parseInt(action, 10) as GloballyIgnoredUsersClearCustomIdActions, userId });
16 | },
17 | encoder(prefix, data) {
18 | return some(`${prefix}${data.action}:${data.userId}`);
19 | },
20 | });
21 |
22 | export const enum GloballyIgnoredUsersClearCustomIdActions {
23 | Confirm,
24 | Reject,
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/structures/customIds/server-ignore-list.ts:
--------------------------------------------------------------------------------
1 | import { none, some } from '@sapphire/framework';
2 | import { createCustomIdFactory } from '#utils/customIds';
3 |
4 | export const ServerIgnoreListClearIdFactory = createCustomIdFactory({
5 | prefix: 'server_ignore_list.clear:',
6 | decoder(id) {
7 | const split = id.split(':');
8 |
9 | if (split.length !== 2) {
10 | return none;
11 | }
12 |
13 | const [action, userId] = split;
14 |
15 | return some({ action: Number.parseInt(action, 10) as ServerIgnoreListClearCustomIdActions, userId });
16 | },
17 | encoder(prefix, data) {
18 | return some(`${prefix}${data.action}:${data.userId}`);
19 | },
20 | });
21 |
22 | export const enum ServerIgnoreListClearCustomIdActions {
23 | Confirm,
24 | Reject,
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/types/WorkerTypes.ts:
--------------------------------------------------------------------------------
1 | import type { Member } from '@prisma/client';
2 |
3 | export enum WorkerType {
4 | Word,
5 | RegularExpression,
6 | }
7 |
8 | export interface HighlightResult {
9 | memberIds: string[];
10 | results: ParsedHighlightData[];
11 | type: WorkerType;
12 | }
13 |
14 | export interface ParsedHighlightData {
15 | memberId: string;
16 | parsedContent: string;
17 | trigger: string;
18 | }
19 |
20 | export interface WorkerData {
21 | type: WorkerType;
22 | }
23 |
24 | // #region Worker Commands
25 | export const enum WorkerCommands {
26 | HandleHighlight,
27 | RemoveTriggerForUser,
28 | UpdateCacheForGuild,
29 | UpdateFullCache,
30 | ValidateRegularExpression,
31 | }
32 |
33 | export type WorkerCommandsUnion =
34 | | HandleHighlightCommand
35 | | RemoveTriggerForUserCommand
36 | | UpdateCacheForGuildCommand
37 | | UpdateFullCacheCommand
38 | | ValidateRegularExpressionCommand;
39 |
40 | export type HandleHighlightCommand = BaseCommand<
41 | WorkerCommands.HandleHighlight,
42 | {
43 | authorId: string;
44 | content: string;
45 | guildId: string;
46 | messageId: string;
47 | }
48 | >;
49 |
50 | export type RemoveTriggerForUserCommand = BaseCommand<
51 | WorkerCommands.RemoveTriggerForUser,
52 | {
53 | guildId: string;
54 | memberId: string;
55 | trigger: string;
56 | }
57 | >;
58 |
59 | export type UpdateCacheForGuildCommand = BaseCommand<
60 | WorkerCommands.UpdateCacheForGuild,
61 | { guildId: string; members: Member[] }
62 | >;
63 |
64 | export type UpdateFullCacheCommand = BaseCommand;
65 |
66 | export type ValidateRegularExpressionCommand = BaseCommand<
67 | WorkerCommands.ValidateRegularExpression,
68 | { regularExpression: string }
69 | >;
70 | // #endregion
71 |
72 | // #region Worker Responses
73 | export const enum WorkerResponseTypes {
74 | DeleteInvalidRegularExpression,
75 | HighlightResult,
76 | Ready,
77 | ValidateRegularExpressionResult,
78 | }
79 |
80 | export type WorkerResponse =
81 | | DeleteInvalidRegularExpressionResponse
82 | | HighlightResultResponse
83 | | ReadyResponse
84 | | ValidateRegularExpressionResultResponse;
85 |
86 | export type DeleteInvalidRegularExpressionResponse = BaseResponse<
87 | WorkerResponseTypes.DeleteInvalidRegularExpression,
88 | { guildId: string; memberId: string; value: string }
89 | >;
90 |
91 | export type HighlightResultResponse = BaseResponse<
92 | WorkerResponseTypes.HighlightResult,
93 | {
94 | messageId: string;
95 | result: HighlightResult;
96 | }
97 | >;
98 |
99 | export type ReadyResponse = BaseResponse;
100 |
101 | export type ValidateRegularExpressionResultResponse = BaseResponse<
102 | WorkerResponseTypes.ValidateRegularExpressionResult,
103 | { input: string; valid: boolean }
104 | >;
105 | // #endregion
106 |
107 | interface BaseCommand {
108 | command: C;
109 | data: D;
110 | }
111 |
112 | interface BaseResponse {
113 | command: C;
114 | data: D;
115 | }
116 |
--------------------------------------------------------------------------------
/src/lib/utils/customIds.ts:
--------------------------------------------------------------------------------
1 | import type { Option } from '@sapphire/framework';
2 | import { none, some } from '@sapphire/framework';
3 |
4 | export interface CustomIdFactory {
5 | decodeId(id: string): Option;
6 | encodeId(data: T): Option;
7 | }
8 |
9 | export interface CreateCustomIdFactoryOptions {
10 | decoder?(id: string): Option;
11 | encoder?(prefix: string, data: T): Option;
12 | prefix: string;
13 | }
14 |
15 | export function createCustomIdFactory(options: CreateCustomIdFactoryOptions): CustomIdFactory {
16 | return {
17 | encodeId(data) {
18 | if (options.encoder) {
19 | return options.encoder(options.prefix, data);
20 | }
21 |
22 | return some(`${options.prefix}${data}`);
23 | },
24 | decodeId(id) {
25 | if (!id.startsWith(options.prefix)) {
26 | return none;
27 | }
28 |
29 | if (options.decoder) {
30 | return options.decoder(id.slice(options.prefix.length));
31 | }
32 |
33 | return some(id.slice(options.prefix.length) as unknown as T);
34 | },
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/src/lib/utils/db.ts:
--------------------------------------------------------------------------------
1 | import type { Member } from '@prisma/client';
2 | import { container } from '@sapphire/framework';
3 |
4 | export interface FullMember extends Member {
5 | ignoredChannels: string[];
6 | ignoredUsers: string[];
7 | }
8 |
9 | export async function getDatabaseMember(guildId: string, userId: string): Promise {
10 | const [_, [rawMember], [rawIgnored]] = await container.prisma.$transaction([
11 | container.prisma.$queryRaw`INSERT INTO users (id) VALUES (${userId}) ON CONFLICT (id) DO NOTHING`,
12 | container.prisma.$queryRaw<
13 | { guild_id: string; regular_expressions: string[] | null; user_id: string }[]
14 | >/* sql */ `
15 | INSERT INTO members (guild_id, user_id)
16 | VALUES (${guildId}, ${userId})
17 | ON CONFLICT (guild_id, user_id) DO
18 | UPDATE SET user_id = ${userId}
19 | RETURNING *
20 | `,
21 | container.prisma.$queryRaw<{ ignored_channels: string[] | null; ignored_users: (string | null)[] | null }[]>`
22 | SELECT
23 | array_agg(guild_ignored_channels.ignored_channel_id) as ignored_channels,
24 | array_agg(guild_ignored_users.ignored_user_id) as ignored_users
25 | FROM guild_ignored_channels
26 | LEFT JOIN guild_ignored_users ON
27 | guild_ignored_users.user_id = guild_ignored_channels.user_id
28 | AND guild_ignored_users.guild_id = guild_ignored_channels.guild_id
29 | WHERE
30 | guild_ignored_channels.user_id = ${userId}
31 | AND guild_ignored_channels.guild_id = ${guildId}
32 | `,
33 | ]);
34 |
35 | let ignoredUsers: string[] = [];
36 | let ignoredChannels: string[] = [];
37 |
38 | if (rawIgnored.ignored_users?.[0] !== null) {
39 | ignoredUsers = rawIgnored.ignored_users as string[];
40 | }
41 |
42 | if (rawIgnored.ignored_channels?.[0] !== null) {
43 | ignoredChannels = rawIgnored.ignored_channels as string[];
44 | }
45 |
46 | return {
47 | guildId: rawMember.guild_id!,
48 | userId: rawMember.user_id!,
49 | regularExpressions: rawMember.regular_expressions ?? [],
50 | ignoredUsers,
51 | ignoredChannels,
52 | };
53 | }
54 |
55 | export async function getDatabaseUser(userId: string) {
56 | return container.prisma.user.upsert({
57 | where: { id: userId },
58 | create: { id: userId },
59 | update: {},
60 | include: { globallyIgnoredUsers: true },
61 | });
62 | }
63 |
--------------------------------------------------------------------------------
/src/lib/utils/embeds.ts:
--------------------------------------------------------------------------------
1 | import { EmbedBuilder } from 'discord.js';
2 |
3 | export function createInfoEmbed(description?: string) {
4 | return new EmbedBuilder({ color: 0x3669fa, description });
5 | }
6 |
7 | export function createErrorEmbed(description?: string) {
8 | return new EmbedBuilder({ color: 0xcc0f16, description });
9 | }
10 |
11 | export function createSuccessEmbed(description?: string) {
12 | return new EmbedBuilder({ color: 0x43b581, description });
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/utils/hooks/useDevelopmentGuildIds.ts:
--------------------------------------------------------------------------------
1 | import { envParseArray } from '@skyra/env-utilities';
2 |
3 | let parsedGuildIds: string[] = null!;
4 |
5 | export function useDevelopmentGuildIds() {
6 | parsedGuildIds ??= envParseArray('DEVELOPMENT_GUILD_IDS', []).filter(Boolean);
7 |
8 | return parsedGuildIds;
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/utils/hooks/useErrorWebhook.ts:
--------------------------------------------------------------------------------
1 | import { envParseString } from '@skyra/env-utilities';
2 | import { WebhookClient } from 'discord.js';
3 |
4 | let webhookInstance: WebhookClient = null!;
5 |
6 | export function useErrorWebhook() {
7 | webhookInstance ??= new WebhookClient({ url: envParseString('ERROR_WEBHOOK_URL') });
8 |
9 | return webhookInstance;
10 | }
11 |
--------------------------------------------------------------------------------
/src/lib/utils/hooks/useGuildJoinLeaveWebhook.ts:
--------------------------------------------------------------------------------
1 | import { envParseString } from '@skyra/env-utilities';
2 | import { WebhookClient } from 'discord.js';
3 |
4 | let webhookInstance: WebhookClient = null!;
5 |
6 | export function useGuildJoinLeaveWebhook() {
7 | webhookInstance ??= new WebhookClient({ url: envParseString('GUILD_JOIN_LEAVE_WEBHOOK_URL') });
8 |
9 | return webhookInstance;
10 | }
11 |
--------------------------------------------------------------------------------
/src/lib/utils/hooks/withDeprecationWarningForMessageCommands.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/framework';
2 | import { deepClone } from '@sapphire/utilities';
3 | import {
4 | ActionRowBuilder,
5 | ButtonBuilder,
6 | ButtonStyle,
7 | ComponentType,
8 | EmbedBuilder,
9 | bold,
10 | hyperlink,
11 | inlineCode,
12 | type InteractionReplyOptions,
13 | type MessageActionRowComponentBuilder,
14 | type MessageCreateOptions,
15 | type WebhookMessageEditOptions,
16 | } from 'discord.js';
17 | import { createInfoEmbed } from '#utils/embeds';
18 | import { inviteOptions } from '#utils/misc';
19 |
20 | export function withDeprecationWarningOnEmbedForMessageCommands(
21 | embed: EmbedBuilder,
22 | commandName: string,
23 | buttonNotice: string | null = null,
24 | ) {
25 | // Add it to the description
26 | if (embed.data.fields?.length === 25) {
27 | embed.setDescription(
28 | [
29 | embed.data.description ? embed.data.description : undefined,
30 | embed.data.description ? '' : undefined,
31 | `> ${bold('Did you know?')}`,
32 | `> Message based commands are ${bold('deprecated')}, and will be removed in the future.`,
33 | `> You should use the ${bold(inlineCode(`/${commandName}`))} slash command instead!`,
34 | buttonNotice ?
35 | `> If you don't see the slash commands popping up when you type ${bold(
36 | inlineCode(`/${commandName}`),
37 | )}, click the ${bold('Re-authorize')} button if present (or click ${bold(
38 | hyperlink('here to re-authorize', buttonNotice),
39 | )}) and try again!`
40 | : undefined,
41 | ]
42 | .filter((item) => typeof item === 'string')
43 | .join('\n'),
44 | );
45 | }
46 | // Add a field if we can
47 | else {
48 | embed.addFields({
49 | name: 'Did you know?',
50 | value: [
51 | `Message based commands are ${bold(
52 | 'deprecated',
53 | )}, and will be removed in the future.\nYou should use the ${bold(
54 | inlineCode(`/${commandName}`),
55 | )} slash command instead!`,
56 | buttonNotice ?
57 | `If you don't see the slash commands popping up when you type ${bold(
58 | inlineCode(`/${commandName}`),
59 | )}, click the ${bold('Re-authorize')} button if present (or click ${bold(
60 | hyperlink('here to re-authorize', buttonNotice),
61 | )}) and try again!`
62 | : undefined,
63 | ]
64 | .filter((item) => typeof item === 'string')
65 | .join('\n'),
66 | });
67 | }
68 |
69 | return embed;
70 | }
71 |
72 | export function withDeprecationWarningForMessageCommands<
73 | T extends InteractionReplyOptions | MessageCreateOptions | WebhookMessageEditOptions,
74 | >({
75 | options,
76 | commandName,
77 | receivedFromMessage,
78 | guildId,
79 | }: {
80 | commandName: string;
81 | guildId: string | null;
82 | options: T;
83 | receivedFromMessage: boolean;
84 | }) {
85 | // If we didn't get it from messages, might as well not do anything
86 | if (!receivedFromMessage) {
87 | return options;
88 | }
89 |
90 | const cloned = deepClone(options);
91 |
92 | const invite = container.client.generateInvite({
93 | ...inviteOptions,
94 | guild: guildId ?? undefined,
95 | });
96 |
97 | // If we have embeds, use the previous behavior
98 | if (cloned.embeds?.length) {
99 | const [first, ...rest] = cloned.embeds;
100 |
101 | cloned.embeds = [
102 | withDeprecationWarningOnEmbedForMessageCommands(EmbedBuilder.from(first), commandName, invite),
103 | ...rest,
104 | ];
105 | }
106 | // Create embed with the warning
107 | else {
108 | cloned.embeds = [withDeprecationWarningOnEmbedForMessageCommands(createInfoEmbed(), commandName, invite)];
109 | }
110 |
111 | const authButton = new ButtonBuilder()
112 | .setStyle(ButtonStyle.Link)
113 | .setURL(invite)
114 | .setLabel('Re-authorize me with slash commands!')
115 | .setEmoji('🤖');
116 |
117 | if (cloned.components?.length) {
118 | const casted = cloned.components as ActionRowBuilder[];
119 | // We have components. If we have 5 rows (unlikely), we cannot do anything but piggyback off of the first free row
120 | // Otherwise, we just push a new row
121 | if (casted.length === 5) {
122 | const freeRow = casted.find((row) => {
123 | if (row.components.length === 5) {
124 | return false;
125 | }
126 |
127 | if (row.components.length) {
128 | return ![
129 | ComponentType.StringSelect,
130 | ComponentType.UserSelect,
131 | ComponentType.RoleSelect,
132 | ComponentType.MentionableSelect,
133 | ComponentType.ChannelSelect,
134 | ].some((type) => row.components[0].data.type !== type);
135 | }
136 |
137 | return true;
138 | });
139 |
140 | if (freeRow) {
141 | freeRow.components.push(authButton);
142 | }
143 | } else {
144 | (cloned.components as ActionRowBuilder[]).push(
145 | new ActionRowBuilder().addComponents(authButton),
146 | );
147 | }
148 | }
149 | // We don't have any components, so we can just add a new one
150 | else {
151 | cloned.components = [new ActionRowBuilder().addComponents(authButton)];
152 | }
153 |
154 | return cloned;
155 | }
156 |
--------------------------------------------------------------------------------
/src/lib/utils/misc.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from 'node:fs/promises';
2 | import { envParseString } from '@skyra/env-utilities';
3 | import {
4 | ButtonBuilder,
5 | ButtonStyle,
6 | Message,
7 | OAuth2Scopes,
8 | PermissionFlagsBits,
9 | PermissionsBitField,
10 | } from 'discord.js';
11 | import type { CommandInteraction, MessageComponentInteraction, InviteGenerationOptions } from 'discord.js';
12 | import re2 from 're2';
13 |
14 | export const rootDir = new URL('../../../', import.meta.url);
15 | export const packageJsonFile = JSON.parse(await readFile(new URL('package.json', rootDir), 'utf8'));
16 |
17 | export const supportServerInvite = envParseString('SUPPORT_SERVER_INVITE', 'https://discord.gg/C6D9bge');
18 |
19 | export const SupportServerButton = new ButtonBuilder()
20 | .setStyle(ButtonStyle.Link)
21 | .setURL(supportServerInvite)
22 | .setLabel('Support server')
23 | .setEmoji({ name: '🆘' });
24 |
25 | export const inviteOptions: InviteGenerationOptions = {
26 | scopes: [OAuth2Scopes.Bot, OAuth2Scopes.ApplicationsCommands],
27 | permissions: new PermissionsBitField([
28 | PermissionFlagsBits.ViewChannel,
29 | PermissionFlagsBits.ReadMessageHistory,
30 | PermissionFlagsBits.SendMessages,
31 | PermissionFlagsBits.EmbedLinks,
32 | ]),
33 | };
34 |
35 | export const InviteButton = new ButtonBuilder() //
36 | .setStyle(ButtonStyle.Link)
37 | .setLabel('Add me to your server!')
38 | .setEmoji({ name: '🎉' });
39 |
40 | export const RegularExpressionCaseSensitiveMatch = '$$HIGHLIGHT_CASE_SENSITIVE$$';
41 |
42 | export const RegularExpressionWordMarker = '$$HIGHLIGHT_WORD_MARKER$$';
43 |
44 | export function tryRegex(input: string): [boolean, re2 | null] {
45 | const caseSensitiveMatch = input.includes(RegularExpressionCaseSensitiveMatch);
46 | const wordMarker = input.includes(RegularExpressionWordMarker);
47 |
48 | const flags = caseSensitiveMatch ? 'g' : 'gi';
49 |
50 | let finalInput = input;
51 |
52 | if (wordMarker) {
53 | finalInput = finalInput.replace(RegularExpressionWordMarker, '');
54 | }
55 |
56 | if (caseSensitiveMatch) {
57 | finalInput = finalInput.replace(RegularExpressionCaseSensitiveMatch, '');
58 | }
59 |
60 | try {
61 | return [true, new re2(finalInput, flags)];
62 | } catch {
63 | return [false, null];
64 | }
65 | }
66 |
67 | export function pluralize(count: number, singular: string, plural: string) {
68 | return count === 1 ? singular : plural;
69 | }
70 |
71 | export const andList = new Intl.ListFormat('en-US', { type: 'conjunction', style: 'long' });
72 |
73 | export const orList = new Intl.ListFormat('en-US', { type: 'disjunction', style: 'long' });
74 |
75 | export const enum Emojis {
76 | ChatInputCommands = '<:chatinputcommands:955124528483283004>',
77 | }
78 |
79 | export const enum HelpDetailedDescriptionReplacers {
80 | UserMention = '{user_mention}',
81 | }
82 |
83 | export function resolveUserIdFromMessageOrInteraction(
84 | messageOrInteraction: CommandInteraction | Message | MessageComponentInteraction,
85 | ): string {
86 | return messageOrInteraction instanceof Message ? messageOrInteraction.author.id : messageOrInteraction.user.id;
87 | }
88 |
89 | export type EnsureArray = { [K in keyof T]: T[K] extends any[] | null ? Exclude : T[K] };
90 |
--------------------------------------------------------------------------------
/src/lib/utils/userTags.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/framework';
2 | import { Time } from '@sapphire/timestamp';
3 | import { GuildMember, escapeMarkdown, type User } from 'discord.js';
4 | import { LRUCache } from 'lru-cache';
5 |
6 | const cache = new LRUCache({
7 | max: 1_000_000,
8 | ttl: Time.Hour * 12,
9 | updateAgeOnGet: true,
10 | allowStale: false,
11 | async fetchMethod(key) {
12 | const user = await container.client.users.fetch(key).catch(() => null);
13 |
14 | if (!user) {
15 | return {
16 | bot: false,
17 | displayTag: `Unknown User (${key})`,
18 | };
19 | }
20 |
21 | return {
22 | bot: user.bot,
23 | displayTag: getUserTag(user),
24 | };
25 | },
26 | });
27 |
28 | export function getUserTag(userOrMember: GuildMember | User) {
29 | if (userOrMember instanceof GuildMember) {
30 | return getUserTag(userOrMember.user);
31 | }
32 |
33 | const escapedDisplayName = escapeMarkdown(userOrMember.displayName);
34 | const escapedUsername = escapeMarkdown(userOrMember.username);
35 | const pomelo = userOrMember.discriminator === '0';
36 |
37 | if (pomelo) {
38 | return `${escapedDisplayName} (@${escapedUsername})`;
39 | }
40 |
41 | return `${escapedDisplayName} (${escapedUsername}#${userOrMember.discriminator})`;
42 | }
43 |
44 | export const UnknownUserTag = 'A Discord User';
45 |
46 | export async function fetchUserTag(id: string) {
47 | return cache.fetch(id);
48 | }
49 |
--------------------------------------------------------------------------------
/src/lib/workers/Worker.ts:
--------------------------------------------------------------------------------
1 | import { setInterval } from 'node:timers';
2 | import { parentPort, workerData } from 'node:worker_threads';
3 | import type { Member } from '@prisma/client';
4 | import { WorkerCommands, WorkerResponseTypes, WorkerType, type WorkerCommandsUnion } from '#types/WorkerTypes';
5 | import { RegularExpressionWordMarker } from '#utils/misc';
6 | import { WorkerCache, type GuildId, type UserId } from '#workers/WorkerCache';
7 | import { checkParentPort, sendToMainProcess } from '#workers/common';
8 |
9 | const CACHE = new WorkerCache();
10 |
11 | checkParentPort(parentPort);
12 | sendToMainProcess({ command: WorkerResponseTypes.Ready, data: { ready: true } });
13 |
14 | // Create keep-alive interval
15 | setInterval(() => {
16 | sendToMainProcess({ command: 69_420 } as any);
17 | }, 45_000);
18 |
19 | parentPort.on('message', (payload: WorkerCommandsUnion) => {
20 | switch (payload.command) {
21 | case WorkerCommands.HandleHighlight: {
22 | const { authorId, content, guildId, messageId } = payload.data;
23 | const result = CACHE.parse(workerData.type, guildId, authorId, content);
24 | sendToMainProcess({ command: WorkerResponseTypes.HighlightResult, data: { messageId, result } });
25 | break;
26 | }
27 |
28 | case WorkerCommands.UpdateCacheForGuild: {
29 | const res = new Map>();
30 |
31 | // Iterate through every member
32 | processMemberCacheUpdate(res, payload.data.members);
33 |
34 | CACHE.updateGuild(payload.data.guildId, res);
35 | break;
36 | }
37 |
38 | case WorkerCommands.UpdateFullCache: {
39 | const guildMap = new Map>>();
40 |
41 | for (const member of payload.data.members) {
42 | // Get cached guild data entry
43 | const guildData = guildMap.get(member.guildId) ?? new Map>();
44 |
45 | // Iterate over the current member's data
46 | for (const wordOrPattern of member.regularExpressions) {
47 | if (
48 | workerData.type === WorkerType.RegularExpression &&
49 | wordOrPattern.includes(RegularExpressionWordMarker)
50 | ) {
51 | continue;
52 | }
53 |
54 | if (workerData.type === WorkerType.Word && !wordOrPattern.includes(RegularExpressionWordMarker)) {
55 | continue;
56 | }
57 |
58 | const cachedWordOrPattern = guildData.get(wordOrPattern) ?? new Set();
59 | cachedWordOrPattern.add(member.userId);
60 | if (cachedWordOrPattern.size) guildData.set(wordOrPattern, cachedWordOrPattern);
61 | }
62 |
63 | // Set the guild data in the map
64 | guildMap.set(member.guildId, guildData);
65 | }
66 |
67 | for (const [guildId, data] of guildMap.entries()) {
68 | if (data.size) CACHE.updateGuild(guildId, data);
69 | }
70 |
71 | break;
72 | }
73 |
74 | case WorkerCommands.ValidateRegularExpression: {
75 | const valid = CACHE.isRegularExpressionValid(payload.data.regularExpression);
76 | sendToMainProcess({
77 | command: WorkerResponseTypes.ValidateRegularExpressionResult,
78 | data: { input: payload.data.regularExpression, valid },
79 | });
80 | break;
81 | }
82 |
83 | case WorkerCommands.RemoveTriggerForUser: {
84 | CACHE.removeTriggerForUser(payload.data);
85 | break;
86 | }
87 | }
88 | });
89 |
90 | function processMemberCacheUpdate(cacheToUpdate: Map>, members: Member[]) {
91 | for (const member of members) {
92 | for (const option of member.regularExpressions) {
93 | if (workerData.type === WorkerType.Word && !option.includes(RegularExpressionWordMarker)) {
94 | continue;
95 | }
96 |
97 | if (workerData.type === WorkerType.RegularExpression && option.includes(RegularExpressionWordMarker)) {
98 | continue;
99 | }
100 |
101 | const cachedInstance = cacheToUpdate.get(option) ?? new Set();
102 | cachedInstance.add(member.userId);
103 | cacheToUpdate.set(option, cachedInstance);
104 | }
105 | }
106 |
107 | return cacheToUpdate;
108 | }
109 |
--------------------------------------------------------------------------------
/src/lib/workers/WorkerCache.ts:
--------------------------------------------------------------------------------
1 | import { escapeMarkdown } from 'discord.js';
2 | import type re2 from 're2';
3 | import {
4 | type WorkerType,
5 | WorkerResponseTypes,
6 | type HighlightResult,
7 | type RemoveTriggerForUserCommand,
8 | } from '#types/WorkerTypes';
9 | import { RegularExpressionCaseSensitiveMatch, RegularExpressionWordMarker, tryRegex } from '#utils/misc';
10 | import { sendToMainProcess } from '#workers/common';
11 |
12 | export type UserId = string;
13 | export type GuildId = string;
14 | export type WordOrRegularExpression = string;
15 |
16 | export class WorkerCache {
17 | // Map of Guild ID => Word | Regular Expression => UserID
18 | private guildMap = new Map>>();
19 |
20 | // Cache of validated regular expressions
21 | private validRegularExpressions = new Map();
22 |
23 | // Cache of created regular expressions
24 | private stringToRegularExpression = new Map();
25 |
26 | public updateGuild(id: GuildId, newEntries: Map>) {
27 | this.guildMap.set(id, newEntries);
28 | }
29 |
30 | public removeTriggerForUser({ guildId, memberId, trigger }: RemoveTriggerForUserCommand['data']) {
31 | // Get all guild triggers
32 | const guildEntry = this.guildMap.get(guildId);
33 |
34 | // If this guild has no entries, return
35 | if (!guildEntry) {
36 | return;
37 | }
38 |
39 | // Get all trigger-able users for this trigger
40 | const triggerables = guildEntry.get(trigger);
41 |
42 | // If this isn't a trigger, return
43 | if (!triggerables) {
44 | return;
45 | }
46 |
47 | // Remove the member
48 | triggerables.delete(memberId);
49 |
50 | // If nobody is left to get highlighted by this, delete it from the map
51 | if (triggerables.size === 0) {
52 | guildEntry.delete(trigger);
53 |
54 | // If there are no more triggers, delete the guild
55 | if (guildEntry.size === 0) {
56 | this.guildMap.delete(guildId);
57 | }
58 | }
59 | }
60 |
61 | /**
62 | * Checks if a regular expression string is valid
63 | */
64 | public isRegularExpressionValid(regex: string) {
65 | const cached = this.validRegularExpressions.get(regex);
66 | if (typeof cached === 'boolean') {
67 | return cached;
68 | }
69 |
70 | const [valid, validatedRegex] = tryRegex(regex);
71 | this.setValidRegex(regex, valid);
72 | if (validatedRegex) this.stringToRegularExpression.set(regex, validatedRegex);
73 | return valid;
74 | }
75 |
76 | public parse(type: WorkerType, guildId: string, authorId: string, content: string) {
77 | const returnData: HighlightResult = { type, results: [], memberIds: [] };
78 |
79 | const guildData = this.guildMap.get(guildId);
80 |
81 | // If we have no guild data (either no words or regular expressions configured, return early)
82 | if (!guildData?.size) {
83 | return returnData;
84 | }
85 |
86 | const alreadyHighlighted = new Set();
87 |
88 | // We now have a map of Word | Regular Expression => User IDs
89 |
90 | for (const [regexString, members] of guildData.entries()) {
91 | const actualRegularExpression = this.getOrCacheRegularExpression(regexString);
92 |
93 | if (!actualRegularExpression) {
94 | for (const member of members) {
95 | sendToMainProcess({
96 | command: WorkerResponseTypes.DeleteInvalidRegularExpression,
97 | data: { guildId, memberId: member, value: regexString },
98 | });
99 | this.removeTriggerForUser({ guildId, memberId: member, trigger: regexString });
100 | }
101 |
102 | continue;
103 | }
104 |
105 | if (!actualRegularExpression.test(content)) {
106 | continue;
107 | }
108 |
109 | const parsedContent = content.trim().replace(actualRegularExpression, (matchedValue) => {
110 | if (matchedValue.trim().length > 0) return `**${escapeMarkdown(matchedValue)}**`;
111 | return `__${escapeMarkdown(matchedValue)}__`;
112 | });
113 |
114 | for (const memberId of members) {
115 | if (memberId === authorId || alreadyHighlighted.has(memberId)) {
116 | continue;
117 | }
118 |
119 | alreadyHighlighted.add(memberId);
120 | returnData.results.push({ memberId, parsedContent, trigger: this.cleanupRegexString(regexString) });
121 | }
122 | }
123 |
124 | returnData.memberIds = [...alreadyHighlighted];
125 |
126 | return returnData;
127 | }
128 |
129 | protected setValidRegex(regex: string, valid: boolean) {
130 | this.validRegularExpressions.set(regex, valid);
131 | }
132 |
133 | private getOrCacheRegularExpression(input: string) {
134 | const cached = this.stringToRegularExpression.get(input);
135 | if (cached) {
136 | return cached;
137 | }
138 |
139 | const [valid, pattern] = tryRegex(input);
140 |
141 | if (!valid) {
142 | return null;
143 | }
144 |
145 | this.stringToRegularExpression.set(input, pattern!);
146 | return pattern;
147 | }
148 |
149 | private cleanupRegexString(input: string) {
150 | const hadWordMarker = input.includes(RegularExpressionWordMarker);
151 | const hadCaseSensitiveMatch = input.includes(RegularExpressionCaseSensitiveMatch);
152 |
153 | let finalString = input;
154 |
155 | if (hadCaseSensitiveMatch) {
156 | finalString = finalString.replace(RegularExpressionCaseSensitiveMatch, '');
157 | }
158 |
159 | if (hadWordMarker) {
160 | finalString = finalString.replace(RegularExpressionWordMarker, '');
161 |
162 | if (finalString.startsWith('\\b')) {
163 | finalString = finalString.slice(2);
164 | }
165 |
166 | if (finalString.endsWith('\\b')) {
167 | finalString = finalString.slice(0, -2);
168 | }
169 | }
170 |
171 | return finalString;
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/lib/workers/common.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import { isMainThread, parentPort } from 'node:worker_threads';
3 | import type { MessagePort } from 'node:worker_threads';
4 | import type { WorkerResponse } from '#types/WorkerTypes';
5 |
6 | export function checkParentPort(port: unknown): asserts port is MessagePort {
7 | if (isMainThread) throw new Error('The worker may only be ran via the `worker_threads` module');
8 | // If the port is null, we probably lost connection to the main process
9 | if (port === null) return process.exit(-1);
10 | }
11 |
12 | export function sendToMainProcess(response: WorkerResponse) {
13 | checkParentPort(parentPort);
14 | parentPort.postMessage(response);
15 | }
16 |
--------------------------------------------------------------------------------
/src/listeners/errorRelated/commandApplicationCommandRegistryError.ts:
--------------------------------------------------------------------------------
1 | import type { Command, Events } from '@sapphire/framework';
2 | import { Listener } from '@sapphire/framework';
3 |
4 | export class CoreEvent extends Listener {
5 | public override run(error: unknown, command: Command) {
6 | const { name, location } = command;
7 | this.container.logger.error(
8 | `Encountered error while handling the command application command registry for command`,
9 | { commandName: name, filePath: location.full },
10 | error,
11 | );
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/listeners/errorRelated/errorHandling.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable sonarjs/no-identical-functions,n/callback-return,promise/prefer-await-to-callbacks */
2 |
3 | import { randomUUID } from 'node:crypto';
4 | import { ApplyOptions } from '@sapphire/decorators';
5 | import { Events, Listener, UserError, container } from '@sapphire/framework';
6 | import type {
7 | Command,
8 | Piece,
9 | Awaitable,
10 | ChatInputCommandErrorPayload,
11 | ContextMenuCommandErrorPayload,
12 | MessageCommandErrorPayload,
13 | } from '@sapphire/framework';
14 | import type {
15 | Subcommand,
16 | ChatInputSubcommandErrorPayload,
17 | MessageSubcommandErrorPayload,
18 | MessageSubcommandNoMatchContext,
19 | } from '@sapphire/plugin-subcommands';
20 | import { SubcommandPluginEvents, SubcommandPluginIdentifiers } from '@sapphire/plugin-subcommands';
21 | import {
22 | ActionRowBuilder,
23 | bold,
24 | codeBlock,
25 | inlineCode,
26 | Message,
27 | PartialGroupDMChannel,
28 | type InteractionReplyOptions,
29 | type MessageCreateOptions,
30 | } from 'discord.js';
31 | import { useErrorWebhook } from '#hooks/useErrorWebhook';
32 | import { withDeprecationWarningForMessageCommands } from '#hooks/withDeprecationWarningForMessageCommands';
33 | import { createErrorEmbed } from '#utils/embeds';
34 | import { SupportServerButton, orList, pluralize, supportServerInvite } from '#utils/misc';
35 |
36 | @ApplyOptions({ name: 'MessageCommandError', event: Events.MessageCommandError })
37 | export class MessageCommandError extends Listener {
38 | public override async run(error: unknown, { message, command }: MessageCommandErrorPayload) {
39 | const maybeError = error as Error;
40 |
41 | await makeAndSendErrorEmbed(
42 | maybeError,
43 | command,
44 | async (options) =>
45 | (message.channel as Exclude).send(
46 | withDeprecationWarningForMessageCommands({
47 | commandName: command.name,
48 | guildId: message.guildId,
49 | receivedFromMessage: true,
50 | options,
51 | }),
52 | ),
53 | command,
54 | );
55 | }
56 | }
57 |
58 | @ApplyOptions({ name: 'ChatInputCommandError', event: Events.ChatInputCommandError })
59 | export class ChatInputCommandError extends Listener {
60 | public override async run(error: unknown, { interaction, command }: ChatInputCommandErrorPayload) {
61 | const maybeError = error as Error;
62 |
63 | await makeAndSendErrorEmbed(
64 | maybeError,
65 | command,
66 | async (options) => {
67 | if (interaction.replied) {
68 | return interaction.followUp({ ...options, ephemeral: true });
69 | } else if (interaction.deferred) {
70 | return interaction.editReply(options as never);
71 | }
72 |
73 | return interaction.reply({ ...options, ephemeral: true });
74 | },
75 | command,
76 | );
77 | }
78 | }
79 |
80 | @ApplyOptions({ name: 'ContextMenuCommandError', event: Events.ContextMenuCommandError })
81 | export class ContextMenuCommandError extends Listener {
82 | public override async run(error: unknown, { interaction, command }: ContextMenuCommandErrorPayload) {
83 | const maybeError = error as Error;
84 |
85 | await makeAndSendErrorEmbed(
86 | maybeError,
87 | command,
88 | async (options) => {
89 | if (interaction.replied) {
90 | return interaction.followUp({ ...options, ephemeral: true });
91 | } else if (interaction.deferred) {
92 | return interaction.editReply(options as never);
93 | }
94 |
95 | return interaction.reply({ ...options, ephemeral: true });
96 | },
97 | command,
98 | );
99 | }
100 | }
101 |
102 | @ApplyOptions({
103 | name: 'MessageCommandSubcommandCommandError',
104 | event: SubcommandPluginEvents.MessageSubcommandError,
105 | })
106 | export class MessageCommandSubcommandCommandError extends Listener<
107 | typeof SubcommandPluginEvents.MessageSubcommandError
108 | > {
109 | public override async run(error: unknown, { message, command }: MessageSubcommandErrorPayload) {
110 | const maybeError = error as Error;
111 |
112 | await makeAndSendErrorEmbed(
113 | maybeError,
114 | command,
115 | async (options) =>
116 | (message.channel as Exclude).send(
117 | withDeprecationWarningForMessageCommands({
118 | commandName: command.name,
119 | guildId: message.guildId,
120 | receivedFromMessage: true,
121 | options,
122 | }),
123 | ),
124 | command,
125 | );
126 | }
127 | }
128 |
129 | @ApplyOptions({
130 | name: 'ChatInputCommandSubcommandCommandError',
131 | event: SubcommandPluginEvents.ChatInputSubcommandError,
132 | })
133 | export class ChatInputCommandSubcommandCommandError extends Listener<
134 | typeof SubcommandPluginEvents.ChatInputSubcommandError
135 | > {
136 | public override async run(error: unknown, { interaction, command }: ChatInputSubcommandErrorPayload) {
137 | const maybeError = error as Error;
138 |
139 | await makeAndSendErrorEmbed(
140 | maybeError,
141 | command,
142 | async (options) => {
143 | if (interaction.replied) {
144 | return interaction.followUp({ ...options, ephemeral: true });
145 | } else if (interaction.deferred) {
146 | return interaction.editReply(options as never);
147 | }
148 |
149 | return interaction.reply({ ...options, ephemeral: true });
150 | },
151 | command,
152 | );
153 | }
154 | }
155 |
156 | async function makeAndSendErrorEmbed(
157 | error: Error | UserError,
158 | command: Command | Subcommand,
159 | callback: (options: Options) => Awaitable,
160 | piece: Piece,
161 | ) {
162 | const errorUuid = randomUUID();
163 | const webhook = useErrorWebhook();
164 | const { name, location } = piece;
165 |
166 | if (error instanceof UserError) {
167 | if (error.identifier === SubcommandPluginIdentifiers.MessageSubcommandNoMatch) {
168 | const casted = command as Subcommand;
169 | const ctx = error.context as MessageSubcommandNoMatchContext;
170 |
171 | if (!casted.supportsMessageCommands()) {
172 | // This command can strictly be ran via slash commands only!
173 | await callback({
174 | embeds: [createErrorEmbed(`🤐 This command can only be ran via slash commands!`)],
175 | } as never);
176 |
177 | return;
178 | }
179 |
180 | const mappings = casted.parsedSubcommandMappings;
181 |
182 | let foundMessageMapping = mappings.find(
183 | (mapping) =>
184 | (mapping.type === 'method' && mapping.name === ctx.possibleSubcommandGroupOrName) ||
185 | (mapping.type === 'group' &&
186 | mapping.entries.some((entry) => entry.name === ctx.possibleSubcommandName)),
187 | );
188 |
189 | if (foundMessageMapping?.type === 'group') {
190 | foundMessageMapping = foundMessageMapping.entries.find(
191 | (mapping) => mapping.type === 'method' && mapping.name === ctx.possibleSubcommandName,
192 | );
193 | }
194 |
195 | if (foundMessageMapping) {
196 | await webhook.send({
197 | content: `Encountered missing message command mapping for command ${inlineCode(
198 | command.name,
199 | )}, subcommand group ${inlineCode(`${ctx.possibleSubcommandGroupOrName}`)}, subcommand ${inlineCode(
200 | `${ctx.possibleSubcommandGroupOrName}`,
201 | )}.\n\nUUID: ${bold(inlineCode(errorUuid))}`,
202 | });
203 |
204 | await callback({
205 | embeds: [
206 | createErrorEmbed(
207 | `😖 I seem to have forgotten to map the ${inlineCode(
208 | ctx.possibleSubcommandName ?? ctx.possibleSubcommandGroupOrName!,
209 | )} properly for you. Please report this error ID to my developer: ${bold(
210 | inlineCode(errorUuid),
211 | )}!`,
212 | ),
213 | ],
214 | components: [new ActionRowBuilder().setComponents(SupportServerButton)],
215 | } as never);
216 |
217 | return;
218 | }
219 |
220 | const actualSubcommandNames = mappings
221 | .map((entry) => bold(inlineCode(entry.name)))
222 | .sort((a, b) => a.localeCompare(b));
223 |
224 | const prettyList = orList.format(actualSubcommandNames);
225 |
226 | await callback({
227 | embeds: [
228 | createErrorEmbed(
229 | `The subcommand you provided is unknown to me or you didn't provide any! ${pluralize(
230 | actualSubcommandNames.length,
231 | 'This is',
232 | 'These are',
233 | )} the ${pluralize(
234 | actualSubcommandNames.length,
235 | 'subcommand',
236 | 'subcommands',
237 | )} I know about: ${prettyList}`,
238 | ),
239 | ],
240 | } as never);
241 |
242 | return;
243 | }
244 |
245 | const errorEmbed = createErrorEmbed(error.message);
246 |
247 | await callback({ embeds: [errorEmbed], allowedMentions: { parse: [] } } as never);
248 |
249 | return;
250 | }
251 |
252 | container.logger.error(
253 | `Encountered error while running command`,
254 | { commandName: name, filePath: location.full },
255 | error,
256 | );
257 |
258 | await webhook.send({
259 | content: `Encountered an unexpected error, take a look @here!\nUUID: ${bold(inlineCode(errorUuid))}`,
260 | embeds: [
261 | createErrorEmbed(codeBlock('ansi', error.stack ?? error.message)).setFields(
262 | { name: 'Command', value: name },
263 | { name: 'Path', value: location.full },
264 | ),
265 | ],
266 | allowedMentions: { parse: ['everyone'] },
267 | avatarURL: container.client.user!.displayAvatarURL(),
268 | username: 'Error encountered',
269 | });
270 |
271 | const errorEmbed = createErrorEmbed(
272 | `Please send the following code to our [support server](${supportServerInvite}): ${bold(
273 | inlineCode(errorUuid),
274 | )}\n\nYou can also mention the following error message: ${codeBlock('ansi', error.message)}`,
275 | ).setTitle('An unexpected error occurred! 😱');
276 |
277 | await callback({
278 | components: [new ActionRowBuilder().setComponents(SupportServerButton)],
279 | embeds: [errorEmbed],
280 | allowedMentions: { parse: [] },
281 | } as never);
282 | }
283 |
--------------------------------------------------------------------------------
/src/listeners/errorRelated/listenerError.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '@sapphire/framework';
2 | import type { Events, ListenerErrorPayload } from '@sapphire/framework';
3 |
4 | export class CoreEvent extends Listener {
5 | public override run(error: unknown, context: ListenerErrorPayload) {
6 | const { name, event, location } = context.piece;
7 | this.container.logger.error(
8 | `Encountered error on event listener`,
9 | { pieceName: name, event, filePath: location.full },
10 | error,
11 | );
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/listeners/highlightRelated/activityUpdater.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable sonarjs/no-identical-functions */
2 |
3 | import { ApplyOptions } from '@sapphire/decorators';
4 | import { Events, Listener, container } from '@sapphire/framework';
5 | import type { Message, MessageReaction, Typing, User } from 'discord.js';
6 |
7 | @ApplyOptions({ event: Events.MessageCreate, name: 'ActivityUpdater.MessageCreate' })
8 | export class MessageCreate extends Listener {
9 | public async run(message: Message) {
10 | if (!message.inGuild()) {
11 | return;
12 | }
13 |
14 | await updateStateForUserInChannel(message.author.id, message.channelId, message.guildId);
15 | }
16 | }
17 |
18 | @ApplyOptions({ event: Events.MessageUpdate, name: 'ActivityUpdater.MessageUpdate' })
19 | export class MessageUpdate extends Listener {
20 | public async run(_: never, message: Message) {
21 | if (!message.inGuild()) {
22 | return;
23 | }
24 |
25 | await updateStateForUserInChannel(message.author.id, message.channelId, message.guildId);
26 | }
27 | }
28 |
29 | @ApplyOptions({ event: Events.MessageReactionAdd, name: 'ActivityUpdater.MessageReactionAdd' })
30 | export class MessageReactionAdd extends Listener {
31 | public async run(reaction: MessageReaction, user: User) {
32 | if (!reaction.message.inGuild()) {
33 | return;
34 | }
35 |
36 | await updateStateForUserInChannel(user.id, reaction.message.channelId, reaction.message.guildId);
37 | }
38 | }
39 |
40 | @ApplyOptions({ event: Events.MessageReactionRemove, name: 'ActivityUpdater.MessageReactionRemove' })
41 | export class MessageReactionRemove extends Listener {
42 | public async run(reaction: MessageReaction, user: User) {
43 | if (!reaction.message.inGuild()) {
44 | return;
45 | }
46 |
47 | await updateStateForUserInChannel(user.id, reaction.message.channelId, reaction.message.guildId);
48 | }
49 | }
50 |
51 | @ApplyOptions({ event: Events.TypingStart, name: 'ActivityUpdater.TypingStart' })
52 | export class TypingStart extends Listener {
53 | public async run(typingData: Typing) {
54 | if (!typingData.inGuild()) {
55 | return;
56 | }
57 |
58 | await updateStateForUserInChannel(typingData.user.id, typingData.channel.id, typingData.guild.id);
59 | }
60 | }
61 |
62 | async function updateStateForUserInChannel(userId: string, channelId: string, guildId: string) {
63 | // First check if the user has a grace period set, and hasn't opted out
64 | const user = await container.prisma.user.findFirst({
65 | where: { id: userId, gracePeriod: { not: null }, optedOut: false },
66 | });
67 |
68 | if (!user) {
69 | return;
70 | }
71 |
72 | // Could it be on one line? Absolutely!
73 | // Is it? No, because it looks pretty when logging it for debug purposes :)
74 | await container.prisma.$executeRaw`
75 | INSERT INTO user_activities (user_id, channel_id, guild_id, last_active_at)
76 | VALUES (${userId}, ${channelId}, ${guildId}, NOW())
77 | ON CONFLICT (user_id, channel_id, guild_id) DO
78 | UPDATE SET last_active_at = NOW()
79 | `; // We love indentations >.>
80 | }
81 |
--------------------------------------------------------------------------------
/src/listeners/miscEvents/dbCleaners/channelWithBotParsingCleaner.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { Events, Listener } from '@sapphire/framework';
3 | import type { AnyThreadChannel, DMChannel, NonThreadGuildBasedChannel } from 'discord.js';
4 |
5 | @ApplyOptions({ event: Events.ChannelDelete, name: 'ChannelWithBotParsing.ChannelDelete' })
6 | export class ChannelDelete extends Listener {
7 | public async run(channel: DMChannel | NonThreadGuildBasedChannel) {
8 | if (channel.isDMBased()) {
9 | return;
10 | }
11 |
12 | // Delete any possible channel with bot parsing for this channel
13 | await this.container.prisma.channelWithBotParsing.delete({ where: { channelId: channel.id } });
14 | }
15 | }
16 |
17 | @ApplyOptions({ event: Events.ThreadDelete, name: 'ChannelWithBotParsing.ThreadDelete' })
18 | export class ThreadDelete extends Listener {
19 | public async run(thread: AnyThreadChannel) {
20 | // Delete any possible channel with bot parsing for this thread
21 | await this.container.prisma.channelWithBotParsing.delete({ where: { channelId: thread.id } });
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/listeners/miscEvents/dbCleaners/userActivityCleaner.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { Events, Listener } from '@sapphire/framework';
3 | import type { AnyThreadChannel, DMChannel, Guild, NonThreadGuildBasedChannel } from 'discord.js';
4 |
5 | @ApplyOptions({ event: Events.ChannelDelete, name: 'UserActivity.ChannelDelete' })
6 | export class ChannelDelete extends Listener {
7 | public async run(channel: DMChannel | NonThreadGuildBasedChannel) {
8 | if (channel.isDMBased()) {
9 | return;
10 | }
11 |
12 | // Delete all user activities for this channel
13 | await this.container.prisma.userActivity.deleteMany({ where: { channelId: channel.id } });
14 | }
15 | }
16 |
17 | @ApplyOptions({ event: Events.GuildDelete, name: 'UserActivity.GuildDelete' })
18 | export class GuildDelete extends Listener {
19 | public async run(guild: Guild) {
20 | // Delete all user activities for this guild
21 | await this.container.prisma.userActivity.deleteMany({ where: { guildId: guild.id } });
22 | }
23 | }
24 |
25 | @ApplyOptions({ event: Events.ThreadDelete, name: 'UserActivity.ThreadDelete' })
26 | export class ThreadDelete extends Listener {
27 | public async run(thread: AnyThreadChannel) {
28 | // Delete all user activities for this thread
29 | await this.container.prisma.userActivity.deleteMany({ where: { channelId: thread.id } });
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/listeners/miscEvents/debug.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { Events, Listener } from '@sapphire/framework';
3 |
4 | @ApplyOptions({
5 | name: 'DebugLogger',
6 | event: Events.Debug,
7 | })
8 | export class DebugListener extends Listener {
9 | public override run(message: string) {
10 | if (/heartbeat/gi.test(message)) {
11 | return;
12 | }
13 |
14 | this.container.logger.debug(`${this.container.colors.cyanBright('discord.js:debug')}: ${message}`);
15 | }
16 | }
17 |
18 | @ApplyOptions({
19 | name: 'CacheSweepLogger',
20 | event: Events.CacheSweep,
21 | })
22 | export class CacheSweepListener extends Listener {
23 | public override run(message: string) {
24 | this.container.logger.debug(`${this.container.colors.cyanBright('discord.js:cache-sweep')}: ${message}`);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/listeners/miscEvents/guildCreate.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { Events, Listener } from '@sapphire/framework';
3 | import { TimestampStyles, time, type Guild } from 'discord.js';
4 | import { useGuildJoinLeaveWebhook } from '#hooks/useGuildJoinLeaveWebhook';
5 | import { createInfoEmbed } from '#utils/embeds';
6 | import { pluralize } from '#utils/misc';
7 | import { getUserTag } from '#utils/userTags';
8 |
9 | @ApplyOptions({
10 | name: 'GuildCreateLogger',
11 | event: Events.GuildCreate,
12 | })
13 | export class GuildCreateListener extends Listener {
14 | public override async run(guild: Guild) {
15 | const { logger, colors, client, prisma } = this.container;
16 |
17 | // Always make sure the guild is in the database
18 | await prisma.$queryRaw`INSERT INTO guilds (guild_id) VALUES (${guild.id}) ON CONFLICT DO NOTHING`;
19 |
20 | const webhook = useGuildJoinLeaveWebhook();
21 |
22 | const memberLabel = pluralize(guild.memberCount, 'member', 'members');
23 |
24 | const owner = await client.users.fetch(guild.ownerId);
25 |
26 | logger.info(
27 | `${colors.magenta('Application added to guild: ')}${colors.cyanBright(guild.name)} (${colors.green(
28 | guild.id,
29 | )}) with ${colors.green(guild.memberCount.toLocaleString())} ${memberLabel}, owned by ${colors.green(
30 | getUserTag(owner),
31 | )}`,
32 | );
33 |
34 | const embed = createInfoEmbed(`Application added to guild: ${guild.name} (${guild.id})`)
35 | .setFields(
36 | {
37 | name: 'Member count',
38 | value: `**${guild.memberCount.toLocaleString()}** ${memberLabel}`,
39 | inline: true,
40 | },
41 | {
42 | name: 'Created at',
43 | value: `${time(
44 | guild.createdTimestamp,
45 | TimestampStyles.ShortDateTime,
46 | )} (${guild.createdAt.toISOString()})`,
47 | inline: true,
48 | },
49 | {
50 | name: 'Owner',
51 | value: getUserTag(owner),
52 | inline: true,
53 | },
54 | )
55 | .setAuthor({
56 | name: client.user!.tag,
57 | iconURL: client.user!.displayAvatarURL(),
58 | });
59 |
60 | if (guild.icon) {
61 | embed.setThumbnail(guild.iconURL());
62 | }
63 |
64 | await webhook.send({ embeds: [embed], avatarURL: client.user!.displayAvatarURL(), username: 'Guild joined' });
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/listeners/miscEvents/guildDelete.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { Events, Listener } from '@sapphire/framework';
3 | import { TimestampStyles, time, type Guild } from 'discord.js';
4 | import { useGuildJoinLeaveWebhook } from '#hooks/useGuildJoinLeaveWebhook';
5 | import { createInfoEmbed } from '#utils/embeds';
6 | import { pluralize } from '#utils/misc';
7 |
8 | @ApplyOptions({
9 | name: 'GuildDeleteLogger',
10 | event: Events.GuildDelete,
11 | })
12 | export class GuildDeleteListener extends Listener {
13 | public override async run(guild: Guild) {
14 | const { logger, colors, client } = this.container;
15 |
16 | const webhook = useGuildJoinLeaveWebhook();
17 |
18 | const memberLabel = pluralize(guild.memberCount, 'member', 'members');
19 |
20 | logger.info(
21 | `${colors.magenta('Application removed to guild: ')}${colors.cyanBright(guild.name)} (${colors.green(
22 | guild.id,
23 | )}) with ${colors.green(guild.memberCount.toLocaleString())} ${memberLabel}`,
24 | );
25 |
26 | const embed = createInfoEmbed(`Application removed from guild: ${guild.name} (${guild.id})`)
27 | .setFields([
28 | {
29 | name: 'Member count',
30 | value: `**${guild.memberCount.toLocaleString()}** ${memberLabel}`,
31 | inline: true,
32 | },
33 | {
34 | name: 'Created at',
35 | value: `${time(
36 | guild.createdTimestamp,
37 | TimestampStyles.ShortDateTime,
38 | )} (${guild.createdAt.toISOString()})`,
39 | inline: true,
40 | },
41 | ])
42 | .setAuthor({
43 | name: client.user!.tag,
44 | iconURL: client.user!.displayAvatarURL(),
45 | });
46 |
47 | if (guild.icon) {
48 | embed.setThumbnail(guild.iconURL()!);
49 | }
50 |
51 | await webhook.send({ embeds: [embed], avatarURL: client.user!.displayAvatarURL(), username: 'Guild left' });
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/listeners/miscEvents/mentionPrefix.ts:
--------------------------------------------------------------------------------
1 | import { ApplyOptions } from '@sapphire/decorators';
2 | import { Events, Listener } from '@sapphire/framework';
3 | import { bold, inlineCode, italic, type Message } from 'discord.js';
4 | import { createInfoEmbed } from '#utils/embeds';
5 |
6 | const phrases = [
7 | "It's dangerous to go alone, let me help you...",
8 | 'You seem lost, adventurer...',
9 | 'Can I give you a hand?',
10 | 'Vladdy (my creator) told me to always help you if you were lost!',
11 | 'Here, it seems that you dropped this ping...',
12 | 'Did you lose your highlighter too? 😔',
13 | 'Hi! Did you want to tell me something? 👂',
14 | '',
15 | ];
16 |
17 | const amnesia = 'Well...this is awkward, I forgot the phrase I was going to say...';
18 |
19 | @ApplyOptions({
20 | name: 'MentionPrefixOnlyListener',
21 | event: Events.MentionPrefixOnly,
22 | })
23 | export class MentionPrefixOnly extends Listener {
24 | public override async run(message: Message) {
25 | const randomEntry = phrases.at(Math.floor(Math.random() * phrases.length)) ?? amnesia;
26 |
27 | await message.reply({
28 | embeds: [
29 | createInfoEmbed(
30 | [
31 | italic(randomEntry),
32 | '',
33 | `If you don't remember what commands I have, run my ${bold(
34 | inlineCode('/help'),
35 | )} slash command!`,
36 | `If that doesn't work, try ${bold(
37 | inlineCode(`@${this.container.client.user!.username} help`),
38 | )}!`,
39 | ].join('\n'),
40 | ),
41 | ],
42 | });
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/listeners/miscEvents/preconditionDeniedHandling.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable sonarjs/no-identical-functions,n/callback-return,promise/prefer-await-to-callbacks */
2 | import { ApplyOptions } from '@sapphire/decorators';
3 | import { Events, Listener } from '@sapphire/framework';
4 | import type {
5 | UserError,
6 | Awaitable,
7 | ChatInputCommandDeniedPayload,
8 | ContextMenuCommandDeniedPayload,
9 | MessageCommandDeniedPayload,
10 | } from '@sapphire/framework';
11 | import type { BaseMessageOptions, InteractionReplyOptions, MessageCreateOptions } from 'discord.js';
12 | import { createErrorEmbed } from '#utils/embeds';
13 |
14 | @ApplyOptions({ name: 'MessageCommandDenied', event: Events.MessageCommandDenied })
15 | export class MessageCommandDenied extends Listener {
16 | public override async run(error: UserError, { message }: MessageCommandDeniedPayload) {
17 | await makeAndSendDeniedEmbed(error, async (options) => message.reply(options));
18 | }
19 | }
20 |
21 | @ApplyOptions({ name: 'ChatInputCommandDenied', event: Events.ChatInputCommandDenied })
22 | export class ChatInputCommandDenied extends Listener {
23 | public override async run(error: UserError, { interaction }: ChatInputCommandDeniedPayload) {
24 | await makeAndSendDeniedEmbed(error, async (options) => {
25 | if (interaction.replied) {
26 | return interaction.followUp({ ...options, ephemeral: true });
27 | } else if (interaction.deferred) {
28 | return interaction.editReply(options as never);
29 | }
30 |
31 | return interaction.reply({ ...options, ephemeral: true });
32 | });
33 | }
34 | }
35 |
36 | @ApplyOptions({ name: 'ContextMenuCommandDenied', event: Events.ContextMenuCommandDenied })
37 | export class ContextMenuCommandDenied extends Listener {
38 | public override async run(error: UserError, { interaction }: ContextMenuCommandDeniedPayload) {
39 | await makeAndSendDeniedEmbed(error, async (options) => {
40 | if (interaction.replied) {
41 | return interaction.followUp({ ...options, ephemeral: true });
42 | } else if (interaction.deferred) {
43 | return interaction.editReply(options as never);
44 | }
45 |
46 | return interaction.reply({ ...options, ephemeral: true });
47 | });
48 | }
49 | }
50 |
51 | async function makeAndSendDeniedEmbed(
52 | error: UserError,
53 | callback: (options: Options) => Awaitable,
54 | ) {
55 | const errorEmbed = createErrorEmbed(`🙈 You cannot run this command! ${error.message}`);
56 |
57 | await callback({ embeds: [errorEmbed] } as never);
58 | }
59 |
--------------------------------------------------------------------------------
/src/listeners/miscEvents/ready.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from '@prisma/client';
2 | import { ApplyOptions } from '@sapphire/decorators';
3 | import { Events, Listener, LogLevel } from '@sapphire/framework';
4 | import { InviteButton, inviteOptions, packageJsonFile, pluralize } from '#utils/misc';
5 |
6 | @ApplyOptions({
7 | name: 'ReadyLogger',
8 | event: Events.ClientReady,
9 | once: true,
10 | })
11 | export class ClientReadyListener extends Listener {
12 | public override async run() {
13 | const { client, colors, logger } = this.container;
14 |
15 | const asciiArt = [
16 | ' _ _ _ _ _ _ _ _ ',
17 | ' | | | (_) | | | (_) | | | | ',
18 | ' | |__| |_ __ _| |__ | |_ __ _| |__ | |_ ',
19 | " | __ | |/ _` | '_ \\| | |/ _` | '_ \\| __|",
20 | ' | | | | | (_| | | | | | | (_| | | | | |_ ',
21 | ' |_| |_|_|\\__, |_| |_|_|_|\\__, |_| |_|\\__|',
22 | ' __/ | __/ | ',
23 | ' |___/ |___/ ',
24 | ].map((item) => colors.yellow(item));
25 |
26 | this.container.clientInvite = client.generateInvite(inviteOptions);
27 |
28 | // Set the invite button to include the generated invite link
29 | InviteButton.setURL(this.container.clientInvite);
30 |
31 | const versionString = `${colors.magenta('Version: ')}${colors.green(
32 | `v${packageJsonFile.version}`,
33 | )} ${colors.magenta('-')} ${colors.blueBright('Sapphire and Application Command Edition')}`;
34 |
35 | const userTagInColor = `${colors.magenta('Logged in as: ')}${colors.cyanBright(
36 | client.user!.tag,
37 | )} (${colors.green(client.user!.id)})`;
38 |
39 | const finalMessageToLog = [
40 | ...asciiArt, //
41 | '',
42 | ` ${versionString}`,
43 | '',
44 | userTagInColor,
45 | `${colors.magenta(' Guild count: ')}${colors.cyanBright(client.guilds.cache.size.toLocaleString())}`,
46 | `${colors.magenta(' Public prefix: ')}${colors.cyanBright('/')}`,
47 | `${colors.magenta(' Developer command prefix: ')}${colors.cyanBright(`@${client.user!.username}`)}`,
48 | `${colors.magenta(' Invite application: ')}${colors.cyanBright(this.container.clientInvite)}`,
49 | ];
50 |
51 | for (const entry of finalMessageToLog) {
52 | logger.info(entry);
53 | }
54 |
55 | if (client.guilds.cache.size && logger.has(LogLevel.Debug)) {
56 | logger.debug('');
57 | logger.debug(`${colors.magenta(' Guilds: ')}`);
58 | for (const guild of client.guilds.cache.values()) {
59 | logger.debug(
60 | ` - ${colors.cyanBright(guild.name)} (${colors.green(guild.id)}) with ${colors.green(
61 | guild.memberCount.toLocaleString(),
62 | )} ${pluralize(guild.memberCount, 'member', 'members')}`,
63 | );
64 | }
65 | }
66 |
67 | await this.ensureAllGuildsAreInDatabase();
68 |
69 | await this.container.highlightManager.updateAllCaches();
70 | }
71 |
72 | private async ensureAllGuildsAreInDatabase() {
73 | const { prisma, client } = this.container;
74 |
75 | // eslint-disable-next-line -- This is validated to work but its SO JANK
76 | await prisma.$executeRaw`INSERT INTO guilds (guild_id) VALUES ${Prisma.join(
77 | [...client.guilds.cache.keys()],
78 | '), (',
79 | '(',
80 | ')',
81 | )} ON CONFLICT DO NOTHING`;
82 | }
83 | }
84 |
85 | declare module '@sapphire/pieces' {
86 | interface Container {
87 | clientInvite: string;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/preconditions/ApplicationOwnerOnly.ts:
--------------------------------------------------------------------------------
1 | import { AllFlowsPrecondition } from '@sapphire/framework';
2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { User } from 'discord.js';
4 |
5 | export class AllowedRole extends AllFlowsPrecondition {
6 | public async chatInputRun(interaction: ChatInputCommandInteraction) {
7 | return this._sharedRun(interaction.user.id);
8 | }
9 |
10 | public async messageRun(message: Message) {
11 | return this._sharedRun(message.author.id);
12 | }
13 |
14 | public async contextMenuRun(interaction: ContextMenuCommandInteraction) {
15 | return this._sharedRun(interaction.user.id);
16 | }
17 |
18 | private async _sharedRun(userId: string) {
19 | const application = await this.container.client.application!.fetch();
20 |
21 | if (application.owner instanceof User) {
22 | return application.owner.id === userId ?
23 | this.ok()
24 | : this.error({ message: `This maze was not meant for you.` });
25 | }
26 |
27 | if (application.owner?.ownerId === userId) return this.ok();
28 | if (application.owner?.members?.has(userId)) return this.ok();
29 |
30 | return this.error({ message: `This maze was not meant for you.` });
31 | }
32 | }
33 |
34 | declare module '@sapphire/framework' {
35 | interface Preconditions {
36 | ApplicationOwnerOnly: never;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/preconditions/GuildStaff.ts:
--------------------------------------------------------------------------------
1 | import { Precondition } from '@sapphire/framework';
2 | import { PermissionFlagsBits, bold, type CommandInteraction, type Message, type PermissionsBitField } from 'discord.js';
3 | import { orList } from '#utils/misc';
4 |
5 | export class GuildStaff extends Precondition {
6 | public override async chatInputRun(interaction: CommandInteraction) {
7 | if (!interaction.inCachedGuild()) {
8 | return this.error({ message: 'You cannot run this command outside of a server.' });
9 | }
10 |
11 | const member = await interaction.guild.members.fetch(interaction.user.id);
12 |
13 | return this._sharedRun(member.permissions);
14 | }
15 |
16 | public override async messageRun(message: Message) {
17 | if (!message.inGuild()) {
18 | return this.error({ message: 'You cannot run this command outside of a server.' });
19 | }
20 |
21 | const member = await message.guild.members.fetch(message.author.id);
22 |
23 | return this._sharedRun(member.permissions);
24 | }
25 |
26 | private async _sharedRun(permissions: PermissionsBitField) {
27 | if (
28 | permissions.any(
29 | [
30 | // Can manage the entire guild
31 | PermissionFlagsBits.ManageGuild,
32 | // Can manage roles
33 | PermissionFlagsBits.ManageRoles,
34 | // Can moderate members
35 | PermissionFlagsBits.ModerateMembers,
36 | ],
37 | true,
38 | )
39 | ) {
40 | return this.ok();
41 | }
42 |
43 | const permissionList = orList.format(
44 | ['Moderate Members', 'Manage Server', 'Manage Roles'].map((item) => bold(item)),
45 | );
46 |
47 | return this.error({
48 | message: `You do not have enough permissions to run this command. Only server members that have one of the following permissions can run this command: ${permissionList}`,
49 | });
50 | }
51 | }
52 |
53 | declare module '@sapphire/framework' {
54 | interface Preconditions {
55 | GuildStaff: never;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/preconditions/UserNotOptedOut.ts:
--------------------------------------------------------------------------------
1 | import { AllFlowsPrecondition } from '@sapphire/framework';
2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { TimestampStyles, time } from 'discord.js';
4 |
5 | export class AllowedRole extends AllFlowsPrecondition {
6 | public async chatInputRun(interaction: ChatInputCommandInteraction) {
7 | return this._sharedRun(interaction.user.id);
8 | }
9 |
10 | public async messageRun(message: Message) {
11 | return this._sharedRun(message.author.id);
12 | }
13 |
14 | public async contextMenuRun(interaction: ContextMenuCommandInteraction) {
15 | return this._sharedRun(interaction.user.id);
16 | }
17 |
18 | private async _sharedRun(userId: string) {
19 | const userSettings = await this.container.prisma.user.findFirst({ where: { id: userId, optedOut: true } });
20 |
21 | // No user settings found or hasn't opted out
22 | if (!userSettings) {
23 | return this.ok();
24 | }
25 |
26 | const formattedTime = time(userSettings.optedOutAt!, TimestampStyles.ShortDateTime);
27 |
28 | return this.error({
29 | message: `\n\n⛔ You opted out from highlight on ${formattedTime}.\nIf you'd like to use my features, you'll need to opt back in again by running **\`/highlight opt-in\`**.`,
30 | });
31 | }
32 | }
33 |
34 | declare module '@sapphire/framework' {
35 | interface Preconditions {
36 | UserNotOptedOut: never;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/tests/__shared__/constants.ts:
--------------------------------------------------------------------------------
1 | export enum GuildIds {
2 | WordGuild = 1,
3 | RegExpGuild,
4 | InvalidRegExpGuild,
5 | NothingGuild,
6 | }
7 |
8 | export const testSubjectUserId = '10';
9 | export const testSubjectTriggerUserId = '20';
10 |
--------------------------------------------------------------------------------
/tests/hooks/withDeprecationWarningForMessageCommands.test.ts:
--------------------------------------------------------------------------------
1 | import { MessageLimits } from '@sapphire/discord-utilities';
2 | import { deepClone } from '@sapphire/utilities';
3 | import {
4 | ActionRowBuilder,
5 | ButtonBuilder,
6 | ButtonStyle,
7 | EmbedBuilder,
8 | OAuth2Routes,
9 | OAuth2Scopes,
10 | PermissionFlagsBits,
11 | PermissionsBitField,
12 | StringSelectMenuBuilder,
13 | bold,
14 | hyperlink,
15 | inlineCode,
16 | } from 'discord.js';
17 | import type { Client, MessageActionRowComponentBuilder } from 'discord.js';
18 | import { createInfoEmbed } from '#utils/embeds';
19 |
20 | vi.mock('@sapphire/framework', async (importActual) => {
21 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports
22 | const actual = await importActual();
23 | return {
24 | ...actual,
25 | container: {
26 | client: {
27 | generateInvite: vi.fn(
28 | (
29 | // eslint-disable-next-line unicorn/no-object-as-default-parameter
30 | options: Parameters[0] = {
31 | scopes: [OAuth2Scopes.Bot, OAuth2Scopes.ApplicationsCommands],
32 | },
33 | ) => {
34 | const query = new URLSearchParams({
35 | client_id: '1',
36 | scope: options.scopes.join(' '),
37 | });
38 |
39 | if (options.permissions) {
40 | const resolved = PermissionsBitField.resolve(options.permissions);
41 | if (resolved) {
42 | query.set('permissions', resolved.toString());
43 | }
44 | }
45 |
46 | if (options.guild) {
47 | query.set('guild_id', options.guild as string);
48 | }
49 |
50 | return `${OAuth2Routes.authorizationURL}?${query.toString()}`;
51 | },
52 | ),
53 | },
54 | },
55 | };
56 | });
57 |
58 | const { container } = await import('@sapphire/framework');
59 | const { withDeprecationWarningForMessageCommands, withDeprecationWarningOnEmbedForMessageCommands } = await import(
60 | '#hooks/withDeprecationWarningForMessageCommands'
61 | );
62 |
63 | const invite = container.client.generateInvite({
64 | scopes: [OAuth2Scopes.Bot, OAuth2Scopes.ApplicationsCommands],
65 | permissions: new PermissionsBitField([
66 | PermissionFlagsBits.ViewChannel,
67 | PermissionFlagsBits.ReadMessageHistory,
68 | PermissionFlagsBits.SendMessages,
69 | PermissionFlagsBits.EmbedLinks,
70 | ]),
71 | guild: '1',
72 | });
73 |
74 | const authButton = new ButtonBuilder()
75 | .setStyle(ButtonStyle.Link)
76 | .setURL(invite)
77 | .setLabel('Re-authorize me with slash commands!')
78 | .setEmoji('🤖');
79 |
80 | const twentyFiveFields = () => Array.from({ length: 25 }, () => ({ name: 'example', value: 'owo' }));
81 |
82 | describe('message command deprecation hooks', () => {
83 | describe('withDeprecationWarningOnEmbedForMessageCommands', () => {
84 | describe('given embed with too many fields, then it should update the description', () => {
85 | test('given no button notice, it should just warn about the migration', () => {
86 | const embed = createInfoEmbed().setFields(twentyFiveFields());
87 | withDeprecationWarningOnEmbedForMessageCommands(embed, 'test');
88 |
89 | expect(embed.data.description).toEqual(
90 | [
91 | `> ${bold('Did you know?')}`,
92 | `> Message based commands are ${bold('deprecated')}, and will be removed in the future.`,
93 | `> You should use the ${bold(inlineCode(`/test`))} slash command instead!`,
94 | ].join('\n'),
95 | );
96 | });
97 |
98 | test('given button notice, it should warn about the migration and link in the event of missing components', () => {
99 | const embed = createInfoEmbed().setFields(twentyFiveFields());
100 | withDeprecationWarningOnEmbedForMessageCommands(embed, 'test', invite);
101 |
102 | expect(embed.data.description).toEqual(
103 | [
104 | `> ${bold('Did you know?')}`,
105 | `> Message based commands are ${bold('deprecated')}, and will be removed in the future.`,
106 | `> You should use the ${bold(inlineCode(`/test`))} slash command instead!`,
107 | `> If you don't see the slash commands popping up when you type ${bold(
108 | inlineCode(`/test`),
109 | )}, click the ${bold('Re-authorize')} button if present (or click ${bold(
110 | hyperlink('here to re-authorize', invite),
111 | )}) and try again!`,
112 | ].join('\n'),
113 | );
114 | });
115 |
116 | test('given embed with existing description, it should append the warning to the description', () => {
117 | const embed = createInfoEmbed('Hey there!').setFields(twentyFiveFields());
118 | withDeprecationWarningOnEmbedForMessageCommands(embed, 'test');
119 |
120 | expect(embed.data.description).toEqual(
121 | [
122 | 'Hey there!',
123 | '',
124 | `> ${bold('Did you know?')}`,
125 | `> Message based commands are ${bold('deprecated')}, and will be removed in the future.`,
126 | `> You should use the ${bold(inlineCode(`/test`))} slash command instead!`,
127 | ].join('\n'),
128 | );
129 | });
130 | });
131 |
132 | describe('given embed with enough space for a field, then it should add a field about the migration', () => {
133 | test('given no button notice, it should just warn about the migration', () => {
134 | const embed = createInfoEmbed();
135 | withDeprecationWarningOnEmbedForMessageCommands(embed, 'test');
136 |
137 | expect(embed.data.fields).toHaveLength(1);
138 | expect(embed.data.fields![0].name).toEqual('Did you know?');
139 | expect(embed.data.fields![0].value).toEqual(
140 | [
141 | `Message based commands are ${bold('deprecated')}, and will be removed in the future.`,
142 | `You should use the ${bold(inlineCode(`/test`))} slash command instead!`,
143 | ].join('\n'),
144 | );
145 | });
146 |
147 | test('given button notice, it should warn about the migration and link in the event of missing components', () => {
148 | const embed = createInfoEmbed();
149 | withDeprecationWarningOnEmbedForMessageCommands(embed, 'test', invite);
150 |
151 | expect(embed.data.fields).toHaveLength(1);
152 | expect(embed.data.fields![0].name).toEqual('Did you know?');
153 | expect(embed.data.fields![0].value).toEqual(
154 | [
155 | `Message based commands are ${bold('deprecated')}, and will be removed in the future.`,
156 | `You should use the ${bold(inlineCode(`/test`))} slash command instead!`,
157 | `If you don't see the slash commands popping up when you type ${bold(
158 | inlineCode(`/test`),
159 | )}, click the ${bold('Re-authorize')} button if present (or click ${bold(
160 | hyperlink('here to re-authorize', invite),
161 | )}) and try again!`,
162 | ].join('\n'),
163 | );
164 | });
165 | });
166 | });
167 |
168 | describe('withDeprecationWarningForMessageCommands', () => {
169 | test('given options received from an interaction, then it should return the exact same data', () => {
170 | const original = { content: 'hi' };
171 |
172 | const result = withDeprecationWarningForMessageCommands({
173 | commandName: 'test',
174 | guildId: '1',
175 | options: deepClone(original),
176 | receivedFromMessage: false,
177 | });
178 |
179 | expect(result).toStrictEqual(original);
180 | });
181 |
182 | describe('given options received from a message, then it should return the data with extra information about the deprecation', () => {
183 | test('given only content, it should add an embed and button', () => {
184 | const original = { content: 'Hi' };
185 |
186 | const result = withDeprecationWarningForMessageCommands({
187 | commandName: 'test',
188 | guildId: '1',
189 | options: deepClone(original),
190 | receivedFromMessage: true,
191 | });
192 |
193 | const expected = {
194 | ...original,
195 | embeds: [withDeprecationWarningOnEmbedForMessageCommands(createInfoEmbed(), 'test', invite)],
196 | components: [new ActionRowBuilder().addComponents(authButton)],
197 | };
198 |
199 | expect(result).toEqual(expected);
200 | });
201 |
202 | test('given an embed, it should enhance it with the deprecation warning', () => {
203 | const original = { embeds: [EmbedBuilder.from(createInfoEmbed('Hey there!'))] };
204 |
205 | const result = withDeprecationWarningForMessageCommands({
206 | commandName: 'test',
207 | guildId: '1',
208 | options: deepClone(original),
209 | receivedFromMessage: true,
210 | });
211 |
212 | const expected = {
213 | embeds: [withDeprecationWarningOnEmbedForMessageCommands(original.embeds[0], 'test', invite)],
214 | components: [new ActionRowBuilder().addComponents(authButton)],
215 | };
216 |
217 | expect(result).toEqual(expected);
218 | });
219 |
220 | test('given content and an action row, it should add another action row', () => {
221 | const original = {
222 | content: 'Hi',
223 | components: [new ActionRowBuilder()],
224 | };
225 |
226 | const result = withDeprecationWarningForMessageCommands({
227 | commandName: 'test',
228 | guildId: '1',
229 | options: deepClone(original),
230 | receivedFromMessage: true,
231 | });
232 |
233 | const expected = {
234 | ...original,
235 | embeds: [withDeprecationWarningOnEmbedForMessageCommands(createInfoEmbed(), 'test', invite)],
236 | components: [...original.components, new ActionRowBuilder().addComponents(authButton)],
237 | };
238 |
239 | expect(result).toEqual(expected);
240 | });
241 |
242 | test('given content and maximum action rows, it should add the auth button on the first available action row', () => {
243 | const original = {
244 | content: 'Hi',
245 | components: Array.from(
246 | { length: MessageLimits.MaximumActionRows },
247 | () => new ActionRowBuilder(),
248 | ),
249 | };
250 |
251 | const result = withDeprecationWarningForMessageCommands({
252 | commandName: 'test',
253 | guildId: '1',
254 | options: deepClone(original),
255 | receivedFromMessage: true,
256 | });
257 |
258 | const expected = {
259 | content: 'Hi',
260 | embeds: [withDeprecationWarningOnEmbedForMessageCommands(createInfoEmbed(), 'test', invite)],
261 | components: [...original.components],
262 | };
263 |
264 | // Add the button to the "first" free row
265 | expected.components[0].addComponents(authButton);
266 |
267 | expect(result).toStrictEqual(expected);
268 | });
269 |
270 | test('given content and maximum action rows, where some have select menus, it should add the auth button on the first available action row', () => {
271 | const original = {
272 | content: 'Hi',
273 | components: Array.from({ length: MessageLimits.MaximumActionRows }, (_, index) => {
274 | const row = new ActionRowBuilder();
275 |
276 | // Even rows get a select menu
277 | if (index % 2 === 0) {
278 | row.setComponents(new StringSelectMenuBuilder());
279 | }
280 |
281 | return row;
282 | }),
283 | };
284 |
285 | const result = withDeprecationWarningForMessageCommands({
286 | commandName: 'test',
287 | guildId: '1',
288 | options: deepClone(original),
289 | receivedFromMessage: true,
290 | });
291 |
292 | const expected = {
293 | content: 'Hi',
294 | embeds: [withDeprecationWarningOnEmbedForMessageCommands(createInfoEmbed(), 'test', invite)],
295 | components: [...original.components],
296 | };
297 |
298 | // Add the button to the "first" free row
299 | expected.components[1].addComponents(authButton);
300 |
301 | expect(result).toStrictEqual(expected);
302 | });
303 | });
304 | });
305 | });
306 |
--------------------------------------------------------------------------------
/tests/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": [".", "../"],
4 | "compilerOptions": {
5 | "rootDir": "../",
6 | "paths": {
7 | "#internals/*": ["../src/lib/internals/*.ts"],
8 | "#hooks/*": ["../src/lib/utils/hooks/*.ts"],
9 | "#setup": ["../src/lib/setup.ts"],
10 | "#customIds/*": ["../src/lib/utils/customIds/*.ts"],
11 | "#structures/*": ["../src/lib/structures/*.ts"],
12 | "#types/*": ["../src/lib/types/*.ts"],
13 | "#utils/*": ["../src/lib/utils/*.ts"],
14 | "#workers/*": ["../src/lib/workers/*.ts"],
15 | "#test/*": ["./__shared__/*.ts"]
16 | },
17 | "types": ["vitest/globals"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/tests/utils/customIds.test.ts:
--------------------------------------------------------------------------------
1 | import { none, some } from '@sapphire/framework';
2 | import { createCustomIdFactory } from '#utils/customIds';
3 |
4 | describe('custom id helper with custom encoder/decoder', () => {
5 | const test = createCustomIdFactory({
6 | prefix: 'server_ignore_list_clear:',
7 | decoder(id) {
8 | const split = id.split(':');
9 |
10 | if (split.length !== 2) {
11 | return none;
12 | }
13 |
14 | const [action, userId] = split;
15 |
16 | return some({ action, userId });
17 | },
18 | encoder(prefix, data) {
19 | return some(`${prefix}${data.action}:${data.userId}`);
20 | },
21 | });
22 |
23 | it('should encode and decode data', () => {
24 | const encoded = test.encodeId({ action: 'clear', userId: '1234' });
25 | expect(encoded.isNone()).toBe(false);
26 | expect(encoded.unwrap()).toBe('server_ignore_list_clear:clear:1234');
27 |
28 | const decoded = test.decodeId('server_ignore_list_clear:clear:1234');
29 | expect(decoded.isNone()).toBe(false);
30 | expect(decoded.unwrap()).toEqual({ action: 'clear', userId: '1234' });
31 | });
32 |
33 | it('should not decode invalid id', () => {
34 | const decoded = test.decodeId('server_ignore_list_clear:clear');
35 |
36 | expect(decoded.isNone()).toBe(true);
37 | });
38 | });
39 |
40 | describe('custom id helper with default encoder/decoder', () => {
41 | const test = createCustomIdFactory({
42 | prefix: 'server_ignore_list_clear:',
43 | });
44 |
45 | it('should encode and decode data', () => {
46 | const encoded = test.encodeId('1234');
47 | expect(encoded.isNone()).toBe(false);
48 | expect(encoded.unwrap()).toBe('server_ignore_list_clear:1234');
49 |
50 | const decoded = test.decodeId('server_ignore_list_clear:1234');
51 | expect(decoded.isNone()).toBe(false);
52 | expect(decoded.unwrap()).toBe('1234');
53 | });
54 |
55 | it('should not decode invalid id', () => {
56 | const decoded = test.decodeId('server_ignore_list_clearnt:clear');
57 |
58 | expect(decoded.isNone()).toBe(true);
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/tests/workers/WorkerCache.test.ts:
--------------------------------------------------------------------------------
1 | import type { MockInstance } from 'vitest';
2 | import { GuildIds, testSubjectTriggerUserId, testSubjectUserId } from '#test/constants';
3 | import { WorkerResponseTypes, WorkerType } from '#types/WorkerTypes';
4 | import { RegularExpressionCaseSensitiveMatch, RegularExpressionWordMarker } from '#utils/misc';
5 |
6 | vi.mock('#workers/common', () => {
7 | return { checkParentPort: vi.fn(() => true), sendToMainProcess: vi.fn(() => void 0) };
8 | });
9 |
10 | const { WorkerCache } = await import('#workers/WorkerCache');
11 | const { sendToMainProcess } = await import('#workers/common');
12 |
13 | const sendToMainProcessSpy = vitest.mocked(sendToMainProcess);
14 |
15 | describe('WorkerCache', () => {
16 | const cache = new WorkerCache();
17 | const removeTriggerForUserSpy = vi.spyOn(cache, 'removeTriggerForUser');
18 |
19 | afterEach(() => {
20 | cache['guildMap'].clear();
21 | });
22 |
23 | describe('removeTriggerForUser', () => {
24 | test("given invalid guild it it shouldn't remove anything", () => {
25 | cache.removeTriggerForUser({
26 | guildId: String(GuildIds.NothingGuild),
27 | memberId: testSubjectUserId,
28 | trigger: 'test',
29 | });
30 |
31 | expect(removeTriggerForUserSpy).toReturn();
32 | });
33 |
34 | test("given invalid trigger for guild it shouldn't remove anything", () => {
35 | cache.updateGuild(String(GuildIds.WordGuild), new Map([['test', new Set([testSubjectUserId])]]));
36 |
37 | cache.removeTriggerForUser({
38 | guildId: String(GuildIds.WordGuild),
39 | memberId: testSubjectUserId,
40 | trigger: 'test2',
41 | });
42 |
43 | expect(removeTriggerForUserSpy).toReturn();
44 | });
45 | });
46 |
47 | describe('Regular expression tests', () => {
48 | test('should return false if the regular expression is invalid', () => {
49 | expect(cache.isRegularExpressionValid('[')).toBe(false);
50 | });
51 |
52 | test('should return true if the regular expression is valid', () => {
53 | expect(cache.isRegularExpressionValid('[a-z]')).toBe(true);
54 | });
55 |
56 | test('should have cached the provided regular expressions and subsequent calls should return cached value', () => {
57 | const cachedExpressions = cache['validRegularExpressions'];
58 | const setValidRegexSpy = vi.spyOn(cache, 'setValidRegex' as never) as MockInstance<
59 | (regex: string, valid: boolean) => void
60 | >;
61 |
62 | expect(cachedExpressions.size).toBe(2);
63 | expect(cachedExpressions.get('[a-z]')).toBe(true);
64 | expect(cachedExpressions.get('[')).toBe(false);
65 |
66 | // Check caching
67 | expect(cache.isRegularExpressionValid('[a-z]')).toBe(true);
68 | expect(setValidRegexSpy).not.toHaveBeenCalled();
69 |
70 | setValidRegexSpy.mockRestore();
71 | });
72 | });
73 |
74 | describe('highlight parsing', () => {
75 | beforeEach(() => {
76 | cache.updateGuild(String(GuildIds.WordGuild), new Map([['word', new Set([testSubjectUserId])]]));
77 | cache.updateGuild(String(GuildIds.RegExpGuild), new Map([['\\bword\\b', new Set([testSubjectUserId])]]));
78 | cache.updateGuild(String(GuildIds.InvalidRegExpGuild), new Map([['[a-z', new Set([testSubjectUserId])]]));
79 | cache.updateGuild(String(GuildIds.NothingGuild), new Map());
80 | });
81 |
82 | test.each([
83 | [WorkerType[WorkerType.Word], WorkerType.Word],
84 | [WorkerType[WorkerType.RegularExpression], WorkerType.RegularExpression],
85 | ])('invalid guild id provided for %s (%p) returns no results', (_, workerType) => {
86 | const result = cache.parse(workerType, '999', testSubjectTriggerUserId, 'word');
87 |
88 | expect(result.results).toHaveLength(0);
89 | });
90 |
91 | test.each([
92 | [WorkerType[WorkerType.Word], WorkerType.Word, GuildIds[GuildIds.WordGuild], GuildIds.WordGuild],
93 | [
94 | WorkerType[WorkerType.RegularExpression],
95 | WorkerType.RegularExpression,
96 | GuildIds[GuildIds.RegExpGuild],
97 | GuildIds.RegExpGuild,
98 | ],
99 | ])(
100 | 'parsing a %s (%p) sent by the author that expects highlights returns no results',
101 | (_, workerType, __, guildId) => {
102 | const result = cache.parse(workerType, String(guildId), testSubjectUserId, 'word');
103 |
104 | expect(result.results).toHaveLength(0);
105 | },
106 | );
107 |
108 | test.each([
109 | [WorkerType[WorkerType.Word], WorkerType.Word, GuildIds[GuildIds.WordGuild], GuildIds.WordGuild],
110 | [
111 | WorkerType[WorkerType.RegularExpression],
112 | WorkerType.RegularExpression,
113 | GuildIds[GuildIds.RegExpGuild],
114 | GuildIds.RegExpGuild,
115 | ],
116 | ])(
117 | "parsing a %s (%p) sent that doesn't match any registered data returns no results",
118 | (_, workerType, __, guildId) => {
119 | const result = cache.parse(workerType, String(guildId), testSubjectUserId, 'owo');
120 |
121 | expect(result.results).toHaveLength(0);
122 | },
123 | );
124 |
125 | test.each([
126 | [WorkerType[WorkerType.Word], WorkerType.Word, GuildIds[GuildIds.WordGuild], GuildIds.WordGuild],
127 | [
128 | WorkerType[WorkerType.RegularExpression],
129 | WorkerType.RegularExpression,
130 | GuildIds[GuildIds.RegExpGuild],
131 | GuildIds.RegExpGuild,
132 | ],
133 | ])(
134 | 'parsing a valid %s (%p) for pre-registered guild %s (%p) should return valid data',
135 | (_, workerType, __, guildId) => {
136 | const { results, memberIds } = cache.parse(
137 | workerType,
138 | String(guildId),
139 | testSubjectTriggerUserId,
140 | 'word',
141 | );
142 |
143 | expect(results).toHaveLength(1);
144 | expect(memberIds).toHaveLength(1);
145 |
146 | const [result] = results;
147 |
148 | expect(result.memberId).toBe(String(testSubjectUserId));
149 | expect(result.trigger).toBe(workerType === WorkerType.RegularExpression ? '\\bword\\b' : 'word');
150 | expect(result.parsedContent).toBe('**word**');
151 | },
152 | );
153 |
154 | test.each([
155 | [WorkerType[WorkerType.Word], WorkerType.Word, GuildIds[GuildIds.NothingGuild], GuildIds.NothingGuild],
156 | [
157 | WorkerType[WorkerType.RegularExpression],
158 | WorkerType.RegularExpression,
159 | GuildIds[GuildIds.NothingGuild],
160 | GuildIds.NothingGuild,
161 | ],
162 | ])(
163 | 'parsing a valid %s (%p) for pre-registered guild %s (%p) should return no results',
164 | (_, workerType, __, guildId) => {
165 | const { results } = cache.parse(workerType, String(guildId), testSubjectTriggerUserId, 'word');
166 |
167 | expect(results).toHaveLength(0);
168 | },
169 | );
170 |
171 | test('parsing an invalid regular expression (1) for pre-registered guild InvalidRegExpGuild (3) should return no results and remove the trigger from the user', () => {
172 | const { results } = cache.parse(
173 | WorkerType.RegularExpression,
174 | String(GuildIds.InvalidRegExpGuild),
175 | testSubjectTriggerUserId,
176 | 'word',
177 | );
178 |
179 | expect(results).toStrictEqual([]);
180 | expect(sendToMainProcessSpy).toHaveBeenCalledWith>({
181 | command: WorkerResponseTypes.DeleteInvalidRegularExpression,
182 | data: { guildId: String(GuildIds.InvalidRegExpGuild), memberId: testSubjectUserId, value: '[a-z' },
183 | });
184 | expect(removeTriggerForUserSpy).toHaveBeenCalledWith>({
185 | guildId: String(GuildIds.InvalidRegExpGuild),
186 | memberId: testSubjectUserId,
187 | trigger: '[a-z',
188 | });
189 | expect(cache['guildMap'].get(String(GuildIds.InvalidRegExpGuild))).toBeUndefined();
190 | });
191 | });
192 |
193 | describe('parsed content for word triggers', () => {
194 | beforeEach(() => {
195 | cache.updateGuild(
196 | String(GuildIds.WordGuild),
197 | new Map([
198 | [
199 | `\\bword\\b${RegularExpressionWordMarker}${RegularExpressionCaseSensitiveMatch}`,
200 | new Set([testSubjectUserId]),
201 | ],
202 | [
203 | `\\bo\\b${RegularExpressionWordMarker}${RegularExpressionCaseSensitiveMatch}`,
204 | new Set([testSubjectUserId]),
205 | ],
206 | [
207 | `\\bhelp\\b${RegularExpressionWordMarker}${RegularExpressionCaseSensitiveMatch}`,
208 | new Set([testSubjectUserId]),
209 | ],
210 | ]),
211 | );
212 | });
213 |
214 | test.each(['word', 'o', 'help'])('given "%s" then it should return bolded content', (trigger) => {
215 | const result = cache.parse(
216 | WorkerType.Word,
217 | String(GuildIds.WordGuild),
218 | testSubjectTriggerUserId,
219 | `hello ${trigger}~`,
220 | );
221 |
222 | expect(result.results).toHaveLength(1);
223 |
224 | const [resultItem] = result.results;
225 |
226 | expect(resultItem.memberId).toBe(String(testSubjectUserId));
227 | expect(resultItem.trigger).toBe(trigger);
228 | expect(resultItem.parsedContent).toBe(`hello **${trigger}**~`);
229 | });
230 |
231 | test.each(['word', 'o', 'help'])(
232 | 'given multiple mentions of "%s" then it should return bolded content',
233 | (trigger) => {
234 | const result = cache.parse(
235 | WorkerType.Word,
236 | String(GuildIds.WordGuild),
237 | testSubjectTriggerUserId,
238 | `hello ${trigger} ${trigger} ${trigger}`,
239 | );
240 |
241 | expect(result.results).toHaveLength(1);
242 |
243 | const [resultItem] = result.results;
244 |
245 | expect(resultItem.memberId).toBe(String(testSubjectUserId));
246 | expect(resultItem.trigger).toBe(trigger);
247 | expect(resultItem.parsedContent).toBe(`hello **${trigger}** **${trigger}** **${trigger}**`);
248 | },
249 | );
250 | });
251 |
252 | describe('parsed content for regular expression triggers', () => {
253 | beforeEach(() => {
254 | cache.updateGuild(
255 | String(GuildIds.RegExpGuild),
256 | new Map([
257 | ['word', new Set([testSubjectUserId])],
258 | ['o', new Set([testSubjectUserId])],
259 | ['help?', new Set([testSubjectUserId])],
260 | ['\\s+', new Set([testSubjectUserId])],
261 | ]),
262 | );
263 | });
264 |
265 | test.each([
266 | ['word', 'word'],
267 | ['o', 'o'],
268 | ['help', 'help?'],
269 | ['hel', 'help?'],
270 | ])('given "%s" then it should return bolded content', (testCase, trigger) => {
271 | const result = cache.parse(
272 | WorkerType.RegularExpression,
273 | String(GuildIds.RegExpGuild),
274 | testSubjectTriggerUserId,
275 | `unrelated ${testCase}`,
276 | );
277 |
278 | expect(result.results).toHaveLength(1);
279 |
280 | const [resultItem] = result.results;
281 |
282 | expect(resultItem.memberId).toBe(String(testSubjectUserId));
283 | expect(resultItem.trigger).toBe(trigger);
284 | expect(resultItem.parsedContent).toBe(`unrelated **${testCase}**`);
285 | });
286 |
287 | test('given a pattern that matches a whitespace, it should replace all whitespaces with underlined whitespaces', () => {
288 | const result = cache.parse(
289 | WorkerType.RegularExpression,
290 | String(GuildIds.RegExpGuild),
291 | testSubjectTriggerUserId,
292 | `unrelated test`,
293 | );
294 |
295 | expect(result.results).toHaveLength(1);
296 |
297 | const [resultItem] = result.results;
298 |
299 | expect(resultItem.memberId).toBe(String(testSubjectUserId));
300 | expect(resultItem.trigger).toBe('\\s+');
301 | expect(resultItem.parsedContent).toBe(`unrelated__ __test`);
302 |
303 | const result2 = cache.parse(
304 | WorkerType.RegularExpression,
305 | String(GuildIds.RegExpGuild),
306 | testSubjectTriggerUserId,
307 | `unrelated test test test`,
308 | );
309 |
310 | expect(result2.results).toHaveLength(1);
311 |
312 | const [resultItem2] = result2.results;
313 |
314 | expect(resultItem2.memberId).toBe(String(testSubjectUserId));
315 | expect(resultItem2.trigger).toBe('\\s+');
316 | expect(resultItem2.parsedContent).toBe(`unrelated__ __test__ __test__ __test`);
317 | });
318 | });
319 | });
320 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true
6 | },
7 | "include": ["src", "scripts", "tests", "vitest.config.ts", "eslint.config.js"]
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "@sapphire/ts-config",
4 | "@sapphire/ts-config/extra-strict",
5 | "@sapphire/ts-config/decorators",
6 | "@sapphire/ts-config/verbatim"
7 | ],
8 | "compilerOptions": {
9 | "target": "ES2022",
10 | "module": "NodeNext",
11 | "moduleResolution": "NodeNext",
12 | "removeComments": true,
13 | "declaration": false,
14 | "declarationMap": false,
15 | "rootDir": "./src",
16 | "outDir": "./dist",
17 | "tsBuildInfoFile": "./dist/.tsbuildinfo",
18 | "paths": {
19 | "#internals/*": ["./src/lib/internals/*.ts"],
20 | "#hooks/*": ["./src/lib/utils/hooks/*.ts"],
21 | "#setup": ["./src/lib/setup.ts"],
22 | "#customIds/*": ["./src/lib/structures/customIds/*.ts"],
23 | "#structures/*": ["./src/lib/structures/*.ts"],
24 | "#types/*": ["./src/lib/types/*.ts"],
25 | "#utils/*": ["./src/lib/utils/*.ts"],
26 | "#workers/*": ["./src/lib/workers/*.ts"]
27 | },
28 | // TODO: remove once 5.5.2 stabilizes or something
29 | "skipLibCheck": true
30 | },
31 | "include": ["src"]
32 | }
33 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'node:path';
2 | import { defineConfig } from 'vitest/config';
3 |
4 | export default defineConfig({
5 | resolve: {
6 | alias: [
7 | { find: '#internals', replacement: resolve('src/lib/internals') },
8 | { find: '#hooks', replacement: resolve('src/lib/utils/hooks') },
9 | { find: '#setup', replacement: resolve('src/lib/utils/setup.ts') },
10 | { find: '#types', replacement: resolve('src/lib/types') },
11 | { find: '#utils', replacement: resolve('src/lib/utils') },
12 | { find: '#workers', replacement: resolve('src/lib/workers') },
13 | { find: '#test', replacement: resolve('tests/__shared__') },
14 | ],
15 | },
16 | test: {
17 | globals: true,
18 | coverage: {
19 | provider: 'v8',
20 | include: ['src/lib/**/*'],
21 | },
22 | },
23 | esbuild: {
24 | target: 'es2022',
25 | },
26 | });
27 |
--------------------------------------------------------------------------------