From e94f494870d29726ca819269ecb2a3bcfe0a9bb1 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 30 Dec 2023 15:12:10 +0100 Subject: [PATCH 001/130] First attempt at object oriented bot --- .gitignore | 129 + jest.config.ts | 14 + jsconfig.json | 7 + package-lock.json | 4267 ++++++++++++++++++++++++++ package.json | 31 + src/Bot.ts | 106 + src/baseCommand/BaseCommand.ts | 76 + src/baseCommand/index.ts | 5 + src/baseEvent/BaseEvent.ts | 29 + src/baseEvent/index.ts | 5 + src/commands/Help.ts | 17 + src/commands/Ping.ts | 26 + src/commands/index.ts | 7 + src/events/InteractionCreateEvent.ts | 25 + src/events/ReadyEvent.ts | 63 + src/events/index.ts | 7 + src/index.ts | 3 + src/managers/ConfigManager.ts | 39 + src/utils/handleError.ts | 19 + tests/mockDiscord.ts | 94 + tests/ping.test.ts | 71 + tests/testutils.ts | 0 tsconfig.json | 22 + 23 files changed, 5062 insertions(+) create mode 100644 .gitignore create mode 100644 jest.config.ts create mode 100644 jsconfig.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/Bot.ts create mode 100644 src/baseCommand/BaseCommand.ts create mode 100644 src/baseCommand/index.ts create mode 100644 src/baseEvent/BaseEvent.ts create mode 100644 src/baseEvent/index.ts create mode 100644 src/commands/Help.ts create mode 100644 src/commands/Ping.ts create mode 100644 src/commands/index.ts create mode 100644 src/events/InteractionCreateEvent.ts create mode 100644 src/events/ReadyEvent.ts create mode 100644 src/events/index.ts create mode 100644 src/index.ts create mode 100644 src/managers/ConfigManager.ts create mode 100644 src/utils/handleError.ts create mode 100644 tests/mockDiscord.ts create mode 100644 tests/ping.test.ts create mode 100644 tests/testutils.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f71f43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Project Specific Files +config.json + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +.VSCodeCounter + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +report*.csv +result.json +result.json.bak + +# macOS +.DS_Store \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..17b8583 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,14 @@ +import type { Config } from 'jest' +import { pathsToModuleNameMapper } from 'ts-jest/' +import { compilerOptions } from './tsconfig.json' + +const config: Config = { + verbose: true, + coverageDirectory: './coverage/', + collectCoverage: false, + preset: 'ts-jest', + testEnvironment: 'node', + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/src/' }) +} + +export default config \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..878b851 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,7 @@ +{ + "typeAcquisition": { + "include": [ + "jest" + ] + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2ce424d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4267 @@ +{ + "name": "rubot2_new", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rubot2_new", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@shoginn/discordjs-mock": "^1.0.2", + "@types/node": "^20.10.5", + "consola": "^3.2.3", + "cron": "^3.1.6", + "discord.js": "^14.14.1", + "dotenv": "^16.3.1", + "reflect-metadata": "^0.2.1", + "tsyringe": "^4.8.0", + "typescript": "^5.3.3" + }, + "devDependencies": { + "@types/jest": "^29.5.11", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz", + "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.6", + "@babel/parser": "^7.23.6", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.6", + "@babel/types": "^7.23.6", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.6.tgz", + "integrity": "sha512-wCfsbN4nBidDRhpDhvcKlzHWCTlgJYUUdSJfzXb2NuBssDSIjc3xcb+znA7l+zYsFljAcGM0aFkN40cR3lXiGA==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.6", + "@babel/types": "^7.23.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", + "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.23.3.tgz", + "integrity": "sha512-9EiNjVJOMwCO+43TqoTrgQ8jMwcAd0sWyXi9RPfIsLTj4R2MADDDQXELhffaUx/uJv2AYcxBgPwH6j4TIA4ytQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz", + "integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.6", + "@babel/types": "^7.23.6", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", + "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.7.0.tgz", + "integrity": "sha512-GDtbKMkg433cOZur8Dv6c25EHxduNIBsxeHrsRoIM8+AwmEZ8r0tEpckx/sHwTLwQPOF3e2JWloZh9ofCaMfAw==", + "dependencies": { + "@discordjs/formatters": "^0.3.3", + "@discordjs/util": "^1.0.2", + "@sapphire/shapeshift": "^3.9.3", + "discord-api-types": "0.37.61", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.3.3.tgz", + "integrity": "sha512-wTcI1Q5cps1eSGhl6+6AzzZkBBlVrBdc9IUhJbijRgVjCNIIIZPgqnUj3ntFODsHrdbGU8BEG9XmDQmgEEYn3w==", + "dependencies": { + "discord-api-types": "0.37.61" + }, + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.2.0.tgz", + "integrity": "sha512-nXm9wT8oqrYFRMEqTXQx9DUTeEtXUDMmnUKIhZn6O2EeDY9VCdwj23XCPq7fkqMPKdF7ldAfeVKyxxFdbZl59A==", + "dependencies": { + "@discordjs/collection": "^2.0.0", + "@discordjs/util": "^1.0.2", + "@sapphire/async-queue": "^1.5.0", + "@sapphire/snowflake": "^3.5.1", + "@vladfrangu/async_event_emitter": "^2.2.2", + "discord-api-types": "0.37.61", + "magic-bytes.js": "^1.5.0", + "tslib": "^2.6.2", + "undici": "5.27.2" + }, + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.0.0.tgz", + "integrity": "sha512-YTWIXLrf5FsrLMycpMM9Q6vnZoR/lN2AWX23/Cuo8uOOtS8eHB2dyQaaGnaF8aZPYnttf2bkLMcXn/j6JUOi3w==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@discordjs/util": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.0.2.tgz", + "integrity": "sha512-IRNbimrmfb75GMNEjyznqM1tkI7HrZOf14njX7tCAAUetyZM1Pr8hX/EK2lxBCOgWDRmigbp24fD1hdMfQK5lw==", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.0.2.tgz", + "integrity": "sha512-+XI82Rm2hKnFwAySXEep4A7Kfoowt6weO6381jgW+wVdTpMS/56qCvoXyFRY0slcv7c/U8My2PwIB2/wEaAh7Q==", + "dependencies": { + "@discordjs/collection": "^2.0.0", + "@discordjs/rest": "^2.1.0", + "@discordjs/util": "^1.0.2", + "@sapphire/async-queue": "^1.5.0", + "@types/ws": "^8.5.9", + "@vladfrangu/async_event_emitter": "^2.2.2", + "discord-api-types": "0.37.61", + "tslib": "^2.6.2", + "ws": "^8.14.2" + }, + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.0.0.tgz", + "integrity": "sha512-YTWIXLrf5FsrLMycpMM9Q6vnZoR/lN2AWX23/Cuo8uOOtS8eHB2dyQaaGnaF8aZPYnttf2bkLMcXn/j6JUOi3w==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", + "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.1.tgz", + "integrity": "sha512-1RdpsmDQR/aWfp8oJzPtn4dNQrbpqSL5PIA0uAB/XwerPXUf994Ug1au1e7uGcD7ei8/F63UDjr5GWps1g/HxQ==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.5.tgz", + "integrity": "sha512-AGdHe+51gF7D3W8hBfuSFLBocURDCXVQczScTHXDS3RpNjNgrktIx/amlz5y8nHhm8SAdFt/X8EF8ZSfjJ0tnA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz", + "integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@shoginn/discordjs-mock": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@shoginn/discordjs-mock/-/discordjs-mock-1.0.2.tgz", + "integrity": "sha512-Kahe20PLCVd4FeyosdNLxNPk2qNX8Fj3NPICfNZKMfjb9REWP/iF67H11+mXh/rMsXU89r0kU/JqLQe+Z/Cf9w==", + "dependencies": { + "@sapphire/snowflake": "^3.5.2", + "discord.js": "^14.14.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "discord.js": "^14.13.0" + } + }, + "node_modules/@shoginn/discordjs-mock/node_modules/@sapphire/snowflake": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.2.tgz", + "integrity": "sha512-FTm9RdyELF21PQN5dS/HLRs90XqWclHa+p0gkonc+BA2X2QKfFySHSjUbO65rmArd/ghR9Ahj2fMfedTZEqzOw==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.4", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.4.tgz", + "integrity": "sha512-mSM/iKUk5fDDrEV/e83qY+Cr3I1+Q3qqTuEn++HAWYjEa1+NxZr6CNrcJGf2ZTnq4HoFGC3zaTPZTobCzCFukA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.11", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz", + "integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/luxon": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.7.tgz", + "integrity": "sha512-gKc9P2d4g5uYwmy4s/MO/yOVPmvHyvzka1YH6i5dM03UrFofHSmgc0D0ymbDRStFWHusk6cwwF6nhLm/ckBbbQ==" + }, + "node_modules/@types/node": { + "version": "20.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", + "integrity": "sha512-nNPsNE65wjMxEKI93yOP+NPGGBJz/PoN3kZsVLee0XMiJolxSekEVD8wRwBUBqkwc7UWop0edW50yrCQW4CyRw==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/ws": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.9.tgz", + "integrity": "sha512-jbdrY0a8lxfdTp/+r7Z4CkycbOFN8WX+IOchLJr3juT/xzbJ8URyTVSJ/hvNdadTgM1mnedb47n+Y31GsFnQlg==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.2.4.tgz", + "integrity": "sha512-ButUPz9E9cXMLgvAW8aLAKKJJsPu1dY1/l/E8xzLFuysowXygs6GBcyunK9rnGC4zTsnIc2mQo71rGw9U+Ykug==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz", + "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001572", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001572.tgz", + "integrity": "sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/consola": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", + "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/cron": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.6.tgz", + "integrity": "sha512-cvFiQCeVzsA+QPM6fhjBtlKGij7tLLISnTSvFxVdnFGLdz+ZdXN37kNe0i2gefmdD17XuZA6n2uPVwzl4FxW/w==", + "dependencies": { + "@types/luxon": "~3.3.0", + "luxon": "~3.4.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "dev": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.37.61", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.61.tgz", + "integrity": "sha512-o/dXNFfhBpYHpQFdT6FWzeO7pKc838QeeZ9d91CfVAtpr5XLK4B/zYxQbYgPdoMiTDvJfzcsLW5naXgmHGDNXw==" + }, + "node_modules/discord.js": { + "version": "14.14.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.14.1.tgz", + "integrity": "sha512-/hUVzkIerxKHyRKopJy5xejp4MYKDPTszAnpYxzVVv4qJYf+Tkt+jnT2N29PIPschicaEEpXwF2ARrTYHYwQ5w==", + "dependencies": { + "@discordjs/builders": "^1.7.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.3.3", + "@discordjs/rest": "^2.1.0", + "@discordjs/util": "^1.0.2", + "@discordjs/ws": "^1.0.2", + "@sapphire/snowflake": "3.5.1", + "@types/ws": "8.5.9", + "discord-api-types": "0.37.61", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "tslib": "2.6.2", + "undici": "5.27.2", + "ws": "8.14.2" + }, + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.616", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz", + "integrity": "sha512-1n7zWYh8eS0L9Uy+GskE0lkBUNK83cXTVJI0pU3mGprFsbfSdAc15VTFbo+A+Bq4pwstmL30AVcEU3Fo463lNg==", + "dev": true + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", + "integrity": "sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-snapshot/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.7.0.tgz", + "integrity": "sha512-YzVU2+/hrjwx8xcgAw+ffNq3jkactpj+f1iSL4LonrFKhvnwDzHSqtFdk/MMRP53y9ScouJ7cKEnqYsJwsHoYA==" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-dir/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pure-rand": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", + "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/reflect-metadata": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.1.tgz", + "integrity": "sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-jest": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", + "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", + "dev": true, + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "^7.5.3", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/ts-mixer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz", + "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/tsyringe": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", + "integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "5.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.27.2.tgz", + "integrity": "sha512-iS857PdOEy/y3wlM3yRp+6SNQQ6xU0mmZcwRSriqk+et/cwWAtwmIGf6WkoDN2EK/AMdCO/dfXzIwi+rFMrjjQ==", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/v8-to-istanbul": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", + "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3a3fa4b --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "rubot2_new", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "ts-node src/index.ts", + "build": "tsc", + "test": "jest --detectOpenHandles" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@shoginn/discordjs-mock": "^1.0.2", + "@types/node": "^20.10.5", + "consola": "^3.2.3", + "cron": "^3.1.6", + "discord.js": "^14.14.1", + "dotenv": "^16.3.1", + "reflect-metadata": "^0.2.1", + "tsyringe": "^4.8.0", + "typescript": "^5.3.3" + }, + "devDependencies": { + "@types/jest": "^29.5.11", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2" + } +} diff --git a/src/Bot.ts b/src/Bot.ts new file mode 100644 index 0000000..968becb --- /dev/null +++ b/src/Bot.ts @@ -0,0 +1,106 @@ +import { Client, Partials, ClientOptions } from 'discord.js' +import { CronJob } from 'cron' +import { ConsolaInstance, createConsola } from 'consola' +import commands from './commands' +import events from './events' +import 'dotenv/config' + +/** + * The main `Bot` class. + */ +export class Bot extends Client { + /** + * The logger used by the bot. + */ + public logger: ConsolaInstance + // TODO + // private configManager: ConfigManager + + + /** + * Timestamp of bot initialization. + * + * Used to calculate bot startup time. + */ + readonly initTimestamp = Date.now() + /** + * The bot commands. + */ + public commands = commands + + /** + * Initializes the bot. + * @param client The Discord client. + * @param token The bot token. + */ + constructor(options: ClientOptions, token: string) { + super(options); + this.token = token + this.logger = createConsola() + } + + /** + * Logs the bot in and starts listening to events. + * Thereby also registers the bot commands. + */ + public listen(): void { + this.logger.info('Listening to events') + this.registerEvents() + this.login(this.token!) + } + + public startQueueGuardJob(): void { + this.queueGuardJob().start() + } + + private queueGuardJob(): CronJob { + return new CronJob("*/30 * * * * *", () => { + // TODO + this.logger.log('Queue Guard Job') + }) + } + + /** + * Registers the bot events. + */ + private registerEvents() { + this.logger.info('Registering events') + for (const event of events) { + const concreteEvent = new event(this) + this.on(event.name, concreteEvent.execute.bind(concreteEvent)) + this.logger.info(`Registered event ${event.name}`) + } + } +} + +export default function initiateBot() { + const clientOptions: ClientOptions = { + intents: [ + "DirectMessages", + "DirectMessageReactions", + "DirectMessageTyping", + "Guilds", + "GuildBans", + "GuildEmojisAndStickers", + "GuildIntegrations", + "GuildInvites", + "GuildMembers", + "GuildMessages", + "GuildMessageReactions", + "GuildMessageTyping", + // "GuildPresences", + "GuildVoiceStates", + ], + partials: [Partials.Channel] + } + // const configManager = ConfigManager.getInstance() + const token = process.env.TOKEN + + if (!token) { + throw new Error('Token not found') + } + + const bot = new Bot(clientOptions, token) + bot.listen() + bot.startQueueGuardJob() +} \ No newline at end of file diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts new file mode 100644 index 0000000..5f2e754 --- /dev/null +++ b/src/baseCommand/BaseCommand.ts @@ -0,0 +1,76 @@ +import { CommandInteraction, Interaction, Message, BaseMessageOptions, ApplicationCommandOptionData, BaseApplicationCommandOptionsData } from "discord.js"; +import { handleInteractionError } from "../utils/handleError"; +import { Bot } from "../Bot"; + +/** + * The base command class. + */ +export default abstract class BaseCommand { + /** + * The command name. + */ + public static name: string; + /** + * The command description. + */ + public static description: string; + /** + * The command options. + */ + public static options: (ApplicationCommandOptionData & Pick)[] + /** + * The interaction. + */ + protected interaction: Interaction; + /** + * The client who received the interaction. + */ + protected client: Bot; + + + /** + * Creates a new instance of the BaseCommand class. + * @param interaction The interaction. + * @param client The client who received the interaction. + */ + constructor(interaction: Interaction, client: Bot) { + this.interaction = interaction; + this.client = client; + } + + /** + * Executes the command with the given arguments. + * @param args The command arguments. + */ + public abstract execute(...args: any[]): void; + + /** + * Sends a message to the interaction channel. + * + * If the interaction has sent previously, it will edit the previous message. + * @param content The message content. + * @returns The sent message. + */ + protected async send(content: BaseMessageOptions | string): Promise { + try { + const interaction = this.interaction as CommandInteraction + const messageContent = typeof content === "string" ? { content } : content + + if (interaction.replied) { + const sentContent = await interaction.editReply({ ...messageContent }) + return sentContent as Message + } else { + const sentContent = await interaction.reply({ ...messageContent, fetchReply: true }) + return sentContent as Message + } + } catch (error) { + if (error instanceof Error) { + handleInteractionError(error, this.interaction) + } else { + this.client.logger.error(error) + } + throw error + } + } + +} diff --git a/src/baseCommand/index.ts b/src/baseCommand/index.ts new file mode 100644 index 0000000..f614b84 --- /dev/null +++ b/src/baseCommand/index.ts @@ -0,0 +1,5 @@ +import BaseCommand from "./BaseCommand"; + +export { + BaseCommand +} \ No newline at end of file diff --git a/src/baseEvent/BaseEvent.ts b/src/baseEvent/BaseEvent.ts new file mode 100644 index 0000000..afa2e35 --- /dev/null +++ b/src/baseEvent/BaseEvent.ts @@ -0,0 +1,29 @@ +import { Bot } from "../Bot"; + +/** + * Base class for all events + */ +export default abstract class BaseEvent { + /** + * Name of the event. + */ + public static name: string; + /** + * Client instance. + */ + protected client: Bot; + + /** + * Creates a new instance of the BaseEvent class. + * @param client The client instance. + */ + constructor(client: Bot) { + this.client = client; + } + + /** + * Executes the event with the given arguments. + * @param args The event arguments. + */ + public abstract execute(...args: any[]): void; +} \ No newline at end of file diff --git a/src/baseEvent/index.ts b/src/baseEvent/index.ts new file mode 100644 index 0000000..f9113f2 --- /dev/null +++ b/src/baseEvent/index.ts @@ -0,0 +1,5 @@ +import BaseEvent from "./BaseEvent"; + +export { + BaseEvent +} \ No newline at end of file diff --git a/src/commands/Help.ts b/src/commands/Help.ts new file mode 100644 index 0000000..7ec5119 --- /dev/null +++ b/src/commands/Help.ts @@ -0,0 +1,17 @@ +import { BaseCommand } from "../baseCommand"; +import { EmbedBuilder } from "discord.js"; + +export default class HelpCommand extends BaseCommand { + public static name = "help"; + public static description = "Get help with the bot"; + public static options = []; + + public async execute() { + const embed = new EmbedBuilder() + .setTitle("Help") + .setDescription("This is a help command") + .setColor("#FF0000") + + await this.send({ embeds: [embed] }); + } +} \ No newline at end of file diff --git a/src/commands/Ping.ts b/src/commands/Ping.ts new file mode 100644 index 0000000..d814dd8 --- /dev/null +++ b/src/commands/Ping.ts @@ -0,0 +1,26 @@ +import { BaseCommand } from "../baseCommand"; +import { EmbedBuilder } from "discord.js"; + +export default class PingCommand extends BaseCommand { + public static name = "ping"; + public static description = "Pong! Displays the api & bot latency."; + public static options = []; + + public async execute(): Promise { + const res = await this.send("Pinging..."); + const messageTimestamp = res.createdTimestamp; + const ping = messageTimestamp - this.interaction.createdTimestamp; + + const embed = await this.mountPingEmbed(ping); + await this.send({ content: "Pong.", embeds: [embed] }); + } + + private async mountPingEmbed(ping: number): Promise { + const embed = new EmbedBuilder() + .setTitle("__Response Times__") + .setColor(this.interaction.guild?.members.me?.roles.highest.color || 0x7289da) + .addFields({ name:"Bot Latency:", value:":hourglass_flowing_sand:" + ping + "ms", inline:true }) + .addFields({ name:"API Latency:", value:":hourglass_flowing_sand:" + Math.round(this.client.ws.ping) + "ms", inline:true }) + return embed + } +} \ No newline at end of file diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..7799924 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,7 @@ +import PingCommand from './Ping'; +import HelpCommand from './Help'; + +export default [ + PingCommand, + HelpCommand +] \ No newline at end of file diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts new file mode 100644 index 0000000..83530fa --- /dev/null +++ b/src/events/InteractionCreateEvent.ts @@ -0,0 +1,25 @@ +import { BaseEvent } from "../baseEvent"; +import { Interaction } from "discord.js"; + +export default class InteractionCreateEvent extends BaseEvent { + public static name = "interactionCreate"; + + public execute(interaction: Interaction) { + if (!interaction.isCommand()) return; + + const command = this.client.commands.find(command => command.name === interaction.commandName); + + if (!command) return; + + this.client.logger.info(`${interaction.user.tag} executed command ${command.name}`); + + const concreteCommand = new command(interaction, this.client); + + try { + concreteCommand.execute(); + } catch (error) { + console.error(error); + interaction.reply({ content: "There was an error while executing this command!", ephemeral: true }); + } + } +} \ No newline at end of file diff --git a/src/events/ReadyEvent.ts b/src/events/ReadyEvent.ts new file mode 100644 index 0000000..cbf8026 --- /dev/null +++ b/src/events/ReadyEvent.ts @@ -0,0 +1,63 @@ +import { BaseEvent } from "../baseEvent"; +import { ActivityType, ApplicationCommandData, Guild } from "discord.js"; + +export default class ReadyEvent extends BaseEvent { + static name: string = "ready"; + + public async execute() { + await this.registerSlashCommandsForAllGuilds(); + this.setBotPresence(); + this.logStats(); + } + + private loadCommandsData(): ApplicationCommandData[] { + const commandsData: ApplicationCommandData[] = []; + for (const command of this.client.commands) { + const commandData: ApplicationCommandData = { + name: command.name, + description: command.description, + options: command.options, + }; + commandsData.push(commandData); + } + return commandsData; + } + + private async registerSlashCommandsForAllGuilds() { + const commandsData = this.loadCommandsData(); + const promises = this.client.guilds.cache.map(async (guild: Guild) => { + await this.registerSlashCommands(guild, commandsData); + }); + await Promise.all(promises); + } + + private async registerSlashCommands(guild: Guild, commandsData: ApplicationCommandData[]): Promise { + try { + await guild.commands.set(commandsData); + this.client.logger.success(`Registered commands in guild ${guild.name}`); + } catch (error) { + this.client.logger.error(`Failed to register commands in guild ${guild.name}`); + throw error; + } + } + + private setBotPresence(): void { + this.client.user?.setPresence({ + status: 'online', + activities: [{ name: 'Sprechstunden', type: ActivityType.Watching }], + afk: false + }) + } + + private logStats(): void { + const message = + `"${this.client.user?.username}" is Ready! (${(Date.now() - this.client.initTimestamp) / 1000}s)\n` + + "-".repeat(26) + "\n" + + "Bot Stats:\n" + + `${this.client.users.cache.size} user(s)\n` + + `${this.client.channels.cache.size} channel(s)\n` + + `${this.client.guilds.cache.size} guild(s)\n` + + "=".repeat(26); + this.client.logger.ready(message); + } +} \ No newline at end of file diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 0000000..96900c1 --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,7 @@ +import ReadyEvent from "./ReadyEvent"; +import InteractionCreateEvent from "./InteractionCreateEvent"; + +export default [ + ReadyEvent, + InteractionCreateEvent +] \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..094e60a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import initiateBot from './Bot' + +initiateBot() diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts new file mode 100644 index 0000000..b92079e --- /dev/null +++ b/src/managers/ConfigManager.ts @@ -0,0 +1,39 @@ +// import { singleton } from "tsyringe"; +// import "reflect-metadata"; +// import { GuildConfig, DefaultGuildConfig } from "types/GuildConfig"; + +// @singleton() +// class ConfigManager { +// private static instance: ConfigManager; +// public hasLoaded: boolean = false; + +// private guildsConfig: GuildConfig[] = []; +// private static readonly defaultGuildConfig: DefaultGuildConfig = { +// } + +// public constructor() { +// // this.load(); +// } + +// public static getInstance(): ConfigManager { +// if (!ConfigManager.instance) { +// ConfigManager.instance = new ConfigManager(); +// } + +// return ConfigManager.instance; +// } + +// public getGuildConfig(guildId: string): GuildConfig | DefaultGuildConfig { +// const guildConfig = this.guildsConfig.find(guildConfig => guildConfig.id === guildId); + +// if (!guildConfig) { +// return this.getDefaultGuildConfig(); +// } + +// return guildConfig; +// } + +// public getDefaultGuildConfig(): DefaultGuildConfig { +// return ConfigManager.defaultGuildConfig; +// } +// } \ No newline at end of file diff --git a/src/utils/handleError.ts b/src/utils/handleError.ts new file mode 100644 index 0000000..c6bb2e3 --- /dev/null +++ b/src/utils/handleError.ts @@ -0,0 +1,19 @@ +import { DMChannel, Interaction } from "discord.js" + +/** + * Handles error on interaction sending. + * + * @param {Error} error + * @param {object} message + */ +export function handleInteractionError(error: Error, interaction: Interaction): void { + const guildName = interaction.guild?.name ?? 'DM' + const channelName = interaction.channel instanceof DMChannel ? 'DM' : interaction.channel?.id ?? 'unknown' + const authorName = interaction.user.username + const authorTag = interaction.user.tag + const errorText = error.toString() || '' + console.log(`${error.name}: ${interaction.isCommand() && interaction.commandName} on guild "${guildName}", channel "${channelName}" by ${authorName}(${authorTag})`) + if (errorText.includes('TypeError') || errorText.includes('RangeError')) { + console.log(error) + } +} diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts new file mode 100644 index 0000000..01d0489 --- /dev/null +++ b/tests/mockDiscord.ts @@ -0,0 +1,94 @@ +import { jest } from '@jest/globals'; +import { + mockClientUser, + mockGuild, + mockGuildMember, + mockTextChannel, + mockUser, + mockChatInputCommandInteraction +} from '@shoginn/discordjs-mock'; +import "reflect-metadata" +import { Bot } from '../src/Bot'; +import { ChatInputCommandInteraction, Guild, GuildMember, TextBasedChannel, TextChannel, User } from 'discord.js'; +import { singleton } from 'tsyringe'; + +@singleton() +export class MockDiscord { + private client!: Bot; + private guild!: Guild; + private channel!: TextChannel; + private user!: User; + private guildMember!: GuildMember; + private interaction!: ChatInputCommandInteraction; + + + getClient(withBots: boolean = false): Bot { + if (withBots) { + const botUser = mockUser(this.client, { bot: true }); + mockGuildMember({ + client: this.client, + user: botUser, + guild: this.guild, + }); + } + return this.client; + } + getUser(): User { + return this.user; + } + getGuild(): Guild { + return this.guild; + } + + getGuildMember(): GuildMember { + return this.guildMember; + } + + getChannel(): TextBasedChannel { + return this.channel; + } + + getInteraction(): ChatInputCommandInteraction { + return this.interaction; + } + + public constructor() { + this.mockClient(); + this.mockGuild(); + this.mockUser(); + this.mockGuildMember(); + this.mockChannel(); + this.mockInteraction(); + } + + private mockClient(): void { + // this.client = new Client({ intents: [] }); + this.client = new Bot({ intents: [] }, "test"); + mockClientUser(this.client); + + this.client.login = jest.fn(() => Promise.resolve('LOGIN_TOKEN')) as any; + } + + private mockGuild(): void { + this.guild = mockGuild(this.client); + } + private mockChannel(): void { + this.channel = mockTextChannel(this.client, this.guild); + } + + private mockUser(): void { + this.user = mockUser(this.client); + } + + private mockGuildMember(): void { + this.guildMember = mockGuildMember({ + client: this.client, + user: this.user, + guild: this.guild, + }); + } + + private mockInteraction(): void { + this.interaction = mockChatInputCommandInteraction({ client: this.client, name: "test", id: "test", channel: this.channel, member: this.guildMember }) + } +} \ No newline at end of file diff --git a/tests/ping.test.ts b/tests/ping.test.ts new file mode 100644 index 0000000..c51cd39 --- /dev/null +++ b/tests/ping.test.ts @@ -0,0 +1,71 @@ +import { APIEmbedField, BaseMessageOptions, EmbedBuilder } from "discord.js"; +import PingCommand from "../src/commands/Ping"; +import { MockDiscord } from "./mockDiscord"; + +describe("PingCommand", () => { + it("should have the correct name", () => { + expect(PingCommand.name).toBe("ping") + }) + + it("should have the correct description", () => { + expect(PingCommand.description).toBe("Pong! Displays the api & bot latency.") + }) + + it("should have no options", () => { + expect(PingCommand.options).toHaveLength(0) + }) + + it("should first reply with pinging", async () => { + const command = PingCommand + const discord = new MockDiscord() + const interaction = discord.getInteraction() + const replySpy = jest.spyOn(interaction, 'reply') + const bot = discord.getClient() + const commandInstance = new command(interaction, bot) + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledWith({ content: "Pinging...", fetchReply: true }) + + }) + + it("should edit the reply with pong and message embed", async () => { + const command = PingCommand + const discord = new MockDiscord() + const interaction = discord.getInteraction() + const editSpy = jest.spyOn(interaction, 'editReply') + const bot = discord.getClient() + const commandInstance = new command(interaction, bot) + await commandInstance.execute() + + expect(editSpy).toHaveBeenCalledWith({ content: "Pong.", embeds: expect.anything() }) + const messageContent = editSpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + expect(embedData.title).toBe("__Response Times__") + const embedFields = embedData.fields as APIEmbedField[] + expect(embedFields).toHaveLength(2) + const botLatencyField = embedFields[0] + expect(botLatencyField.name).toBe("Bot Latency:") + expect(botLatencyField.value).toContain(":hourglass_flowing_sand:") + const apiLatencyField = embedFields[1] + expect(apiLatencyField.name).toBe("API Latency:") + expect(apiLatencyField.value).toContain(":hourglass_flowing_sand:") + }) + + it("should have the interaction reply as the result of the edit", async () => { + const command = PingCommand + const discord = new MockDiscord() + const interaction = discord.getInteraction() + const editSpy = jest.spyOn(interaction, 'editReply') + const bot = discord.getClient() + const commandInstance = new command(interaction, bot) + await commandInstance.execute() + + const reply = await interaction.fetchReply() + const spyResult = await editSpy.mock.results[0].value + expect(reply).toEqual(spyResult) + }) +}); \ No newline at end of file diff --git a/tests/testutils.ts b/tests/testutils.ts new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1329772 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "commonjs", + "baseUrl": "./src", + "paths": { + "@types": ["types.ts"], + "@baseCommand": ["baseCommand"], + "@commands": ["commands"], + "@utils/*": ["utils/*"] + }, + "resolveJsonModule": true, + "outDir": "./dist", + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "exclude": ["node_modules"] +} From e5e57d9f77d2dd904c5a85bf47adf556b85f3ca1 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 30 Dec 2023 15:20:33 +0100 Subject: [PATCH 002/130] User logger instead of console --- src/baseCommand/BaseCommand.ts | 2 +- src/events/InteractionCreateEvent.ts | 2 +- src/utils/handleError.ts | 11 ++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 5f2e754..7960989 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -65,7 +65,7 @@ export default abstract class BaseCommand { } } catch (error) { if (error instanceof Error) { - handleInteractionError(error, this.interaction) + handleInteractionError(error, this.interaction, this.client.logger) } else { this.client.logger.error(error) } diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts index 83530fa..4212446 100644 --- a/src/events/InteractionCreateEvent.ts +++ b/src/events/InteractionCreateEvent.ts @@ -18,7 +18,7 @@ export default class InteractionCreateEvent extends BaseEvent { try { concreteCommand.execute(); } catch (error) { - console.error(error); + this.client.logger.error(error); interaction.reply({ content: "There was an error while executing this command!", ephemeral: true }); } } diff --git a/src/utils/handleError.ts b/src/utils/handleError.ts index c6bb2e3..9d6a7cc 100644 --- a/src/utils/handleError.ts +++ b/src/utils/handleError.ts @@ -1,19 +1,20 @@ +import { ConsolaInstance } from "consola" import { DMChannel, Interaction } from "discord.js" /** * Handles error on interaction sending. - * + * * @param {Error} error - * @param {object} message + * @param {object} interaction */ -export function handleInteractionError(error: Error, interaction: Interaction): void { +export function handleInteractionError(error: Error, interaction: Interaction, logger: ConsolaInstance): void { const guildName = interaction.guild?.name ?? 'DM' const channelName = interaction.channel instanceof DMChannel ? 'DM' : interaction.channel?.id ?? 'unknown' const authorName = interaction.user.username const authorTag = interaction.user.tag const errorText = error.toString() || '' - console.log(`${error.name}: ${interaction.isCommand() && interaction.commandName} on guild "${guildName}", channel "${channelName}" by ${authorName}(${authorTag})`) + logger.error(`${error.name}: ${interaction.isCommand() && interaction.commandName} on guild "${guildName}", channel "${channelName}" by ${authorName}(${authorTag})`) if (errorText.includes('TypeError') || errorText.includes('RangeError')) { - console.log(error) + logger.error(error) } } From 46c3f89a6277bd3e71fb5d6cc444c036858c2461 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 30 Dec 2023 15:26:06 +0100 Subject: [PATCH 003/130] Fix package.json --- package-lock.json | 10 +++++----- package.json | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ce424d..786d47e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { - "name": "rubot2_new", - "version": "1.0.0", + "name": "rubot2", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "rubot2_new", - "version": "1.0.0", - "license": "ISC", + "name": "rubot2", + "version": "2.0.0", + "license": "AGPL-3.0", "dependencies": { "@shoginn/discordjs-mock": "^1.0.2", "@types/node": "^20.10.5", diff --git a/package.json b/package.json index 3a3fa4b..24aefe6 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,16 @@ { - "name": "rubot2_new", - "version": "1.0.0", - "description": "", - "main": "index.js", + "name": "rubot2", + "version": "2.0.0", + "description": "A General Purpose Discord Bot", + "main": "src/index.ts", "scripts": { "start": "ts-node src/index.ts", - "build": "tsc", + "build": "tsc --build --clean tsconfig.json; tsc --build tsconfig.json", "test": "jest --detectOpenHandles" }, "keywords": [], "author": "", - "license": "ISC", + "license": "AGPL-3.0", "dependencies": { "@shoginn/discordjs-mock": "^1.0.2", "@types/node": "^20.10.5", From 73324dc91d047ab5e80f70a53a41767180f247d6 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 2 Jan 2024 18:56:06 +0100 Subject: [PATCH 004/130] Add models --- src/models/BotRoles.ts | 64 +++++++++++++++ src/models/Event.ts | 74 +++++++++++++++++ src/models/Guild.ts | 62 +++++++++++++++ src/models/GuildSettings.ts | 41 ++++++++++ src/models/PermissionOverwriteData.ts | 11 +++ src/models/Queue.ts | 110 ++++++++++++++++++++++++++ src/models/QueueEntry.ts | 27 +++++++ src/models/QueueSpan.ts | 46 +++++++++++ src/models/SlashCommandPermissions.ts | 20 +++++ src/models/SlashCommandSettings.ts | 41 ++++++++++ src/models/TextChannel.ts | 58 ++++++++++++++ src/models/VoiceChannel.ts | 63 +++++++++++++++ src/models/VoiceChannelSpawner.ts | 45 +++++++++++ src/models/WeekTimestamp.ts | 55 +++++++++++++ 14 files changed, 717 insertions(+) create mode 100644 src/models/BotRoles.ts create mode 100644 src/models/Event.ts create mode 100644 src/models/Guild.ts create mode 100644 src/models/GuildSettings.ts create mode 100644 src/models/PermissionOverwriteData.ts create mode 100644 src/models/Queue.ts create mode 100644 src/models/QueueEntry.ts create mode 100644 src/models/QueueSpan.ts create mode 100644 src/models/SlashCommandPermissions.ts create mode 100644 src/models/SlashCommandSettings.ts create mode 100644 src/models/TextChannel.ts create mode 100644 src/models/VoiceChannel.ts create mode 100644 src/models/VoiceChannelSpawner.ts create mode 100644 src/models/WeekTimestamp.ts diff --git a/src/models/BotRoles.ts b/src/models/BotRoles.ts new file mode 100644 index 0000000..c5514af --- /dev/null +++ b/src/models/BotRoles.ts @@ -0,0 +1,64 @@ +import { getModelForClass, prop } from "@typegoose/typegoose"; + +export enum InternalRoles { + SERVER_OWNER = "server_owner", + SERVER_ADMIN = "server_admin", + TUTOR = "tutor", + VERIFIED = "verified", + ACTIVE_SESSION = "active_session", + BOT_OWNER = "bot_owner", + BOT_ADMIN = "bot_admin", +} + +export const InternalGuildRoles = [InternalRoles.SERVER_OWNER, InternalRoles.SERVER_ADMIN, InternalRoles.TUTOR, InternalRoles.VERIFIED, InternalRoles.ACTIVE_SESSION]; + +export enum RoleScopes { + GLOBAL = "global", + SERVER = "server", +} + +export class DBRole { + /** + * The internal Name of the Bot Role + */ + @prop({ required: true, enum: InternalRoles }) + internal_name!: InternalRoles; + /** + * The Scope of the Role + */ + @prop({ required: true, enum: RoleScopes }) + scope!: RoleScopes; + /** + * The ID of the server the role is in + */ + @prop({ required: false, unique: true, sparse: true }) + server_id?: string; + /** + * The Discord Role ID + */ + @prop({ required: false, unique: true, sparse: true }) + role_id?: string; + /** + * The Name of the role on the server (used for recovering the role if it gets deleted) + */ + @prop({ required: false }) + server_role_name?: string; +} + +/** + * Roles that are valid for only one server + */ +export interface ServerRole extends DBRole { + scope: RoleScopes.SERVER, + server_id: string, +} + +/** + * Roles that are valid for only one server + */ +export interface BotRole extends DBRole { + /** + * The Scope of the Role + */ + scope: RoleScopes.GLOBAL, +} \ No newline at end of file diff --git a/src/models/Event.ts b/src/models/Event.ts new file mode 100644 index 0000000..8fe5ff8 --- /dev/null +++ b/src/models/Event.ts @@ -0,0 +1,74 @@ +import { prop } from "@typegoose/typegoose"; + +export enum VoiceChannelEventType { + "create_channel" = "create_channel", + "destroy_channel" = "destroy_channel", + "move_member" = "move_member", + "permit_member" = "permit_member", + "kick_member" = "kick_member", + "lock_channel" = "lock_channel", + "unlock_channel" = "unlock_channel", + "hide_channel" = "hide_channel", + "unhide_channel" = "unhide_channel", + "user_leave" = "user_leave", + "user_join" = "user_join", + "permission_change" = "permission_change", + "other" = "other", +} + +export enum QueueEventType { + JOIN = "join", + LEAVE = "leave", + KICK = "kick", + NEXT = "next", + TUTOR_SESSION_START = "tutor_session_start", + TUTOR_SESSION_QUIT = "tutor_session_quit", + OTHER = "other", +} + +/** + * An Event that is stored in the Database + */ +export abstract class Event { + /** + * The Unix Time Stamp of the Event + */ + @prop({ required: true }) + timestamp!: string; + + /** + * The Event Type + */ + abstract type: T; + /** + * Client ID or "me" + */ + @prop({ required: true }) + emitted_by!: string; + /** + * A Target that was affected + */ + @prop({ required: false }) + target?: string; + /** + * The Reason why the Event was Emitted + */ + @prop({ required: false }) + reason?: string; +} + +/** + * An Event concerning a Voice Channel + */ +export class VoiceChannelEvent extends Event { + @prop({ required: true, enum: VoiceChannelEventType, default: VoiceChannelEventType.other }) + type!: VoiceChannelEventType; +} + +/** + * An Event concerning a Queue + */ +export class QueueEvent extends Event { + @prop({ required: true, enum: QueueEventType, default: QueueEventType.OTHER }) + type!: QueueEventType; +} diff --git a/src/models/Guild.ts b/src/models/Guild.ts new file mode 100644 index 0000000..12de723 --- /dev/null +++ b/src/models/Guild.ts @@ -0,0 +1,62 @@ +import { prop, getModelForClass, SubDocumentType, ArraySubDocumentType, mongoose } from "@typegoose/typegoose"; +import { GuildSettings } from "./GuildSettings"; +import { TextChannel } from "./TextChannel"; +import { VoiceChannel } from "./VoiceChannel"; +import { Queue } from "./Queue"; + +/** + * A Guild from the Database + */ +export class Guild { + /** + * The Guild ID provided by Discord + */ + @prop({ required: true }) + _id!: string; + /** + * The Name of the Guild + */ + @prop({ required: true }) + name!: string; + /** + * The Member Count (Makes it easier to sort Guilds by member counts) + */ + @prop({ required: true, default: 0 }) + member_count!: number; + /** + * The Settings for the Guild + */ + @prop({ required: true, type: () => GuildSettings }) + guild_settings!: SubDocumentType; + /** + * The Relevant Text Channels of the Guild + */ + @prop({ required: true, default: [], type: () => [TextChannel] }) + text_channels!: mongoose.Types.DocumentArray>; + /** + * The Relevant Voice Channels of the Guild + */ + @prop({ required: true, default: [], type: () => [VoiceChannel] }) + voice_channels!: mongoose.Types.DocumentArray>; + /** + * The Queues of the Guild + */ + @prop({ required: true, default: [], type: () => [Queue] }) + queues!: mongoose.Types.DocumentArray>; + /** + * The Welcome Message Text + */ + @prop() + welcome_text?: string; + /** + * The Welcome Message Title + */ + @prop() + welcome_title?: string; +} + +export const GuildModel = getModelForClass(Guild, { + schemaOptions: { + autoCreate: true, + }, +}); \ No newline at end of file diff --git a/src/models/GuildSettings.ts b/src/models/GuildSettings.ts new file mode 100644 index 0000000..a5c42d6 --- /dev/null +++ b/src/models/GuildSettings.ts @@ -0,0 +1,41 @@ +import { prop, mongoose, Ref, modelOptions, getModelForClass, DocumentType, ArraySubDocumentType } from "@typegoose/typegoose"; +import { SlashCommandSettings } from "./SlashCommandSettings"; +import { DBRole } from "./BotRoles"; + +/** + * Command Listen Modes + */ +export enum CommandListenMode { + WHITELIST = 0, + BLACKLIST = 1 +} + +export class GuildSettings { + /** + * @deprecated + * The Bot Prefix for the Guild + */ + @prop({ required: true, default: "!" }) + prefix!: string; + /** + * @deprecated + * The Command Listen Mode for The Guild + */ + @prop({ required: true, enum: CommandListenMode, default: CommandListenMode.BLACKLIST }) + command_listen_mode!: CommandListenMode; + /** + * The Guild Specific command Settings + */ + @prop({ required: true, default: [], type: () => SlashCommandSettings }) + slashCommands!: mongoose.Types.DocumentArray>; + /** + * The Guild Specific role Settings + */ + @prop({ default: [], type: () => DBRole }) + roles?: mongoose.Types.DocumentArray>; + /** + * The User Account URL related to the guild + */ + @prop() + account_url?: string; +} \ No newline at end of file diff --git a/src/models/PermissionOverwriteData.ts b/src/models/PermissionOverwriteData.ts new file mode 100644 index 0000000..be5d013 --- /dev/null +++ b/src/models/PermissionOverwriteData.ts @@ -0,0 +1,11 @@ +import { OverwriteData, PermissionResolvable, Snowflake } from "discord.js"; +import { getModelForClass, prop } from "@typegoose/typegoose"; + +export class PermissionOverwriteData implements OverwriteData { + @prop({ required: true, type: String }) + id!: Snowflake; + @prop({ required: true, type: String, default: [] }) + allow?: PermissionResolvable[]; + @prop({ required: true, type: String, default: [] }) + deny?: PermissionResolvable[]; +} \ No newline at end of file diff --git a/src/models/Queue.ts b/src/models/Queue.ts new file mode 100644 index 0000000..fb0c05c --- /dev/null +++ b/src/models/Queue.ts @@ -0,0 +1,110 @@ +import { prop, mongoose, SubDocumentType, ArraySubDocumentType } from '@typegoose/typegoose'; +import { VoiceChannelSpawner } from './VoiceChannelSpawner'; +import { QueueEventType } from './Event'; +import { QueueSpan } from './QueueSpan'; +import { QueueEntry } from './QueueEntry'; + +/** + * A Queue from the Database + */ +export class Queue { + /** + * The Name Of The Queue + */ + @prop({ required: true }) + name!: string; + /** + * A Description of the Queue + */ + @prop() + description?: string; + /** + * The max Amount of Users that the queue can handle + */ + @prop() + limit?: number; + /** + * The Timeout in Milliseconds if the User disconnects from the Queue (usefull for VC based Queues) + */ + @prop() + disconnect_timeout?: number; + /** + * The Timeout in Milliseconds that the user is kicked off the queue After not accepting a match + */ + @prop() + match_timeout?: number; + /** + * A Custom Join Message for the Queue. Use ${pos} ${total} ${eta} ${user} and so on to create Dynamic Messages. + */ + @prop() + join_message?: string; + /** + * A Custom Match Found Message for the Queue. Use ${pos} ${total} ${eta} ${user} ${match} ${match_channel} and so on to create Dynamic Messages. + */ + @prop() + match_found_message?: string; + /** + * A Custom Timeout Message. Use ${pos} ${total} ${eta} ${user} ${timeout} and so on to create Dynamic Messages. + */ + @prop() + timeout_message?: string; + /** + * A Custom Leave Message. Use ${pos} ${total} ${eta} ${user} ${timeout} and so on to create Dynamic Messages. + */ + @prop() + leave_message?: string; + /** + * A Custom Message that is Displayed when the Room is Left (like Please confirm ur stay) + */ + @prop() + leave_room_message?: string; + /** + * A Template for spawning in Rooms (if empty default template is used) + */ + @prop({ type: () => VoiceChannelSpawner }) + room_spawner?: SubDocumentType; + /** + * A text Channel to use if dms are disabled + */ + @prop() + text_channel?: string; + + /** + * Text Channels ids to log queue events + */ + @prop({ default: [], required: false }) + info_channels!: { + channel_id: string; + events: QueueEventType[]; + }[]; + /** + * Whether the queue is locked (this also disables the /queue join command for this queue) + */ + @prop({ default: false }) + locked?: boolean; + /** + * Whether to automatically lock and unlock the queue according to the opening_times + */ + @prop({ default: false }) + auto_lock?: boolean; + /** + * The opening times of the Queue + */ + @prop({ type: QueueSpan, default: [], required: true }) + opening_times!: mongoose.Types.DocumentArray>; + /** + * The standard time to shift the unlocking of the queue by in milliseconds + */ + @prop({ default: 0 }) + openShift?: number; + /** + * The standard time to shift the locking of the queue by in milliseconds + */ + @prop({ default: 0 }) + closeShift?: number; + /** + * The Entries of the Queue + */ + @prop({ type: QueueEntry, default: [], required: true }) + entries!: mongoose.Types.DocumentArray>; +} \ No newline at end of file diff --git a/src/models/QueueEntry.ts b/src/models/QueueEntry.ts new file mode 100644 index 0000000..f423019 --- /dev/null +++ b/src/models/QueueEntry.ts @@ -0,0 +1,27 @@ +import { getModelForClass, prop } from "@typegoose/typegoose"; + +/** + * A Queue Entry + */ +export class QueueEntry { + /** + * The Discord Client ID of the queue Member + */ + @prop({ required: true }) + discord_id!: string; + /** + * The Unix Time Stamp of the Queue entry point + */ + @prop({ required: true }) + joinedAt!: string; + /** + * A Multiplier for Importance (use carefully) default is 1 + */ + @prop({ required: false, default: 1 }) + importance?: number; + /** + * An intent specified by the User and can be seen by Queue Managers (the ones who accept queues) + */ + @prop({ required: false }) + intent?: string; +} \ No newline at end of file diff --git a/src/models/QueueSpan.ts b/src/models/QueueSpan.ts new file mode 100644 index 0000000..d659d44 --- /dev/null +++ b/src/models/QueueSpan.ts @@ -0,0 +1,46 @@ +import { prop } from "@typegoose/typegoose"; +import { WeekTimestamp } from "./WeekTimestamp"; + +/** + * A Queue Span - A Weekly Timespan with a start- and End Date that can be used to automate Events every week + */ +export class QueueSpan { + /** + * The Begin Timestamp + */ + @prop({ required: true, type: WeekTimestamp }) + begin!: WeekTimestamp; + + /** + * The End Timestamp + */ + @prop({ required: true, type: WeekTimestamp }) + end!: WeekTimestamp; + + /** + * Shift the Opening by X millixeconds + * @default 0 + */ + @prop({ required: true, default: 0 }) + openShift!: number; + + /** + * Shift the Closing by X milliseconds + * @default 0 + */ + @prop({ required: true, default: 0 }) + closeShift!: number; + + /** + * limit the span to after this date + */ + @prop({ required: false }) + startDate?: Date; + + /** + * limit the span to before this date + * @default 0 + */ + @prop({ required: false }) + endDate?: Date; +} \ No newline at end of file diff --git a/src/models/SlashCommandPermissions.ts b/src/models/SlashCommandPermissions.ts new file mode 100644 index 0000000..284bfbe --- /dev/null +++ b/src/models/SlashCommandPermissions.ts @@ -0,0 +1,20 @@ +import { ApplicationCommandPermissionType } from "discord.js"; +import { prop } from "@typegoose/typegoose"; + +export class SlashCommandPermission { + /** + * The User or Role ID + */ + @prop({ required: true }) + id!: string; + /** + * The ID Type (Role or User) + */ + @prop({ required: true, enum: ApplicationCommandPermissionType, default: ApplicationCommandPermissionType.User }) + type!: ApplicationCommandPermissionType; + /** + * Whether to permit or not permit the User Or Role + */ + @prop({ required: true }) + permission!: boolean; +} diff --git a/src/models/SlashCommandSettings.ts b/src/models/SlashCommandSettings.ts new file mode 100644 index 0000000..d6527b7 --- /dev/null +++ b/src/models/SlashCommandSettings.ts @@ -0,0 +1,41 @@ +import { SlashCommandPermission } from "./SlashCommandPermissions"; +import { AllowedMentionsTypes, PermissionResolvable } from "discord.js"; +import { ArraySubDocumentType, getModelForClass, prop, mongoose } from "@typegoose/typegoose"; + +export class SlashCommandSettings { + /** + * The original Command name used to retrieve it from the event handler + */ + @prop({ required: true, sparse: true }) + internal_name!: string; + /** + * The Command Name overwrite + */ + @prop({ required: false }) + name?: string; + /** + * The Command Description overwrite + */ + @prop({ required: false }) + description?: string; + /** + * The Default Command Permission overwrite + */ + @prop({ required: false }) + defaultPermission?: PermissionResolvable; + /** + * If the command should be completely removed from the slash command List + */ + @prop({ required: false }) + disabled?: boolean; + /** + * All the command aliases (won't be shown general help) + */ + @prop({ required: true, default: [], type: String }) + aliases!: mongoose.Types.Array; + /** + * The Command permissions + */ + @prop({ required: true, default: [], type: () => [SlashCommandPermission] }) + permissions!: mongoose.Types.DocumentArray>; +} diff --git a/src/models/TextChannel.ts b/src/models/TextChannel.ts new file mode 100644 index 0000000..58b7104 --- /dev/null +++ b/src/models/TextChannel.ts @@ -0,0 +1,58 @@ +import { ChannelType, TextChannelType } from "discord.js"; +import { getModelForClass, prop } from "@typegoose/typegoose"; + +/** + * Database Representation of a Discord Channel + */ +export interface Channel { + /** + * The Channel ID + */ + _id: string, + /** + * The Channel Type + */ + channel_type: ChannelType, + /** + * Whether the Channel is being managed by or relevant to the bot + */ + managed: boolean, + /** + * The Parent Category channel + */ + category?: string, + /** + * The Channel owner + */ + owner?: string, + +} + +export class TextChannel implements Channel { + @prop({ required: true }) + _id!: string; + @prop({ required: true, type: Number, enum: [ChannelType.DM, ChannelType.GroupDM, ChannelType.GuildAnnouncement, ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.AnnouncementThread, ChannelType.GuildText, ChannelType.GuildForum, ChannelType.GuildVoice, ChannelType.GuildStageVoice] }) + channel_type!: TextChannelType; + @prop({ required: true }) + managed!: boolean; + @prop() + category?: string | undefined; + @prop() + owner?: string | undefined; + /** + * Channel Specific Prefix, cuz why not? :D + */ + @prop() + prefix?: string; + /** + * If the Bot is enabled in this channel + */ + @prop({ required: true }) + listen_for_commands!: boolean; + /** + * WHETHER THE CHANNEL IS CAPS-ONLY + */ + @prop({ default: false }) + rage_channel?: boolean; + +} \ No newline at end of file diff --git a/src/models/VoiceChannel.ts b/src/models/VoiceChannel.ts new file mode 100644 index 0000000..faafe82 --- /dev/null +++ b/src/models/VoiceChannel.ts @@ -0,0 +1,63 @@ +import { mongoose, prop, Ref, SubDocumentType } from "@typegoose/typegoose"; +import { VoiceChannelSpawner } from "./VoiceChannelSpawner"; +import { Queue } from "./Queue"; +import { ChannelType } from "discord.js"; +import { Channel } from "./TextChannel"; + +export class VoiceChannel implements Channel { + @prop({ required: true }) + _id!: string; + @prop({ required: true, type: Number, enum: [0, 1, 2, 3, 4, 5, 6, 7] }) + channel_type!: ChannelType; + @prop({ required: true }) + managed!: boolean; + @prop() + category?: string | undefined; + @prop() + owner?: string | undefined; + /** + * If the channel is an AFK-Hell (constantly plays a Song) + */ + @prop({ default: false }) + afkhell?: boolean; + /** + * The Song Link for AFK Hell + */ + @prop() + song_link?: string; + /** + * If the voice Channel is locked to a specific user group (used to keep track of lock icon) + */ + @prop({ required: true, default: false }) + locked!: boolean; + /** + * The Permitted Users/Roles that can enter this channel + */ + @prop({ required: true, type: String, default: [] }) + permitted!: mongoose.Types.Array; + /** + * Makes the Channel a spawner Channel which creates a new channel for every joined member + */ + @prop({ required: false, type: () => VoiceChannelSpawner }) + spawner?: SubDocumentType; + /** + * The Channel Prefix + */ + @prop() + prefix?: string; + /** + * A Queue that is entered with joining this Channel + */ + @prop({ ref: () => Queue }) + queue?: Ref; + /** + * If the VC is a Temporary Voice Channel + */ + @prop({ default: false }) + temporary?: boolean; + /** + * The Channel Supervisor Roles/ User IDs + */ + @prop({ type: String, default: [] }) + supervisors?: mongoose.Types.Array; +} \ No newline at end of file diff --git a/src/models/VoiceChannelSpawner.ts b/src/models/VoiceChannelSpawner.ts new file mode 100644 index 0000000..2d010ab --- /dev/null +++ b/src/models/VoiceChannelSpawner.ts @@ -0,0 +1,45 @@ +import { prop, mongoose, ArraySubDocumentType } from "@typegoose/typegoose"; +import { PermissionOverwriteData } from "./PermissionOverwriteData"; + +export class VoiceChannelSpawner { + /** + * overwrite the VC Owner for the Bot + */ + @prop() + owner?: string; + /** + * The Roles that can moderate this channel + */ + @prop({ required: true, type: () => [String], default: [] }) + supervisor_roles!: mongoose.Types.Array; + /** + * The Channel Permissions + */ + @prop({ required: true, type: () => [PermissionOverwriteData], default: [] }) + permission_overwrites!: mongoose.Types.DocumentArray>; + /** + * Limit the amount of Users that can join the channel + */ + @prop() + max_users?: number; + /** + * The Name of the Channel; use ${owner} and so on to create dynamic channel names + */ + @prop({ required: true, default: "${owner_name}'s VC" }) + name!: string; + /** + * Whether the Channel should initially be locked or not + */ + @prop({ default: false }) + lock_initially?: boolean; + /** + * Whether the Channel should initially be hidden or not + */ + @prop({ default: false }) + hide_initially?: boolean; + /** + * The Category Channel ID + */ + @prop() + parent?: string; +} diff --git a/src/models/WeekTimestamp.ts b/src/models/WeekTimestamp.ts new file mode 100644 index 0000000..7ab5c64 --- /dev/null +++ b/src/models/WeekTimestamp.ts @@ -0,0 +1,55 @@ +import { prop } from "@typegoose/typegoose"; + +export enum Weekday { + /** + * Sonntag + */ + SUNDAY = 0, + /** + * Montag + */ + MONDAY = 1, + /** + * Dienstag + */ + TUESDAY = 2, + /** + * Mittwoch + */ + WEDNESDAY = 3, + /** + * Donnerstag + */ + THURSDAY = 4, + /** + * Freitag + */ + FRIDAY = 5, + /** + * Samstag + */ + SATURDAY = 6 +} +/** + * A Timestamp of the queue + */ + +export class WeekTimestamp { + + /** + * The Day of the Week + */ + @prop({ required: true, enum: Weekday }) + weekday!: Weekday; + + /** + * The Hour of the Day + */ + @prop({ required: true }) + hour!: number; + /** + * The Minute of the Hour + */ + @prop({ required: true }) + minute!: number; +} \ No newline at end of file From 16130eec2cffa0301c4aaa2f5bd6fb52759c4787 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 2 Jan 2024 18:57:45 +0100 Subject: [PATCH 005/130] Changes to incorporate update bot roles command --- docker-compose.development.yml | 18 + jest.config.ts | 9 +- package-lock.json | 639 +++++++++++++++++++- package.json | 12 +- src/Bot.ts | 44 +- src/Environment.ts | 5 + src/baseCommand/BaseCommand.ts | 37 +- src/baseEvent/BaseEvent.ts | 2 +- src/commands/Ping.ts | 6 +- src/commands/admin/UpdateBotRoles.ts | 104 ++++ src/commands/index.ts | 4 +- src/events/GuildCreateEvent.ts | 12 + src/events/InteractionCreateEvent.ts | 7 +- src/events/ReadyEvent.ts | 31 +- src/index.ts | 5 + src/managers/CommandsManager.ts | 36 ++ src/managers/ConfigManager.ts | 86 +-- src/managers/index.ts | 7 + src/types/OptionRequirement.ts | 27 + src/types/RoleCreationError.ts | 5 + tests/commands/Ping.test.ts | 74 +++ tests/commands/admin/UpdateBotRoles.test.ts | 210 +++++++ tests/globalSetup.ts | 20 + tests/globalTeardown.ts | 9 + tests/mockDiscord.ts | 132 ++-- tests/ping.test.ts | 71 --- tests/testSetup.ts | 17 + tests/testutils.ts | 8 + tsconfig.json | 8 +- 29 files changed, 1413 insertions(+), 232 deletions(-) create mode 100644 docker-compose.development.yml create mode 100644 src/Environment.ts create mode 100644 src/commands/admin/UpdateBotRoles.ts create mode 100644 src/events/GuildCreateEvent.ts create mode 100644 src/managers/CommandsManager.ts create mode 100644 src/managers/index.ts create mode 100644 src/types/OptionRequirement.ts create mode 100644 src/types/RoleCreationError.ts create mode 100644 tests/commands/Ping.test.ts create mode 100644 tests/commands/admin/UpdateBotRoles.test.ts create mode 100644 tests/globalSetup.ts create mode 100644 tests/globalTeardown.ts delete mode 100644 tests/ping.test.ts create mode 100644 tests/testSetup.ts diff --git a/docker-compose.development.yml b/docker-compose.development.yml new file mode 100644 index 0000000..88d5cde --- /dev/null +++ b/docker-compose.development.yml @@ -0,0 +1,18 @@ +version: '3.8' + +volumes: + sprechi-mongodb-data: + +services: + db: + image: mongo + container_name: sprechi-mongodb + environment: + - MONGO_INITDB_ROOT_USERNAME=$MONGODB_USER + - MONGO_INITDB_ROOT_PASSWORD=$MONGODB_PASSWORD + - MONGO_INITDB_DATABASE=$MONGODB_DBNAME + restart: unless-stopped + volumes: + - sprechi-mongodb-data:/data/db + ports: + - 27017:27017 diff --git a/jest.config.ts b/jest.config.ts index 17b8583..dc36876 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,14 +1,15 @@ import type { Config } from 'jest' -import { pathsToModuleNameMapper } from 'ts-jest/' -import { compilerOptions } from './tsconfig.json' const config: Config = { verbose: true, coverageDirectory: './coverage/', collectCoverage: false, preset: 'ts-jest', - testEnvironment: 'node', - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/src/' }) + globalSetup: "/globalSetup.ts", + globalTeardown: "/globalTeardown.ts", + setupFilesAfterEnv: [ + '/testSetup.ts' + ] } export default config \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 786d47e..2e7de54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,22 @@ "version": "2.0.0", "license": "AGPL-3.0", "dependencies": { - "@shoginn/discordjs-mock": "^1.0.2", - "@types/node": "^20.10.5", + "@typegoose/typegoose": "^12.0.0", + "@types/node": "^20.10.6", "consola": "^3.2.3", "cron": "^3.1.6", "discord.js": "^14.14.1", "dotenv": "^16.3.1", "reflect-metadata": "^0.2.1", "tsyringe": "^4.8.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "uuid": "^9.0.1" }, "devDependencies": { + "@shoginn/discordjs-mock": "^1.0.2", "@types/jest": "^29.5.11", "jest": "^29.7.0", + "mongodb-memory-server": "^9.1.3", "ts-jest": "^29.1.1", "ts-node": "^10.9.2" } @@ -1170,6 +1173,14 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.1.tgz", + "integrity": "sha512-t7c5K033joZZMspnHg/gWPE4kandgc2OxE74aYOtGKfgB9VPuVJPix0H6fhmm2erj5PBJ21mqcx34lpIGtUCsQ==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.1.tgz", @@ -1204,6 +1215,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@shoginn/discordjs-mock/-/discordjs-mock-1.0.2.tgz", "integrity": "sha512-Kahe20PLCVd4FeyosdNLxNPk2qNX8Fj3NPICfNZKMfjb9REWP/iF67H11+mXh/rMsXU89r0kU/JqLQe+Z/Cf9w==", + "dev": true, "dependencies": { "@sapphire/snowflake": "^3.5.2", "discord.js": "^14.14.1", @@ -1217,6 +1229,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.2.tgz", "integrity": "sha512-FTm9RdyELF21PQN5dS/HLRs90XqWclHa+p0gkonc+BA2X2QKfFySHSjUbO65rmArd/ghR9Ahj2fMfedTZEqzOw==", + "dev": true, "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" @@ -1270,6 +1283,59 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@typegoose/typegoose": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-12.0.0.tgz", + "integrity": "sha512-ZkRtjiCO4k05bLPtUXX3Ho7zMLLOKvg6+3JBVtadpC4xUu5htfDf5H+TI+w1jL1d8Q51aN8mHH7L5hr7OSkzoA==", + "dependencies": { + "lodash": "^4.17.20", + "loglevel": "^1.8.1", + "reflect-metadata": "^0.1.13", + "semver": "^7.5.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "mongoose": "~8.0.1" + } + }, + "node_modules/@typegoose/typegoose/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typegoose/typegoose/node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==" + }, + "node_modules/@typegoose/typegoose/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typegoose/typegoose/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1360,9 +1426,9 @@ "integrity": "sha512-gKc9P2d4g5uYwmy4s/MO/yOVPmvHyvzka1YH6i5dM03UrFofHSmgc0D0ymbDRStFWHusk6cwwF6nhLm/ckBbbQ==" }, "node_modules/@types/node": { - "version": "20.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", - "integrity": "sha512-nNPsNE65wjMxEKI93yOP+NPGGBJz/PoN3kZsVLee0XMiJolxSekEVD8wRwBUBqkwc7UWop0edW50yrCQW4CyRw==", + "version": "20.10.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz", + "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==", "dependencies": { "undici-types": "~5.26.4" } @@ -1373,6 +1439,20 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "dependencies": { + "@types/node": "*", + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.9.tgz", @@ -1426,6 +1506,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1493,6 +1585,21 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/async-mutex": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", + "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", + "dev": true + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1681,6 +1788,24 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.2.0.tgz", + "integrity": "sha512-ID1cI+7bazPDyL9wYy9GaQ8gEEohWvcUl/Yf0dIdutJxnmInEEyCsb4awy/OiBfall7zBA179Pahi3vCdFze3Q==", + "peer": true, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1819,6 +1944,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1893,7 +2024,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -2113,6 +2243,12 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2128,6 +2264,15 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -2140,6 +2285,38 @@ "node": ">=8" } }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-cache-dir/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -2153,6 +2330,26 @@ "node": ">=8" } }, + "node_modules/follow-redirects": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2283,6 +2480,19 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -2336,6 +2546,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "devOptional": true + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3135,6 +3351,15 @@ "node": ">=6" } }, + "node_modules/kareem": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3187,6 +3412,18 @@ "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" }, + "node_modules/loglevel": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", + "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3272,6 +3509,11 @@ "tmpl": "1.0.5" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -3312,11 +3554,246 @@ "node": "*" } }, + "node_modules/mongodb": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.2.0.tgz", + "integrity": "sha512-d7OSuGjGWDZ5usZPqfvb36laQ9CPhnWkAGHT61x5P95p/8nMVeH8asloMwW6GcYFeB0Vj4CB/1wOTDG2RA9BFA==", + "peer": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.2.0", + "mongodb-connection-string-url": "^2.6.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "dependencies": { + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" + } + }, + "node_modules/mongodb-memory-server": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-9.1.3.tgz", + "integrity": "sha512-EVVNll0e6QEsNhK7IJI0x51nbmv57E6X8izO3LLEnfbSZNwERG4P5nAjJfglqCSNkT4svKp1/0kzc+ldlQttOg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "mongodb-memory-server-core": "9.1.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.20.1" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-9.1.3.tgz", + "integrity": "sha512-94pUuTgjb6NglCbKLEZm457aACxeaT8+Jw8weEy0DyWiCBd1mk8dIuq7GE1CjmHFU2hMOCnOutdR96LhkWpgig==", + "dev": true, + "dependencies": { + "async-mutex": "^0.4.0", + "camelcase": "^6.3.0", + "debug": "^4.3.4", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.15.3", + "https-proxy-agent": "^7.0.2", + "mongodb": "^5.9.1", + "new-find-package-json": "^2.0.0", + "semver": "^7.5.4", + "tar-stream": "^3.0.0", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "engines": { + "node": ">=14.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/bson": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", + "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==", + "dev": true, + "engines": { + "node": ">=14.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/mongodb": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", + "dev": true, + "dependencies": { + "bson": "^5.5.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=14.20.1" + }, + "optionalDependencies": { + "@mongodb-js/saslprep": "^1.1.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.0.0", + "kerberos": "^1.0.0 || ^2.0.0", + "mongodb-client-encryption": ">=2.3.0 <3", + "snappy": "^7.2.2" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + } + } + }, + "node_modules/mongodb-memory-server-core/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/mongoose": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.0.3.tgz", + "integrity": "sha512-LJRT0yP4TW14HT4r2RkxqyvoTylMSzWpl5QOeVHTnRggCLQSpkoBdgbUtORFq/mSL2o9cLCPJz+6uzFj25qbHw==", + "peer": true, + "dependencies": { + "bson": "^6.2.0", + "kareem": "2.5.1", + "mongodb": "6.2.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "16.0.1" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "peer": true + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "peer": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "peer": true, + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -3324,6 +3801,18 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/new-find-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12.22.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -3483,6 +3972,12 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -3561,6 +4056,14 @@ "node": ">= 6" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", @@ -3577,6 +4080,12 @@ } ] }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -3674,6 +4183,12 @@ "node": ">=8" } }, + "node_modules/sift": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==", + "peer": true + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -3695,6 +4210,30 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "devOptional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "devOptional": true, + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3714,6 +4253,14 @@ "source-map": "^0.6.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -3732,6 +4279,16 @@ "node": ">=10" } }, + "node_modules/streamx": { + "version": "2.15.6", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.6.tgz", + "integrity": "sha512-q+vQL4AAz+FdfT137VF69Cc/APqUbxy+MDOImRrMvchJpigHj9GksgDU2LYbO9rx7RX6osWgxJB2WxhYv4SZAw==", + "dev": true, + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -3825,6 +4382,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-stream": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", + "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", + "dev": true, + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -3866,6 +4434,17 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/ts-jest": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", @@ -4090,6 +4669,18 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -4129,6 +4720,26 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4242,6 +4853,16 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 24aefe6..9742879 100644 --- a/package.json +++ b/package.json @@ -6,25 +6,29 @@ "scripts": { "start": "ts-node src/index.ts", "build": "tsc --build --clean tsconfig.json; tsc --build tsconfig.json", - "test": "jest --detectOpenHandles" + "test": "jest --detectOpenHandles --rootDir=./tests -i", + "clean": "rm package-lock.json && rm -rf node_modules" }, "keywords": [], "author": "", "license": "AGPL-3.0", "dependencies": { - "@shoginn/discordjs-mock": "^1.0.2", - "@types/node": "^20.10.5", + "@typegoose/typegoose": "^12.0.0", + "@types/node": "^20.10.6", "consola": "^3.2.3", "cron": "^3.1.6", "discord.js": "^14.14.1", "dotenv": "^16.3.1", "reflect-metadata": "^0.2.1", "tsyringe": "^4.8.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "uuid": "^9.0.1" }, "devDependencies": { + "@shoginn/discordjs-mock": "^1.0.2", "@types/jest": "^29.5.11", "jest": "^29.7.0", + "mongodb-memory-server": "^9.1.3", "ts-jest": "^29.1.1", "ts-node": "^10.9.2" } diff --git a/src/Bot.ts b/src/Bot.ts index 968becb..911cc8d 100644 --- a/src/Bot.ts +++ b/src/Bot.ts @@ -1,20 +1,30 @@ +import "reflect-metadata" +import 'dotenv/config' import { Client, Partials, ClientOptions } from 'discord.js' import { CronJob } from 'cron' import { ConsolaInstance, createConsola } from 'consola' +import { CommandsManager, ConfigManager } from "./managers" import commands from './commands' import events from './events' -import 'dotenv/config' +import { container, delay, inject, injectable, singleton } from "tsyringe" +import Environment from "./Environment" +import mongoose from "mongoose" +import { exit } from "process" /** * The main `Bot` class. */ +@injectable() +@singleton() export class Bot extends Client { /** * The logger used by the bot. */ public logger: ConsolaInstance - // TODO - // private configManager: ConfigManager + + public configManager: ConfigManager + + public commandsManager: CommandsManager /** @@ -33,10 +43,12 @@ export class Bot extends Client { * @param client The Discord client. * @param token The bot token. */ - constructor(options: ClientOptions, token: string) { + constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, configManager: ConfigManager) { super(options); this.token = token - this.logger = createConsola() + this.logger = createConsola({ level: Environment.logLevel }) + this.commandsManager = commandsManager + this.configManager = configManager } /** @@ -60,6 +72,20 @@ export class Bot extends Client { }) } + public async connectToDatabase(): Promise { + this.logger.debug('Connecting to MongoDB') + mongoose.connect(Environment.monogodbUrl, {}) + mongoose.connection.on("connected", () => { + this.logger.success('Connected to MongoDB') + }) + } + + public async disconnectFromDatabase(): Promise { + this.logger.debug('Disconnecting from MongoDB') + await mongoose.disconnect() + this.logger.success('Disconnected from MongoDB') + } + /** * Registers the bot events. */ @@ -73,6 +99,8 @@ export class Bot extends Client { } } +container.registerSingleton(Bot) + export default function initiateBot() { const clientOptions: ClientOptions = { intents: [ @@ -100,7 +128,11 @@ export default function initiateBot() { throw new Error('Token not found') } - const bot = new Bot(clientOptions, token) + container.register("options", { useValue: clientOptions }) + container.register("token", { useValue: token }) + const bot = container.resolve(Bot) + // const bot = new Bot(clientOptions, token) + bot.connectToDatabase() bot.listen() bot.startQueueGuardJob() } \ No newline at end of file diff --git a/src/Environment.ts b/src/Environment.ts new file mode 100644 index 0000000..b715696 --- /dev/null +++ b/src/Environment.ts @@ -0,0 +1,5 @@ +export default class testEnvironment { + public static monogodbUrl = process.env.MONGODB_CONNECTION_URL! + public static token = process.env.TOKEN! + public static logLevel: number = parseInt(process.env.LOG_LEVEL ?? "3") +} \ No newline at end of file diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 7960989..184afbf 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -1,6 +1,7 @@ -import { CommandInteraction, Interaction, Message, BaseMessageOptions, ApplicationCommandOptionData, BaseApplicationCommandOptionsData } from "discord.js"; +import { CommandInteraction, Interaction, Message, BaseMessageOptions } from "discord.js"; import { handleInteractionError } from "../utils/handleError"; import { Bot } from "../Bot"; +import OptionRequirement from "../types/OptionRequirement"; /** * The base command class. @@ -17,7 +18,7 @@ export default abstract class BaseCommand { /** * The command options. */ - public static options: (ApplicationCommandOptionData & Pick)[] + public static options: OptionRequirement[] /** * The interaction. */ @@ -42,7 +43,7 @@ export default abstract class BaseCommand { * Executes the command with the given arguments. * @param args The command arguments. */ - public abstract execute(...args: any[]): void; + public abstract execute(...args: any[]): Promise; /** * Sends a message to the interaction channel. @@ -56,11 +57,15 @@ export default abstract class BaseCommand { const interaction = this.interaction as CommandInteraction const messageContent = typeof content === "string" ? { content } : content - if (interaction.replied) { + if (interaction.replied || interaction.deferred) { + this.client.logger.debug(`Editing reply to interaction ${interaction.id}`) const sentContent = await interaction.editReply({ ...messageContent }) + this.client.logger.debug(`Finished edit reply to interaction ${interaction.id}`) return sentContent as Message } else { + this.client.logger.debug(`Replying to interaction ${interaction.id}`) const sentContent = await interaction.reply({ ...messageContent, fetchReply: true }) + this.client.logger.debug(`Finished reply to interaction ${interaction.id}`) return sentContent as Message } } catch (error) { @@ -73,4 +78,28 @@ export default abstract class BaseCommand { } } + protected async defer(): Promise { + try { + this.client.logger.debug(`Deferring reply to interaction ${this.interaction.id}`) + const interaction = this.interaction as CommandInteraction + await interaction.deferReply() + } catch (error) { + if (error instanceof Error) { + handleInteractionError(error, this.interaction, this.client.logger) + } else { + this.client.logger.error(error) + } + throw error + } + } + + protected async getOptionValue(option: OptionRequirement): Promise { + this.client.logger.debug(`Getting option value ${option.name} from interaction ${this.interaction.id}`) + const interaction = this.interaction as CommandInteraction + const optionValue = interaction.options.get(option.name) + if (optionValue) { + return optionValue.value as T + } + return option.default as T + } } diff --git a/src/baseEvent/BaseEvent.ts b/src/baseEvent/BaseEvent.ts index afa2e35..29508e9 100644 --- a/src/baseEvent/BaseEvent.ts +++ b/src/baseEvent/BaseEvent.ts @@ -25,5 +25,5 @@ export default abstract class BaseEvent { * Executes the event with the given arguments. * @param args The event arguments. */ - public abstract execute(...args: any[]): void; + public abstract execute(...args: any[]): Promise; } \ No newline at end of file diff --git a/src/commands/Ping.ts b/src/commands/Ping.ts index d814dd8..24354d0 100644 --- a/src/commands/Ping.ts +++ b/src/commands/Ping.ts @@ -11,14 +11,14 @@ export default class PingCommand extends BaseCommand { const messageTimestamp = res.createdTimestamp; const ping = messageTimestamp - this.interaction.createdTimestamp; - const embed = await this.mountPingEmbed(ping); + const embed = this.mountPingEmbed(ping); await this.send({ content: "Pong.", embeds: [embed] }); } - private async mountPingEmbed(ping: number): Promise { + private mountPingEmbed(ping: number): EmbedBuilder { const embed = new EmbedBuilder() .setTitle("__Response Times__") - .setColor(this.interaction.guild?.members.me?.roles.highest.color || 0x7289da) + .setColor(this.interaction.guild?.members.me?.roles?.highest.color || 0x7289da) .addFields({ name:"Bot Latency:", value:":hourglass_flowing_sand:" + ping + "ms", inline:true }) .addFields({ name:"API Latency:", value:":hourglass_flowing_sand:" + Math.round(this.client.ws.ping) + "ms", inline:true }) return embed diff --git a/src/commands/admin/UpdateBotRoles.ts b/src/commands/admin/UpdateBotRoles.ts new file mode 100644 index 0000000..fb30d88 --- /dev/null +++ b/src/commands/admin/UpdateBotRoles.ts @@ -0,0 +1,104 @@ +import { ApplicationCommandOptionType, Guild as DiscordGuild, EmbedBuilder } from "discord.js"; +import { BaseCommand } from "../../baseCommand"; +import { InternalGuildRoles, RoleScopes } from "../../models/BotRoles"; +import { Guild as DatabaseGuild } from "../../models/Guild"; +import { DocumentType, mongoose } from "@typegoose/typegoose"; + +export default class UpdateBotRolesCommand extends BaseCommand { + public static name = "updatebotroles"; + public static description = "Creates or updates the database entries for the internal roles"; + public static options = [ + { + name: "create-if-not-exists", + description: "Create the Roles on the guild with the default name if they don't exist, defaults to true", + type: ApplicationCommandOptionType.Boolean, + required: false, + default: true, + }, + ]; + + public async execute(): Promise { + await this.defer(); + if (!this.interaction.guild) { + throw new Error("Interaction is not in a guild"); + } + const dbGuild = await this.client.configManager.getGuildConfig(this.interaction.guild) + const createIfNotExists = await this.getOptionValue(UpdateBotRolesCommand.options[0]); + await this.createDbRoles(this.interaction.guild, dbGuild, createIfNotExists); + const embed = this.mountRoleEmbed(dbGuild); + await this.send({ embeds: [embed] }); + + this.client.logger.info(`Done generating internal Roles for guild ${this.interaction.guild.name}`); + } + + private mountRoleEmbed(dbGuild: DatabaseGuild): EmbedBuilder { + const description: string = `Done generating internal Roles. Internal Roles: \n` + + `${(dbGuild.guild_settings.roles?.map(x => `❯ ${x.internal_name}: <@&${x.role_id}>`).join("\n") ?? "None")}\n` + + `Unassigned Roles:\n` + + `${InternalGuildRoles.filter(x => !dbGuild.guild_settings.roles?.find(y => y.internal_name === x)).map(x => `❯ ${x}`).join("\n") ?? "None"}`; + const embed = new EmbedBuilder() + .setTitle("Administration") + .setDescription(description) + return embed + } + + private async createDbRoles(discordGuild: DiscordGuild, dbGuild: DocumentType, createIfNotExists: boolean): Promise { + // Create the roles if they don't exist + // iterate over type InternalGuildRoles + for (const internalGuildRoleName of InternalGuildRoles) { + let roleOnDiscordServer = discordGuild.roles.cache.find((role) => role.name === internalGuildRoleName); + const roleInDatabase = dbGuild.guild_settings.roles?.find((role) => role.internal_name === internalGuildRoleName); + if (!roleOnDiscordServer) { + // try to find role by id from db + if (roleInDatabase) { + const roleOnDiscordServerById = this.interaction.guild!.roles.resolve(roleInDatabase.role_id!); + if (roleOnDiscordServerById) { + roleOnDiscordServer = roleOnDiscordServerById; + if (roleInDatabase.server_role_name !== roleOnDiscordServer.name) { + this.client.logger.debug(`Role "${internalGuildRoleName}" was renamed from "${roleInDatabase.server_role_name}" to "${roleOnDiscordServer.name}". Updating DB`); + // update db + roleInDatabase.server_role_name = roleOnDiscordServer.name; + await dbGuild.save(); + } else { + this.client.logger.debug(`Role ${internalGuildRoleName} found in guild ${discordGuild.name}. Role was not renamed.`); + } + continue; + } + } + if (!createIfNotExists) { + this.client.logger.debug(`Role ${internalGuildRoleName} not found in guild ${discordGuild.name}. Skipping`); + continue; + } + this.client.logger.debug(`Role ${internalGuildRoleName} not found in guild ${discordGuild.name}. Creating`); + const newRole = await discordGuild.roles.create({ + name: internalGuildRoleName, + mentionable: false, + }); + if (!newRole) { + this.client.logger.debug(`Could not create role ${internalGuildRoleName} in guild ${discordGuild.name}`); + continue; + } + this.client.logger.debug(`Created role ${internalGuildRoleName} with id ${newRole.id} in guild ${discordGuild.name}`); + roleOnDiscordServer = newRole; + } else { + this.client.logger.debug(`Role ${internalGuildRoleName} found in guild ${discordGuild.name}`); + } + if (!roleInDatabase) { + this.client.logger.debug(`Creating role ${internalGuildRoleName} for guild ${discordGuild.name} in database`); + if (!dbGuild.guild_settings.roles) { + dbGuild.guild_settings.roles = new mongoose.Types.DocumentArray([]); + } + dbGuild.guild_settings.roles.push({ + internal_name: internalGuildRoleName, + role_id: roleOnDiscordServer.id, + scope: RoleScopes.SERVER, + server_id: discordGuild.id, + server_role_name: roleOnDiscordServer.name, + }); + await dbGuild.save(); + } else { + this.client.logger.debug(`Role ${internalGuildRoleName} found in database`); + } + } + } +} \ No newline at end of file diff --git a/src/commands/index.ts b/src/commands/index.ts index 7799924..b8ffdca 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,7 +1,9 @@ import PingCommand from './Ping'; import HelpCommand from './Help'; +import UpdateBotRolesCommand from './admin/UpdateBotRoles'; export default [ PingCommand, - HelpCommand + HelpCommand, + UpdateBotRolesCommand, ] \ No newline at end of file diff --git a/src/events/GuildCreateEvent.ts b/src/events/GuildCreateEvent.ts new file mode 100644 index 0000000..dacc036 --- /dev/null +++ b/src/events/GuildCreateEvent.ts @@ -0,0 +1,12 @@ +import { Guild } from "discord.js"; +import { BaseEvent } from "../baseEvent"; + +export default class GuildCreateEvent extends BaseEvent { + public static name = "guildCreate"; + + public execute(guild: Guild) { + this.client.configManager.getGuildConfig(guild) + this.client.commandsManager.registerSlashCommandsFor(guild) + this.client.logger.success(`Joined guild ${guild.name}`) + } +} \ No newline at end of file diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts index 4212446..84ad5b3 100644 --- a/src/events/InteractionCreateEvent.ts +++ b/src/events/InteractionCreateEvent.ts @@ -4,19 +4,20 @@ import { Interaction } from "discord.js"; export default class InteractionCreateEvent extends BaseEvent { public static name = "interactionCreate"; - public execute(interaction: Interaction) { + public async execute(interaction: Interaction) { if (!interaction.isCommand()) return; const command = this.client.commands.find(command => command.name === interaction.commandName); if (!command) return; - this.client.logger.info(`${interaction.user.tag} executed command ${command.name}`); + this.client.logger.info(`${interaction.user.tag} executed command ${command.name} with options ${JSON.stringify(interaction.options)}`); const concreteCommand = new command(interaction, this.client); try { - concreteCommand.execute(); + await concreteCommand.execute(); + this.client.logger.info(`Command ${command.name} executed successfully.`); } catch (error) { this.client.logger.error(error); interaction.reply({ content: "There was an error while executing this command!", ephemeral: true }); diff --git a/src/events/ReadyEvent.ts b/src/events/ReadyEvent.ts index cbf8026..5f5c4fc 100644 --- a/src/events/ReadyEvent.ts +++ b/src/events/ReadyEvent.ts @@ -5,40 +5,21 @@ export default class ReadyEvent extends BaseEvent { static name: string = "ready"; public async execute() { - await this.registerSlashCommandsForAllGuilds(); + await this.prepareAllGuilds(); this.setBotPresence(); this.logStats(); } - private loadCommandsData(): ApplicationCommandData[] { - const commandsData: ApplicationCommandData[] = []; - for (const command of this.client.commands) { - const commandData: ApplicationCommandData = { - name: command.name, - description: command.description, - options: command.options, - }; - commandsData.push(commandData); - } - return commandsData; - } - - private async registerSlashCommandsForAllGuilds() { - const commandsData = this.loadCommandsData(); + private async prepareAllGuilds(): Promise { const promises = this.client.guilds.cache.map(async (guild: Guild) => { - await this.registerSlashCommands(guild, commandsData); + await this.prepareGuild(guild); }); await Promise.all(promises); } - private async registerSlashCommands(guild: Guild, commandsData: ApplicationCommandData[]): Promise { - try { - await guild.commands.set(commandsData); - this.client.logger.success(`Registered commands in guild ${guild.name}`); - } catch (error) { - this.client.logger.error(`Failed to register commands in guild ${guild.name}`); - throw error; - } + private async prepareGuild(guild: Guild): Promise { + await this.client.configManager.getGuildConfig(guild); + await this.client.commandsManager.registerSlashCommandsFor(guild); } private setBotPresence(): void { diff --git a/src/index.ts b/src/index.ts index 094e60a..0b8a2f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,8 @@ +import { Severity, setGlobalOptions } from "@typegoose/typegoose" + +// TODO: is this a good idea? +setGlobalOptions({ options: { allowMixed: Severity.ALLOW } }); + import initiateBot from './Bot' initiateBot() diff --git a/src/managers/CommandsManager.ts b/src/managers/CommandsManager.ts new file mode 100644 index 0000000..84e8f9c --- /dev/null +++ b/src/managers/CommandsManager.ts @@ -0,0 +1,36 @@ +import { ApplicationCommandOptionData, ChatInputApplicationCommandData, Guild } from "discord.js"; +import { Bot } from "../Bot"; +import { delay, inject, injectable, singleton } from "tsyringe"; + +@injectable() +@singleton() +export default class CommandsManager { + protected client: Bot; + private commandsData: ChatInputApplicationCommandData[] = []; + + constructor(@inject(delay(() => Bot)) client: Bot) { + this.client = client; + this.loadCommandsData(); + } + + public async registerSlashCommandsFor(guild: Guild): Promise { + try { + await guild.commands.set(this.commandsData); + this.client.logger.info(`Registered commands in guild ${guild.name}`); + } catch (error) { + this.client.logger.error(`Failed to register commands in guild ${guild.name}`); + throw error; + } + } + + private loadCommandsData() { + for (const command of this.client.commands) { + const commandData: ChatInputApplicationCommandData = { + name: command.name, + description: command.description, + options: command.options as ApplicationCommandOptionData[], + }; + this.commandsData.push(commandData); + } + } +} \ No newline at end of file diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index b92079e..aeafd5d 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -1,39 +1,47 @@ -// import { singleton } from "tsyringe"; -// import "reflect-metadata"; -// import { GuildConfig, DefaultGuildConfig } from "types/GuildConfig"; - -// @singleton() -// class ConfigManager { -// private static instance: ConfigManager; -// public hasLoaded: boolean = false; - -// private guildsConfig: GuildConfig[] = []; -// private static readonly defaultGuildConfig: DefaultGuildConfig = { -// } - -// public constructor() { -// // this.load(); -// } - -// public static getInstance(): ConfigManager { -// if (!ConfigManager.instance) { -// ConfigManager.instance = new ConfigManager(); -// } - -// return ConfigManager.instance; -// } - -// public getGuildConfig(guildId: string): GuildConfig | DefaultGuildConfig { -// const guildConfig = this.guildsConfig.find(guildConfig => guildConfig.id === guildId); - -// if (!guildConfig) { -// return this.getDefaultGuildConfig(); -// } - -// return guildConfig; -// } - -// public getDefaultGuildConfig(): DefaultGuildConfig { -// return ConfigManager.defaultGuildConfig; -// } -// } \ No newline at end of file +import { delay, inject, injectable, singleton } from "tsyringe"; +import { Guild as DiscordGuild } from "discord.js"; +import { Guild, GuildModel } from "../models/Guild"; +import { Bot } from "../Bot"; +import { DocumentType } from "@typegoose/typegoose"; + +@injectable() +@singleton() +export default class ConfigManager { + protected client: Bot; + + constructor(@inject(delay(() => Bot)) client: Bot) { + this.client = client; + } + + public async getGuildConfig(guild: DiscordGuild): Promise> { + var guildModel = await GuildModel.findById(guild.id); + if (!guildModel) { + return await this.getDefaultGuildConfig(guild); + } + this.client.logger.info(`Config for guild ${guild.name} already exists.`) + return guildModel; + } + + public async getDefaultGuildConfig(guild: DiscordGuild): Promise> { + const newGuildData = new GuildModel({ + _id: guild.id, + name: guild.name, + member_count: guild.memberCount, + guild_settings: { + command_listen_mode: 1, + prefix: "!", + slashCommands: [], + }, + text_channels: [], + voice_channels: [], + queues: [], + }); + await newGuildData.save(); + this.client.logger.info(`Created new Guild Config for ${guild.name} (id: ${guild.id})`); + return newGuildData; + } + + public async getGuildConfigs(): Promise[]> { + return await GuildModel.find(); + } +} \ No newline at end of file diff --git a/src/managers/index.ts b/src/managers/index.ts new file mode 100644 index 0000000..2e6da17 --- /dev/null +++ b/src/managers/index.ts @@ -0,0 +1,7 @@ +import CommandsManager from "./CommandsManager"; +import ConfigManager from "./ConfigManager"; + +export { + CommandsManager, + ConfigManager +} \ No newline at end of file diff --git a/src/types/OptionRequirement.ts b/src/types/OptionRequirement.ts new file mode 100644 index 0000000..b7e9e05 --- /dev/null +++ b/src/types/OptionRequirement.ts @@ -0,0 +1,27 @@ +import { ApplicationCommandOptionType } from "discord.js"; + +/** + * The option requirement interface used for the command options. + */ +export default interface OptionRequirement { + /** + * The option name. + */ + name: string; + /** + * The option description. + */ + description: string; + /** + * The option type. + */ + type: ApplicationCommandOptionType + /** + * Whether the option is required. + */ + required: boolean + /** + * The default value of the option. + */ + default: T +} diff --git a/src/types/RoleCreationError.ts b/src/types/RoleCreationError.ts new file mode 100644 index 0000000..3ead28f --- /dev/null +++ b/src/types/RoleCreationError.ts @@ -0,0 +1,5 @@ +export class RoleCreationError extends Error { + constructor(roleName: string, guildName: string) { + super(`Role Creation Error: Role "${roleName}" could not be created on guild "${guildName}"`); + } +} \ No newline at end of file diff --git a/tests/commands/Ping.test.ts b/tests/commands/Ping.test.ts new file mode 100644 index 0000000..e1b0de0 --- /dev/null +++ b/tests/commands/Ping.test.ts @@ -0,0 +1,74 @@ +import { APIEmbedField, BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; +import PingCommand from "../../src/commands/Ping"; +import { MockDiscord } from "../mockDiscord"; +import { container } from "tsyringe"; + +describe("PingCommand", () => { + const command = PingCommand + const discord = container.resolve(MockDiscord) + let commandInstance: PingCommand + let interaction: ChatInputCommandInteraction + + beforeEach(() => { + interaction = discord.mockInteraction() + commandInstance = new command(interaction, discord.getClient()) + }) + + it("should have the correct name", () => { + expect(command.name).toBe("ping") + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Pong! Displays the api & bot latency.") + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0) + }) + + it("should first reply with pinging", async () => { + const replySpy = jest.spyOn(interaction, 'reply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledWith({ content: "Pinging...", fetchReply: true }) + }) + + it("should edit the reply with pong and message embed", async () => { + const editSpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(editSpy).toHaveBeenCalledTimes(1) + expect(editSpy).toHaveBeenCalledWith({ content: "Pong.", embeds: expect.anything() }) + + const messageContent = editSpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + + expect(embedData).toEqual({ + title: "__Response Times__", + fields: expect.arrayContaining([ + expect.objectContaining({ + name: "Bot Latency:", + value: expect.stringContaining(":hourglass_flowing_sand:"), + }), + expect.objectContaining({ + name: "API Latency:", + value: expect.stringContaining(":hourglass_flowing_sand:"), + }), + ]), + color: interaction.guild?.members.me?.roles.highest.color || 0x7289da + }) + }) + + it("should have the interaction reply as the result of the edit", async () => { + const editSpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + const reply = await interaction.fetchReply() + const spyResult = await editSpy.mock.results[0].value + expect(reply).toEqual(spyResult) + }) +}); \ No newline at end of file diff --git a/tests/commands/admin/UpdateBotRoles.test.ts b/tests/commands/admin/UpdateBotRoles.test.ts new file mode 100644 index 0000000..dd3ee74 --- /dev/null +++ b/tests/commands/admin/UpdateBotRoles.test.ts @@ -0,0 +1,210 @@ +import { ApplicationCommandOptionType, BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder, PermissionsBitField, Role, RoleCreateOptions } from "discord.js"; +import { MockDiscord } from "../../mockDiscord"; +import UpdateBotRolesCommand from "../../../src/commands/admin/UpdateBotRoles"; +import { container } from "tsyringe"; +import { mockRole } from "@shoginn/discordjs-mock"; +import { InternalGuildRoles } from "../../../src/models/BotRoles"; +import { GuildModel } from "../../../src/models/Guild"; + +describe("UpdateBotRolesCommand", () => { + const command = UpdateBotRolesCommand + const discord = container.resolve(MockDiscord) + let commandInstance: UpdateBotRolesCommand + let interaction: ChatInputCommandInteraction + let roles: Array = [] + + beforeEach(() => { + interaction = discord.mockInteraction() + const bot = discord.getClient() + interaction.guild!.roles.create = jest.fn().mockImplementation(async (role: RoleCreateOptions) => { + bot.logger.debug(`Creating role ${role.name}`) + const newRole = mockRole(bot, PermissionsBitField.Default, interaction.guild!, { name: role.name, id: `${role.name}_id_${interaction.guild!.id}` }) + roles.push(newRole) + return newRole + }) + interaction.guild!.roles.resolve = jest.fn().mockImplementation((roleId: string) => { + bot.logger.debug(`Resolving role ${roleId}`) + return roles.find((role) => role.id === roleId) + }) + jest.spyOn(command.prototype as any, 'getOptionValue').mockImplementation(async () => true) + commandInstance = new command(interaction, discord.getClient()) + }) + + it("should have the correct name", () => { + expect(command.name).toBe("updatebotroles") + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Creates or updates the database entries for the internal roles") + }) + + it("should have one option", () => { + expect(command.options).toHaveLength(1) + }) + + it("should have the correct option", () => { + expect(command.options[0]).toStrictEqual({ + name: "create-if-not-exists", + description: "Create the Roles on the guild with the default name if they don't exist, defaults to true", + type: ApplicationCommandOptionType.Boolean, + required: false, + default: true, + }) + }) + + it("should create the discord roles if they don't exist and option is true", async () => { + await commandInstance.execute() + + expect(interaction.guild!.roles.create).toHaveBeenCalledTimes(InternalGuildRoles.length) + for (const internalRole of InternalGuildRoles) { + expect(interaction.guild!.roles.create).toHaveBeenCalledWith({ + name: internalRole, + mentionable: false, + }) + } + }) + + it("should not create the discord roles if they don't exist and option is false", async () => { + const dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + + jest.spyOn(command.prototype as any, 'getOptionValue').mockImplementation(async () => false) + await commandInstance.execute() + + expect(interaction.guild!.roles.create).not.toHaveBeenCalled() + expect(dbGuild.guild_settings.roles).toHaveLength(0) + }) + + it("should create the database entries if they don't exist", async () => { + let dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + await commandInstance.execute() + dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + + expect(dbGuild.guild_settings.roles).toHaveLength(InternalGuildRoles.length) + for (const internalRole of InternalGuildRoles) { + expect(dbGuild.guild_settings.roles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + internal_name: internalRole, + role_id: `${internalRole}_id_${interaction.guild!.id}`, + scope: "server", + server_id: interaction.guild!.id, + server_role_name: internalRole, + }) + ]) + ) + } + }) + + it("should update the database if the discord role name changed", async () => { + let dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + // create the roles + for (const internalRole of InternalGuildRoles) { + // create the role on the server + const role = await interaction.guild!.roles.create({ + name: internalRole, + mentionable: false, + }) + // create the role in the db + dbGuild.guild_settings.roles?.push({ + internal_name: internalRole, + role_id: role.id, + scope: "server", + server_id: interaction.guild!.id, + server_role_name: role.name, + }) + // change the role name + roles.find((r) => r.id === role.id)!.name = `${internalRole}_changed` + } + await dbGuild.save() + + await commandInstance.execute() + dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + + expect(dbGuild.guild_settings.roles).toHaveLength(InternalGuildRoles.length) + for (const internalRole of InternalGuildRoles) { + expect(dbGuild.guild_settings.roles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + _id: expect.anything(), + internal_name: internalRole, + role_id: `${internalRole}_id_${interaction.guild!.id}`, + scope: "server", + server_id: interaction.guild!.id, + server_role_name: `${internalRole}_changed`, + }) + ]) + ) + } + }) + + it("should find the discord role by id if the name is different to the internal name", async () => { + let dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + // create the roles + for (const internalRole of InternalGuildRoles) { + // create the role on the server + const role = await interaction.guild!.roles.create({ + name: `${internalRole}_changed`, + mentionable: false, + }) + // create the role in the db + dbGuild.guild_settings.roles?.push({ + internal_name: internalRole, + role_id: role.id, + scope: "server", + server_id: interaction.guild!.id, + server_role_name: role.name, + }) + } + await dbGuild.save() + + + const guildSaveSpy = jest.spyOn(GuildModel.prototype, 'save') + await commandInstance.execute() + dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + + expect(dbGuild.guild_settings.roles).toHaveLength(InternalGuildRoles.length) + for (const internalRole of InternalGuildRoles) { + expect(dbGuild.guild_settings.roles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + _id: expect.anything(), + internal_name: internalRole, + role_id: `${internalRole}_changed_id_${interaction.guild!.id}`, + scope: "server", + server_id: interaction.guild!.id, + server_role_name: `${internalRole}_changed`, + }) + ]) + ) + } + + // check db was not updated + expect(guildSaveSpy).not.toHaveBeenCalled() + }); + + it("should defer the reply", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply') + await commandInstance.execute() + + expect(deferSpy).toHaveBeenCalled() + }) + + it("should edit the deferred reply with an embed with the roles", async () => { + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + expect(embedData.title).toBe("Administration") + expect(embedData.description).toContain("Done generating internal Roles. Internal Roles:") + expect(embedData.description).toContain("Unassigned Roles:") + for (const internalRole of InternalGuildRoles) { + expect(embedData.description).toContain(internalRole) + } + }) +}); diff --git a/tests/globalSetup.ts b/tests/globalSetup.ts new file mode 100644 index 0000000..18b79f0 --- /dev/null +++ b/tests/globalSetup.ts @@ -0,0 +1,20 @@ +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { config } from './testutils'; +import { mongoose } from '@typegoose/typegoose'; + +export = async function globalSetup() { + if (config.Memory) { // Config to decide if an mongodb-memory-server instance should be used + // it's needed in global space, because we don't want to create a new instance every test-suite + const instance = await MongoMemoryServer.create(); + const uri = instance.getUri(); + (global as any).__MONGOINSTANCE = instance; + process.env.MONGODB_CONNECTION_URL = uri.slice(0, uri.lastIndexOf('/')); + } else { + process.env.MONGODB_CONNECTION_URL = `mongodb://${config.IP}:${config.Port}`; + } + + // The following is to make sure the database is clean before an test starts + await mongoose.connect(`${process.env.MONGODB_CONNECTION_URL}/${config.Database}`); + await mongoose.connection.db.dropDatabase(); + await mongoose.disconnect(); +}; diff --git a/tests/globalTeardown.ts b/tests/globalTeardown.ts new file mode 100644 index 0000000..1915b35 --- /dev/null +++ b/tests/globalTeardown.ts @@ -0,0 +1,9 @@ +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { config } from './testutils'; + +export = async function globalTeardown() { + if (config.Memory) { // Config to decide if an mongodb-memory-server instance should be used + const instance: MongoMemoryServer = (global as any).__MONGOINSTANCE; + await instance.stop(); + } +}; \ No newline at end of file diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 01d0489..9dc8f08 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -10,85 +10,107 @@ import { import "reflect-metadata" import { Bot } from '../src/Bot'; import { ChatInputCommandInteraction, Guild, GuildMember, TextBasedChannel, TextChannel, User } from 'discord.js'; -import { singleton } from 'tsyringe'; +import { container, singleton } from 'tsyringe'; +import { randomInt } from 'crypto'; +import assert from 'assert'; @singleton() export class MockDiscord { private client!: Bot; - private guild!: Guild; - private channel!: TextChannel; - private user!: User; - private guildMember!: GuildMember; - private interaction!: ChatInputCommandInteraction; - - - getClient(withBots: boolean = false): Bot { - if (withBots) { - const botUser = mockUser(this.client, { bot: true }); - mockGuildMember({ - client: this.client, - user: botUser, - guild: this.guild, - }); - } + // private guild!: Guild; + // private channel!: TextChannel; + // private user!: User; + // private guildMember!: GuildMember; + // private interaction!: ChatInputCommandInteraction; + + + public getClient(): Bot { return this.client; } - getUser(): User { - return this.user; - } - getGuild(): Guild { - return this.guild; - } - getGuildMember(): GuildMember { - return this.guildMember; - } + // getClient(withBots: boolean = false): Bot { + // if (withBots) { + // const botUser = mockUser(this.client, { bot: true }); + // mockGuildMember({ + // client: this.client, + // user: botUser, + // guild: this.guild, + // }); + // } + // return this.client; + // } - getChannel(): TextBasedChannel { - return this.channel; - } + // getUser(): User { + // return this.user; + // } - getInteraction(): ChatInputCommandInteraction { - return this.interaction; - } + // resetGuild(): void { + // this.mockGuild(); + // } + + // getGuild(): Guild { + // return this.guild; + // } + + // getGuildMember(): GuildMember { + // return this.guildMember; + // } + + // getChannel(): TextBasedChannel { + // return this.channel; + // } + + // getNewInteraction(): ChatInputCommandInteraction { + // this.mockInteraction(); + // return this.interaction; + // } public constructor() { - this.mockClient(); - this.mockGuild(); - this.mockUser(); - this.mockGuildMember(); - this.mockChannel(); - this.mockInteraction(); + this.client = this.mockClient(); + // this.mockGuild(); + // this.mockUser(); + // this.mockGuildMember(); + // this.mockChannel(); + // this.mockInteraction(); } - private mockClient(): void { - // this.client = new Client({ intents: [] }); - this.client = new Bot({ intents: [] }, "test"); - mockClientUser(this.client); + private mockClient(): Bot { + const clientOptions = { intents: [] }; + container.register("options", { useValue: clientOptions }) + container.register("token", { useValue: "test" }) + const client = container.resolve(Bot); + mockClientUser(client); - this.client.login = jest.fn(() => Promise.resolve('LOGIN_TOKEN')) as any; + client.login = jest.fn(() => Promise.resolve('LOGIN_TOKEN')) as any; + return client; } - private mockGuild(): void { - this.guild = mockGuild(this.client); + public mockGuild(): Guild { + const guildId = randomInt(281474976710655).toString(); + return mockGuild(this.client, undefined, { name: guildId, id: guildId }); } - private mockChannel(): void { - this.channel = mockTextChannel(this.client, this.guild); + + public mockChannel(guild: Guild = this.mockGuild()): TextChannel { + return mockTextChannel(this.client, guild); } - private mockUser(): void { - this.user = mockUser(this.client); + public mockUser(): User { + return mockUser(this.client); } - private mockGuildMember(): void { - this.guildMember = mockGuildMember({ + public mockGuildMember(user: User = this.mockUser(), guild: Guild = this.mockGuild()): GuildMember { + return mockGuildMember({ client: this.client, - user: this.user, - guild: this.guild, + user: user, + guild: guild, }); } - private mockInteraction(): void { - this.interaction = mockChatInputCommandInteraction({ client: this.client, name: "test", id: "test", channel: this.channel, member: this.guildMember }) + public mockInteraction(channel?: TextChannel, guildMember?: GuildMember): ChatInputCommandInteraction { + const guild = this.mockGuild(); + channel = channel ? channel : this.mockChannel(guild); + guildMember = guildMember ? guildMember : this.mockGuildMember(this.mockUser(), guild); + assert(guildMember.guild === guild); + return mockChatInputCommandInteraction({ client: this.client, name: "test", id: "test", channel: channel, member: guildMember }) } } \ No newline at end of file diff --git a/tests/ping.test.ts b/tests/ping.test.ts deleted file mode 100644 index c51cd39..0000000 --- a/tests/ping.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { APIEmbedField, BaseMessageOptions, EmbedBuilder } from "discord.js"; -import PingCommand from "../src/commands/Ping"; -import { MockDiscord } from "./mockDiscord"; - -describe("PingCommand", () => { - it("should have the correct name", () => { - expect(PingCommand.name).toBe("ping") - }) - - it("should have the correct description", () => { - expect(PingCommand.description).toBe("Pong! Displays the api & bot latency.") - }) - - it("should have no options", () => { - expect(PingCommand.options).toHaveLength(0) - }) - - it("should first reply with pinging", async () => { - const command = PingCommand - const discord = new MockDiscord() - const interaction = discord.getInteraction() - const replySpy = jest.spyOn(interaction, 'reply') - const bot = discord.getClient() - const commandInstance = new command(interaction, bot) - await commandInstance.execute() - - expect(replySpy).toHaveBeenCalledWith({ content: "Pinging...", fetchReply: true }) - - }) - - it("should edit the reply with pong and message embed", async () => { - const command = PingCommand - const discord = new MockDiscord() - const interaction = discord.getInteraction() - const editSpy = jest.spyOn(interaction, 'editReply') - const bot = discord.getClient() - const commandInstance = new command(interaction, bot) - await commandInstance.execute() - - expect(editSpy).toHaveBeenCalledWith({ content: "Pong.", embeds: expect.anything() }) - const messageContent = editSpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - expect(embedData.title).toBe("__Response Times__") - const embedFields = embedData.fields as APIEmbedField[] - expect(embedFields).toHaveLength(2) - const botLatencyField = embedFields[0] - expect(botLatencyField.name).toBe("Bot Latency:") - expect(botLatencyField.value).toContain(":hourglass_flowing_sand:") - const apiLatencyField = embedFields[1] - expect(apiLatencyField.name).toBe("API Latency:") - expect(apiLatencyField.value).toContain(":hourglass_flowing_sand:") - }) - - it("should have the interaction reply as the result of the edit", async () => { - const command = PingCommand - const discord = new MockDiscord() - const interaction = discord.getInteraction() - const editSpy = jest.spyOn(interaction, 'editReply') - const bot = discord.getClient() - const commandInstance = new command(interaction, bot) - await commandInstance.execute() - - const reply = await interaction.fetchReply() - const spyResult = await editSpy.mock.results[0].value - expect(reply).toEqual(spyResult) - }) -}); \ No newline at end of file diff --git a/tests/testSetup.ts b/tests/testSetup.ts new file mode 100644 index 0000000..17f6310 --- /dev/null +++ b/tests/testSetup.ts @@ -0,0 +1,17 @@ +import { Severity, setGlobalOptions } from "@typegoose/typegoose" + +// TODO: is this a good idea? +setGlobalOptions({ options: { allowMixed: Severity.ALLOW } }); + +import { mongoose } from "@typegoose/typegoose"; +import { after } from "node:test"; + +beforeAll(async () => { + // put your client connection code here, example with mongoose: + await mongoose.connect(process.env.MONGODB_CONNECTION_URL!, {}); +}); + +afterAll(async () => { + // put your client disconnection code here, example with mongodb: + await mongoose.disconnect(); +}); \ No newline at end of file diff --git a/tests/testutils.ts b/tests/testutils.ts index e69de29..f318f0e 100644 --- a/tests/testutils.ts +++ b/tests/testutils.ts @@ -0,0 +1,8 @@ +const config = { + Memory: true, + IP: '127.0.0.1', + Port: '27017', + Database: 'test' +} + +export { config } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1329772..8099242 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,13 +2,7 @@ "compilerOptions": { "target": "es2022", "module": "commonjs", - "baseUrl": "./src", - "paths": { - "@types": ["types.ts"], - "@baseCommand": ["baseCommand"], - "@commands": ["commands"], - "@utils/*": ["utils/*"] - }, + "moduleResolution": "node", "resolveJsonModule": true, "outDir": "./dist", "esModuleInterop": true, From 9e6f38687728c4e67933d4c07d367e5142ff200f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 2 Jan 2024 21:48:22 +0100 Subject: [PATCH 006/130] Split up into smaller functions and add documentation --- src/commands/admin/UpdateBotRoles.ts | 190 ++++++++++++++++++--------- 1 file changed, 129 insertions(+), 61 deletions(-) diff --git a/src/commands/admin/UpdateBotRoles.ts b/src/commands/admin/UpdateBotRoles.ts index fb30d88..16eb5dc 100644 --- a/src/commands/admin/UpdateBotRoles.ts +++ b/src/commands/admin/UpdateBotRoles.ts @@ -1,8 +1,8 @@ -import { ApplicationCommandOptionType, Guild as DiscordGuild, EmbedBuilder } from "discord.js"; +import { ApplicationCommandOptionType, Guild as DiscordGuild, EmbedBuilder, Role } from "discord.js"; import { BaseCommand } from "../../baseCommand"; -import { InternalGuildRoles, RoleScopes } from "../../models/BotRoles"; +import { DBRole, InternalGuildRoles, RoleScopes } from "../../models/BotRoles"; import { Guild as DatabaseGuild } from "../../models/Guild"; -import { DocumentType, mongoose } from "@typegoose/typegoose"; +import { ArraySubDocumentType, DocumentType, mongoose } from "@typegoose/typegoose"; export default class UpdateBotRolesCommand extends BaseCommand { public static name = "updatebotroles"; @@ -17,88 +17,156 @@ export default class UpdateBotRolesCommand extends BaseCommand { }, ]; + /** + * The guild saved in the database. + */ + private dbGuild!: DocumentType; + public async execute(): Promise { await this.defer(); if (!this.interaction.guild) { throw new Error("Interaction is not in a guild"); } - const dbGuild = await this.client.configManager.getGuildConfig(this.interaction.guild) + this.dbGuild = await this.client.configManager.getGuildConfig(this.interaction.guild) const createIfNotExists = await this.getOptionValue(UpdateBotRolesCommand.options[0]); - await this.createDbRoles(this.interaction.guild, dbGuild, createIfNotExists); - const embed = this.mountRoleEmbed(dbGuild); + await this.createDbRoles(createIfNotExists); + const embed = this.mountRoleEmbed(); await this.send({ embeds: [embed] }); this.client.logger.info(`Done generating internal Roles for guild ${this.interaction.guild.name}`); } - private mountRoleEmbed(dbGuild: DatabaseGuild): EmbedBuilder { + /** + * Returns the role embed to be sent to the user. + * @returns The embed to be sent to the user. + */ + private mountRoleEmbed(): EmbedBuilder { const description: string = `Done generating internal Roles. Internal Roles: \n` + - `${(dbGuild.guild_settings.roles?.map(x => `❯ ${x.internal_name}: <@&${x.role_id}>`).join("\n") ?? "None")}\n` + + `${(this.dbGuild.guild_settings.roles?.map(x => `❯ ${x.internal_name}: <@&${x.role_id}>`).join("\n") ?? "None")}\n` + `Unassigned Roles:\n` + - `${InternalGuildRoles.filter(x => !dbGuild.guild_settings.roles?.find(y => y.internal_name === x)).map(x => `❯ ${x}`).join("\n") ?? "None"}`; + `${InternalGuildRoles.filter(x => !this.dbGuild.guild_settings.roles?.find(y => y.internal_name === x)).map(x => `❯ ${x}`).join("\n") ?? "None"}`; const embed = new EmbedBuilder() .setTitle("Administration") .setDescription(description) return embed } - private async createDbRoles(discordGuild: DiscordGuild, dbGuild: DocumentType, createIfNotExists: boolean): Promise { - // Create the roles if they don't exist - // iterate over type InternalGuildRoles + /** + * Creates the internal roles in the database and on the guild if they don't exist. + * @param createIfNotExists Whether to create the roles on the guild if they don't exist. + */ + private async createDbRoles(createIfNotExists: boolean): Promise { for (const internalGuildRoleName of InternalGuildRoles) { - let roleOnDiscordServer = discordGuild.roles.cache.find((role) => role.name === internalGuildRoleName); - const roleInDatabase = dbGuild.guild_settings.roles?.find((role) => role.internal_name === internalGuildRoleName); - if (!roleOnDiscordServer) { - // try to find role by id from db - if (roleInDatabase) { - const roleOnDiscordServerById = this.interaction.guild!.roles.resolve(roleInDatabase.role_id!); - if (roleOnDiscordServerById) { - roleOnDiscordServer = roleOnDiscordServerById; - if (roleInDatabase.server_role_name !== roleOnDiscordServer.name) { - this.client.logger.debug(`Role "${internalGuildRoleName}" was renamed from "${roleInDatabase.server_role_name}" to "${roleOnDiscordServer.name}". Updating DB`); - // update db - roleInDatabase.server_role_name = roleOnDiscordServer.name; - await dbGuild.save(); - } else { - this.client.logger.debug(`Role ${internalGuildRoleName} found in guild ${discordGuild.name}. Role was not renamed.`); - } - continue; + const roleOnDiscordServer = await this.findRoleOnDiscordServer(internalGuildRoleName); + const roleInDatabase = this.dbGuild.guild_settings.roles?.find((role) => role.internal_name === internalGuildRoleName); + await this.processRole(internalGuildRoleName, roleInDatabase, roleOnDiscordServer, createIfNotExists); + } + } + + /** + * Returns the role with the given name on the guild. + * @param internalGuildRoleName The name of the role to find. + * @returns The role if found, undefined otherwise. + */ + private async findRoleOnDiscordServer(internalGuildRoleName: string): Promise { + return this.interaction.guild!.roles.cache.find((role) => role.name === internalGuildRoleName); + } + + /** + * Returns the role with the given id on the guild. + * @param roleId The id of the role to find. + * @returns The role if found, null otherwise. + */ + private async findRoleByIdFromDatabase(roleId: string): Promise { + return this.interaction.guild!.roles.resolve(roleId); + } + + /** + * Processes the role with the given name. + * + * This means: + * - If the role doesn't exist on the guild, create it if `createIfNotExists` is true. + * - If the role doesn't exist in the database, create it. + * - If the role exists in the database but has a different name, update the name in the database. + * + * @param internalGuildRoleName The name of the role to process. + * @param roleInDatabase The role in the database. + * @param roleOnDiscordServer The role on the guild. + * @param createIfNotExists Whether to create the role on the guild if it doesn't exist. + */ + private async processRole(internalGuildRoleName: string, roleInDatabase: ArraySubDocumentType | undefined, roleOnDiscordServer: Role | undefined, createIfNotExists: boolean): Promise { + if (!roleOnDiscordServer) { + if (roleInDatabase) { + const roleOnDiscordServerById = await this.findRoleByIdFromDatabase(roleInDatabase.role_id!); + if (roleOnDiscordServerById) { + roleOnDiscordServer = roleOnDiscordServerById; + if (roleInDatabase.server_role_name !== roleOnDiscordServer.name) { + await this.updateRoleInDatabase(roleInDatabase, roleOnDiscordServer.name); + } else { + this.client.logger.debug(`Role ${internalGuildRoleName} found in guild ${this.interaction.guild!.name}. Role was not renamed.`); } + return; } - if (!createIfNotExists) { - this.client.logger.debug(`Role ${internalGuildRoleName} not found in guild ${discordGuild.name}. Skipping`); - continue; - } - this.client.logger.debug(`Role ${internalGuildRoleName} not found in guild ${discordGuild.name}. Creating`); - const newRole = await discordGuild.roles.create({ - name: internalGuildRoleName, - mentionable: false, - }); - if (!newRole) { - this.client.logger.debug(`Could not create role ${internalGuildRoleName} in guild ${discordGuild.name}`); - continue; - } - this.client.logger.debug(`Created role ${internalGuildRoleName} with id ${newRole.id} in guild ${discordGuild.name}`); - roleOnDiscordServer = newRole; - } else { - this.client.logger.debug(`Role ${internalGuildRoleName} found in guild ${discordGuild.name}`); } - if (!roleInDatabase) { - this.client.logger.debug(`Creating role ${internalGuildRoleName} for guild ${discordGuild.name} in database`); - if (!dbGuild.guild_settings.roles) { - dbGuild.guild_settings.roles = new mongoose.Types.DocumentArray([]); - } - dbGuild.guild_settings.roles.push({ - internal_name: internalGuildRoleName, - role_id: roleOnDiscordServer.id, - scope: RoleScopes.SERVER, - server_id: discordGuild.id, - server_role_name: roleOnDiscordServer.name, - }); - await dbGuild.save(); - } else { - this.client.logger.debug(`Role ${internalGuildRoleName} found in database`); + if (!createIfNotExists) { + this.client.logger.debug(`Role ${internalGuildRoleName} not found in guild ${this.interaction.guild!.name}. Skipping`); + return; } + roleOnDiscordServer = await this.createRoleOnDiscordServer(internalGuildRoleName); + } else { + this.client.logger.debug(`Role ${internalGuildRoleName} found in guild ${this.interaction.guild!.name}`); + } + + if (!roleInDatabase) { + await this.createRoleInDatabase(internalGuildRoleName, roleOnDiscordServer!); + } else { + this.client.logger.debug(`Role ${internalGuildRoleName} found in database`); + } + } + + /** + * Updates the role name in the database. + * @param roleInDatabase The role in the database. + * @param newRoleName The new name of the role. + */ + private async updateRoleInDatabase(roleInDatabase: ArraySubDocumentType, newRoleName: string): Promise { + this.client.logger.debug(`Role "${roleInDatabase.internal_name}" was renamed from "${roleInDatabase.server_role_name}" to "${newRoleName}". Updating DB`); + roleInDatabase.server_role_name = newRoleName; + await this.dbGuild.save(); + } + + /** + * Creates the role with the given name on the guild. + * @param internalGuildRoleName The name of the role to create. + * @returns The created role. + */ + private async createRoleOnDiscordServer(internalGuildRoleName: string): Promise { + this.client.logger.debug(`Role ${internalGuildRoleName} not found in guild ${this.interaction.guild!.name}. Creating`); + const newRole = await this.interaction.guild!.roles.create({ + name: internalGuildRoleName, + mentionable: false, + }); + this.client.logger.debug(`Created role ${internalGuildRoleName} with id ${newRole.id} in guild ${this.interaction.guild!.name}`); + return newRole; + } + + /** + * Creates the role from the discord guid with the given name in the database. + * @param internalGuildRoleName The name of the role to create. + * @param roleOnDiscordServer The role on the discord guild. + */ + private async createRoleInDatabase(internalGuildRoleName: string, roleOnDiscordServer: Role): Promise { + this.client.logger.debug(`Creating role ${internalGuildRoleName} for guild ${this.interaction.guild!.name} in database`); + if (!this.dbGuild.guild_settings.roles) { + this.dbGuild.guild_settings.roles = new mongoose.Types.DocumentArray([]); } + this.dbGuild.guild_settings.roles.push({ + internal_name: internalGuildRoleName, + role_id: roleOnDiscordServer.id, + scope: RoleScopes.SERVER, + server_id: this.interaction.guild!.id, + server_role_name: roleOnDiscordServer.name, + }); + await this.dbGuild.save(); } } \ No newline at end of file From 7c785515fd990d1df8ddec1217fbba56417e745c Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 2 Jan 2024 21:48:50 +0100 Subject: [PATCH 007/130] Split up tests to have clear intents --- tests/commands/admin/UpdateBotRoles.test.ts | 30 ++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/commands/admin/UpdateBotRoles.test.ts b/tests/commands/admin/UpdateBotRoles.test.ts index dd3ee74..15912f9 100644 --- a/tests/commands/admin/UpdateBotRoles.test.ts +++ b/tests/commands/admin/UpdateBotRoles.test.ts @@ -125,7 +125,6 @@ describe("UpdateBotRolesCommand", () => { expect(dbGuild.guild_settings.roles).toEqual( expect.arrayContaining([ expect.objectContaining({ - _id: expect.anything(), internal_name: internalRole, role_id: `${internalRole}_id_${interaction.guild!.id}`, scope: "server", @@ -157,8 +156,6 @@ describe("UpdateBotRolesCommand", () => { } await dbGuild.save() - - const guildSaveSpy = jest.spyOn(GuildModel.prototype, 'save') await commandInstance.execute() dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) @@ -177,10 +174,35 @@ describe("UpdateBotRolesCommand", () => { ]) ) } + }); + + it("should not update the database if the discord role name is the same", async () => { + let dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + // create the roles + for (const internalRole of InternalGuildRoles) { + // create the role on the server + const role = await interaction.guild!.roles.create({ + name: `${internalRole}_changed`, + mentionable: false, + }) + // create the role in the db + dbGuild.guild_settings.roles?.push({ + internal_name: internalRole, + role_id: role.id, + scope: "server", + server_id: interaction.guild!.id, + server_role_name: role.name, + }) + } + await dbGuild.save() + + const guildSaveSpy = jest.spyOn(GuildModel.prototype, 'save') + await commandInstance.execute() + dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) // check db was not updated expect(guildSaveSpy).not.toHaveBeenCalled() - }); + }) it("should defer the reply", async () => { const deferSpy = jest.spyOn(interaction, 'deferReply') From ec95d82e63bc6f7ad5326c833993e211377db556 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 2 Jan 2024 21:49:14 +0100 Subject: [PATCH 008/130] Remove unused error --- src/types/RoleCreationError.ts | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/types/RoleCreationError.ts diff --git a/src/types/RoleCreationError.ts b/src/types/RoleCreationError.ts deleted file mode 100644 index 3ead28f..0000000 --- a/src/types/RoleCreationError.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class RoleCreationError extends Error { - constructor(roleName: string, guildName: string) { - super(`Role Creation Error: Role "${roleName}" could not be created on guild "${guildName}"`); - } -} \ No newline at end of file From 3691140cc8dd5cb51246bd2b8250074b73b698a3 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 2 Jan 2024 21:50:31 +0100 Subject: [PATCH 009/130] Add documentation --- src/commands/Ping.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/Ping.ts b/src/commands/Ping.ts index 24354d0..5d0f0de 100644 --- a/src/commands/Ping.ts +++ b/src/commands/Ping.ts @@ -15,6 +15,11 @@ export default class PingCommand extends BaseCommand { await this.send({ content: "Pong.", embeds: [embed] }); } + /** + * Returns the ping embed to be sent to the user. + * @param ping The ping to be displayed. + * @returns The embed to be sent to the user. + */ private mountPingEmbed(ping: number): EmbedBuilder { const embed = new EmbedBuilder() .setTitle("__Response Times__") From 62c729478574184027b3b59bf90bce107324cfee Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 2 Jan 2024 22:07:54 +0100 Subject: [PATCH 010/130] Delay config manager injection --- src/Bot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bot.ts b/src/Bot.ts index 911cc8d..feb282e 100644 --- a/src/Bot.ts +++ b/src/Bot.ts @@ -43,7 +43,7 @@ export class Bot extends Client { * @param client The Discord client. * @param token The bot token. */ - constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, configManager: ConfigManager) { + constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager) { super(options); this.token = token this.logger = createConsola({ level: Environment.logLevel }) From bae653a4228e1924d3ded1d21333f7388a18396f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 10:24:43 +0100 Subject: [PATCH 011/130] Add guild create event tests --- tests/events/GuildCreateEvent.test.ts | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/events/GuildCreateEvent.test.ts diff --git a/tests/events/GuildCreateEvent.test.ts b/tests/events/GuildCreateEvent.test.ts new file mode 100644 index 0000000..3c18504 --- /dev/null +++ b/tests/events/GuildCreateEvent.test.ts @@ -0,0 +1,52 @@ +import { container } from "tsyringe" +import GuildCreateEvent from "../../src/events/GuildCreateEvent" +import { MockDiscord } from "../mockDiscord" +import { Guild } from "discord.js" +import { CommandsManager } from "../../src/managers" +import { GuildModel } from "../../src/models/Guild" + +describe("GuildCreateEvent", () => { + const event = GuildCreateEvent + const discord = container.resolve(MockDiscord) + let eventInstance: GuildCreateEvent + let guild: Guild + + beforeEach(() => { + eventInstance = new event(discord.getClient()) + guild = discord.mockGuild() + guild.commands.set = jest.fn().mockImplementation(() => Promise.resolve()) + }) + + it("should have the correct name", () => { + expect(event.name).toBe("guildCreate") + }) + + it("should log the guild name and id", async () => { + const logSpy = jest.spyOn(discord.getClient().logger, 'success') + await eventInstance.execute(guild) + + expect(logSpy).toHaveBeenCalledWith(`Joined guild ${guild.name} (id: ${guild.id})`) + }) + + it("should create a new guild in the database", async () => { + const findSpy = jest.spyOn(GuildModel, 'findById') + const saveSpy = jest.spyOn(GuildModel.prototype, 'save') + await eventInstance.execute(guild) + + expect(findSpy).toHaveBeenCalledTimes(1) + expect(findSpy).toHaveBeenCalledWith(guild.id) + + expect(saveSpy).toHaveBeenCalledTimes(1) + const saveSpyRes = await saveSpy.mock.results[0].value + expect(saveSpyRes).toMatchObject({ _id: guild.id }) + }) + + it("should register slash commands for the guild", async () => { + const commandsManager = container.resolve(CommandsManager) + const registerSpy = jest.spyOn(commandsManager, 'registerSlashCommandsFor') + await eventInstance.execute(guild) + + expect(registerSpy).toHaveBeenCalledTimes(1) + expect(registerSpy).toHaveBeenCalledWith(guild) + }) +}) \ No newline at end of file From 4865c911a63f11ac6ea6d630f0f8454a1a195b8f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 10:25:06 +0100 Subject: [PATCH 012/130] Fix guid create event --- src/events/GuildCreateEvent.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/events/GuildCreateEvent.ts b/src/events/GuildCreateEvent.ts index dacc036..21c86ff 100644 --- a/src/events/GuildCreateEvent.ts +++ b/src/events/GuildCreateEvent.ts @@ -4,9 +4,9 @@ import { BaseEvent } from "../baseEvent"; export default class GuildCreateEvent extends BaseEvent { public static name = "guildCreate"; - public execute(guild: Guild) { - this.client.configManager.getGuildConfig(guild) - this.client.commandsManager.registerSlashCommandsFor(guild) - this.client.logger.success(`Joined guild ${guild.name}`) + public async execute(guild: Guild) { + await this.client.configManager.getGuildConfig(guild) + await this.client.commandsManager.registerSlashCommandsFor(guild) + this.client.logger.success(`Joined guild ${guild.name} (id: ${guild.id})`) } } \ No newline at end of file From 0126f36668f65c321d04625e3fa7e1a1504060fc Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 10:25:34 +0100 Subject: [PATCH 013/130] Add more logs --- src/managers/ConfigManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index aeafd5d..4b6a789 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -16,6 +16,7 @@ export default class ConfigManager { public async getGuildConfig(guild: DiscordGuild): Promise> { var guildModel = await GuildModel.findById(guild.id); if (!guildModel) { + this.client.logger.debug(`Config for guild ${guild.name} does not exist. Creating...`) return await this.getDefaultGuildConfig(guild); } this.client.logger.info(`Config for guild ${guild.name} already exists.`) From aa59ce0cfa9d5afb8aa13694246601fa3075edff Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 10:25:44 +0100 Subject: [PATCH 014/130] Remove unused imports --- tests/commands/Ping.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/Ping.test.ts b/tests/commands/Ping.test.ts index e1b0de0..2e5490a 100644 --- a/tests/commands/Ping.test.ts +++ b/tests/commands/Ping.test.ts @@ -1,4 +1,4 @@ -import { APIEmbedField, BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; +import { BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; import PingCommand from "../../src/commands/Ping"; import { MockDiscord } from "../mockDiscord"; import { container } from "tsyringe"; From 75034cb2b17c839fc3e638564bab5a560568d78d Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 11:23:11 +0100 Subject: [PATCH 015/130] Improve guild create test --- tests/events/GuildCreateEvent.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/events/GuildCreateEvent.test.ts b/tests/events/GuildCreateEvent.test.ts index 3c18504..9e42abe 100644 --- a/tests/events/GuildCreateEvent.test.ts +++ b/tests/events/GuildCreateEvent.test.ts @@ -25,6 +25,7 @@ describe("GuildCreateEvent", () => { const logSpy = jest.spyOn(discord.getClient().logger, 'success') await eventInstance.execute(guild) + expect(logSpy).toHaveBeenCalledTimes(1) expect(logSpy).toHaveBeenCalledWith(`Joined guild ${guild.name} (id: ${guild.id})`) }) From e23cb1e9a31ab13eca6e85bf37a6b5107c4c3801 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 11:23:21 +0100 Subject: [PATCH 016/130] Add ready event test --- tests/events/ReadyEvent.test.ts | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/events/ReadyEvent.test.ts diff --git a/tests/events/ReadyEvent.test.ts b/tests/events/ReadyEvent.test.ts new file mode 100644 index 0000000..26c2a1b --- /dev/null +++ b/tests/events/ReadyEvent.test.ts @@ -0,0 +1,81 @@ +import { container } from "tsyringe" +import ReadyEvent from "../../src/events/ReadyEvent" +import { MockDiscord } from "../mockDiscord" +import { ActivityType, Guild } from "discord.js" +import { GuildModel } from "../../src/models/Guild" + + +describe("ReadyEvent", () => { + const event = ReadyEvent + const discord = container.resolve(MockDiscord) + let eventInstance: ReadyEvent + let guild: Guild + + beforeEach(() => { + eventInstance = new event(discord.getClient()) + guild = discord.mockGuild() + guild.commands.set = jest.fn().mockImplementation(() => Promise.resolve()) + }) + + it("should have the correct name", () => { + expect(event.name).toBe("ready") + }) + + it("should log the bot stats", async () => { + const logSpy = jest.spyOn(discord.getClient().logger, 'ready') + await eventInstance.execute() + + expect(logSpy).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`"${discord.getClient().user?.username}" is Ready!`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`Bot Stats:`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${discord.getClient().users.cache.size} user(s)`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${discord.getClient().channels.cache.size} channel(s)`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${discord.getClient().guilds.cache.size} guild(s)`)) + }) + + it("should not create a new guild entry in the database if it already exists", async () => { + await discord.getClient().configManager.getGuildConfig(guild) + + const findSpy = jest.spyOn(GuildModel, 'findById') + const saveSpy = jest.spyOn(GuildModel.prototype, 'save') + await eventInstance.execute() + + expect(findSpy).toHaveBeenCalledWith(guild.id) + + expect(saveSpy).toHaveBeenCalledTimes(0) + }) + + it("should create a new guild entry in the database if it does not exist", async () => { + const findSpy = jest.spyOn(GuildModel, 'findById') + const saveSpy = jest.spyOn(GuildModel.prototype, 'save') + await eventInstance.execute() + + expect(findSpy).toHaveBeenCalledWith(guild.id) + + expect(saveSpy).toHaveBeenCalledTimes(1) + const saveSpyRes = await saveSpy.mock.results[0].value + expect(saveSpyRes).toMatchObject({ _id: guild.id }) + }) + + + it("should register slash commands for the guild", async () => { + const registerSpy = jest.spyOn(discord.getClient().commandsManager, 'registerSlashCommandsFor') + const commandSetSpy = jest.spyOn(guild.commands, 'set') + await eventInstance.execute() + + expect(registerSpy).toHaveBeenCalledWith(guild) + expect(commandSetSpy).toHaveBeenCalled() + }) + + it("should set the bot's presence", async () => { + const setPresenceSpy = jest.spyOn(discord.getClient().user!, 'setPresence') + await eventInstance.execute() + + expect(setPresenceSpy).toHaveBeenCalledTimes(1) + expect(setPresenceSpy).toHaveBeenCalledWith(expect.objectContaining({ + status: 'online', + activities: [{ name: 'Sprechstunden', type: ActivityType.Watching }], + afk: false + })) + }) +}) \ No newline at end of file From 1fee274c746c2810ceaed18292a612397481691e Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 11:23:44 +0100 Subject: [PATCH 017/130] Improve ready message --- src/events/ReadyEvent.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/events/ReadyEvent.ts b/src/events/ReadyEvent.ts index 5f5c4fc..df6ef7e 100644 --- a/src/events/ReadyEvent.ts +++ b/src/events/ReadyEvent.ts @@ -33,12 +33,12 @@ export default class ReadyEvent extends BaseEvent { private logStats(): void { const message = `"${this.client.user?.username}" is Ready! (${(Date.now() - this.client.initTimestamp) / 1000}s)\n` + - "-".repeat(26) + "\n" + - "Bot Stats:\n" + - `${this.client.users.cache.size} user(s)\n` + - `${this.client.channels.cache.size} channel(s)\n` + - `${this.client.guilds.cache.size} guild(s)\n` + - "=".repeat(26); + " " + "-".repeat(26) + "\n" + + " Bot Stats:\n" + + ` ${this.client.users.cache.size} user(s)\n` + + ` ${this.client.channels.cache.size} channel(s)\n` + + ` ${this.client.guilds.cache.size} guild(s)\n` + + " " + "=".repeat(26); this.client.logger.ready(message); } } \ No newline at end of file From 7d9978d18ebeda49418d3484700be0db468973ee Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 11:44:33 +0100 Subject: [PATCH 018/130] Add option to pass command name --- tests/mockDiscord.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 9dc8f08..69cefb3 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -106,11 +106,11 @@ export class MockDiscord { }); } - public mockInteraction(channel?: TextChannel, guildMember?: GuildMember): ChatInputCommandInteraction { + public mockInteraction(commandName: string = "ping", channel?: TextChannel, guildMember?: GuildMember): ChatInputCommandInteraction { const guild = this.mockGuild(); channel = channel ? channel : this.mockChannel(guild); guildMember = guildMember ? guildMember : this.mockGuildMember(this.mockUser(), guild); assert(guildMember.guild === guild); - return mockChatInputCommandInteraction({ client: this.client, name: "test", id: "test", channel: channel, member: guildMember }) + return mockChatInputCommandInteraction({ client: this.client, name: commandName, id: "test", channel: channel, member: guildMember }) } } \ No newline at end of file From 4e10180eeb58b1255b1dd11b6fea7cf733e57620 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 11:44:43 +0100 Subject: [PATCH 019/130] Add interaction create tests --- tests/events/InteractionCreateEvent.test.ts | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/events/InteractionCreateEvent.test.ts diff --git a/tests/events/InteractionCreateEvent.test.ts b/tests/events/InteractionCreateEvent.test.ts new file mode 100644 index 0000000..6cd2b50 --- /dev/null +++ b/tests/events/InteractionCreateEvent.test.ts @@ -0,0 +1,47 @@ +import { container } from "tsyringe" +import InteractionCreateEvent from "../../src/events/InteractionCreateEvent" +import { MockDiscord } from "../mockDiscord" +import { ChatInputCommandInteraction, Interaction } from "discord.js" +import PingCommand from "../../src/commands/Ping" + + +describe("InteractionCreateEvent", () => { + const event = InteractionCreateEvent + const discord = container.resolve(MockDiscord) + let eventInstance: InteractionCreateEvent + let interaction: Interaction + + beforeEach(() => { + eventInstance = new event(discord.getClient()) + interaction = discord.mockInteraction("ping") + }) + + it("should have the correct name", () => { + expect(event.name).toBe("interactionCreate") + }) + + it("should log who executed which command with which options", async () => { + const logSpy = jest.spyOn(discord.getClient().logger, 'info') + await eventInstance.execute(interaction) + + const commandInteraction = interaction as ChatInputCommandInteraction + expect(logSpy).toHaveBeenCalledTimes(2) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${interaction.user.tag} executed command ${commandInteraction.commandName} with options ${JSON.stringify(commandInteraction.options)}`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`Command ${commandInteraction.commandName} executed successfully.`)) + }) + + it("should not execute a command if the interaction is not a command", async () => { + const executeSpy = jest.spyOn(PingCommand.prototype, 'execute') + interaction.isCommand = jest.fn().mockReturnValue(false) + await eventInstance.execute(interaction) + + expect(executeSpy).toHaveBeenCalledTimes(0) + }) + + it("should execute if the interaction is a command", async () => { + const executeSpy = jest.spyOn(PingCommand.prototype, 'execute') + await eventInstance.execute(interaction) + + expect(executeSpy).toHaveBeenCalledTimes(1) + }) +}) \ No newline at end of file From a5e0cf13fdeb1ff7a210df0631b058f6a0b54d42 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:08:03 +0100 Subject: [PATCH 020/130] Improved base classes --- src/baseCommand/BaseCommand.ts | 53 +++++-------------- .../BaseCommandOrSubcommandHandler.ts | 40 ++++++++++++++ src/baseCommand/BaseSubcommandHandler.ts | 37 +++++++++++++ src/baseCommand/index.ts | 4 ++ src/commands/index.ts | 4 +- 5 files changed, 97 insertions(+), 41 deletions(-) create mode 100644 src/baseCommand/BaseCommandOrSubcommandHandler.ts create mode 100644 src/baseCommand/BaseSubcommandHandler.ts diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 184afbf..9b1c483 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -1,63 +1,30 @@ -import { CommandInteraction, Interaction, Message, BaseMessageOptions } from "discord.js"; +import { CommandInteraction, Interaction, Message, BaseMessageOptions, CommandInteractionOption } from "discord.js"; import { handleInteractionError } from "../utils/handleError"; -import { Bot } from "../Bot"; import OptionRequirement from "../types/OptionRequirement"; +import { BaseCommandOrSubcommandHandler } from "./BaseCommandOrSubcommandHandler"; /** - * The base command class. + * The base class for all commands. */ -export default abstract class BaseCommand { - /** - * The command name. - */ - public static name: string; - /** - * The command description. - */ - public static description: string; +export default class BaseCommand extends BaseCommandOrSubcommandHandler { /** * The command options. */ public static options: OptionRequirement[] - /** - * The interaction. - */ - protected interaction: Interaction; - /** - * The client who received the interaction. - */ - protected client: Bot; - - - /** - * Creates a new instance of the BaseCommand class. - * @param interaction The interaction. - * @param client The client who received the interaction. - */ - constructor(interaction: Interaction, client: Bot) { - this.interaction = interaction; - this.client = client; - } - - /** - * Executes the command with the given arguments. - * @param args The command arguments. - */ - public abstract execute(...args: any[]): Promise; /** * Sends a message to the interaction channel. * * If the interaction has sent previously, it will edit the previous message. * @param content The message content. - * @returns The sent message. + * @returns The sent message.1 */ protected async send(content: BaseMessageOptions | string): Promise { try { const interaction = this.interaction as CommandInteraction const messageContent = typeof content === "string" ? { content } : content - if (interaction.replied || interaction.deferred) { + if (interaction.replied || interaction.deferred) { this.client.logger.debug(`Editing reply to interaction ${interaction.id}`) const sentContent = await interaction.editReply({ ...messageContent }) this.client.logger.debug(`Finished edit reply to interaction ${interaction.id}`) @@ -78,6 +45,9 @@ export default abstract class BaseCommand { } } + /** + * Defers the reply to the interaction. + */ protected async defer(): Promise { try { this.client.logger.debug(`Deferring reply to interaction ${this.interaction.id}`) @@ -93,6 +63,11 @@ export default abstract class BaseCommand { } } + /** + * Returns the value of the given option or the default value if the option is not present. + * @param option The option to get the value from. + * @returns The option value. + */ protected async getOptionValue(option: OptionRequirement): Promise { this.client.logger.debug(`Getting option value ${option.name} from interaction ${this.interaction.id}`) const interaction = this.interaction as CommandInteraction diff --git a/src/baseCommand/BaseCommandOrSubcommandHandler.ts b/src/baseCommand/BaseCommandOrSubcommandHandler.ts new file mode 100644 index 0000000..13d6625 --- /dev/null +++ b/src/baseCommand/BaseCommandOrSubcommandHandler.ts @@ -0,0 +1,40 @@ +import { Interaction } from "discord.js"; +import { Bot } from "../Bot"; + +/** + * The base class for all commands and subcommands. + */ +export class BaseCommandOrSubcommandHandler { + /** + * The command name. + */ + public static name: string; + /** + * The command description. + */ + public static description: string; + /** + * The interaction. + */ + protected interaction: Interaction; + /** + * The client who received the interaction. + */ + protected client: Bot; + + /** + * Creates a new instance of the BaseCommand class. + * @param interaction The interaction. + * @param client The client who received the interaction. + */ + constructor(interaction: Interaction, client: Bot) { + this.interaction = interaction; + this.client = client; + } + + /** + * Executes the command with the given arguments. + * @param args The command arguments. + */ + public async execute(...args: any[]) { } +} diff --git a/src/baseCommand/BaseSubcommandHandler.ts b/src/baseCommand/BaseSubcommandHandler.ts new file mode 100644 index 0000000..00d04ce --- /dev/null +++ b/src/baseCommand/BaseSubcommandHandler.ts @@ -0,0 +1,37 @@ +import { CommandInteraction, CommandInteractionOption } from "discord.js"; +import { BaseCommandOrSubcommandHandler } from "./BaseCommandOrSubcommandHandler"; +import { handleInteractionError } from "../utils/handleError"; + +/** + * The base class for all subcommands. + */ +export class BaseSubcommandHandler extends BaseCommandOrSubcommandHandler { + /** + * The subcommands of this command. + */ + public static subcommands: typeof BaseCommandOrSubcommandHandler[] + + public async execute() { + try { + const subcommandInteraction = (this.interaction as CommandInteraction & { resolved_subcommand?: CommandInteractionOption }); + if (!subcommandInteraction.resolved_subcommand) { + subcommandInteraction.resolved_subcommand = subcommandInteraction.options.data[0]; + } else { + subcommandInteraction.resolved_subcommand = subcommandInteraction.resolved_subcommand.options![0]; + } + const subcommandName = subcommandInteraction.resolved_subcommand.name; + this.client.logger.debug(`Executing subcommand ${subcommandName} from interaction ${this.interaction.id}`) + const someClass = this.constructor as typeof BaseSubcommandHandler; + const subcommand = someClass.subcommands.find(subcommand => subcommand.name == subcommandName)!; + const concreteSubcommand = new subcommand(this.interaction, this.client); + await concreteSubcommand.execute(); + } catch (error) { + if (error instanceof Error) { + handleInteractionError(error, this.interaction, this.client.logger) + } else { + this.client.logger.error(error) + } + throw error + } + } +} diff --git a/src/baseCommand/index.ts b/src/baseCommand/index.ts index f614b84..04e30f5 100644 --- a/src/baseCommand/index.ts +++ b/src/baseCommand/index.ts @@ -1,5 +1,9 @@ import BaseCommand from "./BaseCommand"; +import { BaseCommandOrSubcommandHandler } from "./BaseCommandOrSubcommandHandler"; +import { BaseSubcommandHandler } from "./BaseSubcommandHandler"; export { + BaseCommandOrSubcommandHandler, + BaseSubcommandHandler, BaseCommand } \ No newline at end of file diff --git a/src/commands/index.ts b/src/commands/index.ts index b8ffdca..b12d13f 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,9 +1,9 @@ import PingCommand from './Ping'; import HelpCommand from './Help'; -import UpdateBotRolesCommand from './admin/UpdateBotRoles'; +import AdminCommandHandler from './Admin'; export default [ PingCommand, HelpCommand, - UpdateBotRolesCommand, + AdminCommandHandler, ] \ No newline at end of file From 3ce2f7731b9549370eed980c3e7b4fe50d3e7852 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:09:02 +0100 Subject: [PATCH 021/130] Add support to register subcommands --- src/managers/CommandsManager.ts | 60 +++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/src/managers/CommandsManager.ts b/src/managers/CommandsManager.ts index 84e8f9c..6ae32c9 100644 --- a/src/managers/CommandsManager.ts +++ b/src/managers/CommandsManager.ts @@ -1,6 +1,7 @@ -import { ApplicationCommandOptionData, ChatInputApplicationCommandData, Guild } from "discord.js"; +import { ApplicationCommandOptionData, ApplicationCommandOptionType, BaseChannel, ChatInputApplicationCommandData, Guild } from "discord.js"; import { Bot } from "../Bot"; import { delay, inject, injectable, singleton } from "tsyringe"; +import { BaseCommand, BaseCommandOrSubcommandHandler, BaseSubcommandHandler } from "../baseCommand"; @injectable() @singleton() @@ -10,7 +11,7 @@ export default class CommandsManager { constructor(@inject(delay(() => Bot)) client: Bot) { this.client = client; - this.loadCommandsData(); + this.commandsData = this.loadCommandsData(this.client.commands); } public async registerSlashCommandsFor(guild: Guild): Promise { @@ -23,14 +24,53 @@ export default class CommandsManager { } } - private loadCommandsData() { - for (const command of this.client.commands) { - const commandData: ChatInputApplicationCommandData = { - name: command.name, - description: command.description, - options: command.options as ApplicationCommandOptionData[], - }; - this.commandsData.push(commandData); + private loadCommandsData(commands: typeof BaseCommandOrSubcommandHandler[]): ChatInputApplicationCommandData[] { + const commandsData: ChatInputApplicationCommandData[] = []; + + for (const command of commands) { + const commandData = this.loadCommandData(command); + commandsData.push(commandData); + } + + return commandsData; + } + + private loadCommandData(command: typeof BaseCommandOrSubcommandHandler): ChatInputApplicationCommandData { + if (command.prototype instanceof BaseCommand) { + return this.loadBaseCommandData(command as typeof BaseCommand); + } else if (command.prototype instanceof BaseSubcommandHandler) { + return this.loadBaseSubcommandHandlerData(command as typeof BaseSubcommandHandler); + } + throw new Error(`Command ${command.name} is neither a BaseCommand nor a BaseSubcommandHandler.`); + } + + private loadBaseCommandData(command: typeof BaseCommand): ChatInputApplicationCommandData { + const commandData: ChatInputApplicationCommandData = { + name: command.name, + description: command.description, + options: command.options as ApplicationCommandOptionData[], + }; + return commandData; + } + + private loadBaseSubcommandHandlerData(command: typeof BaseSubcommandHandler): ChatInputApplicationCommandData { + const baseSubcommandHandler = command as typeof BaseSubcommandHandler; + const subcommandsData = this.loadCommandsData(baseSubcommandHandler.subcommands); + const subcommandOptions = subcommandsData.map(subcommandData => { + const subcommandDataOptions = subcommandData.options!; + const subcommandIsSubcommandHandler = subcommandDataOptions.flatMap(option => option.type).includes(ApplicationCommandOptionType.Subcommand); + return { + name: subcommandData.name, + description: subcommandData.description, + type: subcommandIsSubcommandHandler ? ApplicationCommandOptionType.SubcommandGroup : ApplicationCommandOptionType.Subcommand, + options: subcommandData.options, + } + }) + const commandData: ChatInputApplicationCommandData = { + name: command.name, + description: command.description, + options: subcommandOptions as ApplicationCommandOptionData[], } + return commandData; } } \ No newline at end of file From bf74d38e30aa6f8298a9bf1ca1b99422e3c8f485 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:23:01 +0100 Subject: [PATCH 022/130] Improve imports and exports --- src/baseCommand/BaseCommand.ts | 2 +- src/baseCommand/BaseCommandOrSubcommandHandler.ts | 6 ++++-- src/baseCommand/BaseSubcommandHandler.ts | 4 ++-- src/baseCommand/index.ts | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 9b1c483..999773f 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -1,7 +1,7 @@ import { CommandInteraction, Interaction, Message, BaseMessageOptions, CommandInteractionOption } from "discord.js"; import { handleInteractionError } from "../utils/handleError"; import OptionRequirement from "../types/OptionRequirement"; -import { BaseCommandOrSubcommandHandler } from "./BaseCommandOrSubcommandHandler"; +import BaseCommandOrSubcommandHandler from "./BaseCommandOrSubcommandHandler"; /** * The base class for all commands. diff --git a/src/baseCommand/BaseCommandOrSubcommandHandler.ts b/src/baseCommand/BaseCommandOrSubcommandHandler.ts index 13d6625..7f95834 100644 --- a/src/baseCommand/BaseCommandOrSubcommandHandler.ts +++ b/src/baseCommand/BaseCommandOrSubcommandHandler.ts @@ -4,7 +4,7 @@ import { Bot } from "../Bot"; /** * The base class for all commands and subcommands. */ -export class BaseCommandOrSubcommandHandler { +export default class BaseCommandOrSubcommandHandler { /** * The command name. */ @@ -36,5 +36,7 @@ export class BaseCommandOrSubcommandHandler { * Executes the command with the given arguments. * @param args The command arguments. */ - public async execute(...args: any[]) { } + public async execute(...args: any[]) { + throw new Error("Method not implemented."); + } } diff --git a/src/baseCommand/BaseSubcommandHandler.ts b/src/baseCommand/BaseSubcommandHandler.ts index 00d04ce..965bd91 100644 --- a/src/baseCommand/BaseSubcommandHandler.ts +++ b/src/baseCommand/BaseSubcommandHandler.ts @@ -1,11 +1,11 @@ import { CommandInteraction, CommandInteractionOption } from "discord.js"; -import { BaseCommandOrSubcommandHandler } from "./BaseCommandOrSubcommandHandler"; import { handleInteractionError } from "../utils/handleError"; +import BaseCommandOrSubcommandHandler from "./BaseCommandOrSubcommandHandler"; /** * The base class for all subcommands. */ -export class BaseSubcommandHandler extends BaseCommandOrSubcommandHandler { +export default class BaseSubcommandHandler extends BaseCommandOrSubcommandHandler { /** * The subcommands of this command. */ diff --git a/src/baseCommand/index.ts b/src/baseCommand/index.ts index 04e30f5..153e5b9 100644 --- a/src/baseCommand/index.ts +++ b/src/baseCommand/index.ts @@ -1,6 +1,6 @@ import BaseCommand from "./BaseCommand"; -import { BaseCommandOrSubcommandHandler } from "./BaseCommandOrSubcommandHandler"; -import { BaseSubcommandHandler } from "./BaseSubcommandHandler"; +import BaseCommandOrSubcommandHandler from "./BaseCommandOrSubcommandHandler"; +import BaseSubcommandHandler from "./BaseSubcommandHandler"; export { BaseCommandOrSubcommandHandler, From e6f06987e6fc653f9095e0249a30d94c565e1888 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:23:32 +0100 Subject: [PATCH 023/130] Add subcommands --- src/commands/Admin.ts | 13 +++++++++++++ src/commands/admin/Queue.ts | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/commands/Admin.ts create mode 100644 src/commands/admin/Queue.ts diff --git a/src/commands/Admin.ts b/src/commands/Admin.ts new file mode 100644 index 0000000..5487b35 --- /dev/null +++ b/src/commands/Admin.ts @@ -0,0 +1,13 @@ +import { BaseSubcommandHandler } from "../baseCommand"; +import QueueCommandHandler from "./admin/Queue"; +import UpdateBotRolesCommand from "./admin/UpdateBotRoles"; + +export default class AdminCommandHandler extends BaseSubcommandHandler { + public static name = "admin"; + public static description = "Admin command handler."; + + public static subcommands = [ + QueueCommandHandler, + UpdateBotRolesCommand + ] +} \ No newline at end of file diff --git a/src/commands/admin/Queue.ts b/src/commands/admin/Queue.ts new file mode 100644 index 0000000..518da18 --- /dev/null +++ b/src/commands/admin/Queue.ts @@ -0,0 +1,11 @@ +import { BaseSubcommandHandler } from "../../baseCommand"; +import HelpCommand from "../Help"; +import UpdateBotRolesCommand from "./UpdateBotRoles"; + +export default class QueueCommandHandler extends BaseSubcommandHandler { + public static name = "queue"; + public static description = "Queue command handler."; + + public static subcommands = [ + ] +} \ No newline at end of file From 5b071d12466992308ccecd0f37c86c632304d0eb Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 20:06:43 +0100 Subject: [PATCH 024/130] Same filenames and class names --- src/commands/{Admin.ts => AdminCommandHandler.ts} | 2 +- src/commands/admin/{Queue.ts => QueueCommandHandler.ts} | 2 -- src/commands/index.ts | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) rename src/commands/{Admin.ts => AdminCommandHandler.ts} (85%) rename src/commands/admin/{Queue.ts => QueueCommandHandler.ts} (75%) diff --git a/src/commands/Admin.ts b/src/commands/AdminCommandHandler.ts similarity index 85% rename from src/commands/Admin.ts rename to src/commands/AdminCommandHandler.ts index 5487b35..df46217 100644 --- a/src/commands/Admin.ts +++ b/src/commands/AdminCommandHandler.ts @@ -1,5 +1,5 @@ import { BaseSubcommandHandler } from "../baseCommand"; -import QueueCommandHandler from "./admin/Queue"; +import QueueCommandHandler from "./admin/QueueCommandHandler"; import UpdateBotRolesCommand from "./admin/UpdateBotRoles"; export default class AdminCommandHandler extends BaseSubcommandHandler { diff --git a/src/commands/admin/Queue.ts b/src/commands/admin/QueueCommandHandler.ts similarity index 75% rename from src/commands/admin/Queue.ts rename to src/commands/admin/QueueCommandHandler.ts index 518da18..8b271b4 100644 --- a/src/commands/admin/Queue.ts +++ b/src/commands/admin/QueueCommandHandler.ts @@ -1,6 +1,4 @@ import { BaseSubcommandHandler } from "../../baseCommand"; -import HelpCommand from "../Help"; -import UpdateBotRolesCommand from "./UpdateBotRoles"; export default class QueueCommandHandler extends BaseSubcommandHandler { public static name = "queue"; diff --git a/src/commands/index.ts b/src/commands/index.ts index b12d13f..78290ef 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,6 +1,6 @@ import PingCommand from './Ping'; import HelpCommand from './Help'; -import AdminCommandHandler from './Admin'; +import AdminCommandHandler from './AdminCommandHandler'; export default [ PingCommand, From 3fec780a422b41f8c69ccf8e8a0ef7def43ab6dc Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 20:14:35 +0100 Subject: [PATCH 025/130] Rename to subcommands handler --- src/baseCommand/BaseCommand.ts | 4 ++-- ....ts => BaseCommandOrSubcommandsHandler.ts} | 2 +- ...ndHandler.ts => BaseSubcommandsHandler.ts} | 8 ++++---- src/baseCommand/index.ts | 8 ++++---- src/commands/AdminCommandHandler.ts | 13 ------------ src/commands/AdminCommandsHandler.ts | 13 ++++++++++++ ...mandHandler.ts => QueueCommandsHandler.ts} | 4 ++-- src/commands/index.ts | 4 ++-- src/managers/CommandsManager.ts | 20 +++++++++---------- 9 files changed, 38 insertions(+), 38 deletions(-) rename src/baseCommand/{BaseCommandOrSubcommandHandler.ts => BaseCommandOrSubcommandsHandler.ts} (94%) rename src/baseCommand/{BaseSubcommandHandler.ts => BaseSubcommandsHandler.ts} (85%) delete mode 100644 src/commands/AdminCommandHandler.ts create mode 100644 src/commands/AdminCommandsHandler.ts rename src/commands/admin/{QueueCommandHandler.ts => QueueCommandsHandler.ts} (50%) diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 999773f..148a787 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -1,12 +1,12 @@ import { CommandInteraction, Interaction, Message, BaseMessageOptions, CommandInteractionOption } from "discord.js"; import { handleInteractionError } from "../utils/handleError"; import OptionRequirement from "../types/OptionRequirement"; -import BaseCommandOrSubcommandHandler from "./BaseCommandOrSubcommandHandler"; +import BaseCommandOrSubcommandsHandler from "./BaseCommandOrSubcommandsHandler"; /** * The base class for all commands. */ -export default class BaseCommand extends BaseCommandOrSubcommandHandler { +export default class BaseCommand extends BaseCommandOrSubcommandsHandler { /** * The command options. */ diff --git a/src/baseCommand/BaseCommandOrSubcommandHandler.ts b/src/baseCommand/BaseCommandOrSubcommandsHandler.ts similarity index 94% rename from src/baseCommand/BaseCommandOrSubcommandHandler.ts rename to src/baseCommand/BaseCommandOrSubcommandsHandler.ts index 7f95834..1a6118b 100644 --- a/src/baseCommand/BaseCommandOrSubcommandHandler.ts +++ b/src/baseCommand/BaseCommandOrSubcommandsHandler.ts @@ -4,7 +4,7 @@ import { Bot } from "../Bot"; /** * The base class for all commands and subcommands. */ -export default class BaseCommandOrSubcommandHandler { +export default class BaseCommandOrSubcommandsHandler { /** * The command name. */ diff --git a/src/baseCommand/BaseSubcommandHandler.ts b/src/baseCommand/BaseSubcommandsHandler.ts similarity index 85% rename from src/baseCommand/BaseSubcommandHandler.ts rename to src/baseCommand/BaseSubcommandsHandler.ts index 965bd91..21b84db 100644 --- a/src/baseCommand/BaseSubcommandHandler.ts +++ b/src/baseCommand/BaseSubcommandsHandler.ts @@ -1,15 +1,15 @@ import { CommandInteraction, CommandInteractionOption } from "discord.js"; import { handleInteractionError } from "../utils/handleError"; -import BaseCommandOrSubcommandHandler from "./BaseCommandOrSubcommandHandler"; +import BaseCommandOrSubcommandsHandler from "./BaseCommandOrSubcommandsHandler"; /** * The base class for all subcommands. */ -export default class BaseSubcommandHandler extends BaseCommandOrSubcommandHandler { +export default class BaseSubcommandsHandler extends BaseCommandOrSubcommandsHandler { /** * The subcommands of this command. */ - public static subcommands: typeof BaseCommandOrSubcommandHandler[] + public static subcommands: typeof BaseCommandOrSubcommandsHandler[] public async execute() { try { @@ -21,7 +21,7 @@ export default class BaseSubcommandHandler extends BaseCommandOrSubcommandHandle } const subcommandName = subcommandInteraction.resolved_subcommand.name; this.client.logger.debug(`Executing subcommand ${subcommandName} from interaction ${this.interaction.id}`) - const someClass = this.constructor as typeof BaseSubcommandHandler; + const someClass = this.constructor as typeof BaseSubcommandsHandler; const subcommand = someClass.subcommands.find(subcommand => subcommand.name == subcommandName)!; const concreteSubcommand = new subcommand(this.interaction, this.client); await concreteSubcommand.execute(); diff --git a/src/baseCommand/index.ts b/src/baseCommand/index.ts index 153e5b9..0b21015 100644 --- a/src/baseCommand/index.ts +++ b/src/baseCommand/index.ts @@ -1,9 +1,9 @@ import BaseCommand from "./BaseCommand"; -import BaseCommandOrSubcommandHandler from "./BaseCommandOrSubcommandHandler"; -import BaseSubcommandHandler from "./BaseSubcommandHandler"; +import BaseCommandOrSubcommandsHandler from "./BaseCommandOrSubcommandsHandler"; +import BaseSubcommandsHandler from "./BaseSubcommandsHandler"; export { - BaseCommandOrSubcommandHandler, - BaseSubcommandHandler, + BaseCommandOrSubcommandsHandler, + BaseSubcommandsHandler, BaseCommand } \ No newline at end of file diff --git a/src/commands/AdminCommandHandler.ts b/src/commands/AdminCommandHandler.ts deleted file mode 100644 index df46217..0000000 --- a/src/commands/AdminCommandHandler.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseSubcommandHandler } from "../baseCommand"; -import QueueCommandHandler from "./admin/QueueCommandHandler"; -import UpdateBotRolesCommand from "./admin/UpdateBotRoles"; - -export default class AdminCommandHandler extends BaseSubcommandHandler { - public static name = "admin"; - public static description = "Admin command handler."; - - public static subcommands = [ - QueueCommandHandler, - UpdateBotRolesCommand - ] -} \ No newline at end of file diff --git a/src/commands/AdminCommandsHandler.ts b/src/commands/AdminCommandsHandler.ts new file mode 100644 index 0000000..7feb9c5 --- /dev/null +++ b/src/commands/AdminCommandsHandler.ts @@ -0,0 +1,13 @@ +import { BaseSubcommandsHandler } from "../baseCommand"; +import QueueCommandsHandler from "./admin/QueueCommandsHandler"; +import UpdateBotRolesCommand from "./admin/UpdateBotRoles"; + +export default class AdminCommandsHandler extends BaseSubcommandsHandler { + public static name = "admin"; + public static description = "Admin command handler."; + + public static subcommands = [ + QueueCommandsHandler, + UpdateBotRolesCommand + ] +} \ No newline at end of file diff --git a/src/commands/admin/QueueCommandHandler.ts b/src/commands/admin/QueueCommandsHandler.ts similarity index 50% rename from src/commands/admin/QueueCommandHandler.ts rename to src/commands/admin/QueueCommandsHandler.ts index 8b271b4..c75951d 100644 --- a/src/commands/admin/QueueCommandHandler.ts +++ b/src/commands/admin/QueueCommandsHandler.ts @@ -1,6 +1,6 @@ -import { BaseSubcommandHandler } from "../../baseCommand"; +import { BaseSubcommandsHandler } from "../../baseCommand"; -export default class QueueCommandHandler extends BaseSubcommandHandler { +export default class QueueCommandsHandler extends BaseSubcommandsHandler { public static name = "queue"; public static description = "Queue command handler."; diff --git a/src/commands/index.ts b/src/commands/index.ts index 78290ef..f828f72 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,9 +1,9 @@ import PingCommand from './Ping'; import HelpCommand from './Help'; -import AdminCommandHandler from './AdminCommandHandler'; +import AdminCommandsHandler from './AdminCommandsHandler'; export default [ PingCommand, HelpCommand, - AdminCommandHandler, + AdminCommandsHandler, ] \ No newline at end of file diff --git a/src/managers/CommandsManager.ts b/src/managers/CommandsManager.ts index 6ae32c9..27f45aa 100644 --- a/src/managers/CommandsManager.ts +++ b/src/managers/CommandsManager.ts @@ -1,7 +1,7 @@ import { ApplicationCommandOptionData, ApplicationCommandOptionType, BaseChannel, ChatInputApplicationCommandData, Guild } from "discord.js"; import { Bot } from "../Bot"; import { delay, inject, injectable, singleton } from "tsyringe"; -import { BaseCommand, BaseCommandOrSubcommandHandler, BaseSubcommandHandler } from "../baseCommand"; +import { BaseCommand, BaseCommandOrSubcommandsHandler, BaseSubcommandsHandler } from "../baseCommand"; @injectable() @singleton() @@ -24,7 +24,7 @@ export default class CommandsManager { } } - private loadCommandsData(commands: typeof BaseCommandOrSubcommandHandler[]): ChatInputApplicationCommandData[] { + private loadCommandsData(commands: typeof BaseCommandOrSubcommandsHandler[]): ChatInputApplicationCommandData[] { const commandsData: ChatInputApplicationCommandData[] = []; for (const command of commands) { @@ -35,11 +35,11 @@ export default class CommandsManager { return commandsData; } - private loadCommandData(command: typeof BaseCommandOrSubcommandHandler): ChatInputApplicationCommandData { + private loadCommandData(command: typeof BaseCommandOrSubcommandsHandler): ChatInputApplicationCommandData { if (command.prototype instanceof BaseCommand) { return this.loadBaseCommandData(command as typeof BaseCommand); - } else if (command.prototype instanceof BaseSubcommandHandler) { - return this.loadBaseSubcommandHandlerData(command as typeof BaseSubcommandHandler); + } else if (command.prototype instanceof BaseSubcommandsHandler) { + return this.loadBaseSubcommandsHandlerData(command as typeof BaseSubcommandsHandler); } throw new Error(`Command ${command.name} is neither a BaseCommand nor a BaseSubcommandHandler.`); } @@ -53,16 +53,16 @@ export default class CommandsManager { return commandData; } - private loadBaseSubcommandHandlerData(command: typeof BaseSubcommandHandler): ChatInputApplicationCommandData { - const baseSubcommandHandler = command as typeof BaseSubcommandHandler; - const subcommandsData = this.loadCommandsData(baseSubcommandHandler.subcommands); + private loadBaseSubcommandsHandlerData(command: typeof BaseSubcommandsHandler): ChatInputApplicationCommandData { + const baseSubcommandsHandler = command as typeof BaseSubcommandsHandler; + const subcommandsData = this.loadCommandsData(baseSubcommandsHandler.subcommands); const subcommandOptions = subcommandsData.map(subcommandData => { const subcommandDataOptions = subcommandData.options!; - const subcommandIsSubcommandHandler = subcommandDataOptions.flatMap(option => option.type).includes(ApplicationCommandOptionType.Subcommand); + const subcommandIsSubcommandsHandler = subcommandDataOptions.flatMap(option => option.type).includes(ApplicationCommandOptionType.Subcommand); return { name: subcommandData.name, description: subcommandData.description, - type: subcommandIsSubcommandHandler ? ApplicationCommandOptionType.SubcommandGroup : ApplicationCommandOptionType.Subcommand, + type: subcommandIsSubcommandsHandler ? ApplicationCommandOptionType.SubcommandGroup : ApplicationCommandOptionType.Subcommand, options: subcommandData.options, } }) From 447e6f8cdbf7a6800cd1c420278f5588bc08c0b3 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 21:31:54 +0100 Subject: [PATCH 026/130] Make base classes abstract again --- src/baseCommand/BaseCommand.ts | 2 +- src/baseCommand/BaseCommandOrSubcommandsHandler.ts | 6 ++---- src/baseCommand/BaseSubcommandsHandler.ts | 7 ++++--- src/commands/AdminCommandsHandler.ts | 1 + src/managers/CommandsManager.ts | 3 ++- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 148a787..862ae7f 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -6,7 +6,7 @@ import BaseCommandOrSubcommandsHandler from "./BaseCommandOrSubcommandsHandler"; /** * The base class for all commands. */ -export default class BaseCommand extends BaseCommandOrSubcommandsHandler { +export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandler { /** * The command options. */ diff --git a/src/baseCommand/BaseCommandOrSubcommandsHandler.ts b/src/baseCommand/BaseCommandOrSubcommandsHandler.ts index 1a6118b..ef99cda 100644 --- a/src/baseCommand/BaseCommandOrSubcommandsHandler.ts +++ b/src/baseCommand/BaseCommandOrSubcommandsHandler.ts @@ -4,7 +4,7 @@ import { Bot } from "../Bot"; /** * The base class for all commands and subcommands. */ -export default class BaseCommandOrSubcommandsHandler { +export default abstract class BaseCommandOrSubcommandsHandler { /** * The command name. */ @@ -36,7 +36,5 @@ export default class BaseCommandOrSubcommandsHandler { * Executes the command with the given arguments. * @param args The command arguments. */ - public async execute(...args: any[]) { - throw new Error("Method not implemented."); - } + public abstract execute(...args: any[]): Promise; } diff --git a/src/baseCommand/BaseSubcommandsHandler.ts b/src/baseCommand/BaseSubcommandsHandler.ts index 21b84db..9eba558 100644 --- a/src/baseCommand/BaseSubcommandsHandler.ts +++ b/src/baseCommand/BaseSubcommandsHandler.ts @@ -1,15 +1,16 @@ -import { CommandInteraction, CommandInteractionOption } from "discord.js"; +import { CommandInteraction, CommandInteractionOption, Interaction } from "discord.js"; import { handleInteractionError } from "../utils/handleError"; import BaseCommandOrSubcommandsHandler from "./BaseCommandOrSubcommandsHandler"; +import { Bot } from "../Bot"; /** * The base class for all subcommands. */ -export default class BaseSubcommandsHandler extends BaseCommandOrSubcommandsHandler { +export default abstract class BaseSubcommandsHandler extends BaseCommandOrSubcommandsHandler { /** * The subcommands of this command. */ - public static subcommands: typeof BaseCommandOrSubcommandsHandler[] + public static subcommands: (new (interaction: Interaction, client: Bot) => BaseCommandOrSubcommandsHandler)[] public async execute() { try { diff --git a/src/commands/AdminCommandsHandler.ts b/src/commands/AdminCommandsHandler.ts index 7feb9c5..b3b1da5 100644 --- a/src/commands/AdminCommandsHandler.ts +++ b/src/commands/AdminCommandsHandler.ts @@ -1,6 +1,7 @@ import { BaseSubcommandsHandler } from "../baseCommand"; import QueueCommandsHandler from "./admin/QueueCommandsHandler"; import UpdateBotRolesCommand from "./admin/UpdateBotRoles"; +import { Bot } from "../Bot"; export default class AdminCommandsHandler extends BaseSubcommandsHandler { public static name = "admin"; diff --git a/src/managers/CommandsManager.ts b/src/managers/CommandsManager.ts index 27f45aa..f6ba285 100644 --- a/src/managers/CommandsManager.ts +++ b/src/managers/CommandsManager.ts @@ -55,7 +55,8 @@ export default class CommandsManager { private loadBaseSubcommandsHandlerData(command: typeof BaseSubcommandsHandler): ChatInputApplicationCommandData { const baseSubcommandsHandler = command as typeof BaseSubcommandsHandler; - const subcommandsData = this.loadCommandsData(baseSubcommandsHandler.subcommands); + const subcommandTypes: typeof BaseCommandOrSubcommandsHandler[] = baseSubcommandsHandler.subcommands.map(subcommand => subcommand.prototype.constructor) + const subcommandsData = this.loadCommandsData(subcommandTypes); const subcommandOptions = subcommandsData.map(subcommandData => { const subcommandDataOptions = subcommandData.options!; const subcommandIsSubcommandsHandler = subcommandDataOptions.flatMap(option => option.type).includes(ApplicationCommandOptionType.Subcommand); From d3e22339e7207f1f3e5b264d94916c965411fe49 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 22:10:47 +0100 Subject: [PATCH 027/130] Add default value for options --- src/baseCommand/BaseCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 862ae7f..b020c3a 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -10,7 +10,7 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle /** * The command options. */ - public static options: OptionRequirement[] + public static options: OptionRequirement[] = [] /** * Sends a message to the interaction channel. From f34b610cc6b83ae57b026794022b13a2d9ed7434 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 22:11:31 +0100 Subject: [PATCH 028/130] Add admin queue command handler --- src/commands/AdminCommandsHandler.ts | 5 ++--- src/commands/admin/AdminQueueCommandsHandler.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 src/commands/admin/AdminQueueCommandsHandler.ts diff --git a/src/commands/AdminCommandsHandler.ts b/src/commands/AdminCommandsHandler.ts index b3b1da5..f719ce9 100644 --- a/src/commands/AdminCommandsHandler.ts +++ b/src/commands/AdminCommandsHandler.ts @@ -1,14 +1,13 @@ import { BaseSubcommandsHandler } from "../baseCommand"; -import QueueCommandsHandler from "./admin/QueueCommandsHandler"; +import AdminQueueCommandsHandler from "./admin/AdminQueueCommandsHandler"; import UpdateBotRolesCommand from "./admin/UpdateBotRoles"; -import { Bot } from "../Bot"; export default class AdminCommandsHandler extends BaseSubcommandsHandler { public static name = "admin"; public static description = "Admin command handler."; public static subcommands = [ - QueueCommandsHandler, + AdminQueueCommandsHandler, UpdateBotRolesCommand ] } \ No newline at end of file diff --git a/src/commands/admin/AdminQueueCommandsHandler.ts b/src/commands/admin/AdminQueueCommandsHandler.ts new file mode 100644 index 0000000..e9d5931 --- /dev/null +++ b/src/commands/admin/AdminQueueCommandsHandler.ts @@ -0,0 +1,9 @@ +import { BaseSubcommandsHandler } from "../../baseCommand"; + +export default class AdminQueueCommandsHandler extends BaseSubcommandsHandler { + public static name = "queue"; + public static description = "Admin queue command handler."; + + public static subcommands = [ + ] +} \ No newline at end of file From 55b9c35f1e6a62533aba16b30fe580e5cf231b1f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 22:12:32 +0100 Subject: [PATCH 029/130] Add config create queue command --- src/commands/ConfigCommandsHandler.ts | 12 +++ src/commands/admin/QueueCommandsHandler.ts | 9 --- .../config/ConfigQueueCommandsHandler.ts | 11 +++ .../config/queue/CreateQueueCommand.ts | 79 +++++++++++++++++++ src/commands/index.ts | 2 + 5 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 src/commands/ConfigCommandsHandler.ts delete mode 100644 src/commands/admin/QueueCommandsHandler.ts create mode 100644 src/commands/config/ConfigQueueCommandsHandler.ts create mode 100644 src/commands/config/queue/CreateQueueCommand.ts diff --git a/src/commands/ConfigCommandsHandler.ts b/src/commands/ConfigCommandsHandler.ts new file mode 100644 index 0000000..d63ed3b --- /dev/null +++ b/src/commands/ConfigCommandsHandler.ts @@ -0,0 +1,12 @@ +import { BaseSubcommandsHandler } from "../baseCommand"; +import HelpCommand from "./Help"; +import ConfigQueueCommandsHandler from "./config/ConfigQueueCommandsHandler"; + +export default class ConfigCommandsHandler extends BaseSubcommandsHandler { + public static name = "config"; + public static description = "Config command handler."; + + public static subcommands = [ + ConfigQueueCommandsHandler, + ] +} \ No newline at end of file diff --git a/src/commands/admin/QueueCommandsHandler.ts b/src/commands/admin/QueueCommandsHandler.ts deleted file mode 100644 index c75951d..0000000 --- a/src/commands/admin/QueueCommandsHandler.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BaseSubcommandsHandler } from "../../baseCommand"; - -export default class QueueCommandsHandler extends BaseSubcommandsHandler { - public static name = "queue"; - public static description = "Queue command handler."; - - public static subcommands = [ - ] -} \ No newline at end of file diff --git a/src/commands/config/ConfigQueueCommandsHandler.ts b/src/commands/config/ConfigQueueCommandsHandler.ts new file mode 100644 index 0000000..1aca9a4 --- /dev/null +++ b/src/commands/config/ConfigQueueCommandsHandler.ts @@ -0,0 +1,11 @@ +import { BaseSubcommandsHandler } from "../../baseCommand"; +import CreateQueueCommand from "./queue/CreateQueueCommand"; + +export default class ConfigQueueCommandsHandler extends BaseSubcommandsHandler { + public static name = "queue"; + public static description = "Config queue command handler."; + + public static subcommands = [ + CreateQueueCommand, + ] +} \ No newline at end of file diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts new file mode 100644 index 0000000..087feaa --- /dev/null +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -0,0 +1,79 @@ +import { ApplicationCommandOptionType, EmbedBuilder } from "discord.js"; +import { BaseCommand } from "../../../baseCommand"; +import { Guild as DatabaseGuild } from "../../../models/Guild"; +import { DocumentType, mongoose } from "@typegoose/typegoose"; +import { Queue } from "../../../models/Queue"; + +export default class CreateQueueCommand extends BaseCommand { + public static name = "create"; + public static description = "Create a queue."; + public static options = [ + { + name: "name", + description: "The Queue Name", + type: ApplicationCommandOptionType.String, + required: true, + default: "", + }, + { + name: "description", + description: "The Queue Description", + type: ApplicationCommandOptionType.String, + required: true, + default: "", + }, + ]; + + /** + * The guild saved in the database.s + */ + private dbGuild!: DocumentType; + + public async execute() { + await this.defer(); + if (!this.interaction.guild) { + throw new Error("Interaction is not in a guild"); + } + this.dbGuild = await this.client.configManager.getGuildConfig(this.interaction.guild) + const queueName = await this.getOptionValue(CreateQueueCommand.options[0]); + const queueDescription = await this.getOptionValue(CreateQueueCommand.options[1]); + await this.createQueue(queueName, queueDescription); + const embed = this.mountCreateQueueEmbed(); + this.send({ embeds: [embed] }); + } + + /** + * Returns the create queue embed to be sent to the user. + * @returns The embed to be sent to the user. + */ + private mountCreateQueueEmbed(): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Queue Created") + .setDescription(`Queue ${this.dbGuild.queues[this.dbGuild.queues.length - 1].name} created.`) + return embed + } + + /** + * Creates a queue on the database. + * @param queueName The queue name. + * @param queueDescription The queue description. + */ + private async createQueue(queueName: string, queueDescription: string): Promise { + const queue: Queue = { + name: queueName, + description: queueDescription, + disconnect_timeout: 60000, + match_timeout: 120000, + limit: 150, + join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}\n\\> Total Time Spent: ${time_spent}", + match_found_message: "You have found a Match with ${match}. Please Join ${match_channel} if you are not moved automatically. If you don't join in ${timeout} seconds, your position in the queue is dropped.", + timeout_message: "Your queue Timed out after ${timeout} seconds.", + leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", + entries: new mongoose.Types.DocumentArray([]), + opening_times: new mongoose.Types.DocumentArray([]), + info_channels: [], + } + this.dbGuild.queues.push(queue); + await this.dbGuild.save(); + } +} \ No newline at end of file diff --git a/src/commands/index.ts b/src/commands/index.ts index f828f72..2f879f9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,9 +1,11 @@ import PingCommand from './Ping'; import HelpCommand from './Help'; import AdminCommandsHandler from './AdminCommandsHandler'; +import ConfigCommandsHandler from './ConfigCommandsHandler'; export default [ PingCommand, HelpCommand, AdminCommandsHandler, + ConfigCommandsHandler, ] \ No newline at end of file From 533fafba415dc6567d68762bcb5144d83eacac26 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 22:14:10 +0100 Subject: [PATCH 030/130] Rename command files to end with command --- src/commands/AdminCommandsHandler.ts | 2 +- src/commands/ConfigCommandsHandler.ts | 1 - src/commands/{Help.ts => HelpCommand.ts} | 0 src/commands/{Ping.ts => PingCommand.ts} | 0 .../admin/{UpdateBotRoles.ts => UpdateBotRolesCommand.ts} | 0 src/commands/index.ts | 4 ++-- tests/commands/Ping.test.ts | 2 +- tests/commands/admin/UpdateBotRoles.test.ts | 2 +- tests/events/InteractionCreateEvent.test.ts | 2 +- 9 files changed, 6 insertions(+), 7 deletions(-) rename src/commands/{Help.ts => HelpCommand.ts} (100%) rename src/commands/{Ping.ts => PingCommand.ts} (100%) rename src/commands/admin/{UpdateBotRoles.ts => UpdateBotRolesCommand.ts} (100%) diff --git a/src/commands/AdminCommandsHandler.ts b/src/commands/AdminCommandsHandler.ts index f719ce9..8e76553 100644 --- a/src/commands/AdminCommandsHandler.ts +++ b/src/commands/AdminCommandsHandler.ts @@ -1,6 +1,6 @@ import { BaseSubcommandsHandler } from "../baseCommand"; import AdminQueueCommandsHandler from "./admin/AdminQueueCommandsHandler"; -import UpdateBotRolesCommand from "./admin/UpdateBotRoles"; +import UpdateBotRolesCommand from "./admin/UpdateBotRolesCommand"; export default class AdminCommandsHandler extends BaseSubcommandsHandler { public static name = "admin"; diff --git a/src/commands/ConfigCommandsHandler.ts b/src/commands/ConfigCommandsHandler.ts index d63ed3b..622397a 100644 --- a/src/commands/ConfigCommandsHandler.ts +++ b/src/commands/ConfigCommandsHandler.ts @@ -1,5 +1,4 @@ import { BaseSubcommandsHandler } from "../baseCommand"; -import HelpCommand from "./Help"; import ConfigQueueCommandsHandler from "./config/ConfigQueueCommandsHandler"; export default class ConfigCommandsHandler extends BaseSubcommandsHandler { diff --git a/src/commands/Help.ts b/src/commands/HelpCommand.ts similarity index 100% rename from src/commands/Help.ts rename to src/commands/HelpCommand.ts diff --git a/src/commands/Ping.ts b/src/commands/PingCommand.ts similarity index 100% rename from src/commands/Ping.ts rename to src/commands/PingCommand.ts diff --git a/src/commands/admin/UpdateBotRoles.ts b/src/commands/admin/UpdateBotRolesCommand.ts similarity index 100% rename from src/commands/admin/UpdateBotRoles.ts rename to src/commands/admin/UpdateBotRolesCommand.ts diff --git a/src/commands/index.ts b/src/commands/index.ts index 2f879f9..a0f1dd0 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,5 +1,5 @@ -import PingCommand from './Ping'; -import HelpCommand from './Help'; +import PingCommand from './PingCommand'; +import HelpCommand from './HelpCommand'; import AdminCommandsHandler from './AdminCommandsHandler'; import ConfigCommandsHandler from './ConfigCommandsHandler'; diff --git a/tests/commands/Ping.test.ts b/tests/commands/Ping.test.ts index 2e5490a..ce84e76 100644 --- a/tests/commands/Ping.test.ts +++ b/tests/commands/Ping.test.ts @@ -1,5 +1,5 @@ import { BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; -import PingCommand from "../../src/commands/Ping"; +import PingCommand from "../../src/commands/PingCommand"; import { MockDiscord } from "../mockDiscord"; import { container } from "tsyringe"; diff --git a/tests/commands/admin/UpdateBotRoles.test.ts b/tests/commands/admin/UpdateBotRoles.test.ts index 15912f9..bb253ef 100644 --- a/tests/commands/admin/UpdateBotRoles.test.ts +++ b/tests/commands/admin/UpdateBotRoles.test.ts @@ -1,6 +1,6 @@ import { ApplicationCommandOptionType, BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder, PermissionsBitField, Role, RoleCreateOptions } from "discord.js"; import { MockDiscord } from "../../mockDiscord"; -import UpdateBotRolesCommand from "../../../src/commands/admin/UpdateBotRoles"; +import UpdateBotRolesCommand from "../../../src/commands/admin/UpdateBotRolesCommand"; import { container } from "tsyringe"; import { mockRole } from "@shoginn/discordjs-mock"; import { InternalGuildRoles } from "../../../src/models/BotRoles"; diff --git a/tests/events/InteractionCreateEvent.test.ts b/tests/events/InteractionCreateEvent.test.ts index 6cd2b50..a34bca7 100644 --- a/tests/events/InteractionCreateEvent.test.ts +++ b/tests/events/InteractionCreateEvent.test.ts @@ -2,7 +2,7 @@ import { container } from "tsyringe" import InteractionCreateEvent from "../../src/events/InteractionCreateEvent" import { MockDiscord } from "../mockDiscord" import { ChatInputCommandInteraction, Interaction } from "discord.js" -import PingCommand from "../../src/commands/Ping" +import PingCommand from "../../src/commands/PingCommand" describe("InteractionCreateEvent", () => { From 65b379c26f882ce021fb223ed23eeca337172183 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 22:37:57 +0100 Subject: [PATCH 031/130] Improve logging for executed command --- src/events/InteractionCreateEvent.ts | 35 +++++++++++++++++++-- tests/events/InteractionCreateEvent.test.ts | 2 +- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts index 84ad5b3..e81235d 100644 --- a/src/events/InteractionCreateEvent.ts +++ b/src/events/InteractionCreateEvent.ts @@ -1,5 +1,5 @@ import { BaseEvent } from "../baseEvent"; -import { Interaction } from "discord.js"; +import { ApplicationCommandOptionType, CommandInteractionOption, Interaction } from "discord.js"; export default class InteractionCreateEvent extends BaseEvent { public static name = "interactionCreate"; @@ -11,7 +11,9 @@ export default class InteractionCreateEvent extends BaseEvent { if (!command) return; - this.client.logger.info(`${interaction.user.tag} executed command ${command.name} with options ${JSON.stringify(interaction.options)}`); + const options = this.getOptions(interaction.options.data); + const commandName = interaction.commandName + (options.commandName.length > 0 ? ` ${options.commandName.map(option => option.name).join(" ")}` : ""); + this.client.logger.info(`${interaction.user.tag} executed command "${commandName}" with options ${JSON.stringify(options.options)}`); const concreteCommand = new command(interaction, this.client); @@ -23,4 +25,33 @@ export default class InteractionCreateEvent extends BaseEvent { interaction.reply({ content: "There was an error while executing this command!", ephemeral: true }); } } + + private getOptions(options: readonly CommandInteractionOption[] | undefined): {commandName: Option[], options: Option[]} { + if (!options) return {commandName: [], options: []}; + const commandName: Option[] = [] + const commandOptions: Option[] = [] + + options.forEach(option => { + const optionData = { + name: option.name, + value: option.value, + type: option.type, + } + if (option.type == ApplicationCommandOptionType.Subcommand || option.type == ApplicationCommandOptionType.SubcommandGroup) { + commandName.push(optionData) + } else { + commandOptions.push(optionData) + } + const nestedOptions = this.getOptions(option.options) + commandName.push(...nestedOptions.commandName) + commandOptions.push(...nestedOptions.options) + }) + return {commandName: commandName, options: commandOptions} + } +} + +interface Option { + name: string; + value: T + type: ApplicationCommandOptionType; } \ No newline at end of file diff --git a/tests/events/InteractionCreateEvent.test.ts b/tests/events/InteractionCreateEvent.test.ts index a34bca7..926987b 100644 --- a/tests/events/InteractionCreateEvent.test.ts +++ b/tests/events/InteractionCreateEvent.test.ts @@ -26,7 +26,7 @@ describe("InteractionCreateEvent", () => { const commandInteraction = interaction as ChatInputCommandInteraction expect(logSpy).toHaveBeenCalledTimes(2) - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${interaction.user.tag} executed command ${commandInteraction.commandName} with options ${JSON.stringify(commandInteraction.options)}`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${interaction.user.tag} executed command "${commandInteraction.commandName}" with options`)) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`Command ${commandInteraction.commandName} executed successfully.`)) }) From 1791d807e5301b36decfdee97193ce8152bd62ea Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 3 Jan 2024 22:38:52 +0100 Subject: [PATCH 032/130] Remove -o option for testing --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9742879..fdd771e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "ts-node src/index.ts", "build": "tsc --build --clean tsconfig.json; tsc --build tsconfig.json", - "test": "jest --detectOpenHandles --rootDir=./tests -i", + "test": "jest --detectOpenHandles --rootDir=./tests", "clean": "rm package-lock.json && rm -rf node_modules" }, "keywords": [], From 0dca2a9073333d389126dc98582f168a8a868159 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 11:48:56 +0100 Subject: [PATCH 033/130] Move tests files to be with source files --- jest.config.ts | 9 +++++---- .../Ping.test.ts => src/commands/PingCommand.test.ts | 4 ++-- .../commands/admin/UpdateBotRolesCommand.test.ts | 8 ++++---- {tests => src}/events/GuildCreateEvent.test.ts | 8 ++++---- {tests => src}/events/InteractionCreateEvent.test.ts | 6 +++--- {tests => src}/events/ReadyEvent.test.ts | 6 +++--- 6 files changed, 21 insertions(+), 20 deletions(-) rename tests/commands/Ping.test.ts => src/commands/PingCommand.test.ts (96%) rename tests/commands/admin/UpdateBotRoles.test.ts => src/commands/admin/UpdateBotRolesCommand.test.ts (97%) rename {tests => src}/events/GuildCreateEvent.test.ts (89%) rename {tests => src}/events/InteractionCreateEvent.test.ts (90%) rename {tests => src}/events/ReadyEvent.test.ts (95%) diff --git a/jest.config.ts b/jest.config.ts index dc36876..0a1433b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -5,11 +5,12 @@ const config: Config = { coverageDirectory: './coverage/', collectCoverage: false, preset: 'ts-jest', - globalSetup: "/globalSetup.ts", - globalTeardown: "/globalTeardown.ts", + globalSetup: "/tests/globalSetup.ts", + globalTeardown: "/tests/globalTeardown.ts", setupFilesAfterEnv: [ - '/testSetup.ts' - ] + '/tests/testSetup.ts' + ], + modulePathIgnorePatterns: ["/dist/"], } export default config \ No newline at end of file diff --git a/tests/commands/Ping.test.ts b/src/commands/PingCommand.test.ts similarity index 96% rename from tests/commands/Ping.test.ts rename to src/commands/PingCommand.test.ts index ce84e76..9198c3c 100644 --- a/tests/commands/Ping.test.ts +++ b/src/commands/PingCommand.test.ts @@ -1,6 +1,6 @@ import { BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; -import PingCommand from "../../src/commands/PingCommand"; -import { MockDiscord } from "../mockDiscord"; +import PingCommand from "./PingCommand"; +import { MockDiscord } from "../../tests/mockDiscord"; import { container } from "tsyringe"; describe("PingCommand", () => { diff --git a/tests/commands/admin/UpdateBotRoles.test.ts b/src/commands/admin/UpdateBotRolesCommand.test.ts similarity index 97% rename from tests/commands/admin/UpdateBotRoles.test.ts rename to src/commands/admin/UpdateBotRolesCommand.test.ts index bb253ef..0e34a07 100644 --- a/tests/commands/admin/UpdateBotRoles.test.ts +++ b/src/commands/admin/UpdateBotRolesCommand.test.ts @@ -1,10 +1,10 @@ import { ApplicationCommandOptionType, BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder, PermissionsBitField, Role, RoleCreateOptions } from "discord.js"; -import { MockDiscord } from "../../mockDiscord"; -import UpdateBotRolesCommand from "../../../src/commands/admin/UpdateBotRolesCommand"; +import { MockDiscord } from "../../../tests/mockDiscord"; +import UpdateBotRolesCommand from "./UpdateBotRolesCommand"; import { container } from "tsyringe"; import { mockRole } from "@shoginn/discordjs-mock"; -import { InternalGuildRoles } from "../../../src/models/BotRoles"; -import { GuildModel } from "../../../src/models/Guild"; +import { InternalGuildRoles } from "../../models/BotRoles"; +import { GuildModel } from "../../models/Guild"; describe("UpdateBotRolesCommand", () => { const command = UpdateBotRolesCommand diff --git a/tests/events/GuildCreateEvent.test.ts b/src/events/GuildCreateEvent.test.ts similarity index 89% rename from tests/events/GuildCreateEvent.test.ts rename to src/events/GuildCreateEvent.test.ts index 9e42abe..79aa05a 100644 --- a/tests/events/GuildCreateEvent.test.ts +++ b/src/events/GuildCreateEvent.test.ts @@ -1,9 +1,9 @@ import { container } from "tsyringe" -import GuildCreateEvent from "../../src/events/GuildCreateEvent" -import { MockDiscord } from "../mockDiscord" +import GuildCreateEvent from "./GuildCreateEvent" +import { MockDiscord } from "../../tests/mockDiscord" import { Guild } from "discord.js" -import { CommandsManager } from "../../src/managers" -import { GuildModel } from "../../src/models/Guild" +import { CommandsManager } from "../managers" +import { GuildModel } from "../models/Guild" describe("GuildCreateEvent", () => { const event = GuildCreateEvent diff --git a/tests/events/InteractionCreateEvent.test.ts b/src/events/InteractionCreateEvent.test.ts similarity index 90% rename from tests/events/InteractionCreateEvent.test.ts rename to src/events/InteractionCreateEvent.test.ts index 926987b..0a5e144 100644 --- a/tests/events/InteractionCreateEvent.test.ts +++ b/src/events/InteractionCreateEvent.test.ts @@ -1,8 +1,8 @@ import { container } from "tsyringe" -import InteractionCreateEvent from "../../src/events/InteractionCreateEvent" -import { MockDiscord } from "../mockDiscord" +import InteractionCreateEvent from "./InteractionCreateEvent" +import { MockDiscord } from "../../tests/mockDiscord" import { ChatInputCommandInteraction, Interaction } from "discord.js" -import PingCommand from "../../src/commands/PingCommand" +import PingCommand from "../commands/PingCommand" describe("InteractionCreateEvent", () => { diff --git a/tests/events/ReadyEvent.test.ts b/src/events/ReadyEvent.test.ts similarity index 95% rename from tests/events/ReadyEvent.test.ts rename to src/events/ReadyEvent.test.ts index 26c2a1b..d041f02 100644 --- a/tests/events/ReadyEvent.test.ts +++ b/src/events/ReadyEvent.test.ts @@ -1,8 +1,8 @@ import { container } from "tsyringe" -import ReadyEvent from "../../src/events/ReadyEvent" -import { MockDiscord } from "../mockDiscord" +import ReadyEvent from "./ReadyEvent" +import { MockDiscord } from "../../tests/mockDiscord" import { ActivityType, Guild } from "discord.js" -import { GuildModel } from "../../src/models/Guild" +import { GuildModel } from "../models/Guild" describe("ReadyEvent", () => { From cdd39656de0ddd27e06ae249e688a9583c2360ee Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 12:08:02 +0100 Subject: [PATCH 034/130] Use @ imports for shorter paths --- package-lock.json | 141 +++++++++++++++++- package.json | 8 +- src/baseCommand/BaseCommand.ts | 4 +- src/baseCommand/BaseSubcommandsHandler.ts | 2 +- src/commands/AdminCommandsHandler.ts | 2 +- src/commands/ConfigCommandsHandler.ts | 2 +- src/commands/HelpCommand.ts | 2 +- src/commands/PingCommand.test.ts | 2 +- src/commands/PingCommand.ts | 2 +- .../admin/AdminQueueCommandsHandler.ts | 2 +- .../admin/UpdateBotRolesCommand.test.ts | 6 +- src/commands/admin/UpdateBotRolesCommand.ts | 6 +- .../config/ConfigQueueCommandsHandler.ts | 2 +- .../config/queue/CreateQueueCommand.ts | 6 +- src/events/GuildCreateEvent.test.ts | 6 +- src/events/GuildCreateEvent.ts | 2 +- src/events/InteractionCreateEvent.test.ts | 4 +- src/events/InteractionCreateEvent.ts | 2 +- src/events/ReadyEvent.test.ts | 4 +- src/events/ReadyEvent.ts | 2 +- src/managers/ConfigManager.ts | 2 +- src/types/index.ts | 5 + tsconfig.json | 30 +++- 23 files changed, 208 insertions(+), 36 deletions(-) create mode 100644 src/types/index.ts diff --git a/package-lock.json b/package-lock.json index 2e7de54..c40506c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,10 @@ "jest": "^29.7.0", "mongodb-memory-server": "^9.1.3", "ts-jest": "^29.1.1", - "ts-node": "^10.9.2" + "ts-node": "^10.9.2", + "ts-patch": "^3.1.1", + "tsconfig-paths": "^4.2.0", + "typescript-transform-paths": "^3.4.6" } }, "node_modules/@ampproject/remapping": { @@ -2438,6 +2441,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -2546,6 +2575,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -3360,6 +3395,15 @@ "node": ">=12.0.0" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3554,6 +3598,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mongodb": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.2.0.tgz", @@ -4569,6 +4622,80 @@ } } }, + "node_modules/ts-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/ts-patch/-/ts-patch-3.1.1.tgz", + "integrity": "sha512-ReGYz9jQYC80PFafBx25TC0UI9cSgmUBtpT+WIy8IrhpLVzEHf430k03XQYOMldQMyZDBbzn5fBPELgtIl65cA==", + "dev": true, + "dependencies": { + "chalk": "^4.1.2", + "global-prefix": "^3.0.0", + "minimist": "^1.2.8", + "resolve": "^1.22.2", + "semver": "^7.5.4", + "strip-ansi": "^6.0.1" + }, + "bin": { + "ts-patch": "bin/ts-patch.js", + "tspc": "bin/tspc.js" + } + }, + "node_modules/ts-patch/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-patch/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-patch/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -4623,6 +4750,18 @@ "node": ">=14.17" } }, + "node_modules/typescript-transform-paths": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/typescript-transform-paths/-/typescript-transform-paths-3.4.6.tgz", + "integrity": "sha512-qdgpCk9oRHkIBhznxaHAapCFapJt5e4FbFik7Y4qdqtp6VyC3smAIPoDEIkjZ2eiF7x5+QxUPYNwJAtw0thsTw==", + "dev": true, + "dependencies": { + "minimatch": "^3.0.4" + }, + "peerDependencies": { + "typescript": ">=3.6.5" + } + }, "node_modules/undici": { "version": "5.27.2", "resolved": "https://registry.npmjs.org/undici/-/undici-5.27.2.tgz", diff --git a/package.json b/package.json index fdd771e..97f7f59 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,10 @@ "description": "A General Purpose Discord Bot", "main": "src/index.ts", "scripts": { + "prepare": "npx ts-patch install -s", "start": "ts-node src/index.ts", "build": "tsc --build --clean tsconfig.json; tsc --build tsconfig.json", - "test": "jest --detectOpenHandles --rootDir=./tests", + "test": "jest --detectOpenHandles --rootDir=.", "clean": "rm package-lock.json && rm -rf node_modules" }, "keywords": [], @@ -30,6 +31,9 @@ "jest": "^29.7.0", "mongodb-memory-server": "^9.1.3", "ts-jest": "^29.1.1", - "ts-node": "^10.9.2" + "ts-node": "^10.9.2", + "ts-patch": "^3.1.1", + "tsconfig-paths": "^4.2.0", + "typescript-transform-paths": "^3.4.6" } } diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index b020c3a..b84edfc 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -1,7 +1,7 @@ import { CommandInteraction, Interaction, Message, BaseMessageOptions, CommandInteractionOption } from "discord.js"; -import { handleInteractionError } from "../utils/handleError"; -import OptionRequirement from "../types/OptionRequirement"; +import { handleInteractionError } from "@utils/handleError"; import BaseCommandOrSubcommandsHandler from "./BaseCommandOrSubcommandsHandler"; +import { OptionRequirement } from "@types"; /** * The base class for all commands. diff --git a/src/baseCommand/BaseSubcommandsHandler.ts b/src/baseCommand/BaseSubcommandsHandler.ts index 9eba558..5738cc0 100644 --- a/src/baseCommand/BaseSubcommandsHandler.ts +++ b/src/baseCommand/BaseSubcommandsHandler.ts @@ -1,5 +1,5 @@ import { CommandInteraction, CommandInteractionOption, Interaction } from "discord.js"; -import { handleInteractionError } from "../utils/handleError"; +import { handleInteractionError } from "@utils/handleError"; import BaseCommandOrSubcommandsHandler from "./BaseCommandOrSubcommandsHandler"; import { Bot } from "../Bot"; diff --git a/src/commands/AdminCommandsHandler.ts b/src/commands/AdminCommandsHandler.ts index 8e76553..055f67b 100644 --- a/src/commands/AdminCommandsHandler.ts +++ b/src/commands/AdminCommandsHandler.ts @@ -1,4 +1,4 @@ -import { BaseSubcommandsHandler } from "../baseCommand"; +import { BaseSubcommandsHandler } from "@baseCommand"; import AdminQueueCommandsHandler from "./admin/AdminQueueCommandsHandler"; import UpdateBotRolesCommand from "./admin/UpdateBotRolesCommand"; diff --git a/src/commands/ConfigCommandsHandler.ts b/src/commands/ConfigCommandsHandler.ts index 622397a..356f098 100644 --- a/src/commands/ConfigCommandsHandler.ts +++ b/src/commands/ConfigCommandsHandler.ts @@ -1,4 +1,4 @@ -import { BaseSubcommandsHandler } from "../baseCommand"; +import { BaseSubcommandsHandler } from "@baseCommand"; import ConfigQueueCommandsHandler from "./config/ConfigQueueCommandsHandler"; export default class ConfigCommandsHandler extends BaseSubcommandsHandler { diff --git a/src/commands/HelpCommand.ts b/src/commands/HelpCommand.ts index 7ec5119..81809e5 100644 --- a/src/commands/HelpCommand.ts +++ b/src/commands/HelpCommand.ts @@ -1,4 +1,4 @@ -import { BaseCommand } from "../baseCommand"; +import { BaseCommand } from "@baseCommand"; import { EmbedBuilder } from "discord.js"; export default class HelpCommand extends BaseCommand { diff --git a/src/commands/PingCommand.test.ts b/src/commands/PingCommand.test.ts index 9198c3c..62ea7ae 100644 --- a/src/commands/PingCommand.test.ts +++ b/src/commands/PingCommand.test.ts @@ -1,6 +1,6 @@ import { BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; import PingCommand from "./PingCommand"; -import { MockDiscord } from "../../tests/mockDiscord"; +import { MockDiscord } from "@tests/mockDiscord"; import { container } from "tsyringe"; describe("PingCommand", () => { diff --git a/src/commands/PingCommand.ts b/src/commands/PingCommand.ts index 5d0f0de..36bb278 100644 --- a/src/commands/PingCommand.ts +++ b/src/commands/PingCommand.ts @@ -1,4 +1,4 @@ -import { BaseCommand } from "../baseCommand"; +import { BaseCommand } from "@baseCommand"; import { EmbedBuilder } from "discord.js"; export default class PingCommand extends BaseCommand { diff --git a/src/commands/admin/AdminQueueCommandsHandler.ts b/src/commands/admin/AdminQueueCommandsHandler.ts index e9d5931..908f1b6 100644 --- a/src/commands/admin/AdminQueueCommandsHandler.ts +++ b/src/commands/admin/AdminQueueCommandsHandler.ts @@ -1,4 +1,4 @@ -import { BaseSubcommandsHandler } from "../../baseCommand"; +import { BaseSubcommandsHandler } from "@baseCommand"; export default class AdminQueueCommandsHandler extends BaseSubcommandsHandler { public static name = "queue"; diff --git a/src/commands/admin/UpdateBotRolesCommand.test.ts b/src/commands/admin/UpdateBotRolesCommand.test.ts index 0e34a07..4d2dfb0 100644 --- a/src/commands/admin/UpdateBotRolesCommand.test.ts +++ b/src/commands/admin/UpdateBotRolesCommand.test.ts @@ -1,10 +1,10 @@ import { ApplicationCommandOptionType, BaseMessageOptions, ChatInputCommandInteraction, EmbedBuilder, PermissionsBitField, Role, RoleCreateOptions } from "discord.js"; -import { MockDiscord } from "../../../tests/mockDiscord"; +import { MockDiscord } from "@tests/mockDiscord"; import UpdateBotRolesCommand from "./UpdateBotRolesCommand"; import { container } from "tsyringe"; import { mockRole } from "@shoginn/discordjs-mock"; -import { InternalGuildRoles } from "../../models/BotRoles"; -import { GuildModel } from "../../models/Guild"; +import { InternalGuildRoles } from "@models/BotRoles"; +import { GuildModel } from "@models/Guild"; describe("UpdateBotRolesCommand", () => { const command = UpdateBotRolesCommand diff --git a/src/commands/admin/UpdateBotRolesCommand.ts b/src/commands/admin/UpdateBotRolesCommand.ts index 16eb5dc..c065189 100644 --- a/src/commands/admin/UpdateBotRolesCommand.ts +++ b/src/commands/admin/UpdateBotRolesCommand.ts @@ -1,7 +1,7 @@ import { ApplicationCommandOptionType, Guild as DiscordGuild, EmbedBuilder, Role } from "discord.js"; -import { BaseCommand } from "../../baseCommand"; -import { DBRole, InternalGuildRoles, RoleScopes } from "../../models/BotRoles"; -import { Guild as DatabaseGuild } from "../../models/Guild"; +import { BaseCommand } from "@baseCommand"; +import { DBRole, InternalGuildRoles, RoleScopes } from "@models/BotRoles"; +import { Guild as DatabaseGuild } from "@models/Guild"; import { ArraySubDocumentType, DocumentType, mongoose } from "@typegoose/typegoose"; export default class UpdateBotRolesCommand extends BaseCommand { diff --git a/src/commands/config/ConfigQueueCommandsHandler.ts b/src/commands/config/ConfigQueueCommandsHandler.ts index 1aca9a4..4828ea0 100644 --- a/src/commands/config/ConfigQueueCommandsHandler.ts +++ b/src/commands/config/ConfigQueueCommandsHandler.ts @@ -1,4 +1,4 @@ -import { BaseSubcommandsHandler } from "../../baseCommand"; +import { BaseSubcommandsHandler } from "@baseCommand"; import CreateQueueCommand from "./queue/CreateQueueCommand"; export default class ConfigQueueCommandsHandler extends BaseSubcommandsHandler { diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index 087feaa..c00fd8a 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -1,8 +1,8 @@ import { ApplicationCommandOptionType, EmbedBuilder } from "discord.js"; -import { BaseCommand } from "../../../baseCommand"; -import { Guild as DatabaseGuild } from "../../../models/Guild"; +import { BaseCommand } from "@baseCommand"; +import { Guild as DatabaseGuild } from "@models/Guild"; import { DocumentType, mongoose } from "@typegoose/typegoose"; -import { Queue } from "../../../models/Queue"; +import { Queue } from "@models/Queue"; export default class CreateQueueCommand extends BaseCommand { public static name = "create"; diff --git a/src/events/GuildCreateEvent.test.ts b/src/events/GuildCreateEvent.test.ts index 79aa05a..38f3272 100644 --- a/src/events/GuildCreateEvent.test.ts +++ b/src/events/GuildCreateEvent.test.ts @@ -1,9 +1,9 @@ import { container } from "tsyringe" import GuildCreateEvent from "./GuildCreateEvent" -import { MockDiscord } from "../../tests/mockDiscord" +import { MockDiscord } from "@tests/mockDiscord" import { Guild } from "discord.js" -import { CommandsManager } from "../managers" -import { GuildModel } from "../models/Guild" +import { CommandsManager } from "@managers" +import { GuildModel } from "@models/Guild" describe("GuildCreateEvent", () => { const event = GuildCreateEvent diff --git a/src/events/GuildCreateEvent.ts b/src/events/GuildCreateEvent.ts index 21c86ff..154e878 100644 --- a/src/events/GuildCreateEvent.ts +++ b/src/events/GuildCreateEvent.ts @@ -1,5 +1,5 @@ import { Guild } from "discord.js"; -import { BaseEvent } from "../baseEvent"; +import { BaseEvent } from "@baseEvent"; export default class GuildCreateEvent extends BaseEvent { public static name = "guildCreate"; diff --git a/src/events/InteractionCreateEvent.test.ts b/src/events/InteractionCreateEvent.test.ts index 0a5e144..508ffa2 100644 --- a/src/events/InteractionCreateEvent.test.ts +++ b/src/events/InteractionCreateEvent.test.ts @@ -1,8 +1,8 @@ import { container } from "tsyringe" import InteractionCreateEvent from "./InteractionCreateEvent" -import { MockDiscord } from "../../tests/mockDiscord" +import { MockDiscord } from "@tests/mockDiscord" import { ChatInputCommandInteraction, Interaction } from "discord.js" -import PingCommand from "../commands/PingCommand" +import PingCommand from "@commands/PingCommand" describe("InteractionCreateEvent", () => { diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts index e81235d..e1abf40 100644 --- a/src/events/InteractionCreateEvent.ts +++ b/src/events/InteractionCreateEvent.ts @@ -1,4 +1,4 @@ -import { BaseEvent } from "../baseEvent"; +import { BaseEvent } from "@baseEvent"; import { ApplicationCommandOptionType, CommandInteractionOption, Interaction } from "discord.js"; export default class InteractionCreateEvent extends BaseEvent { diff --git a/src/events/ReadyEvent.test.ts b/src/events/ReadyEvent.test.ts index d041f02..1fed770 100644 --- a/src/events/ReadyEvent.test.ts +++ b/src/events/ReadyEvent.test.ts @@ -1,8 +1,8 @@ import { container } from "tsyringe" import ReadyEvent from "./ReadyEvent" -import { MockDiscord } from "../../tests/mockDiscord" +import { MockDiscord } from "@tests/mockDiscord" import { ActivityType, Guild } from "discord.js" -import { GuildModel } from "../models/Guild" +import { GuildModel } from "@models/Guild" describe("ReadyEvent", () => { diff --git a/src/events/ReadyEvent.ts b/src/events/ReadyEvent.ts index df6ef7e..0edec33 100644 --- a/src/events/ReadyEvent.ts +++ b/src/events/ReadyEvent.ts @@ -1,4 +1,4 @@ -import { BaseEvent } from "../baseEvent"; +import { BaseEvent } from "@baseEvent"; import { ActivityType, ApplicationCommandData, Guild } from "discord.js"; export default class ReadyEvent extends BaseEvent { diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index 4b6a789..e48371a 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -1,6 +1,6 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { Guild as DiscordGuild } from "discord.js"; -import { Guild, GuildModel } from "../models/Guild"; +import { Guild, GuildModel } from "@models/Guild"; import { Bot } from "../Bot"; import { DocumentType } from "@typegoose/typegoose"; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..066b5ac --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,5 @@ +import OptionRequirement from "./OptionRequirement"; + +export { + OptionRequirement +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8099242..131bd7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,31 @@ "emitDecoratorMetadata": true, "forceConsistentCasingInFileNames": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "baseUrl": "./", + "paths": { + "@baseCommand": ["src/baseCommand"], + "@baseEvent": ["src/baseEvent"], + "@managers": ["src/managers"], + "@types": ["src/types"], + "@utils/*": ["src/utils/*"], + "@tests/*": ["tests/*"], + "@models/*": ["src/models/*"], + "@commands/*": ["src/commands/*"], + }, + "plugins": [ + /* Transform paths in output .js files */ + { + "transform": "typescript-transform-paths" + }, + /* Transform paths in output .d.ts files */ + { + "transform": "typescript-transform-paths", + "afterDeclarations": true + } + ] }, - "exclude": ["node_modules"] -} + "exclude": [ + "node_modules" + ] +} \ No newline at end of file From 25730a3cda831de229a7cd9fc39662ee5b3d568c Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 13:15:26 +0100 Subject: [PATCH 035/130] Rename Bot to Application --- src/{Bot.ts => Application.ts} | 27 +++---- src/baseCommand/BaseCommand.ts | 20 ++--- .../BaseCommandOrSubcommandsHandler.ts | 13 +-- src/baseCommand/BaseSubcommandsHandler.ts | 12 +-- src/baseEvent/BaseEvent.ts | 10 +-- src/commands/PingCommand.test.ts | 2 +- src/commands/PingCommand.ts | 2 +- .../admin/UpdateBotRolesCommand.test.ts | 28 +++---- src/commands/admin/UpdateBotRolesCommand.ts | 20 ++--- .../config/queue/CreateQueueCommand.ts | 2 +- src/events/GuildCreateEvent.test.ts | 4 +- src/events/GuildCreateEvent.ts | 6 +- src/events/InteractionCreateEvent.test.ts | 4 +- src/events/InteractionCreateEvent.ts | 10 +-- src/events/ReadyEvent.test.ts | 18 ++--- src/events/ReadyEvent.ts | 20 ++--- src/index.ts | 4 +- src/managers/CommandsManager.ts | 14 ++-- src/managers/ConfigManager.ts | 14 ++-- tests/mockDiscord.ts | 80 ++++--------------- tsconfig.json | 2 + 21 files changed, 133 insertions(+), 179 deletions(-) rename src/{Bot.ts => Application.ts} (88%) diff --git a/src/Bot.ts b/src/Application.ts similarity index 88% rename from src/Bot.ts rename to src/Application.ts index feb282e..b255230 100644 --- a/src/Bot.ts +++ b/src/Application.ts @@ -9,14 +9,14 @@ import events from './events' import { container, delay, inject, injectable, singleton } from "tsyringe" import Environment from "./Environment" import mongoose from "mongoose" -import { exit } from "process" /** - * The main `Bot` class. + * The main `Application` class. */ @injectable() @singleton() -export class Bot extends Client { +export class Application { + public client: Client /** * The logger used by the bot. */ @@ -27,6 +27,8 @@ export class Bot extends Client { public commandsManager: CommandsManager + private readonly token: string + /** * Timestamp of bot initialization. * @@ -44,7 +46,7 @@ export class Bot extends Client { * @param token The bot token. */ constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager) { - super(options); + this.client = new Client(options) this.token = token this.logger = createConsola({ level: Environment.logLevel }) this.commandsManager = commandsManager @@ -58,7 +60,7 @@ export class Bot extends Client { public listen(): void { this.logger.info('Listening to events') this.registerEvents() - this.login(this.token!) + this.client.login(this.token) } public startQueueGuardJob(): void { @@ -93,15 +95,13 @@ export class Bot extends Client { this.logger.info('Registering events') for (const event of events) { const concreteEvent = new event(this) - this.on(event.name, concreteEvent.execute.bind(concreteEvent)) + this.client.on(event.name, concreteEvent.execute.bind(concreteEvent)) this.logger.info(`Registered event ${event.name}`) } } } -container.registerSingleton(Bot) - -export default function initiateBot() { +export default function start() { const clientOptions: ClientOptions = { intents: [ "DirectMessages", @@ -130,9 +130,8 @@ export default function initiateBot() { container.register("options", { useValue: clientOptions }) container.register("token", { useValue: token }) - const bot = container.resolve(Bot) - // const bot = new Bot(clientOptions, token) - bot.connectToDatabase() - bot.listen() - bot.startQueueGuardJob() + const app = container.resolve(Application) + app.connectToDatabase() + app.listen() + app.startQueueGuardJob() } \ No newline at end of file diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index b84edfc..a31eb78 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -25,21 +25,21 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle const messageContent = typeof content === "string" ? { content } : content if (interaction.replied || interaction.deferred) { - this.client.logger.debug(`Editing reply to interaction ${interaction.id}`) + this.app.logger.debug(`Editing reply to interaction ${interaction.id}`) const sentContent = await interaction.editReply({ ...messageContent }) - this.client.logger.debug(`Finished edit reply to interaction ${interaction.id}`) + this.app.logger.debug(`Finished edit reply to interaction ${interaction.id}`) return sentContent as Message } else { - this.client.logger.debug(`Replying to interaction ${interaction.id}`) + this.app.logger.debug(`Replying to interaction ${interaction.id}`) const sentContent = await interaction.reply({ ...messageContent, fetchReply: true }) - this.client.logger.debug(`Finished reply to interaction ${interaction.id}`) + this.app.logger.debug(`Finished reply to interaction ${interaction.id}`) return sentContent as Message } } catch (error) { if (error instanceof Error) { - handleInteractionError(error, this.interaction, this.client.logger) + handleInteractionError(error, this.interaction, this.app.logger) } else { - this.client.logger.error(error) + this.app.logger.error(error) } throw error } @@ -50,14 +50,14 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle */ protected async defer(): Promise { try { - this.client.logger.debug(`Deferring reply to interaction ${this.interaction.id}`) + this.app.logger.debug(`Deferring reply to interaction ${this.interaction.id}`) const interaction = this.interaction as CommandInteraction await interaction.deferReply() } catch (error) { if (error instanceof Error) { - handleInteractionError(error, this.interaction, this.client.logger) + handleInteractionError(error, this.interaction, this.app.logger) } else { - this.client.logger.error(error) + this.app.logger.error(error) } throw error } @@ -69,7 +69,7 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle * @returns The option value. */ protected async getOptionValue(option: OptionRequirement): Promise { - this.client.logger.debug(`Getting option value ${option.name} from interaction ${this.interaction.id}`) + this.app.logger.debug(`Getting option value ${option.name} from interaction ${this.interaction.id}`) const interaction = this.interaction as CommandInteraction const optionValue = interaction.options.get(option.name) if (optionValue) { diff --git a/src/baseCommand/BaseCommandOrSubcommandsHandler.ts b/src/baseCommand/BaseCommandOrSubcommandsHandler.ts index ef99cda..fe3d942 100644 --- a/src/baseCommand/BaseCommandOrSubcommandsHandler.ts +++ b/src/baseCommand/BaseCommandOrSubcommandsHandler.ts @@ -1,5 +1,6 @@ +import { Application } from "@application"; import { Interaction } from "discord.js"; -import { Bot } from "../Bot"; + /** * The base class for all commands and subcommands. @@ -18,18 +19,18 @@ export default abstract class BaseCommandOrSubcommandsHandler { */ protected interaction: Interaction; /** - * The client who received the interaction. + * The app which received the interaction. */ - protected client: Bot; + protected app: Application; /** * Creates a new instance of the BaseCommand class. * @param interaction The interaction. - * @param client The client who received the interaction. + * @param app The app which received the interaction. */ - constructor(interaction: Interaction, client: Bot) { + constructor(interaction: Interaction, app: Application) { this.interaction = interaction; - this.client = client; + this.app = app; } /** diff --git a/src/baseCommand/BaseSubcommandsHandler.ts b/src/baseCommand/BaseSubcommandsHandler.ts index 5738cc0..0f0df1d 100644 --- a/src/baseCommand/BaseSubcommandsHandler.ts +++ b/src/baseCommand/BaseSubcommandsHandler.ts @@ -1,7 +1,7 @@ import { CommandInteraction, CommandInteractionOption, Interaction } from "discord.js"; import { handleInteractionError } from "@utils/handleError"; import BaseCommandOrSubcommandsHandler from "./BaseCommandOrSubcommandsHandler"; -import { Bot } from "../Bot"; +import { Application } from "@application"; /** * The base class for all subcommands. @@ -10,7 +10,7 @@ export default abstract class BaseSubcommandsHandler extends BaseCommandOrSubcom /** * The subcommands of this command. */ - public static subcommands: (new (interaction: Interaction, client: Bot) => BaseCommandOrSubcommandsHandler)[] + public static subcommands: (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)[] public async execute() { try { @@ -21,16 +21,16 @@ export default abstract class BaseSubcommandsHandler extends BaseCommandOrSubcom subcommandInteraction.resolved_subcommand = subcommandInteraction.resolved_subcommand.options![0]; } const subcommandName = subcommandInteraction.resolved_subcommand.name; - this.client.logger.debug(`Executing subcommand ${subcommandName} from interaction ${this.interaction.id}`) + this.app.logger.debug(`Executing subcommand ${subcommandName} from interaction ${this.interaction.id}`) const someClass = this.constructor as typeof BaseSubcommandsHandler; const subcommand = someClass.subcommands.find(subcommand => subcommand.name == subcommandName)!; - const concreteSubcommand = new subcommand(this.interaction, this.client); + const concreteSubcommand = new subcommand(this.interaction, this.app); await concreteSubcommand.execute(); } catch (error) { if (error instanceof Error) { - handleInteractionError(error, this.interaction, this.client.logger) + handleInteractionError(error, this.interaction, this.app.logger) } else { - this.client.logger.error(error) + this.app.logger.error(error) } throw error } diff --git a/src/baseEvent/BaseEvent.ts b/src/baseEvent/BaseEvent.ts index 29508e9..23289ca 100644 --- a/src/baseEvent/BaseEvent.ts +++ b/src/baseEvent/BaseEvent.ts @@ -1,4 +1,4 @@ -import { Bot } from "../Bot"; +import { Application } from "@application"; /** * Base class for all events @@ -9,16 +9,16 @@ export default abstract class BaseEvent { */ public static name: string; /** - * Client instance. + * The app which received the event. */ - protected client: Bot; + protected app: Application; /** * Creates a new instance of the BaseEvent class. * @param client The client instance. */ - constructor(client: Bot) { - this.client = client; + constructor(app: Application) { + this.app = app; } /** diff --git a/src/commands/PingCommand.test.ts b/src/commands/PingCommand.test.ts index 62ea7ae..a05a232 100644 --- a/src/commands/PingCommand.test.ts +++ b/src/commands/PingCommand.test.ts @@ -11,7 +11,7 @@ describe("PingCommand", () => { beforeEach(() => { interaction = discord.mockInteraction() - commandInstance = new command(interaction, discord.getClient()) + commandInstance = new command(interaction, discord.getApplication()) }) it("should have the correct name", () => { diff --git a/src/commands/PingCommand.ts b/src/commands/PingCommand.ts index 36bb278..ff32cf1 100644 --- a/src/commands/PingCommand.ts +++ b/src/commands/PingCommand.ts @@ -25,7 +25,7 @@ export default class PingCommand extends BaseCommand { .setTitle("__Response Times__") .setColor(this.interaction.guild?.members.me?.roles?.highest.color || 0x7289da) .addFields({ name:"Bot Latency:", value:":hourglass_flowing_sand:" + ping + "ms", inline:true }) - .addFields({ name:"API Latency:", value:":hourglass_flowing_sand:" + Math.round(this.client.ws.ping) + "ms", inline:true }) + .addFields({ name:"API Latency:", value:":hourglass_flowing_sand:" + Math.round(this.app.client.ws.ping) + "ms", inline:true }) return embed } } \ No newline at end of file diff --git a/src/commands/admin/UpdateBotRolesCommand.test.ts b/src/commands/admin/UpdateBotRolesCommand.test.ts index 4d2dfb0..8e3e8cd 100644 --- a/src/commands/admin/UpdateBotRolesCommand.test.ts +++ b/src/commands/admin/UpdateBotRolesCommand.test.ts @@ -15,19 +15,19 @@ describe("UpdateBotRolesCommand", () => { beforeEach(() => { interaction = discord.mockInteraction() - const bot = discord.getClient() + const app = discord.getApplication() interaction.guild!.roles.create = jest.fn().mockImplementation(async (role: RoleCreateOptions) => { - bot.logger.debug(`Creating role ${role.name}`) - const newRole = mockRole(bot, PermissionsBitField.Default, interaction.guild!, { name: role.name, id: `${role.name}_id_${interaction.guild!.id}` }) + app.logger.debug(`Creating role ${role.name}`) + const newRole = mockRole(app.client, PermissionsBitField.Default, interaction.guild!, { name: role.name, id: `${role.name}_id_${interaction.guild!.id}` }) roles.push(newRole) return newRole }) interaction.guild!.roles.resolve = jest.fn().mockImplementation((roleId: string) => { - bot.logger.debug(`Resolving role ${roleId}`) + app.logger.debug(`Resolving role ${roleId}`) return roles.find((role) => role.id === roleId) }) jest.spyOn(command.prototype as any, 'getOptionValue').mockImplementation(async () => true) - commandInstance = new command(interaction, discord.getClient()) + commandInstance = new command(interaction, discord.getApplication()) }) it("should have the correct name", () => { @@ -65,7 +65,7 @@ describe("UpdateBotRolesCommand", () => { }) it("should not create the discord roles if they don't exist and option is false", async () => { - const dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) jest.spyOn(command.prototype as any, 'getOptionValue').mockImplementation(async () => false) await commandInstance.execute() @@ -75,9 +75,9 @@ describe("UpdateBotRolesCommand", () => { }) it("should create the database entries if they don't exist", async () => { - let dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) await commandInstance.execute() - dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) expect(dbGuild.guild_settings.roles).toHaveLength(InternalGuildRoles.length) for (const internalRole of InternalGuildRoles) { @@ -96,7 +96,7 @@ describe("UpdateBotRolesCommand", () => { }) it("should update the database if the discord role name changed", async () => { - let dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) // create the roles for (const internalRole of InternalGuildRoles) { // create the role on the server @@ -118,7 +118,7 @@ describe("UpdateBotRolesCommand", () => { await dbGuild.save() await commandInstance.execute() - dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) expect(dbGuild.guild_settings.roles).toHaveLength(InternalGuildRoles.length) for (const internalRole of InternalGuildRoles) { @@ -137,7 +137,7 @@ describe("UpdateBotRolesCommand", () => { }) it("should find the discord role by id if the name is different to the internal name", async () => { - let dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) // create the roles for (const internalRole of InternalGuildRoles) { // create the role on the server @@ -157,7 +157,7 @@ describe("UpdateBotRolesCommand", () => { await dbGuild.save() await commandInstance.execute() - dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) expect(dbGuild.guild_settings.roles).toHaveLength(InternalGuildRoles.length) for (const internalRole of InternalGuildRoles) { @@ -177,7 +177,7 @@ describe("UpdateBotRolesCommand", () => { }); it("should not update the database if the discord role name is the same", async () => { - let dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) // create the roles for (const internalRole of InternalGuildRoles) { // create the role on the server @@ -198,7 +198,7 @@ describe("UpdateBotRolesCommand", () => { const guildSaveSpy = jest.spyOn(GuildModel.prototype, 'save') await commandInstance.execute() - dbGuild = await discord.getClient().configManager.getGuildConfig(interaction.guild!) + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) // check db was not updated expect(guildSaveSpy).not.toHaveBeenCalled() diff --git a/src/commands/admin/UpdateBotRolesCommand.ts b/src/commands/admin/UpdateBotRolesCommand.ts index c065189..0adf198 100644 --- a/src/commands/admin/UpdateBotRolesCommand.ts +++ b/src/commands/admin/UpdateBotRolesCommand.ts @@ -27,13 +27,13 @@ export default class UpdateBotRolesCommand extends BaseCommand { if (!this.interaction.guild) { throw new Error("Interaction is not in a guild"); } - this.dbGuild = await this.client.configManager.getGuildConfig(this.interaction.guild) + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) const createIfNotExists = await this.getOptionValue(UpdateBotRolesCommand.options[0]); await this.createDbRoles(createIfNotExists); const embed = this.mountRoleEmbed(); await this.send({ embeds: [embed] }); - this.client.logger.info(`Done generating internal Roles for guild ${this.interaction.guild.name}`); + this.app.logger.info(`Done generating internal Roles for guild ${this.interaction.guild.name}`); } /** @@ -103,24 +103,24 @@ export default class UpdateBotRolesCommand extends BaseCommand { if (roleInDatabase.server_role_name !== roleOnDiscordServer.name) { await this.updateRoleInDatabase(roleInDatabase, roleOnDiscordServer.name); } else { - this.client.logger.debug(`Role ${internalGuildRoleName} found in guild ${this.interaction.guild!.name}. Role was not renamed.`); + this.app.logger.debug(`Role ${internalGuildRoleName} found in guild ${this.interaction.guild!.name}. Role was not renamed.`); } return; } } if (!createIfNotExists) { - this.client.logger.debug(`Role ${internalGuildRoleName} not found in guild ${this.interaction.guild!.name}. Skipping`); + this.app.logger.debug(`Role ${internalGuildRoleName} not found in guild ${this.interaction.guild!.name}. Skipping`); return; } roleOnDiscordServer = await this.createRoleOnDiscordServer(internalGuildRoleName); } else { - this.client.logger.debug(`Role ${internalGuildRoleName} found in guild ${this.interaction.guild!.name}`); + this.app.logger.debug(`Role ${internalGuildRoleName} found in guild ${this.interaction.guild!.name}`); } if (!roleInDatabase) { await this.createRoleInDatabase(internalGuildRoleName, roleOnDiscordServer!); } else { - this.client.logger.debug(`Role ${internalGuildRoleName} found in database`); + this.app.logger.debug(`Role ${internalGuildRoleName} found in database`); } } @@ -130,7 +130,7 @@ export default class UpdateBotRolesCommand extends BaseCommand { * @param newRoleName The new name of the role. */ private async updateRoleInDatabase(roleInDatabase: ArraySubDocumentType, newRoleName: string): Promise { - this.client.logger.debug(`Role "${roleInDatabase.internal_name}" was renamed from "${roleInDatabase.server_role_name}" to "${newRoleName}". Updating DB`); + this.app.logger.debug(`Role "${roleInDatabase.internal_name}" was renamed from "${roleInDatabase.server_role_name}" to "${newRoleName}". Updating DB`); roleInDatabase.server_role_name = newRoleName; await this.dbGuild.save(); } @@ -141,12 +141,12 @@ export default class UpdateBotRolesCommand extends BaseCommand { * @returns The created role. */ private async createRoleOnDiscordServer(internalGuildRoleName: string): Promise { - this.client.logger.debug(`Role ${internalGuildRoleName} not found in guild ${this.interaction.guild!.name}. Creating`); + this.app.logger.debug(`Role ${internalGuildRoleName} not found in guild ${this.interaction.guild!.name}. Creating`); const newRole = await this.interaction.guild!.roles.create({ name: internalGuildRoleName, mentionable: false, }); - this.client.logger.debug(`Created role ${internalGuildRoleName} with id ${newRole.id} in guild ${this.interaction.guild!.name}`); + this.app.logger.debug(`Created role ${internalGuildRoleName} with id ${newRole.id} in guild ${this.interaction.guild!.name}`); return newRole; } @@ -156,7 +156,7 @@ export default class UpdateBotRolesCommand extends BaseCommand { * @param roleOnDiscordServer The role on the discord guild. */ private async createRoleInDatabase(internalGuildRoleName: string, roleOnDiscordServer: Role): Promise { - this.client.logger.debug(`Creating role ${internalGuildRoleName} for guild ${this.interaction.guild!.name} in database`); + this.app.logger.debug(`Creating role ${internalGuildRoleName} for guild ${this.interaction.guild!.name} in database`); if (!this.dbGuild.guild_settings.roles) { this.dbGuild.guild_settings.roles = new mongoose.Types.DocumentArray([]); } diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index c00fd8a..a243b36 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -34,7 +34,7 @@ export default class CreateQueueCommand extends BaseCommand { if (!this.interaction.guild) { throw new Error("Interaction is not in a guild"); } - this.dbGuild = await this.client.configManager.getGuildConfig(this.interaction.guild) + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) const queueName = await this.getOptionValue(CreateQueueCommand.options[0]); const queueDescription = await this.getOptionValue(CreateQueueCommand.options[1]); await this.createQueue(queueName, queueDescription); diff --git a/src/events/GuildCreateEvent.test.ts b/src/events/GuildCreateEvent.test.ts index 38f3272..8ea5801 100644 --- a/src/events/GuildCreateEvent.test.ts +++ b/src/events/GuildCreateEvent.test.ts @@ -12,7 +12,7 @@ describe("GuildCreateEvent", () => { let guild: Guild beforeEach(() => { - eventInstance = new event(discord.getClient()) + eventInstance = new event(discord.getApplication()) guild = discord.mockGuild() guild.commands.set = jest.fn().mockImplementation(() => Promise.resolve()) }) @@ -22,7 +22,7 @@ describe("GuildCreateEvent", () => { }) it("should log the guild name and id", async () => { - const logSpy = jest.spyOn(discord.getClient().logger, 'success') + const logSpy = jest.spyOn(discord.getApplication().logger, 'success') await eventInstance.execute(guild) expect(logSpy).toHaveBeenCalledTimes(1) diff --git a/src/events/GuildCreateEvent.ts b/src/events/GuildCreateEvent.ts index 154e878..ca6d2b5 100644 --- a/src/events/GuildCreateEvent.ts +++ b/src/events/GuildCreateEvent.ts @@ -5,8 +5,8 @@ export default class GuildCreateEvent extends BaseEvent { public static name = "guildCreate"; public async execute(guild: Guild) { - await this.client.configManager.getGuildConfig(guild) - await this.client.commandsManager.registerSlashCommandsFor(guild) - this.client.logger.success(`Joined guild ${guild.name} (id: ${guild.id})`) + await this.app.configManager.getGuildConfig(guild) + await this.app.commandsManager.registerSlashCommandsFor(guild) + this.app.logger.success(`Joined guild ${guild.name} (id: ${guild.id})`) } } \ No newline at end of file diff --git a/src/events/InteractionCreateEvent.test.ts b/src/events/InteractionCreateEvent.test.ts index 508ffa2..bd2e0cd 100644 --- a/src/events/InteractionCreateEvent.test.ts +++ b/src/events/InteractionCreateEvent.test.ts @@ -12,7 +12,7 @@ describe("InteractionCreateEvent", () => { let interaction: Interaction beforeEach(() => { - eventInstance = new event(discord.getClient()) + eventInstance = new event(discord.getApplication()) interaction = discord.mockInteraction("ping") }) @@ -21,7 +21,7 @@ describe("InteractionCreateEvent", () => { }) it("should log who executed which command with which options", async () => { - const logSpy = jest.spyOn(discord.getClient().logger, 'info') + const logSpy = jest.spyOn(discord.getApplication().logger, 'info') await eventInstance.execute(interaction) const commandInteraction = interaction as ChatInputCommandInteraction diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts index e1abf40..0be2861 100644 --- a/src/events/InteractionCreateEvent.ts +++ b/src/events/InteractionCreateEvent.ts @@ -7,21 +7,21 @@ export default class InteractionCreateEvent extends BaseEvent { public async execute(interaction: Interaction) { if (!interaction.isCommand()) return; - const command = this.client.commands.find(command => command.name === interaction.commandName); + const command = this.app.commands.find(command => command.name === interaction.commandName); if (!command) return; const options = this.getOptions(interaction.options.data); const commandName = interaction.commandName + (options.commandName.length > 0 ? ` ${options.commandName.map(option => option.name).join(" ")}` : ""); - this.client.logger.info(`${interaction.user.tag} executed command "${commandName}" with options ${JSON.stringify(options.options)}`); + this.app.logger.info(`${interaction.user.tag} executed command "${commandName}" with options ${JSON.stringify(options.options)}`); - const concreteCommand = new command(interaction, this.client); + const concreteCommand = new command(interaction, this.app); try { await concreteCommand.execute(); - this.client.logger.info(`Command ${command.name} executed successfully.`); + this.app.logger.info(`Command ${command.name} executed successfully.`); } catch (error) { - this.client.logger.error(error); + this.app.logger.error(error); interaction.reply({ content: "There was an error while executing this command!", ephemeral: true }); } } diff --git a/src/events/ReadyEvent.test.ts b/src/events/ReadyEvent.test.ts index 1fed770..52e4cad 100644 --- a/src/events/ReadyEvent.test.ts +++ b/src/events/ReadyEvent.test.ts @@ -12,7 +12,7 @@ describe("ReadyEvent", () => { let guild: Guild beforeEach(() => { - eventInstance = new event(discord.getClient()) + eventInstance = new event(discord.getApplication()) guild = discord.mockGuild() guild.commands.set = jest.fn().mockImplementation(() => Promise.resolve()) }) @@ -22,19 +22,19 @@ describe("ReadyEvent", () => { }) it("should log the bot stats", async () => { - const logSpy = jest.spyOn(discord.getClient().logger, 'ready') + const logSpy = jest.spyOn(discord.getApplication().logger, 'ready') await eventInstance.execute() expect(logSpy).toHaveBeenCalledTimes(1) - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`"${discord.getClient().user?.username}" is Ready!`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`"${discord.getApplication().client.user?.username}" is Ready!`)) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`Bot Stats:`)) - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${discord.getClient().users.cache.size} user(s)`)) - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${discord.getClient().channels.cache.size} channel(s)`)) - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${discord.getClient().guilds.cache.size} guild(s)`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${discord.getApplication().client.users.cache.size} user(s)`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${discord.getApplication().client.channels.cache.size} channel(s)`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${discord.getApplication().client.guilds.cache.size} guild(s)`)) }) it("should not create a new guild entry in the database if it already exists", async () => { - await discord.getClient().configManager.getGuildConfig(guild) + await discord.getApplication().configManager.getGuildConfig(guild) const findSpy = jest.spyOn(GuildModel, 'findById') const saveSpy = jest.spyOn(GuildModel.prototype, 'save') @@ -59,7 +59,7 @@ describe("ReadyEvent", () => { it("should register slash commands for the guild", async () => { - const registerSpy = jest.spyOn(discord.getClient().commandsManager, 'registerSlashCommandsFor') + const registerSpy = jest.spyOn(discord.getApplication().commandsManager, 'registerSlashCommandsFor') const commandSetSpy = jest.spyOn(guild.commands, 'set') await eventInstance.execute() @@ -68,7 +68,7 @@ describe("ReadyEvent", () => { }) it("should set the bot's presence", async () => { - const setPresenceSpy = jest.spyOn(discord.getClient().user!, 'setPresence') + const setPresenceSpy = jest.spyOn(discord.getApplication().client.user!, 'setPresence') await eventInstance.execute() expect(setPresenceSpy).toHaveBeenCalledTimes(1) diff --git a/src/events/ReadyEvent.ts b/src/events/ReadyEvent.ts index 0edec33..ed4e51e 100644 --- a/src/events/ReadyEvent.ts +++ b/src/events/ReadyEvent.ts @@ -1,5 +1,5 @@ import { BaseEvent } from "@baseEvent"; -import { ActivityType, ApplicationCommandData, Guild } from "discord.js"; +import { ActivityType, Guild } from "discord.js"; export default class ReadyEvent extends BaseEvent { static name: string = "ready"; @@ -11,19 +11,19 @@ export default class ReadyEvent extends BaseEvent { } private async prepareAllGuilds(): Promise { - const promises = this.client.guilds.cache.map(async (guild: Guild) => { + const promises = this.app.client.guilds.cache.map(async (guild: Guild) => { await this.prepareGuild(guild); }); await Promise.all(promises); } private async prepareGuild(guild: Guild): Promise { - await this.client.configManager.getGuildConfig(guild); - await this.client.commandsManager.registerSlashCommandsFor(guild); + await this.app.configManager.getGuildConfig(guild); + await this.app.commandsManager.registerSlashCommandsFor(guild); } private setBotPresence(): void { - this.client.user?.setPresence({ + this.app.client.user?.setPresence({ status: 'online', activities: [{ name: 'Sprechstunden', type: ActivityType.Watching }], afk: false @@ -32,13 +32,13 @@ export default class ReadyEvent extends BaseEvent { private logStats(): void { const message = - `"${this.client.user?.username}" is Ready! (${(Date.now() - this.client.initTimestamp) / 1000}s)\n` + + `"${this.app.client.user?.username}" is Ready! (${(Date.now() - this.app.initTimestamp) / 1000}s)\n` + " " + "-".repeat(26) + "\n" + " Bot Stats:\n" + - ` ${this.client.users.cache.size} user(s)\n` + - ` ${this.client.channels.cache.size} channel(s)\n` + - ` ${this.client.guilds.cache.size} guild(s)\n` + + ` ${this.app.client.users.cache.size} user(s)\n` + + ` ${this.app.client.channels.cache.size} channel(s)\n` + + ` ${this.app.client.guilds.cache.size} guild(s)\n` + " " + "=".repeat(26); - this.client.logger.ready(message); + this.app.logger.ready(message); } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0b8a2f3..b7f76c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,6 @@ import { Severity, setGlobalOptions } from "@typegoose/typegoose" // TODO: is this a good idea? setGlobalOptions({ options: { allowMixed: Severity.ALLOW } }); -import initiateBot from './Bot' +import start from "@application" -initiateBot() +start() diff --git a/src/managers/CommandsManager.ts b/src/managers/CommandsManager.ts index f6ba285..1795b7e 100644 --- a/src/managers/CommandsManager.ts +++ b/src/managers/CommandsManager.ts @@ -1,25 +1,25 @@ import { ApplicationCommandOptionData, ApplicationCommandOptionType, BaseChannel, ChatInputApplicationCommandData, Guild } from "discord.js"; -import { Bot } from "../Bot"; import { delay, inject, injectable, singleton } from "tsyringe"; import { BaseCommand, BaseCommandOrSubcommandsHandler, BaseSubcommandsHandler } from "../baseCommand"; +import { Application } from "@application"; @injectable() @singleton() export default class CommandsManager { - protected client: Bot; + protected app: Application; private commandsData: ChatInputApplicationCommandData[] = []; - constructor(@inject(delay(() => Bot)) client: Bot) { - this.client = client; - this.commandsData = this.loadCommandsData(this.client.commands); + constructor(@inject(delay(() => Application)) app: Application) { + this.app = app; + this.commandsData = this.loadCommandsData(this.app.commands); } public async registerSlashCommandsFor(guild: Guild): Promise { try { await guild.commands.set(this.commandsData); - this.client.logger.info(`Registered commands in guild ${guild.name}`); + this.app.logger.info(`Registered commands in guild ${guild.name}`); } catch (error) { - this.client.logger.error(`Failed to register commands in guild ${guild.name}`); + this.app.logger.error(`Failed to register commands in guild ${guild.name}`); throw error; } } diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index e48371a..8f055f1 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -1,25 +1,25 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { Guild as DiscordGuild } from "discord.js"; import { Guild, GuildModel } from "@models/Guild"; -import { Bot } from "../Bot"; import { DocumentType } from "@typegoose/typegoose"; +import { Application } from "@application"; @injectable() @singleton() export default class ConfigManager { - protected client: Bot; + protected app: Application; - constructor(@inject(delay(() => Bot)) client: Bot) { - this.client = client; + constructor(@inject(delay(() => Application)) app: Application) { + this.app = app; } public async getGuildConfig(guild: DiscordGuild): Promise> { var guildModel = await GuildModel.findById(guild.id); if (!guildModel) { - this.client.logger.debug(`Config for guild ${guild.name} does not exist. Creating...`) + this.app.logger.debug(`Config for guild ${guild.name} does not exist. Creating...`) return await this.getDefaultGuildConfig(guild); } - this.client.logger.info(`Config for guild ${guild.name} already exists.`) + this.app.logger.info(`Config for guild ${guild.name} already exists.`) return guildModel; } @@ -38,7 +38,7 @@ export default class ConfigManager { queues: [], }); await newGuildData.save(); - this.client.logger.info(`Created new Guild Config for ${guild.name} (id: ${guild.id})`); + this.app.logger.info(`Created new Guild Config for ${guild.name} (id: ${guild.id})`); return newGuildData; } diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 69cefb3..171aae4 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -8,99 +8,51 @@ import { mockChatInputCommandInteraction } from '@shoginn/discordjs-mock'; import "reflect-metadata" -import { Bot } from '../src/Bot'; import { ChatInputCommandInteraction, Guild, GuildMember, TextBasedChannel, TextChannel, User } from 'discord.js'; import { container, singleton } from 'tsyringe'; import { randomInt } from 'crypto'; import assert from 'assert'; +import { Application } from '@application'; @singleton() export class MockDiscord { - private client!: Bot; - // private guild!: Guild; - // private channel!: TextChannel; - // private user!: User; - // private guildMember!: GuildMember; - // private interaction!: ChatInputCommandInteraction; + private app: Application; - - public getClient(): Bot { - return this.client; + public getApplication(): Application { + return this.app; } - // getClient(withBots: boolean = false): Bot { - // if (withBots) { - // const botUser = mockUser(this.client, { bot: true }); - // mockGuildMember({ - // client: this.client, - // user: botUser, - // guild: this.guild, - // }); - // } - // return this.client; - // } - - // getUser(): User { - // return this.user; - // } - - // resetGuild(): void { - // this.mockGuild(); - // } - - // getGuild(): Guild { - // return this.guild; - // } - - // getGuildMember(): GuildMember { - // return this.guildMember; - // } - - // getChannel(): TextBasedChannel { - // return this.channel; - // } - - // getNewInteraction(): ChatInputCommandInteraction { - // this.mockInteraction(); - // return this.interaction; - // } - public constructor() { - this.client = this.mockClient(); - // this.mockGuild(); - // this.mockUser(); - // this.mockGuildMember(); - // this.mockChannel(); - // this.mockInteraction(); + this.app = this.mockApplication(); } - - private mockClient(): Bot { + + private mockApplication(): Application { const clientOptions = { intents: [] }; container.register("options", { useValue: clientOptions }) container.register("token", { useValue: "test" }) - const client = container.resolve(Bot); - mockClientUser(client); + const app = container.resolve(Application); + mockClientUser(app.client); - client.login = jest.fn(() => Promise.resolve('LOGIN_TOKEN')) as any; - return client; + app.client.login = jest.fn(() => Promise.resolve('LOGIN_TOKEN')) as any; + return app; } public mockGuild(): Guild { const guildId = randomInt(281474976710655).toString(); - return mockGuild(this.client, undefined, { name: guildId, id: guildId }); + return mockGuild(this.app.client, undefined, { name: guildId, id: guildId }); } public mockChannel(guild: Guild = this.mockGuild()): TextChannel { - return mockTextChannel(this.client, guild); + return mockTextChannel(this.app.client, guild); } public mockUser(): User { - return mockUser(this.client); + return mockUser(this.app.client); } public mockGuildMember(user: User = this.mockUser(), guild: Guild = this.mockGuild()): GuildMember { return mockGuildMember({ - client: this.client, + client: this.app.client, user: user, guild: guild, }); @@ -111,6 +63,6 @@ export class MockDiscord { channel = channel ? channel : this.mockChannel(guild); guildMember = guildMember ? guildMember : this.mockGuildMember(this.mockUser(), guild); assert(guildMember.guild === guild); - return mockChatInputCommandInteraction({ client: this.client, name: commandName, id: "test", channel: channel, member: guildMember }) + return mockChatInputCommandInteraction({ client: this.app.client, name: commandName, id: "test", channel: channel, member: guildMember }) } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 131bd7b..fdb6e76 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,8 @@ "@tests/*": ["tests/*"], "@models/*": ["src/models/*"], "@commands/*": ["src/commands/*"], + "@events/*": ["src/events/*"], + "@application": ["src/Application.ts"], }, "plugins": [ /* Transform paths in output .js files */ From 3bd7dba91942fe9c032183748bcff48f61d2f04c Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 13:30:48 +0100 Subject: [PATCH 036/130] Improve logging of executed command --- src/events/InteractionCreateEvent.test.ts | 3 ++- src/events/InteractionCreateEvent.ts | 26 ++++++++++------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/events/InteractionCreateEvent.test.ts b/src/events/InteractionCreateEvent.test.ts index bd2e0cd..1985680 100644 --- a/src/events/InteractionCreateEvent.test.ts +++ b/src/events/InteractionCreateEvent.test.ts @@ -20,13 +20,14 @@ describe("InteractionCreateEvent", () => { expect(event.name).toBe("interactionCreate") }) - it("should log who executed which command with which options", async () => { + it("should log who executed which command with which options in which guild", async () => { const logSpy = jest.spyOn(discord.getApplication().logger, 'info') await eventInstance.execute(interaction) const commandInteraction = interaction as ChatInputCommandInteraction expect(logSpy).toHaveBeenCalledTimes(2) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${interaction.user.tag} executed command "${commandInteraction.commandName}" with options`)) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`in guild ${interaction.guild?.name} (id: ${interaction.guild?.id})`)) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`Command ${commandInteraction.commandName} executed successfully.`)) }) diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts index 0be2861..27f72cb 100644 --- a/src/events/InteractionCreateEvent.ts +++ b/src/events/InteractionCreateEvent.ts @@ -12,8 +12,10 @@ export default class InteractionCreateEvent extends BaseEvent { if (!command) return; const options = this.getOptions(interaction.options.data); - const commandName = interaction.commandName + (options.commandName.length > 0 ? ` ${options.commandName.map(option => option.name).join(" ")}` : ""); - this.app.logger.info(`${interaction.user.tag} executed command "${commandName}" with options ${JSON.stringify(options.options)}`); + const commandName = interaction.commandName + (options.commandName.length > 0 ? ` ${options.commandName.join(" ")}` : ""); + const optionsArray = Array.from(options.options.entries()) + const optionsString = `[${optionsArray.map(([key, value]) => `"${key}": "${value}"`).join(", ")}]` + this.app.logger.info(`${interaction.user.tag} executed command "${commandName}" with options ${optionsString} in guild ${interaction.guild?.name} (id: ${interaction.guild?.id})`); const concreteCommand = new command(interaction, this.app); @@ -26,25 +28,20 @@ export default class InteractionCreateEvent extends BaseEvent { } } - private getOptions(options: readonly CommandInteractionOption[] | undefined): {commandName: Option[], options: Option[]} { - if (!options) return {commandName: [], options: []}; - const commandName: Option[] = [] - const commandOptions: Option[] = [] + private getOptions(options: readonly CommandInteractionOption[] | undefined): {commandName: Array, options: Map} { + if (!options) return {commandName: [], options: new Map()}; + const commandName: Array = [] + const commandOptions: Map = new Map() options.forEach(option => { - const optionData = { - name: option.name, - value: option.value, - type: option.type, - } if (option.type == ApplicationCommandOptionType.Subcommand || option.type == ApplicationCommandOptionType.SubcommandGroup) { - commandName.push(optionData) + commandName.push(option.name) } else { - commandOptions.push(optionData) + commandOptions.set(option.name, option.value) } const nestedOptions = this.getOptions(option.options) commandName.push(...nestedOptions.commandName) - commandOptions.push(...nestedOptions.options) + nestedOptions.options.forEach((value, key) => { commandOptions.set(key, value) }) }) return {commandName: commandName, options: commandOptions} } @@ -53,5 +50,4 @@ export default class InteractionCreateEvent extends BaseEvent { interface Option { name: string; value: T - type: ApplicationCommandOptionType; } \ No newline at end of file From 5de44bb9b72d3a08f190b5ee8302d32c4bdc01b9 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 13:33:00 +0100 Subject: [PATCH 037/130] Add documentation --- src/Application.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Application.ts b/src/Application.ts index b255230..0010216 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -22,11 +22,19 @@ export class Application { */ public logger: ConsolaInstance + /** + * The config manager responsible for managing the bot config in the database. + */ public configManager: ConfigManager + /** + * The commands manager responsible for managing the bot commands. + */ public commandsManager: CommandsManager - + /** + * The bot token. + */ private readonly token: string /** @@ -63,10 +71,17 @@ export class Application { this.client.login(this.token) } + /** + * Starts the queue guard job. + */ public startQueueGuardJob(): void { this.queueGuardJob().start() } + /** + * Returns the queue guard job. + * @returns The queue guard job. + */ private queueGuardJob(): CronJob { return new CronJob("*/30 * * * * *", () => { // TODO @@ -74,6 +89,9 @@ export class Application { }) } + /** + * Connects the application to the database. + */ public async connectToDatabase(): Promise { this.logger.debug('Connecting to MongoDB') mongoose.connect(Environment.monogodbUrl, {}) @@ -82,6 +100,9 @@ export class Application { }) } + /** + * Disconnects the application from the database. + */ public async disconnectFromDatabase(): Promise { this.logger.debug('Disconnecting from MongoDB') await mongoose.disconnect() @@ -101,6 +122,9 @@ export class Application { } } +/** + * Starts the bot. + */ export default function start() { const clientOptions: ClientOptions = { intents: [ From c4f6e91a097e493e1040c7e785e37b497360482e Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:42:03 +0100 Subject: [PATCH 038/130] Add queue create tests --- .../config/queue/CreateQueueCommand.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/commands/config/queue/CreateQueueCommand.test.ts diff --git a/src/commands/config/queue/CreateQueueCommand.test.ts b/src/commands/config/queue/CreateQueueCommand.test.ts new file mode 100644 index 0000000..8559964 --- /dev/null +++ b/src/commands/config/queue/CreateQueueCommand.test.ts @@ -0,0 +1,125 @@ +import { MockDiscord } from "@tests/mockDiscord" +import CreateQueueCommand from "./CreateQueueCommand" +import { container } from "tsyringe" +import { Application, ApplicationCommandOptionType, BaseMessageOptions, ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js" +import { OptionRequirement } from "@types" + +describe("CreateQueueCommand", () => { + const command = CreateQueueCommand + const discord = container.resolve(MockDiscord) + let commandInstance: CreateQueueCommand + let interaction: ChatInputCommandInteraction + + beforeEach(() => { + interaction = discord.mockInteraction() + commandInstance = new command(interaction, discord.getApplication()) + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "name": + return { value: "test name" } + case "description": + return { value: "test description" } + default: + return null + } + }) + }) + + it("should have the correct name", () => { + expect(command.name).toBe("create") + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Creates a new queue.") + }) + + it("should have the correct options", () => { + expect(command.options).toHaveLength(2) + expect(command.options[0]).toStrictEqual({ + name: "name", + description: "The name of the queue.", + type: ApplicationCommandOptionType.String, + required: true, + default: "", + }) + expect(command.options[1]).toStrictEqual({ + name: "description", + description: "The description of the queue.", + type: ApplicationCommandOptionType.String, + required: true, + default: "", + }) + }) + + it("should defer the interaction", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply') + await commandInstance.execute() + + expect(deferSpy).toHaveBeenCalledTimes(1) + }) + + it("should edit the reply with the created queue", async () => { + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + + expect(embedData).toEqual({ + title: "Queue Created", + description: expect.stringContaining("Queue"), + }) + }) + + it("should create a queue on the database", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + await commandInstance.execute() + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + + expect(dbGuild.queues).toHaveLength(1) + expect(dbGuild.queues[0].name).toBe("test name") + expect(dbGuild.queues[0].description).toBe("test description") + }) + + it("should fail if the queue name is already taken", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + const queue = { + name: "test name", + description: "test description", + tracks: [] + } + dbGuild.queues.push(queue) + await dbGuild.save() + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + expect(embedData).toEqual({ + title: "Queue Creation Failed", + description: expect.stringContaining(`Queue with name "${queue.name}" already exists.`), + color: Colors.Red + }) + }) + + it("should log the queue creation", async () => { + const logSpy = jest.spyOn(discord.getApplication().logger, 'info') + await commandInstance.execute() + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`Queue "test name" created on guild "${interaction.guild?.name}" (id: ${interaction.guild?.id})`)) + }) +}) \ No newline at end of file From 070fe331351a222c19a9acba88dfa5d37e29314a Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:43:20 +0100 Subject: [PATCH 039/130] Queue create fail with duplicate name --- .../config/queue/CreateQueueCommand.ts | 51 ++++++++++++++++--- src/types/errors/QueueAlreadyExistsError.ts | 8 +++ src/types/index.ts | 4 +- 3 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 src/types/errors/QueueAlreadyExistsError.ts diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index a243b36..7adb2f9 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -1,23 +1,24 @@ -import { ApplicationCommandOptionType, EmbedBuilder } from "discord.js"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder } from "discord.js"; import { BaseCommand } from "@baseCommand"; import { Guild as DatabaseGuild } from "@models/Guild"; import { DocumentType, mongoose } from "@typegoose/typegoose"; import { Queue } from "@models/Queue"; +import { QueueAlreadyExistsError } from "@types"; export default class CreateQueueCommand extends BaseCommand { public static name = "create"; - public static description = "Create a queue."; + public static description = "Creates a new queue."; public static options = [ { name: "name", - description: "The Queue Name", + description: "The name of the queue.", type: ApplicationCommandOptionType.String, required: true, default: "", }, { name: "description", - description: "The Queue Description", + description: "The description of the queue.", type: ApplicationCommandOptionType.String, required: true, default: "", @@ -37,9 +38,18 @@ export default class CreateQueueCommand extends BaseCommand { this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) const queueName = await this.getOptionValue(CreateQueueCommand.options[0]); const queueDescription = await this.getOptionValue(CreateQueueCommand.options[1]); - await this.createQueue(queueName, queueDescription); + try { + await this.createQueue(queueName, queueDescription); + } catch (error) { + if (error instanceof QueueAlreadyExistsError) { + const embed = this.mountCreateQueueFailedEmbed(error.queueName); + this.send({ embeds: [embed] }); + return; + } + throw error; + } const embed = this.mountCreateQueueEmbed(); - this.send({ embeds: [embed] }); + this.send({ embeds: [embed] }); } /** @@ -53,12 +63,28 @@ export default class CreateQueueCommand extends BaseCommand { return embed } + /** + * Returns the create queue failed embed with the duplicate queue name to be sent to the user. + * @param duplicateQueueName The name of the queue that already exists. + * @returns The embed to be sent to the user. + */ + private mountCreateQueueFailedEmbed(duplicateQueueName: string): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Queue Creation Failed") + .setDescription(`Queue with name "${duplicateQueueName}" already exists.`) + .setColor(Colors.Red) + return embed + } + /** * Creates a queue on the database. * @param queueName The queue name. * @param queueDescription The queue description. */ private async createQueue(queueName: string, queueDescription: string): Promise { + if (this.checkQueueName(queueName)) { + throw new QueueAlreadyExistsError(queueName); + } const queue: Queue = { name: queueName, description: queueDescription, @@ -73,7 +99,20 @@ export default class CreateQueueCommand extends BaseCommand { opening_times: new mongoose.Types.DocumentArray([]), info_channels: [], } + this.app.logger.debug(`Creating queue "${queueName}" on guild "${this.interaction.guild?.name}" (id: ${this.interaction.guild?.id})`) this.dbGuild.queues.push(queue); await this.dbGuild.save(); + this.app.logger.info(`Queue "${queueName}" created on guild "${this.interaction.guild?.name}" (id: ${this.interaction.guild?.id})`) + } + + /** + * Returns whether the queue name already exists on this guild. + * + * The check is case insensitive. + * @param queueName The queue name to check. + * @returns Whether the queue name already exists on this guild. + */ + private checkQueueName(queueName: string): boolean { + return this.dbGuild.queues.some((queue) => queue.name.toLowerCase === queueName.toLowerCase); } } \ No newline at end of file diff --git a/src/types/errors/QueueAlreadyExistsError.ts b/src/types/errors/QueueAlreadyExistsError.ts new file mode 100644 index 0000000..6ff0a5b --- /dev/null +++ b/src/types/errors/QueueAlreadyExistsError.ts @@ -0,0 +1,8 @@ +export default class QueueAlreadyExistsError extends Error { + public queueName: string; + + constructor(queueName: string) { + super(`Queue ${queueName} already exists.`); + this.queueName = queueName; + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 066b5ac..6b0d317 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,7 @@ import OptionRequirement from "./OptionRequirement"; +import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; export { - OptionRequirement + OptionRequirement, + QueueAlreadyExistsError, } \ No newline at end of file From f8e824e4aac2a2455874b16b2bc6c26d0e86a205 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:43:34 +0100 Subject: [PATCH 040/130] Streamline logging of guild name and id --- src/commands/admin/UpdateBotRolesCommand.ts | 2 +- src/managers/CommandsManager.ts | 4 ++-- src/managers/ConfigManager.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/admin/UpdateBotRolesCommand.ts b/src/commands/admin/UpdateBotRolesCommand.ts index 0adf198..66e8a90 100644 --- a/src/commands/admin/UpdateBotRolesCommand.ts +++ b/src/commands/admin/UpdateBotRolesCommand.ts @@ -33,7 +33,7 @@ export default class UpdateBotRolesCommand extends BaseCommand { const embed = this.mountRoleEmbed(); await this.send({ embeds: [embed] }); - this.app.logger.info(`Done generating internal Roles for guild ${this.interaction.guild.name}`); + this.app.logger.info(`Done generating internal Roles for guild "${this.interaction.guild.name}" (id: ${this.interaction.guild.id})`); } /** diff --git a/src/managers/CommandsManager.ts b/src/managers/CommandsManager.ts index 1795b7e..74fa625 100644 --- a/src/managers/CommandsManager.ts +++ b/src/managers/CommandsManager.ts @@ -17,9 +17,9 @@ export default class CommandsManager { public async registerSlashCommandsFor(guild: Guild): Promise { try { await guild.commands.set(this.commandsData); - this.app.logger.info(`Registered commands in guild ${guild.name}`); + this.app.logger.info(`Registered commands in guild "${guild.name}" (id: ${guild.id})`); } catch (error) { - this.app.logger.error(`Failed to register commands in guild ${guild.name}`); + this.app.logger.error(`Failed to register commands in guild "${guild.name}" (id: ${guild.id})`); throw error; } } diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index 8f055f1..88ad18c 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -16,10 +16,10 @@ export default class ConfigManager { public async getGuildConfig(guild: DiscordGuild): Promise> { var guildModel = await GuildModel.findById(guild.id); if (!guildModel) { - this.app.logger.debug(`Config for guild ${guild.name} does not exist. Creating...`) + this.app.logger.debug(`Config for guild "${guild.name}" (id: ${guild.id}) does not exist. Creating...`) return await this.getDefaultGuildConfig(guild); } - this.app.logger.info(`Config for guild ${guild.name} already exists.`) + this.app.logger.debug(`Config for guild "${guild.name}" (id: ${guild.id}) already exists.`) return guildModel; } From b6447e496b4ad5329012eef22fef3763aa918084 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 17:40:09 +0100 Subject: [PATCH 041/130] Log when duplicate queue name --- src/commands/config/queue/CreateQueueCommand.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index 7adb2f9..4902448 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -83,6 +83,7 @@ export default class CreateQueueCommand extends BaseCommand { */ private async createQueue(queueName: string, queueDescription: string): Promise { if (this.checkQueueName(queueName)) { + this.app.logger.info(`Queue "${queueName}" already exists on guild "${this.interaction.guild?.name}" (id: ${this.interaction.guild?.id}). Aborting.`) throw new QueueAlreadyExistsError(queueName); } const queue: Queue = { From 44b3765272d966b2b8f911beebfc70171f415125 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 17:40:21 +0100 Subject: [PATCH 042/130] Test same queue name on different guilds allowed --- .../config/queue/CreateQueueCommand.test.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/commands/config/queue/CreateQueueCommand.test.ts b/src/commands/config/queue/CreateQueueCommand.test.ts index 8559964..62cb543 100644 --- a/src/commands/config/queue/CreateQueueCommand.test.ts +++ b/src/commands/config/queue/CreateQueueCommand.test.ts @@ -87,7 +87,7 @@ describe("CreateQueueCommand", () => { expect(dbGuild.queues[0].description).toBe("test description") }) - it("should fail if the queue name is already taken", async () => { + it("should fail if the queue name is already taken on the same guild", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) const queue = { name: "test name", @@ -116,6 +116,40 @@ describe("CreateQueueCommand", () => { }) }) + it("should create a queue if the queue name is already taken on another guild", async () => { + const queue = { + name: "test name", + description: "test description", + tracks: [] + } + const otherGuild = discord.mockGuild() + let dbGuild = await discord.getApplication().configManager.getGuildConfig(otherGuild) + dbGuild.queues.push(queue) + await dbGuild.save() + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + + expect(dbGuild.queues).toHaveLength(1) + expect(dbGuild.queues[0].name).toBe("test name") + expect(dbGuild.queues[0].description).toBe("test description") + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + + expect(embedData).toEqual({ + title: "Queue Created", + description: expect.stringContaining("Queue"), + }) + }) + it("should log the queue creation", async () => { const logSpy = jest.spyOn(discord.getApplication().logger, 'info') await commandInstance.execute() From 3a2c849f7e7f46241f0698d759ce2e99f1dbb3e4 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 22:30:00 +0100 Subject: [PATCH 043/130] Streamline logging of guild name and id --- src/events/GuildCreateEvent.test.ts | 2 +- src/events/GuildCreateEvent.ts | 2 +- src/managers/ConfigManager.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/events/GuildCreateEvent.test.ts b/src/events/GuildCreateEvent.test.ts index 8ea5801..1dfeada 100644 --- a/src/events/GuildCreateEvent.test.ts +++ b/src/events/GuildCreateEvent.test.ts @@ -26,7 +26,7 @@ describe("GuildCreateEvent", () => { await eventInstance.execute(guild) expect(logSpy).toHaveBeenCalledTimes(1) - expect(logSpy).toHaveBeenCalledWith(`Joined guild ${guild.name} (id: ${guild.id})`) + expect(logSpy).toHaveBeenCalledWith(`Joined guild "${guild.name}" (id: ${guild.id})`) }) it("should create a new guild in the database", async () => { diff --git a/src/events/GuildCreateEvent.ts b/src/events/GuildCreateEvent.ts index ca6d2b5..9133a54 100644 --- a/src/events/GuildCreateEvent.ts +++ b/src/events/GuildCreateEvent.ts @@ -7,6 +7,6 @@ export default class GuildCreateEvent extends BaseEvent { public async execute(guild: Guild) { await this.app.configManager.getGuildConfig(guild) await this.app.commandsManager.registerSlashCommandsFor(guild) - this.app.logger.success(`Joined guild ${guild.name} (id: ${guild.id})`) + this.app.logger.success(`Joined guild "${guild.name}" (id: ${guild.id})`) } } \ No newline at end of file diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index 88ad18c..fc08568 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -38,7 +38,7 @@ export default class ConfigManager { queues: [], }); await newGuildData.save(); - this.app.logger.info(`Created new Guild Config for ${guild.name} (id: ${guild.id})`); + this.app.logger.info(`Created new Guild Config for "${guild.name}" (id: ${guild.id})`); return newGuildData; } From 82cdd5f78727f4e7488be6074f98997f72e00324 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 22:42:59 +0100 Subject: [PATCH 044/130] Improve option default values --- src/commands/config/queue/CreateQueueCommand.test.ts | 4 ++-- src/commands/config/queue/CreateQueueCommand.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/config/queue/CreateQueueCommand.test.ts b/src/commands/config/queue/CreateQueueCommand.test.ts index 62cb543..4de44ee 100644 --- a/src/commands/config/queue/CreateQueueCommand.test.ts +++ b/src/commands/config/queue/CreateQueueCommand.test.ts @@ -40,14 +40,14 @@ describe("CreateQueueCommand", () => { description: "The name of the queue.", type: ApplicationCommandOptionType.String, required: true, - default: "", + default: String.prototype, }) expect(command.options[1]).toStrictEqual({ name: "description", description: "The description of the queue.", type: ApplicationCommandOptionType.String, required: true, - default: "", + default: String.prototype, }) }) diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index 4902448..621839a 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -14,14 +14,14 @@ export default class CreateQueueCommand extends BaseCommand { description: "The name of the queue.", type: ApplicationCommandOptionType.String, required: true, - default: "", + default: String.prototype, }, { name: "description", description: "The description of the queue.", type: ApplicationCommandOptionType.String, required: true, - default: "", + default: String.prototype, }, ]; @@ -39,7 +39,7 @@ export default class CreateQueueCommand extends BaseCommand { const queueName = await this.getOptionValue(CreateQueueCommand.options[0]); const queueDescription = await this.getOptionValue(CreateQueueCommand.options[1]); try { - await this.createQueue(queueName, queueDescription); + await this.createQueue(queueName.valueOf(), queueDescription.valueOf()); } catch (error) { if (error instanceof QueueAlreadyExistsError) { const embed = this.mountCreateQueueFailedEmbed(error.queueName); From f6fb1e24f14c3b07250f084f00f7dac591a58840 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 23:06:34 +0100 Subject: [PATCH 045/130] Improve option handling --- src/baseCommand/BaseCommand.ts | 15 ++++++++----- .../admin/UpdateBotRolesCommand.test.ts | 10 +++++++-- src/commands/admin/UpdateBotRolesCommand.ts | 3 ++- .../config/queue/CreateQueueCommand.test.ts | 4 +--- .../config/queue/CreateQueueCommand.ts | 2 -- src/types/OptionRequirement.ts | 4 ++-- src/types/errors/MissingOptionError.ts | 21 +++++++++++++++++++ src/types/index.ts | 2 ++ 8 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 src/types/errors/MissingOptionError.ts diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index a31eb78..4480127 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -1,7 +1,7 @@ import { CommandInteraction, Interaction, Message, BaseMessageOptions, CommandInteractionOption } from "discord.js"; import { handleInteractionError } from "@utils/handleError"; import BaseCommandOrSubcommandsHandler from "./BaseCommandOrSubcommandsHandler"; -import { OptionRequirement } from "@types"; +import { MissingOptionError, OptionRequirement } from "@types"; /** * The base class for all commands. @@ -10,7 +10,7 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle /** * The command options. */ - public static options: OptionRequirement[] = [] + public static options: OptionRequirement[] = [] /** * Sends a message to the interaction channel. @@ -65,16 +65,21 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle /** * Returns the value of the given option or the default value if the option is not present. + * + * If the option is not present and there is no default value, throws an error. * @param option The option to get the value from. * @returns The option value. */ - protected async getOptionValue(option: OptionRequirement): Promise { + protected async getOptionValue(option: OptionRequirement): Promise { this.app.logger.debug(`Getting option value ${option.name} from interaction ${this.interaction.id}`) const interaction = this.interaction as CommandInteraction const optionValue = interaction.options.get(option.name) if (optionValue) { - return optionValue.value as T + console.log(optionValue) + return optionValue.value as string + } else if (option.default) { + return option.default as string } - return option.default as T + throw new MissingOptionError(option.name, interaction.commandName) } } diff --git a/src/commands/admin/UpdateBotRolesCommand.test.ts b/src/commands/admin/UpdateBotRolesCommand.test.ts index 8e3e8cd..f74c3ab 100644 --- a/src/commands/admin/UpdateBotRolesCommand.test.ts +++ b/src/commands/admin/UpdateBotRolesCommand.test.ts @@ -26,7 +26,6 @@ describe("UpdateBotRolesCommand", () => { app.logger.debug(`Resolving role ${roleId}`) return roles.find((role) => role.id === roleId) }) - jest.spyOn(command.prototype as any, 'getOptionValue').mockImplementation(async () => true) commandInstance = new command(interaction, discord.getApplication()) }) @@ -67,7 +66,14 @@ describe("UpdateBotRolesCommand", () => { it("should not create the discord roles if they don't exist and option is false", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - jest.spyOn(command.prototype as any, 'getOptionValue').mockImplementation(async () => false) + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "create-if-not-exists": + return { value: false } + default: + return null + } + }) await commandInstance.execute() expect(interaction.guild!.roles.create).not.toHaveBeenCalled() diff --git a/src/commands/admin/UpdateBotRolesCommand.ts b/src/commands/admin/UpdateBotRolesCommand.ts index 66e8a90..f195362 100644 --- a/src/commands/admin/UpdateBotRolesCommand.ts +++ b/src/commands/admin/UpdateBotRolesCommand.ts @@ -28,7 +28,8 @@ export default class UpdateBotRolesCommand extends BaseCommand { throw new Error("Interaction is not in a guild"); } this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) - const createIfNotExists = await this.getOptionValue(UpdateBotRolesCommand.options[0]); + const createIfNotExists = Boolean(await this.getOptionValue(UpdateBotRolesCommand.options[0])); + console.log(`createIfNotExists: ${createIfNotExists}`); await this.createDbRoles(createIfNotExists); const embed = this.mountRoleEmbed(); await this.send({ embeds: [embed] }); diff --git a/src/commands/config/queue/CreateQueueCommand.test.ts b/src/commands/config/queue/CreateQueueCommand.test.ts index 4de44ee..60b841b 100644 --- a/src/commands/config/queue/CreateQueueCommand.test.ts +++ b/src/commands/config/queue/CreateQueueCommand.test.ts @@ -40,14 +40,12 @@ describe("CreateQueueCommand", () => { description: "The name of the queue.", type: ApplicationCommandOptionType.String, required: true, - default: String.prototype, }) expect(command.options[1]).toStrictEqual({ name: "description", description: "The description of the queue.", type: ApplicationCommandOptionType.String, - required: true, - default: String.prototype, + required: true }) }) diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index 621839a..acf5d8a 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -14,14 +14,12 @@ export default class CreateQueueCommand extends BaseCommand { description: "The name of the queue.", type: ApplicationCommandOptionType.String, required: true, - default: String.prototype, }, { name: "description", description: "The description of the queue.", type: ApplicationCommandOptionType.String, required: true, - default: String.prototype, }, ]; diff --git a/src/types/OptionRequirement.ts b/src/types/OptionRequirement.ts index b7e9e05..f1b445f 100644 --- a/src/types/OptionRequirement.ts +++ b/src/types/OptionRequirement.ts @@ -3,7 +3,7 @@ import { ApplicationCommandOptionType } from "discord.js"; /** * The option requirement interface used for the command options. */ -export default interface OptionRequirement { +export default interface OptionRequirement { /** * The option name. */ @@ -23,5 +23,5 @@ export default interface OptionRequirement { /** * The default value of the option. */ - default: T + default?: string | boolean | number } diff --git a/src/types/errors/MissingOptionError.ts b/src/types/errors/MissingOptionError.ts new file mode 100644 index 0000000..b08484a --- /dev/null +++ b/src/types/errors/MissingOptionError.ts @@ -0,0 +1,21 @@ +export default class MissingOptionError extends Error { + /** + * The missing option name. + */ + public optionName: string; + /** + * The command name for which the option is missing. + */ + public commandName: string; + + /** + * Creates a new missing option error. + * @param optionName The missing option name. + * @param commandName The command name for which the option is missing. + */ + constructor(optionName: string, commandName: string) { + super(`Missing option "${optionName}" for command "${commandName}"`); + this.optionName = optionName; + this.commandName = commandName; + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 6b0d317..2c26d6f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,9 @@ import OptionRequirement from "./OptionRequirement"; +import MissingOptionError from "./errors/MissingOptionError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; export { OptionRequirement, QueueAlreadyExistsError, + MissingOptionError, } \ No newline at end of file From f18adedbde80e1402f1c38b5896d80bf00e66858 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 23:06:43 +0100 Subject: [PATCH 046/130] Add documentation --- src/types/errors/QueueAlreadyExistsError.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/types/errors/QueueAlreadyExistsError.ts b/src/types/errors/QueueAlreadyExistsError.ts index 6ff0a5b..6e513d7 100644 --- a/src/types/errors/QueueAlreadyExistsError.ts +++ b/src/types/errors/QueueAlreadyExistsError.ts @@ -1,6 +1,13 @@ export default class QueueAlreadyExistsError extends Error { + /** + * The queue name which already exists. + */ public queueName: string; + /** + * Creates a new queue already exists error. + * @param queueName The queue name which already exists. + */ constructor(queueName: string) { super(`Queue ${queueName} already exists.`); this.queueName = queueName; From d6f0aa2601c82bd51d25390d562ab622155566d3 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 23:09:10 +0100 Subject: [PATCH 047/130] Remove debug logs --- src/baseCommand/BaseCommand.ts | 1 - src/commands/admin/UpdateBotRolesCommand.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 4480127..89a5846 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -75,7 +75,6 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle const interaction = this.interaction as CommandInteraction const optionValue = interaction.options.get(option.name) if (optionValue) { - console.log(optionValue) return optionValue.value as string } else if (option.default) { return option.default as string diff --git a/src/commands/admin/UpdateBotRolesCommand.ts b/src/commands/admin/UpdateBotRolesCommand.ts index f195362..a6238b9 100644 --- a/src/commands/admin/UpdateBotRolesCommand.ts +++ b/src/commands/admin/UpdateBotRolesCommand.ts @@ -29,7 +29,6 @@ export default class UpdateBotRolesCommand extends BaseCommand { } this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) const createIfNotExists = Boolean(await this.getOptionValue(UpdateBotRolesCommand.options[0])); - console.log(`createIfNotExists: ${createIfNotExists}`); await this.createDbRoles(createIfNotExists); const embed = this.mountRoleEmbed(); await this.send({ embeds: [embed] }); From d65a88dce322880399eec9123310bdd6b0291f0f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 4 Jan 2024 23:14:54 +0100 Subject: [PATCH 048/130] Better description string --- src/types/errors/QueueAlreadyExistsError.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/errors/QueueAlreadyExistsError.ts b/src/types/errors/QueueAlreadyExistsError.ts index 6e513d7..b0fdd05 100644 --- a/src/types/errors/QueueAlreadyExistsError.ts +++ b/src/types/errors/QueueAlreadyExistsError.ts @@ -9,7 +9,7 @@ export default class QueueAlreadyExistsError extends Error { * @param queueName The queue name which already exists. */ constructor(queueName: string) { - super(`Queue ${queueName} already exists.`); + super(`Queue "${queueName}" already exists.`); this.queueName = queueName; } } \ No newline at end of file From 34b87f46ba68a68398cbdfe171882e5155917064 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 5 Jan 2024 11:00:24 +0100 Subject: [PATCH 049/130] Rome redundant async from getOptionValue --- src/baseCommand/BaseCommand.ts | 2 +- src/commands/admin/UpdateBotRolesCommand.ts | 2 +- src/commands/config/queue/CreateQueueCommand.ts | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 89a5846..4811366 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -70,7 +70,7 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle * @param option The option to get the value from. * @returns The option value. */ - protected async getOptionValue(option: OptionRequirement): Promise { + protected getOptionValue(option: OptionRequirement): string { this.app.logger.debug(`Getting option value ${option.name} from interaction ${this.interaction.id}`) const interaction = this.interaction as CommandInteraction const optionValue = interaction.options.get(option.name) diff --git a/src/commands/admin/UpdateBotRolesCommand.ts b/src/commands/admin/UpdateBotRolesCommand.ts index a6238b9..848e7ee 100644 --- a/src/commands/admin/UpdateBotRolesCommand.ts +++ b/src/commands/admin/UpdateBotRolesCommand.ts @@ -28,7 +28,7 @@ export default class UpdateBotRolesCommand extends BaseCommand { throw new Error("Interaction is not in a guild"); } this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) - const createIfNotExists = Boolean(await this.getOptionValue(UpdateBotRolesCommand.options[0])); + const createIfNotExists = Boolean(this.getOptionValue(UpdateBotRolesCommand.options[0])); await this.createDbRoles(createIfNotExists); const embed = this.mountRoleEmbed(); await this.send({ embeds: [embed] }); diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index acf5d8a..3a7d369 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -24,7 +24,7 @@ export default class CreateQueueCommand extends BaseCommand { ]; /** - * The guild saved in the database.s + * The guild saved in the database. */ private dbGuild!: DocumentType; @@ -34,10 +34,10 @@ export default class CreateQueueCommand extends BaseCommand { throw new Error("Interaction is not in a guild"); } this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) - const queueName = await this.getOptionValue(CreateQueueCommand.options[0]); - const queueDescription = await this.getOptionValue(CreateQueueCommand.options[1]); + const queueName = this.getOptionValue(CreateQueueCommand.options[0]); + const queueDescription = this.getOptionValue(CreateQueueCommand.options[1]); try { - await this.createQueue(queueName.valueOf(), queueDescription.valueOf()); + await this.createQueue(queueName, queueDescription); } catch (error) { if (error instanceof QueueAlreadyExistsError) { const embed = this.mountCreateQueueFailedEmbed(error.queueName); @@ -47,7 +47,7 @@ export default class CreateQueueCommand extends BaseCommand { throw error; } const embed = this.mountCreateQueueEmbed(); - this.send({ embeds: [embed] }); + await this.send({ embeds: [embed] }); } /** From 3404e8f89429a58e2b34c9778ed8f159847eed95 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 5 Jan 2024 11:09:39 +0100 Subject: [PATCH 050/130] Add Model --- src/models/VoiceChannel.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/models/VoiceChannel.ts b/src/models/VoiceChannel.ts index faafe82..b9e65c1 100644 --- a/src/models/VoiceChannel.ts +++ b/src/models/VoiceChannel.ts @@ -1,4 +1,4 @@ -import { mongoose, prop, Ref, SubDocumentType } from "@typegoose/typegoose"; +import { getModelForClass, mongoose, prop, Ref, SubDocumentType } from "@typegoose/typegoose"; import { VoiceChannelSpawner } from "./VoiceChannelSpawner"; import { Queue } from "./Queue"; import { ChannelType } from "discord.js"; @@ -60,4 +60,10 @@ export class VoiceChannel implements Channel { */ @prop({ type: String, default: [] }) supervisors?: mongoose.Types.Array; -} \ No newline at end of file +} + +export const VoiceChannelModel = getModelForClass(VoiceChannel, { + schemaOptions: { + autoCreate: false, + }, +}); \ No newline at end of file From ae3370b18c49e70b44bbf509a4d5de0186aa778c Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 5 Jan 2024 11:37:34 +0100 Subject: [PATCH 051/130] Rename command to update_bot_roles --- src/commands/admin/UpdateBotRolesCommand.test.ts | 2 +- src/commands/admin/UpdateBotRolesCommand.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/admin/UpdateBotRolesCommand.test.ts b/src/commands/admin/UpdateBotRolesCommand.test.ts index f74c3ab..b4ba70e 100644 --- a/src/commands/admin/UpdateBotRolesCommand.test.ts +++ b/src/commands/admin/UpdateBotRolesCommand.test.ts @@ -30,7 +30,7 @@ describe("UpdateBotRolesCommand", () => { }) it("should have the correct name", () => { - expect(command.name).toBe("updatebotroles") + expect(command.name).toBe("update_bot_roles") }) it("should have the correct description", () => { diff --git a/src/commands/admin/UpdateBotRolesCommand.ts b/src/commands/admin/UpdateBotRolesCommand.ts index 848e7ee..0031d15 100644 --- a/src/commands/admin/UpdateBotRolesCommand.ts +++ b/src/commands/admin/UpdateBotRolesCommand.ts @@ -5,7 +5,7 @@ import { Guild as DatabaseGuild } from "@models/Guild"; import { ArraySubDocumentType, DocumentType, mongoose } from "@typegoose/typegoose"; export default class UpdateBotRolesCommand extends BaseCommand { - public static name = "updatebotroles"; + public static name = "update_bot_roles"; public static description = "Creates or updates the database entries for the internal roles"; public static options = [ { From 8a3c603f723692137a9628495c6651a86a6d0866 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:07:25 +0100 Subject: [PATCH 052/130] Add Models --- src/models/BotRoles.ts | 8 +++++++- src/models/Queue.ts | 10 ++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/models/BotRoles.ts b/src/models/BotRoles.ts index c5514af..4243bf4 100644 --- a/src/models/BotRoles.ts +++ b/src/models/BotRoles.ts @@ -61,4 +61,10 @@ export interface BotRole extends DBRole { * The Scope of the Role */ scope: RoleScopes.GLOBAL, -} \ No newline at end of file +} + +export const DBRoleModel = getModelForClass(DBRole, { + schemaOptions: { + autoCreate: false, + }, +}); \ No newline at end of file diff --git a/src/models/Queue.ts b/src/models/Queue.ts index fb0c05c..4c14ff1 100644 --- a/src/models/Queue.ts +++ b/src/models/Queue.ts @@ -1,4 +1,4 @@ -import { prop, mongoose, SubDocumentType, ArraySubDocumentType } from '@typegoose/typegoose'; +import { prop, mongoose, SubDocumentType, ArraySubDocumentType, getModelForClass } from '@typegoose/typegoose'; import { VoiceChannelSpawner } from './VoiceChannelSpawner'; import { QueueEventType } from './Event'; import { QueueSpan } from './QueueSpan'; @@ -107,4 +107,10 @@ export class Queue { */ @prop({ type: QueueEntry, default: [], required: true }) entries!: mongoose.Types.DocumentArray>; -} \ No newline at end of file +} + +export const QueueModel = getModelForClass(Queue, { + schemaOptions: { + autoCreate: false, + }, +}); \ No newline at end of file From 9ab56e7b3c6a39d0ad662df6a33cc29574e2884c Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:07:36 +0100 Subject: [PATCH 053/130] Add utils for db operations --- tests/testutils.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/tests/testutils.ts b/tests/testutils.ts index f318f0e..a04f1b9 100644 --- a/tests/testutils.ts +++ b/tests/testutils.ts @@ -1,8 +1,62 @@ -const config = { +import { DBRole, DBRoleModel, RoleScopes } from "@models/BotRoles"; +import { Guild } from "@models/Guild" +import { Queue, QueueModel } from "@models/Queue"; +import { VoiceChannel, VoiceChannelModel } from "@models/VoiceChannel"; +import { DocumentType, mongoose } from "@typegoose/typegoose" +import { ChannelType } from "discord.js"; + +export const config = { Memory: true, IP: '127.0.0.1', Port: '27017', Database: 'test' } -export { config } \ No newline at end of file +export async function createQueue(guild: DocumentType, name: string, description: string): Promise { + const queue = new QueueModel({ + name: name, + description: description, + disconnect_timeout: 60000, + match_timeout: 120000, + limit: 150, + join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}\n\\> Total Time Spent: ${time_spent}", + match_found_message: "You have found a Match with ${match}. Please Join ${match_channel} if you are not moved automatically. If you don't join in ${timeout} seconds, your position in the queue is dropped.", + timeout_message: "Your queue Timed out after ${timeout} seconds.", + leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", + entries: [], + opening_times: [], + info_channels: [], + }); + guild.queues.push(queue); + await guild.save(); + return queue; +} + +export async function createRole(guild: DocumentType, name: string): Promise { + if (!guild.guild_settings.roles) guild.guild_settings.roles = new mongoose.Types.DocumentArray([]); + const role = new DBRoleModel({ + internal_name: "tutor", + role_id: name, + scope: RoleScopes.SERVER, + server_id: guild.id, + server_role_name: name, + }); + guild.guild_settings.roles.push(role); + await guild.save(); + return role; +} + +export async function createWaitingRoom(guild: DocumentType, channel: string, queue: Queue, supervisor: string): Promise { + const waitingRoomChannel = new VoiceChannelModel({ + _id: channel, + channel_type: ChannelType.GuildVoice, + locked: false, + managed: true, + permitted: [], + queue: queue, + supervisors: [supervisor], + }); + guild.voice_channels.push(waitingRoomChannel); + await guild.save(); + return waitingRoomChannel; +} \ No newline at end of file From 00584942921388993a5ad41a5a401266225ce664 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:07:58 +0100 Subject: [PATCH 054/130] Add set waiting room command --- .../config/ConfigQueueCommandsHandler.ts | 2 + .../queue/SetWaitingRoomCommand.test.ts | 283 ++++++++++++++++++ .../config/queue/SetWaitingRoomCommand.ts | 149 +++++++++ src/types/errors/CouldNotFindChannelError.ts | 23 ++ src/types/errors/CouldNotFindGuildError.ts | 15 + src/types/errors/CouldNotFindRoleError.ts | 15 + src/types/errors/RoleNotInDatabaseError.ts | 10 + src/types/index.ts | 8 + 8 files changed, 505 insertions(+) create mode 100644 src/commands/config/queue/SetWaitingRoomCommand.test.ts create mode 100644 src/commands/config/queue/SetWaitingRoomCommand.ts create mode 100644 src/types/errors/CouldNotFindChannelError.ts create mode 100644 src/types/errors/CouldNotFindGuildError.ts create mode 100644 src/types/errors/CouldNotFindRoleError.ts create mode 100644 src/types/errors/RoleNotInDatabaseError.ts diff --git a/src/commands/config/ConfigQueueCommandsHandler.ts b/src/commands/config/ConfigQueueCommandsHandler.ts index 4828ea0..3ba1cd5 100644 --- a/src/commands/config/ConfigQueueCommandsHandler.ts +++ b/src/commands/config/ConfigQueueCommandsHandler.ts @@ -1,5 +1,6 @@ import { BaseSubcommandsHandler } from "@baseCommand"; import CreateQueueCommand from "./queue/CreateQueueCommand"; +import SetWaitingRoomCommand from "./queue/SetWaitingRoomCommand"; export default class ConfigQueueCommandsHandler extends BaseSubcommandsHandler { public static name = "queue"; @@ -7,5 +8,6 @@ export default class ConfigQueueCommandsHandler extends BaseSubcommandsHandler { public static subcommands = [ CreateQueueCommand, + SetWaitingRoomCommand, ] } \ No newline at end of file diff --git a/src/commands/config/queue/SetWaitingRoomCommand.test.ts b/src/commands/config/queue/SetWaitingRoomCommand.test.ts new file mode 100644 index 0000000..e5fa6db --- /dev/null +++ b/src/commands/config/queue/SetWaitingRoomCommand.test.ts @@ -0,0 +1,283 @@ +import { MockDiscord } from "@tests/mockDiscord" +import SetWaitingRoomCommand from "./SetWaitingRoomCommand" +import { container } from "tsyringe" +import { BaseMessageOptions, ChannelType, ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js" +import { createQueue, createRole, createWaitingRoom } from "@tests/testutils" + +describe("SetWaitingRoomCommand", () => { + const command = SetWaitingRoomCommand + const discord = container.resolve(MockDiscord) + let commandInstance: SetWaitingRoomCommand + let interaction: ChatInputCommandInteraction + + beforeEach(() => { + interaction = discord.mockInteraction() + commandInstance = new command(interaction, discord.getApplication()) + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "channel": + return { value: "test channel" } + case "queue": + return { value: "test queue" } + case "supervisor": + return { value: `test supervisor ${interaction.guild}` } + default: + return null + } + }) + interaction.guild!.channels.cache.get = jest.fn().mockImplementation((key: string) => { + if (key == "test channel") { + return { + id: "test channel", + type: ChannelType.GuildVoice, + } + } else { + return { + id: "another channel", + type: ChannelType.GuildVoice, + } + } + }) + interaction.guild!.roles.cache.get = jest.fn().mockImplementation((key: string) => { + if (key == `test supervisor ${interaction.guild}`) { + return { + id: `test supervisor ${interaction.guild}`, + } + } else { + return { + id: `another supervisor ${interaction.guild}`, + } + } + }) + }) + + it("should have the correct name", () => { + expect(command.name).toBe("set_waiting_room") + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Sets or overwrites the waiting room for the queue.") + }) + + it("should have the correct options", () => { + expect(command.options).toHaveLength(3) + expect(command.options[0]).toStrictEqual({ + name: "channel", + description: "The voice channel to be set as the waiting room.", + type: 7, + required: true, + }) + expect(command.options[1]).toStrictEqual({ + name: "queue", + description: "The queue for which the waiting room will be set.", + type: 3, + required: true, + }) + expect(command.options[2]).toStrictEqual({ + name: "supervisor", + description: "The role that will be able to supervise the waiting room.", + type: 8, + required: true, + }) + }) + + it("should defer the interaction", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply') + await commandInstance.execute() + + expect(deferSpy).toHaveBeenCalledTimes(1) + }) + + it("should edit the reply with the created waiting room", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + + expect(embedData).toEqual({ + title: "Waiting Room Set", + description: expect.stringContaining(`:white_check_mark: Waiting room [object Object] set for queue "${interaction.options.get("queue")?.value}".`), + color: Colors.Green + }) + }) + + it("should set the waiting room on the database", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) + await commandInstance.execute() + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + + expect(dbGuild.voice_channels).toHaveLength(1) + expect(dbGuild.voice_channels[0].id).toBe("test channel") + expect(dbGuild.voice_channels[0].supervisors).toHaveLength(1) + expect(dbGuild.voice_channels[0].supervisors![0]).toBe(`test supervisor ${interaction.guild}`) + }) + + it("should overwrite the waiting room on the database if it already exists for the queue", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + const queue = await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) + await createWaitingRoom(dbGuild, "another channel", queue, `another supervisor ${interaction.guild}`) + await commandInstance.execute() + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + + expect(dbGuild.voice_channels).toHaveLength(1) + expect(dbGuild.voice_channels[0].id).toBe("test channel") + expect(dbGuild.voice_channels[0].supervisors).toHaveLength(1) + expect(dbGuild.voice_channels[0].supervisors![0]).toBe(`test supervisor ${interaction.guild}`) + }) + + it("should add another waiting room on the database if it is for another queue", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + // create other waiting room + const queue = await createQueue(dbGuild, "another channel", "another description") + await createRole(dbGuild, "another supervisor") + await createWaitingRoom(dbGuild, "another channel", queue, `another supervisor ${interaction.guild}`) + // preparations for the command + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) + await commandInstance.execute() + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + + expect(dbGuild.voice_channels).toHaveLength(2) + expect(dbGuild.voice_channels[1].id).toBe("test channel") + expect(dbGuild.voice_channels[1].supervisors).toHaveLength(1) + expect(dbGuild.voice_channels[1].supervisors![0]).toBe(`test supervisor ${interaction.guild}`) + }) + + it("should fail if the channel does not exist", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) + + interaction.guild!.channels.cache.get = jest.fn().mockReturnValue(null) + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + expect(embedData).toEqual({ + title: "Could Not Set Waiting Room", + description: expect.stringContaining(`Could not find channel "test channel" with type "GuildVoice".`), + color: Colors.Red + }) + }) + + it("should fail if the channel is not a voice channel", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) + + interaction.guild!.channels.cache.get = jest.fn().mockImplementation(() => { + return { + id: "test channel", + type: ChannelType.GuildText, + } + }) + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + expect(embedData).toEqual({ + title: "Could Not Set Waiting Room", + description: expect.stringContaining(`:x: Could not find channel "test channel" with type "GuildVoice".`), + color: Colors.Red + }) + }) + + it("should fail if the queue does not exist", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + expect(embedData).toEqual({ + title: "Could Not Set Waiting Room", + description: expect.stringContaining(`:x: Could not find queue "test queue".`), + color: Colors.Red + }) + }) + + it("should fail if the role does not exist", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + + interaction.guild!.roles.cache.get = jest.fn().mockReturnValue(null) + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + expect(embedData).toEqual({ + title: "Could Not Set Waiting Room", + description: expect.stringContaining(`:x: Could not find role "test supervisor ${interaction.guild}".`), + color: Colors.Red + }) + }) + + it("should fail if the role is not in the database", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + + const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + expect(embedData).toEqual({ + title: "Could Not Set Waiting Room", + description: expect.stringContaining(`:x: Role [object Object] is not an internal role. Try running \`/admin update_bot_roles\` to update the internal roles.`), + color: Colors.Red + }) + }) +}) \ No newline at end of file diff --git a/src/commands/config/queue/SetWaitingRoomCommand.ts b/src/commands/config/queue/SetWaitingRoomCommand.ts new file mode 100644 index 0000000..4f1266a --- /dev/null +++ b/src/commands/config/queue/SetWaitingRoomCommand.ts @@ -0,0 +1,149 @@ +import { BaseCommand } from "@baseCommand"; +import { Queue } from "@models/Queue"; +import { CouldNotFindChannelError, CouldNotFindQueueError, CouldNotFindRoleError, RoleNotInDatabaseError } from "@types"; +import { Guild as DatabaseGuild } from "@models/Guild"; +import { ArraySubDocumentType, DocumentType, mongoose } from "@typegoose/typegoose"; +import { ApplicationCommandOptionType, ChannelType, Colors, EmbedBuilder, Role, VoiceChannel } from "discord.js"; +import { VoiceChannelModel } from "@models/VoiceChannel"; + +export default class SetWaitingRoomCommand extends BaseCommand { + public static name = "set_waiting_room"; + public static description = "Sets or overwrites the waiting room for the queue."; + public static options = [ + { + name: "channel", + description: "The voice channel to be set as the waiting room.", + type: ApplicationCommandOptionType.Channel, + required: true, + }, + { + name: "queue", + description: "The queue for which the waiting room will be set.", + type: ApplicationCommandOptionType.String, + required: true, + }, + { + name: "supervisor", + description: "The role that will be able to supervise the waiting room.", + type: ApplicationCommandOptionType.Role, + required: true, + }, + ]; + + /** + * The guild saved in the database. + */ + private dbGuild!: DocumentType; + + public async execute() { + await this.defer(); + if (!this.interaction.guild) { + throw new Error("Interaction is not in a guild"); + } + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) + try { + const { channel, queue, supervisor } = this.getOptionValues(); + await this.createWaitingRoom(channel, queue, supervisor); + + const embed = this.mountSetWaitingRoomEmbed(channel, queue, supervisor); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof CouldNotFindChannelError || error instanceof CouldNotFindQueueError || error instanceof CouldNotFindRoleError || error instanceof RoleNotInDatabaseError) { + const embed = this.mountSetWaitingRoomFailedEmbed(error); + await this.send({ embeds: [embed] }); + return; + } + throw error; + } + } + + private mountSetWaitingRoomEmbed(channel: VoiceChannel, queue: ArraySubDocumentType, supervisor: Role): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Waiting Room Set") + .setDescription(`:white_check_mark: Waiting room ${channel} set for queue "${queue.name}".`) + .setColor(Colors.Green) + return embed + } + + private mountSetWaitingRoomFailedEmbed(error: CouldNotFindChannelError | CouldNotFindQueueError | CouldNotFindRoleError | RoleNotInDatabaseError): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Could Not Set Waiting Room") + .setColor(Colors.Red) + if (error instanceof RoleNotInDatabaseError) { + embed.setDescription(`:x: Role ${error.role} is not an internal role. Try running \`/admin update_bot_roles\` to update the internal roles.`) + } else { + embed.setDescription(`:x: ${error.message}`) + } + return embed + } + + private async createWaitingRoom(channel: VoiceChannel, queue: ArraySubDocumentType, supervisor: Role): Promise { + const existingWaitingRoom = this.dbGuild.voice_channels.find(voiceChannel => voiceChannel.queue == queue.id) + if (existingWaitingRoom) { + this.app.logger.debug(`Found existing waiting room for queue ${queue.name}. Overwriting.`) + existingWaitingRoom._id = channel.id; + existingWaitingRoom.supervisors = new mongoose.Types.DocumentArray([supervisor.id as any]); + await this.dbGuild.save(); + return; + } else { + this.app.logger.debug(`Creating new waiting room for queue ${queue.name}.`) + const waitingRoomChannel = new VoiceChannelModel({ + _id: channel.id, + channel_type: channel.type, + locked: false, + managed: true, + permitted: [], + queue: queue.id, + supervisors: [supervisor.id], + }); + this.dbGuild.voice_channels.push(waitingRoomChannel); + await this.dbGuild.save(); + } + } + + private getOptionValues(): { channel: VoiceChannel, queue: ArraySubDocumentType, supervisor: Role } { + const channelId = this.getOptionValue(SetWaitingRoomCommand.options[0]); + const channel = this.getVoiceChannel(channelId); + const queueId = this.getOptionValue(SetWaitingRoomCommand.options[1]); + const queue = this.getQueue(queueId); + const supervisorId = this.getOptionValue(SetWaitingRoomCommand.options[2]); + const supervisor = this.getRole(supervisorId); + return { channel, queue, supervisor }; + } + + private getVoiceChannel(channelId: string): VoiceChannel { + const channel = this.interaction.guild?.channels.cache.get(channelId); + if (!channel || channel.type != ChannelType.GuildVoice) { + const error = new CouldNotFindChannelError(channel?.name ?? channelId, ChannelType.GuildVoice); + this.app.logger.debug(error.message); + throw error; + } + return channel; + } + + private getQueue(queueName: string): ArraySubDocumentType { + const queue = this.dbGuild.queues.find(x => x.name.toLowerCase() === queueName.toLowerCase()); + if (!queue) { + const error = new CouldNotFindQueueError(queueName); + this.app.logger.debug(error.message); + throw error; + } + return queue; + } + + private getRole(roleId: string): Role { + const role = this.interaction.guild?.roles.cache.get(roleId); + if (!role) { + const error = new CouldNotFindRoleError(roleId); + this.app.logger.debug(error.message); + throw error; + } + const roleInDatabase = this.dbGuild.guild_settings.roles?.find(x => x.role_id === roleId); + if (!roleInDatabase) { + const error = new RoleNotInDatabaseError(role); + this.app.logger.debug(error.message); + throw error; + } + return role; + } +} \ No newline at end of file diff --git a/src/types/errors/CouldNotFindChannelError.ts b/src/types/errors/CouldNotFindChannelError.ts new file mode 100644 index 0000000..421a3de --- /dev/null +++ b/src/types/errors/CouldNotFindChannelError.ts @@ -0,0 +1,23 @@ +import { ChannelType } from "discord.js"; + +export default class CouldNotFindChannelError extends Error { + /** + * The name or id of the channel which could not be found. + */ + public channelNameOrId: string + /** + * The type of the channel which was expected. + */ + public channelType?: ChannelType + + /** + * Crate a new could not find channel error. + * @param channelNameOrId The name or id of the channel which could not be found. + * @param channelType The type of the channel which was expected. + */ + constructor(channelNameOrId: string, channelType?: ChannelType) { + super(`Could not find channel "${channelNameOrId}"${channelType ? ` with type "${ChannelType[channelType]}"` : ""}.`) + this.channelNameOrId = channelNameOrId + this.channelType = channelType + } +} \ No newline at end of file diff --git a/src/types/errors/CouldNotFindGuildError.ts b/src/types/errors/CouldNotFindGuildError.ts new file mode 100644 index 0000000..864ca52 --- /dev/null +++ b/src/types/errors/CouldNotFindGuildError.ts @@ -0,0 +1,15 @@ +export default class CouldNotFindQueueError extends Error { + /** + * The name of the queue which could not be found. + */ + public queueName: string + + /** + * Creates a new could not find queue error. + * @param queueName The name of the queue which could not be found. + */ + constructor(queueName: string) { + super(`Could not find queue "${queueName}".`) + this.queueName = queueName + } +} \ No newline at end of file diff --git a/src/types/errors/CouldNotFindRoleError.ts b/src/types/errors/CouldNotFindRoleError.ts new file mode 100644 index 0000000..d37d420 --- /dev/null +++ b/src/types/errors/CouldNotFindRoleError.ts @@ -0,0 +1,15 @@ +export default class CouldNotFindRoleError extends Error { + /** + * The id of the role which could not be found. + */ + public roleId: string + + /** + * Creates a new could not find role error. + * @param roleId The id of the role which could not be found. + */ + constructor(roleId: string) { + super(`Could not find role "${roleId}".`) + this.roleId = roleId + } +} \ No newline at end of file diff --git a/src/types/errors/RoleNotInDatabaseError.ts b/src/types/errors/RoleNotInDatabaseError.ts new file mode 100644 index 0000000..0300d29 --- /dev/null +++ b/src/types/errors/RoleNotInDatabaseError.ts @@ -0,0 +1,10 @@ +import { Role } from "discord.js" + +export default class RoleNotInDatabaseError extends Error { + public role: Role + + constructor(role: Role) { + super(`Role "${role.name}" is not in the database.`) + this.role = role + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 2c26d6f..4907b83 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,9 +1,17 @@ import OptionRequirement from "./OptionRequirement"; +import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; +import CouldNotFindQueueError from "./errors/CouldNotFindGuildError"; +import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import MissingOptionError from "./errors/MissingOptionError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; +import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; export { OptionRequirement, QueueAlreadyExistsError, MissingOptionError, + CouldNotFindChannelError, + CouldNotFindQueueError, + CouldNotFindRoleError, + RoleNotInDatabaseError, } \ No newline at end of file From e7fc39c7eb4447c47f9c6f408838c4bd5bb2c123 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:08:10 +0100 Subject: [PATCH 055/130] Improve logging --- src/events/InteractionCreateEvent.test.ts | 3 +-- src/events/InteractionCreateEvent.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/events/InteractionCreateEvent.test.ts b/src/events/InteractionCreateEvent.test.ts index 1985680..87599e6 100644 --- a/src/events/InteractionCreateEvent.test.ts +++ b/src/events/InteractionCreateEvent.test.ts @@ -25,10 +25,9 @@ describe("InteractionCreateEvent", () => { await eventInstance.execute(interaction) const commandInteraction = interaction as ChatInputCommandInteraction - expect(logSpy).toHaveBeenCalledTimes(2) + expect(logSpy).toHaveBeenCalledTimes(1) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`${interaction.user.tag} executed command "${commandInteraction.commandName}" with options`)) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`in guild ${interaction.guild?.name} (id: ${interaction.guild?.id})`)) - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`Command ${commandInteraction.commandName} executed successfully.`)) }) it("should not execute a command if the interaction is not a command", async () => { diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts index 27f72cb..fd4a50f 100644 --- a/src/events/InteractionCreateEvent.ts +++ b/src/events/InteractionCreateEvent.ts @@ -21,7 +21,7 @@ export default class InteractionCreateEvent extends BaseEvent { try { await concreteCommand.execute(); - this.app.logger.info(`Command ${command.name} executed successfully.`); + this.app.logger.debug(`Finished executing "${commandName}" in guild ${interaction.guild?.name} (id: ${interaction.guild?.id})`); } catch (error) { this.app.logger.error(error); interaction.reply({ content: "There was an error while executing this command!", ephemeral: true }); From 3580189f2fcf480d7e96cf0b3f9c5fb842a2a897 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:11:03 +0100 Subject: [PATCH 056/130] Improve embeds --- src/commands/config/queue/CreateQueueCommand.test.ts | 4 +++- src/commands/config/queue/CreateQueueCommand.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/config/queue/CreateQueueCommand.test.ts b/src/commands/config/queue/CreateQueueCommand.test.ts index 60b841b..b698749 100644 --- a/src/commands/config/queue/CreateQueueCommand.test.ts +++ b/src/commands/config/queue/CreateQueueCommand.test.ts @@ -71,7 +71,8 @@ describe("CreateQueueCommand", () => { expect(embedData).toEqual({ title: "Queue Created", - description: expect.stringContaining("Queue"), + description: expect.stringContaining(`Queue "${interaction.options.get("name")?.value}" created.`), + color: Colors.Green }) }) @@ -145,6 +146,7 @@ describe("CreateQueueCommand", () => { expect(embedData).toEqual({ title: "Queue Created", description: expect.stringContaining("Queue"), + color: Colors.Green }) }) diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index 3a7d369..2ca7ad1 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -57,7 +57,8 @@ export default class CreateQueueCommand extends BaseCommand { private mountCreateQueueEmbed(): EmbedBuilder { const embed = new EmbedBuilder() .setTitle("Queue Created") - .setDescription(`Queue ${this.dbGuild.queues[this.dbGuild.queues.length - 1].name} created.`) + .setDescription(`Queue "${this.dbGuild.queues[this.dbGuild.queues.length - 1].name}" created.`) + .setColor(Colors.Green) return embed } From a995cafe691f8a7d2744fe7f34ad04d0fe8efa2d Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 6 Jan 2024 21:11:25 +0100 Subject: [PATCH 057/130] Add guild update event --- src/events/GuildUpdateEvent.test.ts | 45 +++++++++++++++++++++++++++++ src/events/GuildUpdateEvent.ts | 18 ++++++++++++ src/events/index.ts | 6 +++- 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/events/GuildUpdateEvent.test.ts create mode 100644 src/events/GuildUpdateEvent.ts diff --git a/src/events/GuildUpdateEvent.test.ts b/src/events/GuildUpdateEvent.test.ts new file mode 100644 index 0000000..d4c2223 --- /dev/null +++ b/src/events/GuildUpdateEvent.test.ts @@ -0,0 +1,45 @@ +import { container } from "tsyringe" +import GuildUpdateEvent from "./GuildUpdateEvent" +import { MockDiscord } from "@tests/mockDiscord" +import { Guild } from "discord.js" +import { GuildModel } from "@models/Guild" + +describe("GuildUpdateEvent", () => { + const event = GuildUpdateEvent + const discord = container.resolve(MockDiscord) + let eventInstance: GuildUpdateEvent + let oldGuild: Guild + + beforeEach(() => { + eventInstance = new event(discord.getApplication()) + oldGuild = discord.mockGuild() + }) + + it("should have the correct name", () => { + expect(event.name).toBe("guildUpdate") + }) + + it ("should log the guild name and id if the name changed", async () => { + await discord.getApplication().configManager.getGuildConfig(oldGuild) + const logSpy = jest.spyOn(discord.getApplication().logger, 'info') + const newGuild = { ...oldGuild } as Guild + newGuild.name = "new name" + await eventInstance.execute(oldGuild, newGuild) + + expect(logSpy).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledWith(`Guild "${oldGuild.name}" (id: ${oldGuild.id}) changed name to "${newGuild.name}"`) + }) + + it ("should update the guild name in the database if the name changed", async () => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(oldGuild) + const saveSpy = jest.spyOn(GuildModel.prototype, 'save') + const newGuild = { ...oldGuild } as Guild + newGuild.name = "new name" + await eventInstance.execute(oldGuild, newGuild) + + expect(saveSpy).toHaveBeenCalledTimes(1) + + dbGuild = await discord.getApplication().configManager.getGuildConfig(oldGuild) + expect(dbGuild.name).toBe(newGuild.name) + }) +}) \ No newline at end of file diff --git a/src/events/GuildUpdateEvent.ts b/src/events/GuildUpdateEvent.ts new file mode 100644 index 0000000..fd2cf71 --- /dev/null +++ b/src/events/GuildUpdateEvent.ts @@ -0,0 +1,18 @@ +import { BaseEvent } from "@baseEvent"; +import { Guild } from "discord.js"; + +export default class GuildUpdateEvent extends BaseEvent { + public static name = "guildUpdate"; + + public async execute(oldGuild: Guild, newGuild: Guild) { + if (oldGuild.name !== newGuild.name) { + this.app.logger.info(`Guild "${oldGuild.name}" (id: ${oldGuild.id}) changed name to "${newGuild.name}"`) + const dbGuild = await this.app.configManager.getGuildConfig(newGuild) + dbGuild.name = newGuild.name + await dbGuild.save() + } else { + this.app.logger.info(`Guild "${oldGuild.name}" (id: ${oldGuild.id}) updated with unhandled changes.`) + this.app.logger.debug(`Old guild: ${JSON.stringify(oldGuild)}\nNew guild: ${JSON.stringify(newGuild)}`) + } + } +} \ No newline at end of file diff --git a/src/events/index.ts b/src/events/index.ts index 96900c1..3db2997 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -1,7 +1,11 @@ import ReadyEvent from "./ReadyEvent"; import InteractionCreateEvent from "./InteractionCreateEvent"; +import GuildCreateEvent from "./GuildCreateEvent"; +import GuildUpdateEvent from "./GuildUpdateEvent"; export default [ ReadyEvent, - InteractionCreateEvent + InteractionCreateEvent, + GuildCreateEvent, + GuildUpdateEvent, ] \ No newline at end of file From ee26dc6798b768f5a536a0166e9c5f36471018a4 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Mon, 8 Jan 2024 18:33:51 +0100 Subject: [PATCH 058/130] Add guild add member event --- package-lock.json | 9 +++ package.json | 1 + src/Application.ts | 10 ++- src/events/GuildAddMemberEvent.test.ts | 84 ++++++++++++++++++++++++++ src/events/GuildAddMemberEvent.ts | 47 ++++++++++++++ src/events/index.ts | 2 + src/managers/ConfigManager.ts | 4 -- src/managers/UserManager.ts | 34 +++++++++++ src/managers/index.ts | 4 +- src/models/Room.ts | 41 +++++++++++++ src/models/Session.ts | 61 +++++++++++++++++++ src/models/User.ts | 46 ++++++++++++++ src/types/StringReplacements.ts | 6 ++ src/utils/interpolateString.ts | 25 ++++++++ tests/mockDiscord.ts | 3 +- 15 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 src/events/GuildAddMemberEvent.test.ts create mode 100644 src/events/GuildAddMemberEvent.ts create mode 100644 src/managers/UserManager.ts create mode 100644 src/models/Room.ts create mode 100644 src/models/Session.ts create mode 100644 src/models/User.ts create mode 100644 src/types/StringReplacements.ts create mode 100644 src/utils/interpolateString.ts diff --git a/package-lock.json b/package-lock.json index c40506c..c6e7c74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "cron": "^3.1.6", "discord.js": "^14.14.1", "dotenv": "^16.3.1", + "moment": "^2.30.1", "reflect-metadata": "^0.2.1", "tsyringe": "^4.8.0", "typescript": "^5.3.3", @@ -3607,6 +3608,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "engines": { + "node": "*" + } + }, "node_modules/mongodb": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.2.0.tgz", diff --git a/package.json b/package.json index 97f7f59..7ebc4bd 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "cron": "^3.1.6", "discord.js": "^14.14.1", "dotenv": "^16.3.1", + "moment": "^2.30.1", "reflect-metadata": "^0.2.1", "tsyringe": "^4.8.0", "typescript": "^5.3.3", diff --git a/src/Application.ts b/src/Application.ts index 0010216..0fc030e 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -3,7 +3,7 @@ import 'dotenv/config' import { Client, Partials, ClientOptions } from 'discord.js' import { CronJob } from 'cron' import { ConsolaInstance, createConsola } from 'consola' -import { CommandsManager, ConfigManager } from "./managers" +import { CommandsManager, ConfigManager, UserManager } from "./managers" import commands from './commands' import events from './events' import { container, delay, inject, injectable, singleton } from "tsyringe" @@ -27,6 +27,11 @@ export class Application { */ public configManager: ConfigManager + /** + * The user manager responsible for managing the users in the database. + */ + public userManager: UserManager + /** * The commands manager responsible for managing the bot commands. */ @@ -53,12 +58,13 @@ export class Application { * @param client The Discord client. * @param token The bot token. */ - constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager) { + constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager, @inject(delay(() => UserManager)) userManager: UserManager) { this.client = new Client(options) this.token = token this.logger = createConsola({ level: Environment.logLevel }) this.commandsManager = commandsManager this.configManager = configManager + this.userManager = userManager } /** diff --git a/src/events/GuildAddMemberEvent.test.ts b/src/events/GuildAddMemberEvent.test.ts new file mode 100644 index 0000000..4ad63dd --- /dev/null +++ b/src/events/GuildAddMemberEvent.test.ts @@ -0,0 +1,84 @@ +import { MockDiscord } from "@tests/mockDiscord" +import GuildAddMemberEvent from "./GuildAddMemberEvent" +import { container } from "tsyringe" +import { BaseMessageOptions, Guild as DiscordGuild, EmbedBuilder, GuildMember } from "discord.js" +import { Guild as DatabaseGuild } from "@models/Guild" +import { UserModel } from "@models/User" +import { DocumentType } from "@typegoose/typegoose" + +describe("GuildAddMemberEvent", () => { + const event = GuildAddMemberEvent + const discord = container.resolve(MockDiscord) + let eventInstance: GuildAddMemberEvent + let discordGuild: DiscordGuild + let databaseGuild: DocumentType + let member: GuildMember + + beforeEach(async () => { + eventInstance = new event(discord.getApplication()) + discordGuild = discord.mockGuild() + member = discord.mockGuildMember(discord.mockUser(), discordGuild) + databaseGuild = await discord.getApplication().configManager.getDefaultGuildConfig(discordGuild) + }) + + it("should have the correct name", () => { + expect(event.name).toBe("guildMemberAdd") + }) + + it("should log the guild name and id and new member name and id", async () => { + const logSpy = jest.spyOn(discord.getApplication().logger, 'info') + await eventInstance.execute(member) + + expect(logSpy).toHaveBeenCalledWith(`Member ${member.user.tag} (id: ${member.id}) joined guild "${member.guild.name}" (id: ${member.guild.id})`) + }) + + it("should create a new user in the database if it doesn't exist", async () => { + const saveSpy = jest.spyOn(UserModel.prototype, 'save') + await eventInstance.execute(member) + + expect(saveSpy).toHaveBeenCalledTimes(1) + const saveSpyRes = await saveSpy.mock.results[0].value + expect(saveSpyRes).toMatchObject({ _id: member.id }) + }) + + it("should not create a new user in the database if it already exists", async () => { + await discord.getApplication().userManager.getUser(member.user) + jest.clearAllMocks() // seems to be necessary here because the saveSpy is still set from the previous test + const saveSpy = jest.spyOn(UserModel.prototype, 'save') + + await eventInstance.execute(member) + + expect(saveSpy).toHaveBeenCalledTimes(0) + }) + + it("should send a welcome message to the new member, when it is set for the guild", async () => { + member.send = jest.fn() + databaseGuild.welcome_text = "Welcome to ${guild_name}, ${member}!" + databaseGuild.welcome_title = "Welcome to ${guild_name}!" + await databaseGuild.save() + + const sendSpy = jest.spyOn(member, 'send') + await eventInstance.execute(member) + + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith({ embeds: expect.anything() }) + const messageContent = sendSpy.mock.calls[0][0] as BaseMessageOptions + expect(messageContent.embeds).toBeDefined() + const embeds = messageContent.embeds as EmbedBuilder[] + expect(embeds).toHaveLength(1) + const embed = embeds[0] + const embedData = embed.data + expect(embedData.title).toBe(`Welcome to ${discordGuild.name}!`) + expect(embedData.description).toBe(`Welcome to ${discordGuild.name}, ${member}!`) + }) + + it("should not send a welcome message to the new member, when it is not set for the guild", async () => { + databaseGuild.welcome_text = undefined + await databaseGuild.save() + + member.send = jest.fn() + await eventInstance.execute(member) + + expect(member.send).toHaveBeenCalledTimes(0) + }) +}) \ No newline at end of file diff --git a/src/events/GuildAddMemberEvent.ts b/src/events/GuildAddMemberEvent.ts new file mode 100644 index 0000000..48928e8 --- /dev/null +++ b/src/events/GuildAddMemberEvent.ts @@ -0,0 +1,47 @@ +import { BaseEvent } from "@baseEvent"; +import { Guild as DatabaseGuild } from "@models/Guild"; +import { EmbedBuilder, GuildMember } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { StringReplacements } from "src/types/StringReplacements"; +import { interpolateString } from "@utils/interpolateString"; + +export default class GuildAddMemberEvent extends BaseEvent { + public static name = "guildMemberAdd"; + + /** + * The guild saved in the database. + */ + private dbGuild!: DocumentType; + + public async execute(member: GuildMember) { + this.app.logger.info(`Member ${member.user.tag} (id: ${member.id}) joined guild "${member.guild.name}" (id: ${member.guild.id})`) + this.dbGuild = await this.app.configManager.getGuildConfig(member.guild) + await this.app.userManager.getUser(member.user) + + await this.sendWelcomeMessage(member) + } + + private async sendWelcomeMessage(member: GuildMember) { + if (!this.dbGuild.welcome_text) return; + + const guild = member.guild; + + try { + const replacements: StringReplacements = { + "guild_name": guild.name, + "member": member, + "member_count": guild.memberCount, + "guild_owner": await guild.fetchOwner(), + }; + const title = interpolateString(this.dbGuild.welcome_title ?? "Welcome to ${name}", replacements); + const text = interpolateString(this.dbGuild.welcome_text, replacements); + + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(text) + await member.send({ embeds: [embed] }) + } catch (error) { + this.app.logger.error(error) + } + } +} \ No newline at end of file diff --git a/src/events/index.ts b/src/events/index.ts index 3db2997..2261e33 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -2,10 +2,12 @@ import ReadyEvent from "./ReadyEvent"; import InteractionCreateEvent from "./InteractionCreateEvent"; import GuildCreateEvent from "./GuildCreateEvent"; import GuildUpdateEvent from "./GuildUpdateEvent"; +import GuildAddMemberEvent from "./GuildAddMemberEvent"; export default [ ReadyEvent, InteractionCreateEvent, GuildCreateEvent, GuildUpdateEvent, + GuildAddMemberEvent, ] \ No newline at end of file diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index fc08568..2aec8f0 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -41,8 +41,4 @@ export default class ConfigManager { this.app.logger.info(`Created new Guild Config for "${guild.name}" (id: ${guild.id})`); return newGuildData; } - - public async getGuildConfigs(): Promise[]> { - return await GuildModel.find(); - } } \ No newline at end of file diff --git a/src/managers/UserManager.ts b/src/managers/UserManager.ts new file mode 100644 index 0000000..0c6c086 --- /dev/null +++ b/src/managers/UserManager.ts @@ -0,0 +1,34 @@ +import { Application } from "@application"; +import { delay, inject, injectable, singleton } from "tsyringe"; +import { User as DiscordUser } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { User, UserModel } from "@models/User"; + +@injectable() +@singleton() +export default class UserManager { + protected app: Application; + + constructor(@inject(delay(() => Application)) app: Application) { + this.app = app; + } + + public async getUser(user: DiscordUser): Promise> { + var userModel = await UserModel.findById(user.id); + if (!userModel) { + this.app.logger.debug(`User "${user.tag}" (id: ${user.id}) does not exist. Creating...`) + return await this.getDefaultUser(user); + } + this.app.logger.debug(`User "${user.tag}" (id: ${user.id}) already exists.`) + return userModel; + } + + public async getDefaultUser(user: DiscordUser): Promise> { + const newUser = new UserModel({ + _id: user.id, + }); + await newUser.save(); + this.app.logger.info(`Created new User "${user.tag}" (id: ${user.id})`); + return newUser; + } +} \ No newline at end of file diff --git a/src/managers/index.ts b/src/managers/index.ts index 2e6da17..062e1be 100644 --- a/src/managers/index.ts +++ b/src/managers/index.ts @@ -1,7 +1,9 @@ import CommandsManager from "./CommandsManager"; import ConfigManager from "./ConfigManager"; +import UserManager from "./UserManager"; export { CommandsManager, - ConfigManager + ConfigManager, + UserManager, } \ No newline at end of file diff --git a/src/models/Room.ts b/src/models/Room.ts new file mode 100644 index 0000000..7fdc995 --- /dev/null +++ b/src/models/Room.ts @@ -0,0 +1,41 @@ +import { ArraySubDocumentType, getModelForClass, mongoose, prop } from "@typegoose/typegoose"; +import { VoiceChannelEvent } from "./Event"; + +export class Room { + /** + * The Channel ID provided by Discord + */ + @prop({ required: true }) + _id!: string; + /** + * If the Channel exists, it's active + */ + @prop({ required: true }) + active!: boolean; + /** + * If Someone tampered with the Permissions/Name or Position of the Channel (or other Settings) + */ + @prop({ required: true }) + tampered!: boolean; + /** + * Only set to true if session had a clean exit + */ + @prop({ required: true }) + end_certain!: boolean; + /** + * The Guild The Room is in + */ + @prop({ required: true }) + guild!: string; + /** + * The Events that happen in the Channel + */ + @prop({ required: true, type: () => [VoiceChannelEvent], default: [] }) + events!: mongoose.Types.DocumentArray>; +} + +export const RoomModel = getModelForClass(Room, { + schemaOptions: { + autoCreate: true, + }, +}); diff --git a/src/models/Session.ts b/src/models/Session.ts new file mode 100644 index 0000000..9d3c871 --- /dev/null +++ b/src/models/Session.ts @@ -0,0 +1,61 @@ +import { getModelForClass, prop, mongoose } from "@typegoose/typegoose"; + +export enum SessionRole { + "participant" = "participant", + "coach" = "coach", + "supervisor" = "supervisor", +} + +export class Session { + /** + * Whether the Session is currently active + */ + @prop({ required: true }) + active!: boolean; + /** + * The User that the Session belongs to + */ + @prop({ required: true }) + user!: string; + /** + * A Guild that the Session belongs to + */ + @prop() + guild?: string; + /** + * A Queue that is linked to the Session + */ + @prop() + queue?: mongoose.Types.ObjectId; + /** + * The Role that the user plays + */ + @prop({ required: true, enum: SessionRole }) + role!: SessionRole; + /** + * The Start Timestamp + */ + @prop() + started_at?: string; + /** + * The End Timestamp (Set when active==false) + */ + @prop() + ended_at?: string; + /** + * Only set to true if session had a clean exit + */ + @prop({ required: true }) + end_certain!: boolean; + /** + * The Room IDs That Were Visited in the Session + */ + @prop({ required: true, type: () => [String], default: [] }) + rooms!: mongoose.Types.Array; +} + +export const SessionModel = getModelForClass(Session, { + schemaOptions: { + autoCreate: true, + }, +}); diff --git a/src/models/User.ts b/src/models/User.ts new file mode 100644 index 0000000..08773a2 --- /dev/null +++ b/src/models/User.ts @@ -0,0 +1,46 @@ +import { getModelForClass, prop, mongoose, Ref } from "@typegoose/typegoose"; +import { Session } from "./Session"; +import { DBRole } from "./BotRoles"; + +/** + * A User from the Database + */ +export class User { + /** + * The User ID provided by Discord + */ + @prop({ required: true }) + _id!: string; + // TODO: Some Link to Moodle Account + /** + * The Sessions + */ + @prop({ required: true, ref: () => Session, default: [] }) + sessions!: mongoose.Types.Array>; + /** + * Die TU-ID + */ + @prop({ required: false, unique: true, sparse: true }) + tu_id?: string; + /** + * Die Moodle-ID + */ + @prop({ required: false, unique: true, sparse: true }) + moodle_id?: string; + /** + * Log all the Server Roles when a Member leaves + */ + @prop({ required: true, default: [] }) + server_roles!: mongoose.Types.Array; + /** + * The Roles that the User was assigned after verification + */ + @prop({ required: true, default: [], ref: () => DBRole }) + token_roles!: mongoose.Types.Array>; +} + +export const UserModel = getModelForClass(User, { + schemaOptions: { + autoCreate: true, + }, +}); diff --git a/src/types/StringReplacements.ts b/src/types/StringReplacements.ts new file mode 100644 index 0000000..d36e57c --- /dev/null +++ b/src/types/StringReplacements.ts @@ -0,0 +1,6 @@ +/** + * Replacements for String Interpolation + */ +export type StringReplacements = { + [key: string]: any; +} diff --git a/src/utils/interpolateString.ts b/src/utils/interpolateString.ts new file mode 100644 index 0000000..5deb135 --- /dev/null +++ b/src/utils/interpolateString.ts @@ -0,0 +1,25 @@ +import { StringReplacements } from "src/types/StringReplacements"; +import moment from "moment"; + +/** + * Replaces placeholders in a String with dynamic values + * + * Default variables: + * + * 'now': System Time + * 'mem_usage': Memory Usage + * @param str The String to interpolate + * @param replacements Additional Replace values, you can also overwrite the default ones by using the same name. + * @returns The interpolated String + */ +export function interpolateString(str: string, replacements?: StringReplacements): string { + // Interpolate String + const default_replacements: StringReplacements = { + "now": moment().format("DD.MMMM YYYY hh:mm:ss"), + "mem_usage": `${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`, + }; + for (const [key, value] of Object.entries({ ...default_replacements, ...replacements })) { + str = str.replace(`\${${key}}`, value as string); + } + return str; +}; diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 171aae4..591a491 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -47,7 +47,8 @@ export class MockDiscord { } public mockUser(): User { - return mockUser(this.app.client); + const userId = randomInt(281474976710655).toString(); + return mockUser(this.app.client, { id: userId, username: userId, global_name: userId, discriminator: randomInt(9999).toString() }); } public mockGuildMember(user: User = this.mockUser(), guild: Guild = this.mockGuild()): GuildMember { From d6cb7db5d84f495c066fd03748bd1ceabc1dbd12 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 13 Jan 2024 17:59:37 +0100 Subject: [PATCH 059/130] Dynamically load commands and subcommand handlers --- src/Application.ts | 30 ++++- src/commands/AdminCommandsHandler.ts | 13 --- src/commands/ConfigCommandsHandler.ts | 11 -- .../admin/AdminQueueCommandsHandler.ts | 9 -- .../config/ConfigQueueCommandsHandler.ts | 13 --- src/commands/index.ts | 11 -- src/events/InteractionCreateEvent.test.ts | 2 + src/managers/CommandsManager.ts | 2 +- src/utils/CommandsLoader.ts | 107 ++++++++++++++++++ 9 files changed, 134 insertions(+), 64 deletions(-) delete mode 100644 src/commands/AdminCommandsHandler.ts delete mode 100644 src/commands/ConfigCommandsHandler.ts delete mode 100644 src/commands/admin/AdminQueueCommandsHandler.ts delete mode 100644 src/commands/config/ConfigQueueCommandsHandler.ts delete mode 100644 src/commands/index.ts create mode 100644 src/utils/CommandsLoader.ts diff --git a/src/Application.ts b/src/Application.ts index 0fc030e..64f5a28 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -1,14 +1,16 @@ import "reflect-metadata" import 'dotenv/config' -import { Client, Partials, ClientOptions } from 'discord.js' +import { Client, Partials, ClientOptions, Interaction } from 'discord.js' import { CronJob } from 'cron' import { ConsolaInstance, createConsola } from 'consola' import { CommandsManager, ConfigManager, UserManager } from "./managers" -import commands from './commands' -import events from './events' import { container, delay, inject, injectable, singleton } from "tsyringe" import Environment from "./Environment" import mongoose from "mongoose" +import BaseCommandOrSubcommandsHandler from "./baseCommand/BaseCommandOrSubcommandsHandler" +import path from "path" +import CommandsLoader from "@utils/CommandsLoader" +import events from "./events" /** * The main `Application` class. @@ -47,11 +49,17 @@ export class Application { * * Used to calculate bot startup time. */ - readonly initTimestamp = Date.now() + public readonly initTimestamp = Date.now() + + /** + * The folder where the bot commands are located. + */ + private readonly commandsFolder = path.join(__dirname, "commands"); + /** * The bot commands. */ - public commands = commands + public commands: (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)[] = [] /** * Initializes the bot. @@ -67,14 +75,24 @@ export class Application { this.userManager = userManager } + /** + * Loads the bot commands. + */ + private loadCommands(): void { + this.logger.info('Loading commands') + this.commands = container.resolve(CommandsLoader).loadCommands(this.commandsFolder) + this.logger.success('Loaded commands') + } + /** * Logs the bot in and starts listening to events. * Thereby also registers the bot commands. */ public listen(): void { - this.logger.info('Listening to events') + this.loadCommands() this.registerEvents() this.client.login(this.token) + this.logger.info('Listening to events') } /** diff --git a/src/commands/AdminCommandsHandler.ts b/src/commands/AdminCommandsHandler.ts deleted file mode 100644 index 055f67b..0000000 --- a/src/commands/AdminCommandsHandler.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseSubcommandsHandler } from "@baseCommand"; -import AdminQueueCommandsHandler from "./admin/AdminQueueCommandsHandler"; -import UpdateBotRolesCommand from "./admin/UpdateBotRolesCommand"; - -export default class AdminCommandsHandler extends BaseSubcommandsHandler { - public static name = "admin"; - public static description = "Admin command handler."; - - public static subcommands = [ - AdminQueueCommandsHandler, - UpdateBotRolesCommand - ] -} \ No newline at end of file diff --git a/src/commands/ConfigCommandsHandler.ts b/src/commands/ConfigCommandsHandler.ts deleted file mode 100644 index 356f098..0000000 --- a/src/commands/ConfigCommandsHandler.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BaseSubcommandsHandler } from "@baseCommand"; -import ConfigQueueCommandsHandler from "./config/ConfigQueueCommandsHandler"; - -export default class ConfigCommandsHandler extends BaseSubcommandsHandler { - public static name = "config"; - public static description = "Config command handler."; - - public static subcommands = [ - ConfigQueueCommandsHandler, - ] -} \ No newline at end of file diff --git a/src/commands/admin/AdminQueueCommandsHandler.ts b/src/commands/admin/AdminQueueCommandsHandler.ts deleted file mode 100644 index 908f1b6..0000000 --- a/src/commands/admin/AdminQueueCommandsHandler.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BaseSubcommandsHandler } from "@baseCommand"; - -export default class AdminQueueCommandsHandler extends BaseSubcommandsHandler { - public static name = "queue"; - public static description = "Admin queue command handler."; - - public static subcommands = [ - ] -} \ No newline at end of file diff --git a/src/commands/config/ConfigQueueCommandsHandler.ts b/src/commands/config/ConfigQueueCommandsHandler.ts deleted file mode 100644 index 3ba1cd5..0000000 --- a/src/commands/config/ConfigQueueCommandsHandler.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseSubcommandsHandler } from "@baseCommand"; -import CreateQueueCommand from "./queue/CreateQueueCommand"; -import SetWaitingRoomCommand from "./queue/SetWaitingRoomCommand"; - -export default class ConfigQueueCommandsHandler extends BaseSubcommandsHandler { - public static name = "queue"; - public static description = "Config queue command handler."; - - public static subcommands = [ - CreateQueueCommand, - SetWaitingRoomCommand, - ] -} \ No newline at end of file diff --git a/src/commands/index.ts b/src/commands/index.ts deleted file mode 100644 index a0f1dd0..0000000 --- a/src/commands/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import PingCommand from './PingCommand'; -import HelpCommand from './HelpCommand'; -import AdminCommandsHandler from './AdminCommandsHandler'; -import ConfigCommandsHandler from './ConfigCommandsHandler'; - -export default [ - PingCommand, - HelpCommand, - AdminCommandsHandler, - ConfigCommandsHandler, -] \ No newline at end of file diff --git a/src/events/InteractionCreateEvent.test.ts b/src/events/InteractionCreateEvent.test.ts index 87599e6..e0e09bf 100644 --- a/src/events/InteractionCreateEvent.test.ts +++ b/src/events/InteractionCreateEvent.test.ts @@ -14,6 +14,7 @@ describe("InteractionCreateEvent", () => { beforeEach(() => { eventInstance = new event(discord.getApplication()) interaction = discord.mockInteraction("ping") + jest.spyOn(discord.getApplication().commands, 'find').mockReturnValue(PingCommand) }) it("should have the correct name", () => { @@ -21,6 +22,7 @@ describe("InteractionCreateEvent", () => { }) it("should log who executed which command with which options in which guild", async () => { + console.log(discord.getApplication().commands) const logSpy = jest.spyOn(discord.getApplication().logger, 'info') await eventInstance.execute(interaction) diff --git a/src/managers/CommandsManager.ts b/src/managers/CommandsManager.ts index 74fa625..1202b4c 100644 --- a/src/managers/CommandsManager.ts +++ b/src/managers/CommandsManager.ts @@ -11,7 +11,7 @@ export default class CommandsManager { constructor(@inject(delay(() => Application)) app: Application) { this.app = app; - this.commandsData = this.loadCommandsData(this.app.commands); + this.commandsData = this.loadCommandsData(this.app.commands.map(command => command.prototype.constructor)); } public async registerSlashCommandsFor(guild: Guild): Promise { diff --git a/src/utils/CommandsLoader.ts b/src/utils/CommandsLoader.ts new file mode 100644 index 0000000..5bd2a99 --- /dev/null +++ b/src/utils/CommandsLoader.ts @@ -0,0 +1,107 @@ +import { Application } from "@application" +import { BaseCommandOrSubcommandsHandler, BaseSubcommandsHandler } from "@baseCommand" +import { Interaction } from "discord.js" +import { BaseCommand } from "@baseCommand" +import fs from "fs" +import path from "path" +import { delay, inject, singleton } from "tsyringe" + +/** + * The `CommandsLoader` class. + * Responsible for loading the bot commands. + */ +@singleton() +export default class CommandsLoader { + /** + * The main `Application` class. + */ + private readonly app: Application + + /** + * Creates a new `CommandsLoader` instance. + * @param app The main `Application` class. + */ + constructor(@inject(delay(() => Application)) app: Application) { + this.app = app + } + + /** + * Loads the bot commands from the specified folder. + * @param commandsFolder The folder where the bot commands are located. + * @returns The bot commands in the specified folder. + */ + public loadCommands(commandsFolder: string): (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)[] { + const commands: (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)[] = [] + + const folderContents = fs.readdirSync(commandsFolder) + const commandFileNames = folderContents.filter(fileName => fileName.endsWith("Command.ts")) + for (const commandFileName of commandFileNames) { + const command = this.loadCommand(commandsFolder, commandFileName) + if (command) { + commands.push(command) + } + } + + const subfolders = folderContents.filter(fileName => fs.lstatSync(path.join(commandsFolder, fileName)).isDirectory()) + for (const subfolder of subfolders) { + commands.push(this.loadSubcommands(commandsFolder, subfolder)) + } + + return commands + } + + /** + * Loads a bot command from the specified file. + * @param commandsFolder The folder where the bot commands are located. + * @param commandFileName The name of the command file. + * @returns The command class if it was successfully loaded, `undefined` otherwise. + */ + private loadCommand(commandsFolder: string, commandFileName: string): (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler) | undefined { + const commandFilePath = path.join(commandsFolder, commandFileName) + const commandFileObj = require(commandFilePath) + const command = commandFileObj.default + if (this.hasBaseCommandClass(commandFilePath)) { + this.app.logger.info(`Successfully loaded command ${commandFileName}`) + return command + } else { + this.app.logger.error(`Could not find a BaseCommand class in command ${commandFileName}`) + + } + } + + /** + * Loads the subcommands in the specified subfolder and returns a subcommands handler class for them. + * @param commandsFolder The folder where the bot commands are located. + * @param subfolder The name of the subfolder containing the subcommands. + * @returns A subcommands handler class for the commands in the specified subfolder. + */ + private loadSubcommands(commandsFolder: string, subfolder: string): (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler) { + const subfolderPath = path.join(commandsFolder, subfolder) + const subfolderCommands = this.loadCommands(subfolderPath) + + const subcommandsHandler = class extends BaseSubcommandsHandler { + public static name = subfolder + public static description = `The ${subfolder} subcommands handler.` + public static subcommands = subfolderCommands + } + return subcommandsHandler + } + + /** + * Checks if the specified file contains a class that extends `BaseCommand`. + * @param filePath The path of the file to check. + * @returns Whether the file contains a class that extends `BaseCommand`. + */ + private hasBaseCommandClass(filePath: string): boolean { + const file = require(filePath) + const classNames = Object.keys(file) + for (const className of classNames) { + const classObj = file[className] + if (typeof classObj === 'function' && classObj.prototype instanceof BaseCommand) { + return true + } + } + return false + } + +} \ No newline at end of file From 81f5c4640c447a47a00a00d3c7787f771ea5898f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 13 Jan 2024 18:00:11 +0100 Subject: [PATCH 060/130] Remove unused code --- src/events/InteractionCreateEvent.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts index fd4a50f..6347a89 100644 --- a/src/events/InteractionCreateEvent.ts +++ b/src/events/InteractionCreateEvent.ts @@ -45,9 +45,4 @@ export default class InteractionCreateEvent extends BaseEvent { }) return {commandName: commandName, options: commandOptions} } -} - -interface Option { - name: string; - value: T } \ No newline at end of file From dee797c5d67b31be56e0cdf010d836b02ed79be2 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 14 Jan 2024 17:30:49 +0100 Subject: [PATCH 061/130] Add abstract class to dynamically load commands and events --- src/Application.ts | 26 +++++- src/types/errors/CouldNotFindTypeError.ts | 22 +++++ src/types/index.ts | 2 + src/utils/CommandsLoader.ts | 89 +++----------------- src/utils/EventsLoader.ts | 30 +++++++ src/utils/Loader.ts | 98 +++++++++++++++++++++++ 6 files changed, 186 insertions(+), 81 deletions(-) create mode 100644 src/types/errors/CouldNotFindTypeError.ts create mode 100644 src/utils/EventsLoader.ts create mode 100644 src/utils/Loader.ts diff --git a/src/Application.ts b/src/Application.ts index 64f5a28..296dea8 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -7,10 +7,11 @@ import { CommandsManager, ConfigManager, UserManager } from "./managers" import { container, delay, inject, injectable, singleton } from "tsyringe" import Environment from "./Environment" import mongoose from "mongoose" -import BaseCommandOrSubcommandsHandler from "./baseCommand/BaseCommandOrSubcommandsHandler" import path from "path" import CommandsLoader from "@utils/CommandsLoader" -import events from "./events" +import { BaseEvent } from "@baseEvent" +import { BaseCommandOrSubcommandsHandler } from "@baseCommand" +import EventsLoader from "@utils/EventsLoader" /** * The main `Application` class. @@ -60,6 +61,16 @@ export class Application { * The bot commands. */ public commands: (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)[] = [] + + /** + * The folder where the bot events are located. + */ + private readonly eventsFolder = path.join(__dirname, "events"); + + /** + * The bot events. + */ + public events: (new (app: Application) => BaseEvent)[] = [] /** * Initializes the bot. @@ -75,12 +86,18 @@ export class Application { this.userManager = userManager } + private loadEvents(): void { + this.logger.info('Loading events') + this.events = container.resolve(EventsLoader).load(this.eventsFolder) + this.logger.success('Loaded events') + } + /** * Loads the bot commands. */ private loadCommands(): void { this.logger.info('Loading commands') - this.commands = container.resolve(CommandsLoader).loadCommands(this.commandsFolder) + this.commands = container.resolve(CommandsLoader).load(this.commandsFolder) this.logger.success('Loaded commands') } @@ -89,6 +106,7 @@ export class Application { * Thereby also registers the bot commands. */ public listen(): void { + this.loadEvents() this.loadCommands() this.registerEvents() this.client.login(this.token) @@ -138,7 +156,7 @@ export class Application { */ private registerEvents() { this.logger.info('Registering events') - for (const event of events) { + for (const event of this.events) { const concreteEvent = new event(this) this.client.on(event.name, concreteEvent.execute.bind(concreteEvent)) this.logger.info(`Registered event ${event.name}`) diff --git a/src/types/errors/CouldNotFindTypeError.ts b/src/types/errors/CouldNotFindTypeError.ts new file mode 100644 index 0000000..afcd8fd --- /dev/null +++ b/src/types/errors/CouldNotFindTypeError.ts @@ -0,0 +1,22 @@ +export default class CouldNotFindTypeInFileError extends Error { + /** + * The type which could not be found. + */ + public expectedType: string + + /** + * The name of the file in which the type could not be found. + */ + public fileName: string + + /** + * Creates a new could not find type in file error. + * @param expectedType The type which could not be found. + * @param fileName The name of the file in which the type could not be found. + */ + constructor(expectedType: string, fileName: string) { + super(`Could not find type "${expectedType}" in file "${fileName}".`) + this.expectedType = expectedType + this.fileName = fileName + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 4907b83..a0620bd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,7 @@ import OptionRequirement from "./OptionRequirement"; import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; import CouldNotFindQueueError from "./errors/CouldNotFindGuildError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; +import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; import MissingOptionError from "./errors/MissingOptionError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; @@ -14,4 +15,5 @@ export { CouldNotFindQueueError, CouldNotFindRoleError, RoleNotInDatabaseError, + CouldNotFindTypeInFileError, } \ No newline at end of file diff --git a/src/utils/CommandsLoader.ts b/src/utils/CommandsLoader.ts index 5bd2a99..447cab1 100644 --- a/src/utils/CommandsLoader.ts +++ b/src/utils/CommandsLoader.ts @@ -2,106 +2,41 @@ import { Application } from "@application" import { BaseCommandOrSubcommandsHandler, BaseSubcommandsHandler } from "@baseCommand" import { Interaction } from "discord.js" import { BaseCommand } from "@baseCommand" -import fs from "fs" -import path from "path" import { delay, inject, singleton } from "tsyringe" +import Loader from "./Loader" /** * The `CommandsLoader` class. * Responsible for loading the bot commands. */ @singleton() -export default class CommandsLoader { - /** - * The main `Application` class. - */ - private readonly app: Application - +export default class CommandsLoader extends Loader<(new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)> { /** * Creates a new `CommandsLoader` instance. * @param app The main `Application` class. */ constructor(@inject(delay(() => Application)) app: Application) { - this.app = app + super(app) } - /** - * Loads the bot commands from the specified folder. - * @param commandsFolder The folder where the bot commands are located. - * @returns The bot commands in the specified folder. - */ - public loadCommands(commandsFolder: string): (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)[] { - const commands: (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)[] = [] - - const folderContents = fs.readdirSync(commandsFolder) - const commandFileNames = folderContents.filter(fileName => fileName.endsWith("Command.ts")) - for (const commandFileName of commandFileNames) { - const command = this.loadCommand(commandsFolder, commandFileName) - if (command) { - commands.push(command) - } - } - - const subfolders = folderContents.filter(fileName => fs.lstatSync(path.join(commandsFolder, fileName)).isDirectory()) - for (const subfolder of subfolders) { - commands.push(this.loadSubcommands(commandsFolder, subfolder)) - } - - return commands + protected fileNamePredicate(fileName: string): boolean { + return fileName.endsWith('Command.ts') } - /** - * Loads a bot command from the specified file. - * @param commandsFolder The folder where the bot commands are located. - * @param commandFileName The name of the command file. - * @returns The command class if it was successfully loaded, `undefined` otherwise. - */ - private loadCommand(commandsFolder: string, commandFileName: string): (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler) | undefined { - const commandFilePath = path.join(commandsFolder, commandFileName) - const commandFileObj = require(commandFilePath) - const command = commandFileObj.default - if (this.hasBaseCommandClass(commandFilePath)) { - this.app.logger.info(`Successfully loaded command ${commandFileName}`) - return command - } else { - this.app.logger.error(`Could not find a BaseCommand class in command ${commandFileName}`) - + protected isInstanceOfBaseClass(command: any): boolean { + if (typeof command === 'function' && command.prototype instanceof BaseCommand) { + return true } + return false } - /** - * Loads the subcommands in the specified subfolder and returns a subcommands handler class for them. - * @param commandsFolder The folder where the bot commands are located. - * @param subfolder The name of the subfolder containing the subcommands. - * @returns A subcommands handler class for the commands in the specified subfolder. - */ - private loadSubcommands(commandsFolder: string, subfolder: string): (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler) { - const subfolderPath = path.join(commandsFolder, subfolder) - const subfolderCommands = this.loadCommands(subfolderPath) - + protected subfolderContentsTransformer(subfolder: string, subfolderContents: (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)[]): (new (interaction: Interaction, app: Application) => BaseCommandOrSubcommandsHandler)[] { const subcommandsHandler = class extends BaseSubcommandsHandler { public static name = subfolder public static description = `The ${subfolder} subcommands handler.` - public static subcommands = subfolderCommands - } - return subcommandsHandler - } - - /** - * Checks if the specified file contains a class that extends `BaseCommand`. - * @param filePath The path of the file to check. - * @returns Whether the file contains a class that extends `BaseCommand`. - */ - private hasBaseCommandClass(filePath: string): boolean { - const file = require(filePath) - const classNames = Object.keys(file) - for (const className of classNames) { - const classObj = file[className] - if (typeof classObj === 'function' && classObj.prototype instanceof BaseCommand) { - return true - } + public static subcommands = subfolderContents } - return false + return [subcommandsHandler] } } \ No newline at end of file diff --git a/src/utils/EventsLoader.ts b/src/utils/EventsLoader.ts new file mode 100644 index 0000000..126fe66 --- /dev/null +++ b/src/utils/EventsLoader.ts @@ -0,0 +1,30 @@ +import { BaseEvent } from "@baseEvent"; +import { Application } from "@application"; +import { delay, inject, singleton } from "tsyringe"; +import Loader from "./Loader"; + +@singleton() +export default class EventsLoader extends Loader<(new (app: Application) => BaseEvent)> { + /** + * Creates a new `EventsLoader` instance. + * @param app The main `Application` class. + */ + constructor(@inject(delay(() => Application)) app: Application) { + super(app) + } + + protected fileNamePredicate(fileName: string): boolean { + return fileName.endsWith("Event.ts"); + } + + protected isInstanceOfBaseClass(event: any): boolean { + if (typeof event === "function" && event.prototype instanceof BaseEvent) { + return true; + } + return false; + } + + protected subfolderContentsTransformer(subfolder: string, subfolderContents: (new (app: Application) => BaseEvent)[]): (new (app: Application) => BaseEvent)[] { + return subfolderContents; + } +} \ No newline at end of file diff --git a/src/utils/Loader.ts b/src/utils/Loader.ts new file mode 100644 index 0000000..a67c95c --- /dev/null +++ b/src/utils/Loader.ts @@ -0,0 +1,98 @@ +import { Application } from "@application"; +import path from "path"; +import fs from "fs"; + +export default abstract class Loader { + /** + * The main `Application` class. + */ + protected readonly app: Application + + /** + * Creates a new `Loader` instance. + * @param app The main `Application` class. + */ + constructor(app: Application) { + this.app = app; + } + + /** + * Loads the specified folder. + * @param folder The folder to load. + * @returns The contents of the specified folder. + */ + public load(folder: string): T[] { + const contents: T[] = []; + + const folderContents = fs.readdirSync(folder); + const fileNames = folderContents.filter(this.fileNamePredicate); + for (const fileName of fileNames) { + const item = this.loadItem(folder, fileName); + if (item) { + contents.push(item); + } + } + + const subfolders = folderContents.filter(fileName => fs.lstatSync(path.join(folder, fileName)).isDirectory()); + for (const subfolder of subfolders) { + const subfolderContents = this.loadSubfolder(folder, subfolder); + contents.push(...subfolderContents); + } + + return contents; + } + + /** + * Checks whether the specified file name is valid. + * @param fileName The name of the file to check. + * @returns Whether the specified file name is valid. + */ + protected abstract fileNamePredicate(fileName: string): boolean; + + /** + * Loads an item from the specified file. + * @param folder The folder in which the item is located. + * @param fileName The name of the file in which the item is located. + * @returns The item if it was successfully loaded, `undefined` otherwise. + */ + private loadItem(folder: string, fileName: string): T | undefined { + const filePath = path.join(folder, fileName); + const item = require(filePath).default + if (!item) { + this.app.logger.error(`Failed to load item from file "${filePath}"`); + return undefined; + } else if (!this.isInstanceOfBaseClass(item)) { + this.app.logger.error(`Could not find a Base class in file "${filePath}"`); + return undefined; + } + this.app.logger.debug(`Loaded item "${item.name}" from file "${filePath}"`); + return item; + } + + /** + * Checks whether the specified item is an instance of a base class. + * @param item The item to check. + * @returns Whether the item is an instance of a base class. + */ + protected abstract isInstanceOfBaseClass(item: any): boolean; + + /** + * Loads the contents of the specified subfolder in the specified folder. + * @param folder The folder in which the subfolder is located. + * @param subfolder The name of the subfolder to load. + * @returns The contents of the specified subfolder. + */ + private loadSubfolder(folder: string, subfolder: string): T[] { + const subfolderPath = path.join(folder, subfolder); + const subfolderContents = this.load(subfolderPath); + + return this.subfolderContentsTransformer(subfolder, subfolderContents); + } + + /** + * Transforms the contents of the specified subfolder. + * @param subfolder The name of the subfolder. + * @param subfolderContents The contents of the subfolder. + */ + protected abstract subfolderContentsTransformer(subfolder: string, subfolderContents: T[]): T[]; +} \ No newline at end of file From c7e0fd9dec87bc49a885e1a0a19fbdbce2c4e49c Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 25 Jan 2024 11:49:32 +0100 Subject: [PATCH 062/130] Remove redundant index.ts --- src/events/index.ts | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 src/events/index.ts diff --git a/src/events/index.ts b/src/events/index.ts deleted file mode 100644 index 2261e33..0000000 --- a/src/events/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import ReadyEvent from "./ReadyEvent"; -import InteractionCreateEvent from "./InteractionCreateEvent"; -import GuildCreateEvent from "./GuildCreateEvent"; -import GuildUpdateEvent from "./GuildUpdateEvent"; -import GuildAddMemberEvent from "./GuildAddMemberEvent"; - -export default [ - ReadyEvent, - InteractionCreateEvent, - GuildCreateEvent, - GuildUpdateEvent, - GuildAddMemberEvent, -] \ No newline at end of file From b90b9567a95a7f5b1ba8e233ab95ceae744ba882 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 25 Jan 2024 13:46:34 +0100 Subject: [PATCH 063/130] Add Queue info command --- .../config/queue/CreateQueueCommand.ts | 3 +- src/commands/queue/QueueInfoCommand.test.ts | 104 ++++++++++++++++++ src/commands/queue/QueueInfoCommand.ts | 66 +++++++++++ src/models/Queue.ts | 32 ++++++ src/types/errors/NotInQueueError.ts | 5 + src/types/index.ts | 2 + 6 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/commands/queue/QueueInfoCommand.test.ts create mode 100644 src/commands/queue/QueueInfoCommand.ts create mode 100644 src/types/errors/NotInQueueError.ts diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index 2ca7ad1..a7c0258 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -4,6 +4,7 @@ import { Guild as DatabaseGuild } from "@models/Guild"; import { DocumentType, mongoose } from "@typegoose/typegoose"; import { Queue } from "@models/Queue"; import { QueueAlreadyExistsError } from "@types"; +import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; export default class CreateQueueCommand extends BaseCommand { public static name = "create"; @@ -85,7 +86,7 @@ export default class CreateQueueCommand extends BaseCommand { this.app.logger.info(`Queue "${queueName}" already exists on guild "${this.interaction.guild?.name}" (id: ${this.interaction.guild?.id}). Aborting.`) throw new QueueAlreadyExistsError(queueName); } - const queue: Queue = { + const queue: FilterOutFunctionKeys = { name: queueName, description: queueDescription, disconnect_timeout: 60000, diff --git a/src/commands/queue/QueueInfoCommand.test.ts b/src/commands/queue/QueueInfoCommand.test.ts new file mode 100644 index 0000000..8cab0f2 --- /dev/null +++ b/src/commands/queue/QueueInfoCommand.test.ts @@ -0,0 +1,104 @@ +import InfoCommand from "./QueueInfoCommand"; +import { MockDiscord } from "@tests/mockDiscord"; +import { Queue } from "@models/Queue"; +import { mongoose } from "@typegoose/typegoose"; +import { EmbedBuilder } from "@discordjs/builders"; +import { ChatInputCommandInteraction, Colors } from "discord.js"; +import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; +import { QueueEntry } from "@models/QueueEntry"; + +describe("InfoCommand", () => { + const command = InfoCommand; + const discord = new MockDiscord(); + let commandInstance: InfoCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("info"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Displays information about the queue."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + it("should reply with an error if the user is not in a queue", async () => { + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); + + const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; + expect(messageContent.embeds).toBeDefined(); + const embeds = messageContent.embeds; + expect(embeds).toHaveLength(1); + const embed = embeds[0]; + const embedData = embed.data; + + expect(embedData).toEqual({ + title: "Error", + description: "You are currently not in a queue.", + color: Colors.Red, + }); + }) + + it("should reply with the queue info if the user is in a queue", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queueEntry: FilterOutFunctionKeys = { + discord_id: interaction.user.id, + joinedAt: Date.now().toString(), + }; + const queue: FilterOutFunctionKeys = { + name: "test", + description: "test description", + entries: new mongoose.Types.DocumentArray([queueEntry]), + info_channels: [], + opening_times: new mongoose.Types.DocumentArray([]), + }; + dbGuild.queues.push(queue); + await dbGuild.save(); + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); + + const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; + expect(messageContent.embeds).toBeDefined(); + const embeds = messageContent.embeds; + expect(embeds).toHaveLength(1); + const embed = embeds[0]; + const embedData = embed.data; + + expect(embedData).toEqual({ + title: "Queue Information", + fields: expect.arrayContaining([ + expect.objectContaining({ + name: "❯ Name", + value: "test", + }), + expect.objectContaining({ + name: "❯ Description", + value: "test description", + }), + expect.objectContaining({ + name: "❯ Active Entries", + value: "1", + }), + expect.objectContaining({ + name: "❯ Your Position", + value: "1/1", + }), + ]), + }); + }) +}); \ No newline at end of file diff --git a/src/commands/queue/QueueInfoCommand.ts b/src/commands/queue/QueueInfoCommand.ts new file mode 100644 index 0000000..2392b9c --- /dev/null +++ b/src/commands/queue/QueueInfoCommand.ts @@ -0,0 +1,66 @@ +import { BaseCommand } from "@baseCommand"; +import { Queue } from "@models/Queue"; +import { DocumentType } from "@typegoose/typegoose"; +import { NotInQueueError } from "@types"; +import { Colors, EmbedBuilder } from "discord.js"; + +export default class InfoCommand extends BaseCommand { + public static name = "info"; + public static description = "Displays information about the queue."; + public static options = []; + + public async execute(): Promise { + try { + const { queue, position } = await this.loadQueueAndPosition(); + const embed = this.mountInfoEmbed(queue, position); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private async loadQueueAndPosition(): Promise<{ queue: DocumentType, position: number }> { + if (!this.interaction.guild) { + throw new Error("Interaction is not in a guild"); + } + const user = this.interaction.user; + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) + const queueData = dbGuild.queues.find(x => x.contains(user.id)); + if (!queueData) { + this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to get queue info but is not in a queue`); + throw new NotInQueueError(); + } + + const queuePosition = queueData.getPosition(user.id); + return { queue: queueData, position: queuePosition }; + } + + + private mountInfoEmbed(queue: DocumentType, position: number): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Queue Information") + .addFields( + { name: "❯ Name", value: `${queue.name}` }, + { name: "❯ Description", value: `${queue.description}` }, + { name: "❯ Active Entries", value: `${queue.entries.length}` }, + { name: "❯ Your Position", value: `${position}/${queue.entries.length}` }, + ) + return embed + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (!(error instanceof NotInQueueError)) { + throw error; + } + const embed = new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red) + return embed + } +} \ No newline at end of file diff --git a/src/models/Queue.ts b/src/models/Queue.ts index 4c14ff1..f32d201 100644 --- a/src/models/Queue.ts +++ b/src/models/Queue.ts @@ -3,6 +3,7 @@ import { VoiceChannelSpawner } from './VoiceChannelSpawner'; import { QueueEventType } from './Event'; import { QueueSpan } from './QueueSpan'; import { QueueEntry } from './QueueEntry'; +import { DocumentType } from '@typegoose/typegoose'; /** * A Queue from the Database @@ -107,6 +108,37 @@ export class Queue { */ @prop({ type: QueueEntry, default: [], required: true }) entries!: mongoose.Types.DocumentArray>; + + + /** + * Gets the Sorted Entries with the First ones being the ones with the highest Importance + * @param limit How many entries should we get at most? + */ + public getSortedEntries(this: DocumentType, limit?: number | undefined): DocumentType[] { + const entries = this.entries.sort((x, y) => { + const x_importance = (Date.now() - (+x.joinedAt)) * (x.importance || 1); + const y_importance = (Date.now() - (+y.joinedAt)) * (y.importance || 1); + return y_importance - x_importance; + }); + return entries.slice(0, limit); + } + + /** + * Returns true if the ID is contained in the queue + * @param discord_id the Discord ID to check if it's contained + */ + public contains(this: DocumentType, discord_id: string): boolean { + return this.entries.some(x => x.discord_id === discord_id); + } + + /** + * Gets the Position in the Current Queue + * @param discord_id the Discord ID of the entry + */ + public getPosition(this: DocumentType, discord_id: string): number { + return this.getSortedEntries().findIndex(x => x.discord_id === discord_id) + 1; + } + } export const QueueModel = getModelForClass(Queue, { diff --git a/src/types/errors/NotInQueueError.ts b/src/types/errors/NotInQueueError.ts new file mode 100644 index 0000000..95884ad --- /dev/null +++ b/src/types/errors/NotInQueueError.ts @@ -0,0 +1,5 @@ +export default class NotInQueueError extends Error { + constructor() { + super(`You are currently not in a queue.`); + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index a0620bd..02c45bc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,7 @@ import CouldNotFindQueueError from "./errors/CouldNotFindGuildError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; import MissingOptionError from "./errors/MissingOptionError"; +import NotInQueueError from "./errors/NotInQueueError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; @@ -16,4 +17,5 @@ export { CouldNotFindRoleError, RoleNotInDatabaseError, CouldNotFindTypeInFileError, + NotInQueueError, } \ No newline at end of file From cf80da9a0cc658949aa5a9d9f29a8c88b13b101a Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 25 Jan 2024 13:46:42 +0100 Subject: [PATCH 064/130] Update versions --- package-lock.json | 717 ++++++++++++++++++---------------------------- 1 file changed, 274 insertions(+), 443 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6e7c74..fe79530 100644 --- a/package-lock.json +++ b/package-lock.json @@ -140,9 +140,9 @@ } }, "node_modules/@babel/core": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz", - "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", + "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -150,10 +150,10 @@ "@babel/generator": "^7.23.6", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.6", + "@babel/helpers": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.6", + "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -169,6 +169,15 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", @@ -184,16 +193,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@babel/helper-compilation-targets": { "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", @@ -210,6 +209,15 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-environment-visitor": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", @@ -336,13 +344,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.6.tgz", - "integrity": "sha512-wCfsbN4nBidDRhpDhvcKlzHWCTlgJYUUdSJfzXb2NuBssDSIjc3xcb+znA7l+zYsFljAcGM0aFkN40cR3lXiGA==", + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.8.tgz", + "integrity": "sha512-KDqYz4PiOWvDFrdHLPhKtCThtIcKVy6avWD2oG4GEvyQ+XDZwHD4YQd+H2vNMnq2rkdxsDkU82T+Vk8U/WXHRQ==", "dev": true, "dependencies": { "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.6", + "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" }, "engines": { @@ -638,9 +646,9 @@ } }, "node_modules/@babel/traverse": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz", - "integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", + "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", "dev": true, "dependencies": { "@babel/code-frame": "^7.23.5", @@ -690,6 +698,16 @@ "node": ">=12" } }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@discordjs/builders": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.7.0.tgz", @@ -1000,16 +1018,6 @@ } } }, - "node_modules/@jest/reporters/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1036,16 +1044,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/source-map/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@jest/test-result": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", @@ -1102,16 +1100,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/transform/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@jest/types": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", @@ -1168,36 +1156,36 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", + "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", "dev": true, "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.1.tgz", - "integrity": "sha512-t7c5K033joZZMspnHg/gWPE4kandgc2OxE74aYOtGKfgB9VPuVJPix0H6fhmm2erj5PBJ21mqcx34lpIGtUCsQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.4.tgz", + "integrity": "sha512-8zJ8N1x51xo9hwPh6AWnKdLGEC5N3lDa6kms1YHmFBoRhTpJR6HG8wWk0td1MVCu9cD4YBrvjZEtd5Obw0Fbnw==", "dependencies": { "sparse-bitfield": "^3.0.3" } }, "node_modules/@sapphire/async-queue": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.1.tgz", - "integrity": "sha512-1RdpsmDQR/aWfp8oJzPtn4dNQrbpqSL5PIA0uAB/XwerPXUf994Ug1au1e7uGcD7ei8/F63UDjr5GWps1g/HxQ==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.2.tgz", + "integrity": "sha512-7X7FFAA4DngXUl95+hYbUF19bp1LGiffjJtu7ygrZrbdCSsdDDBaSjB7Akw0ZbOu6k0xpXyljnJ6/RZUvLfRdg==", "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" } }, "node_modules/@sapphire/shapeshift": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.5.tgz", - "integrity": "sha512-AGdHe+51gF7D3W8hBfuSFLBocURDCXVQczScTHXDS3RpNjNgrktIx/amlz5y8nHhm8SAdFt/X8EF8ZSfjJ0tnA==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.6.tgz", + "integrity": "sha512-4+Na/fxu2SEepZRb9z0dbsVh59QtwPuBg/UVaDib3av7ZY14b14+z09z6QVn0P6Dv6eOU2NDTsjIi0mbtgP56g==", "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" @@ -1207,9 +1195,9 @@ } }, "node_modules/@sapphire/snowflake": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz", - "integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" @@ -1229,16 +1217,6 @@ "discord.js": "^14.13.0" } }, - "node_modules/@shoginn/discordjs-mock/node_modules/@sapphire/snowflake": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.2.tgz", - "integrity": "sha512-FTm9RdyELF21PQN5dS/HLRs90XqWclHa+p0gkonc+BA2X2QKfFySHSjUbO65rmArd/ghR9Ahj2fMfedTZEqzOw==", - "dev": true, - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1246,9 +1224,9 @@ "dev": true }, "node_modules/@sinonjs/commons": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", - "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, "dependencies": { "type-detect": "4.0.8" @@ -1288,13 +1266,13 @@ "dev": true }, "node_modules/@typegoose/typegoose": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-12.0.0.tgz", - "integrity": "sha512-ZkRtjiCO4k05bLPtUXX3Ho7zMLLOKvg6+3JBVtadpC4xUu5htfDf5H+TI+w1jL1d8Q51aN8mHH7L5hr7OSkzoA==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-12.1.0.tgz", + "integrity": "sha512-RhqsFvTCTshtYxuzsHCGwPLJXgX1sc5aguZJ4w3ax6shVHaVQSG4VZddo/BowfP+0CSjp4J8XeCtrunkzkhJOg==", "dependencies": { "lodash": "^4.17.20", "loglevel": "^1.8.1", - "reflect-metadata": "^0.1.13", + "reflect-metadata": "^0.2.1", "semver": "^7.5.4", "tslib": "^2.6.2" }, @@ -1302,44 +1280,9 @@ "node": ">=16.20.1" }, "peerDependencies": { - "mongoose": "~8.0.1" - } - }, - "node_modules/@typegoose/typegoose/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" + "mongoose": "~8.1.0" } }, - "node_modules/@typegoose/typegoose/node_modules/reflect-metadata": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", - "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==" - }, - "node_modules/@typegoose/typegoose/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typegoose/typegoose/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1373,9 +1316,9 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.4", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.4.tgz", - "integrity": "sha512-mSM/iKUk5fDDrEV/e83qY+Cr3I1+Q3qqTuEn++HAWYjEa1+NxZr6CNrcJGf2ZTnq4HoFGC3zaTPZTobCzCFukA==", + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", + "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", "dev": true, "dependencies": { "@babel/types": "^7.20.7" @@ -1425,14 +1368,14 @@ } }, "node_modules/@types/luxon": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.7.tgz", - "integrity": "sha512-gKc9P2d4g5uYwmy4s/MO/yOVPmvHyvzka1YH6i5dM03UrFofHSmgc0D0ymbDRStFWHusk6cwwF6nhLm/ckBbbQ==" + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.8.tgz", + "integrity": "sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ==" }, "node_modules/@types/node": { - "version": "20.10.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz", - "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==", + "version": "20.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.6.tgz", + "integrity": "sha512-+EOokTnksGVgip2PbYbr3xnR7kZigh4LbybAfBAw5BpnQ+FqBYUsvCEjYd70IXKlbohQ64mzEYmMtlWUY8q//Q==", "dependencies": { "undici-types": "~5.26.4" } @@ -1452,6 +1395,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "dev": true, "dependencies": { "@types/node": "*", "@types/webidl-conversions": "*" @@ -1502,9 +1446,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz", - "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true, "engines": { "node": ">=0.4.0" @@ -1590,9 +1534,9 @@ } }, "node_modules/async-mutex": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", - "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.1.tgz", + "integrity": "sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==", "dev": true, "dependencies": { "tslib": "^2.4.0" @@ -1657,6 +1601,15 @@ "node": ">=8" } }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/babel-plugin-jest-hoist": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", @@ -1793,12 +1746,12 @@ } }, "node_modules/bson": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.2.0.tgz", - "integrity": "sha512-ID1cI+7bazPDyL9wYy9GaQ8gEEohWvcUl/Yf0dIdutJxnmInEEyCsb4awy/OiBfall7zBA179Pahi3vCdFze3Q==", - "peer": true, + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", + "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==", + "dev": true, "engines": { - "node": ">=16.20.1" + "node": ">=14.20.1" } }, "node_modules/buffer-crc32": { @@ -1835,9 +1788,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001572", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001572.tgz", - "integrity": "sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw==", + "version": "1.0.30001580", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001580.tgz", + "integrity": "sha512-mtj5ur2FFPZcCEpXFy8ADXbDACuNFXg6mxVDqp7tqooX6l3zwm+d8EPoeOSIFRDvHs8qu7/SLFOGniULkcH2iA==", "dev": true, "funding": [ { @@ -2119,10 +2072,19 @@ "node": ">=16.11.0" } }, + "node_modules/discord.js/node_modules/@sapphire/snowflake": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz", + "integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", + "integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==", "engines": { "node": ">=12" }, @@ -2131,9 +2093,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.616", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz", - "integrity": "sha512-1n7zWYh8eS0L9Uy+GskE0lkBUNK83cXTVJI0pU3mGprFsbfSdAc15VTFbo+A+Bq4pwstmL30AVcEU3Fo463lNg==", + "version": "1.4.645", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.645.tgz", + "integrity": "sha512-EeS1oQDCmnYsRDRy2zTeC336a/4LZ6WKqvSaM1jLocEk5ZuyszkQtCpsqvuvaIXGOUjwtvF6LTcS8WueibXvSw==", "dev": true }, "node_modules/emittery": { @@ -2321,6 +2283,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-cache-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -2335,9 +2306,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "dev": true, "funding": [ { @@ -2676,39 +2647,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -3210,39 +3148,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-snapshot/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-snapshot/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -3487,9 +3392,9 @@ } }, "node_modules/magic-bytes.js": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.7.0.tgz", - "integrity": "sha512-YzVU2+/hrjwx8xcgAw+ffNq3jkactpj+f1iSL4LonrFKhvnwDzHSqtFdk/MMRP53y9ScouJ7cKEnqYsJwsHoYA==" + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.8.0.tgz", + "integrity": "sha512-lyWpfvNGVb5lu8YUAbER0+UMBTdR63w2mcSUlhhBTyVbxJvjgqwyAf3AZD6MprgK0uHuBoWXSDAMWLupX83o3Q==" }, "node_modules/make-dir": { "version": "4.0.0", @@ -3506,39 +3411,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-dir/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -3617,26 +3489,27 @@ } }, "node_modules/mongodb": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.2.0.tgz", - "integrity": "sha512-d7OSuGjGWDZ5usZPqfvb36laQ9CPhnWkAGHT61x5P95p/8nMVeH8asloMwW6GcYFeB0Vj4CB/1wOTDG2RA9BFA==", - "peer": true, + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", + "dev": true, "dependencies": { - "@mongodb-js/saslprep": "^1.1.0", - "bson": "^6.2.0", - "mongodb-connection-string-url": "^2.6.0" + "bson": "^5.5.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" }, "engines": { - "node": ">=16.20.1" + "node": ">=14.20.1" + }, + "optionalDependencies": { + "@mongodb-js/saslprep": "^1.1.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", - "socks": "^2.7.1" + "@mongodb-js/zstd": "^1.0.0", + "kerberos": "^1.0.0 || ^2.0.0", + "mongodb-client-encryption": ">=2.3.0 <3", + "snappy": "^7.2.2" }, "peerDependenciesMeta": { "@aws-sdk/credential-providers": { @@ -3645,9 +3518,6 @@ "@mongodb-js/zstd": { "optional": true }, - "gcp-metadata": { - "optional": true - }, "kerberos": { "optional": true }, @@ -3656,9 +3526,6 @@ }, "snappy": { "optional": true - }, - "socks": { - "optional": true } } }, @@ -3666,19 +3533,20 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "dev": true, "dependencies": { "@types/whatwg-url": "^8.2.1", "whatwg-url": "^11.0.0" } }, "node_modules/mongodb-memory-server": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-9.1.3.tgz", - "integrity": "sha512-EVVNll0e6QEsNhK7IJI0x51nbmv57E6X8izO3LLEnfbSZNwERG4P5nAjJfglqCSNkT4svKp1/0kzc+ldlQttOg==", + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-9.1.6.tgz", + "integrity": "sha512-gzcpgGYlPhuKmria37W+bvYy6W+OkX2UVG7MoP41OWFvQv2Hn7A+fLXkV+lsMmhog1lMQprdV6AR+gixgheLaw==", "dev": true, "hasInstallScript": true, "dependencies": { - "mongodb-memory-server-core": "9.1.3", + "mongodb-memory-server-core": "9.1.6", "tslib": "^2.6.2" }, "engines": { @@ -3686,9 +3554,9 @@ } }, "node_modules/mongodb-memory-server-core": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-9.1.3.tgz", - "integrity": "sha512-94pUuTgjb6NglCbKLEZm457aACxeaT8+Jw8weEy0DyWiCBd1mk8dIuq7GE1CjmHFU2hMOCnOutdR96LhkWpgig==", + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-9.1.6.tgz", + "integrity": "sha512-3H/dq5II+XcSbK80hicMw4zFlDxcpjt4oWJq76RlOVuLoaf3AFqVheR6Vqx9ymlIqER4Jni58FMCIIRbesia1A==", "dev": true, "dependencies": { "async-mutex": "^0.4.0", @@ -3708,15 +3576,6 @@ "node": ">=14.20.1" } }, - "node_modules/mongodb-memory-server-core/node_modules/bson": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", - "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==", - "dev": true, - "engines": { - "node": ">=14.20.1" - } - }, "node_modules/mongodb-memory-server-core/node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3729,40 +3588,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mongodb-memory-server-core/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, + "node_modules/mongoose": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.1.1.tgz", + "integrity": "sha512-DbLb0NsiEXmaqLOpEz+AtAsgwhRw6f25gwa1dF5R7jj6lS1D8X6uTdhBSC8GDVtOwe5Tfw2EL7nTn6hiJT3Bgg==", + "peer": true, "dependencies": { - "yallist": "^4.0.0" + "bson": "^6.2.0", + "kareem": "2.5.1", + "mongodb": "6.3.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "16.0.1" }, "engines": { - "node": ">=10" + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongodb-memory-server-core/node_modules/mongodb": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", - "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", - "dev": true, + "node_modules/mongoose/node_modules/@types/whatwg-url": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.4.tgz", + "integrity": "sha512-lXCmTWSHJvf0TRSO58nm978b8HJ/EdsSsEKLd3ODHFjo+3VGAyyTp4v50nWvwtzBxSMQrVOK7tcuN0zGPLICMw==", + "peer": true, "dependencies": { - "bson": "^5.5.0", - "mongodb-connection-string-url": "^2.6.0", - "socks": "^2.7.1" - }, + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongoose/node_modules/bson": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.2.0.tgz", + "integrity": "sha512-ID1cI+7bazPDyL9wYy9GaQ8gEEohWvcUl/Yf0dIdutJxnmInEEyCsb4awy/OiBfall7zBA179Pahi3vCdFze3Q==", + "peer": true, "engines": { - "node": ">=14.20.1" + "node": ">=16.20.1" + } + }, + "node_modules/mongoose/node_modules/mongodb": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.3.0.tgz", + "integrity": "sha512-tt0KuGjGtLUhLoU263+xvQmPHEGTw5LbcNC73EoFRYgSHwZt5tsoJC110hDyO1kjQzpgNrpdcSza9PknWN4LrA==", + "peer": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.2.0", + "mongodb-connection-string-url": "^3.0.0" }, - "optionalDependencies": { - "@mongodb-js/saslprep": "^1.1.0" + "engines": { + "node": ">=16.20.1" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.0.0", - "kerberos": "^1.0.0 || ^2.0.0", - "mongodb-client-encryption": ">=2.3.0 <3", - "snappy": "^7.2.2" + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" }, "peerDependenciesMeta": { "@aws-sdk/credential-providers": { @@ -3771,6 +3657,9 @@ "@mongodb-js/zstd": { "optional": true }, + "gcp-metadata": { + "optional": true + }, "kerberos": { "optional": true }, @@ -3779,57 +3668,52 @@ }, "snappy": { "optional": true + }, + "socks": { + "optional": true } } }, - "node_modules/mongodb-memory-server-core/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, + "node_modules/mongoose/node_modules/mongodb-connection-string-url": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.0.tgz", + "integrity": "sha512-t1Vf+m1I5hC2M5RJx/7AtxgABy1cZmIPQRMXw+gEIPn/cZNF3Oiy+l0UIypUwVB5trcWHq3crg2g3uAR9aAwsQ==", + "peer": true, "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" } }, - "node_modules/mongodb-memory-server-core/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "peer": true }, - "node_modules/mongoose": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.0.3.tgz", - "integrity": "sha512-LJRT0yP4TW14HT4r2RkxqyvoTylMSzWpl5QOeVHTnRggCLQSpkoBdgbUtORFq/mSL2o9cLCPJz+6uzFj25qbHw==", + "node_modules/mongoose/node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", "peer": true, "dependencies": { - "bson": "^6.2.0", - "kareem": "2.5.1", - "mongodb": "6.2.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "16.0.1" + "punycode": "^2.3.0" }, "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" + "node": ">=14" } }, - "node_modules/mongoose/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "peer": true + "node_modules/mongoose/node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "peer": true, + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } }, "node_modules/mpath": { "version": "0.9.0", @@ -4216,14 +4100,35 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" } }, + "node_modules/semver/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4445,9 +4350,9 @@ } }, "node_modules/tar-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", - "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "dev": true, "dependencies": { "b4a": "^1.6.4", @@ -4500,6 +4405,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, "dependencies": { "punycode": "^2.1.1" }, @@ -4508,9 +4414,9 @@ } }, "node_modules/ts-jest": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", - "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", + "version": "29.1.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", + "integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==", "dev": true, "dependencies": { "bs-logger": "0.x", @@ -4526,7 +4432,7 @@ "ts-jest": "cli.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", @@ -4550,39 +4456,6 @@ } } }, - "node_modules/ts-jest/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-jest/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/ts-mixer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz", @@ -4632,9 +4505,9 @@ } }, "node_modules/ts-patch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/ts-patch/-/ts-patch-3.1.1.tgz", - "integrity": "sha512-ReGYz9jQYC80PFafBx25TC0UI9cSgmUBtpT+WIy8IrhpLVzEHf430k03XQYOMldQMyZDBbzn5fBPELgtIl65cA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/ts-patch/-/ts-patch-3.1.2.tgz", + "integrity": "sha512-n58F5AqjUMdp9RAKq+E1YBkmONltPVbt1nN+wrmZXoYZek6QcvaTuqvKMhYhr5BxtC53kD/exxIPA1cP1RQxsA==", "dev": true, "dependencies": { "chalk": "^4.1.2", @@ -4649,39 +4522,6 @@ "tspc": "bin/tspc.js" } }, - "node_modules/ts-patch/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-patch/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-patch/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -4849,16 +4689,6 @@ "node": ">=10.12.0" } }, - "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -4880,6 +4710,7 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" From fa204d77f5c54b1e69afe1dd18ec2c32d8e23851 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 25 Jan 2024 13:47:02 +0100 Subject: [PATCH 065/130] Remove redundant console.log --- src/events/InteractionCreateEvent.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/events/InteractionCreateEvent.test.ts b/src/events/InteractionCreateEvent.test.ts index e0e09bf..2f97258 100644 --- a/src/events/InteractionCreateEvent.test.ts +++ b/src/events/InteractionCreateEvent.test.ts @@ -22,7 +22,6 @@ describe("InteractionCreateEvent", () => { }) it("should log who executed which command with which options in which guild", async () => { - console.log(discord.getApplication().commands) const logSpy = jest.spyOn(discord.getApplication().logger, 'info') await eventInstance.execute(interaction) From f04c6dac0551e60d6747d1af8c752a870b833bf5 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 25 Jan 2024 14:26:14 +0100 Subject: [PATCH 066/130] Fix command file name --- src/commands/queue/QueueInfoCommand.test.ts | 6 +++--- src/commands/queue/QueueInfoCommand.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/queue/QueueInfoCommand.test.ts b/src/commands/queue/QueueInfoCommand.test.ts index 8cab0f2..05b311f 100644 --- a/src/commands/queue/QueueInfoCommand.test.ts +++ b/src/commands/queue/QueueInfoCommand.test.ts @@ -1,4 +1,4 @@ -import InfoCommand from "./QueueInfoCommand"; +import QueueInfoCommand from "./QueueInfoCommand"; import { MockDiscord } from "@tests/mockDiscord"; import { Queue } from "@models/Queue"; import { mongoose } from "@typegoose/typegoose"; @@ -8,9 +8,9 @@ import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEntry } from "@models/QueueEntry"; describe("InfoCommand", () => { - const command = InfoCommand; + const command = QueueInfoCommand; const discord = new MockDiscord(); - let commandInstance: InfoCommand; + let commandInstance: QueueInfoCommand; let interaction: ChatInputCommandInteraction; beforeEach(() => { diff --git a/src/commands/queue/QueueInfoCommand.ts b/src/commands/queue/QueueInfoCommand.ts index 2392b9c..d05a447 100644 --- a/src/commands/queue/QueueInfoCommand.ts +++ b/src/commands/queue/QueueInfoCommand.ts @@ -4,7 +4,7 @@ import { DocumentType } from "@typegoose/typegoose"; import { NotInQueueError } from "@types"; import { Colors, EmbedBuilder } from "discord.js"; -export default class InfoCommand extends BaseCommand { +export default class QueueInfoCommand extends BaseCommand { public static name = "info"; public static description = "Displays information about the queue."; public static options = []; From e5642dd7541126dc5bfc9a3d7fb9a799aacd5be9 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 25 Jan 2024 14:30:34 +0100 Subject: [PATCH 067/130] Add separate Interaction not in guild error --- src/commands/admin/UpdateBotRolesCommand.ts | 3 ++- src/commands/config/queue/CreateQueueCommand.ts | 4 ++-- src/commands/config/queue/SetWaitingRoomCommand.ts | 4 ++-- src/commands/queue/QueueInfoCommand.ts | 4 ++-- src/types/errors/InteractionNotInGuildError.ts | 9 +++++++++ src/types/index.ts | 2 ++ 6 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 src/types/errors/InteractionNotInGuildError.ts diff --git a/src/commands/admin/UpdateBotRolesCommand.ts b/src/commands/admin/UpdateBotRolesCommand.ts index 0031d15..1557783 100644 --- a/src/commands/admin/UpdateBotRolesCommand.ts +++ b/src/commands/admin/UpdateBotRolesCommand.ts @@ -3,6 +3,7 @@ import { BaseCommand } from "@baseCommand"; import { DBRole, InternalGuildRoles, RoleScopes } from "@models/BotRoles"; import { Guild as DatabaseGuild } from "@models/Guild"; import { ArraySubDocumentType, DocumentType, mongoose } from "@typegoose/typegoose"; +import { InteractionNotInGuildError } from "@types"; export default class UpdateBotRolesCommand extends BaseCommand { public static name = "update_bot_roles"; @@ -25,7 +26,7 @@ export default class UpdateBotRolesCommand extends BaseCommand { public async execute(): Promise { await this.defer(); if (!this.interaction.guild) { - throw new Error("Interaction is not in a guild"); + throw new InteractionNotInGuildError(this.interaction); } this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) const createIfNotExists = Boolean(this.getOptionValue(UpdateBotRolesCommand.options[0])); diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index a7c0258..bf4c350 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -3,7 +3,7 @@ import { BaseCommand } from "@baseCommand"; import { Guild as DatabaseGuild } from "@models/Guild"; import { DocumentType, mongoose } from "@typegoose/typegoose"; import { Queue } from "@models/Queue"; -import { QueueAlreadyExistsError } from "@types"; +import { InteractionNotInGuildError, QueueAlreadyExistsError } from "@types"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; export default class CreateQueueCommand extends BaseCommand { @@ -32,7 +32,7 @@ export default class CreateQueueCommand extends BaseCommand { public async execute() { await this.defer(); if (!this.interaction.guild) { - throw new Error("Interaction is not in a guild"); + throw new InteractionNotInGuildError(this.interaction); } this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) const queueName = this.getOptionValue(CreateQueueCommand.options[0]); diff --git a/src/commands/config/queue/SetWaitingRoomCommand.ts b/src/commands/config/queue/SetWaitingRoomCommand.ts index 4f1266a..eb7db7b 100644 --- a/src/commands/config/queue/SetWaitingRoomCommand.ts +++ b/src/commands/config/queue/SetWaitingRoomCommand.ts @@ -1,6 +1,6 @@ import { BaseCommand } from "@baseCommand"; import { Queue } from "@models/Queue"; -import { CouldNotFindChannelError, CouldNotFindQueueError, CouldNotFindRoleError, RoleNotInDatabaseError } from "@types"; +import { CouldNotFindChannelError, CouldNotFindQueueError, CouldNotFindRoleError, InteractionNotInGuildError, RoleNotInDatabaseError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; import { ArraySubDocumentType, DocumentType, mongoose } from "@typegoose/typegoose"; import { ApplicationCommandOptionType, ChannelType, Colors, EmbedBuilder, Role, VoiceChannel } from "discord.js"; @@ -38,7 +38,7 @@ export default class SetWaitingRoomCommand extends BaseCommand { public async execute() { await this.defer(); if (!this.interaction.guild) { - throw new Error("Interaction is not in a guild"); + throw new InteractionNotInGuildError(this.interaction); } this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) try { diff --git a/src/commands/queue/QueueInfoCommand.ts b/src/commands/queue/QueueInfoCommand.ts index d05a447..2cfedb3 100644 --- a/src/commands/queue/QueueInfoCommand.ts +++ b/src/commands/queue/QueueInfoCommand.ts @@ -1,7 +1,7 @@ import { BaseCommand } from "@baseCommand"; import { Queue } from "@models/Queue"; import { DocumentType } from "@typegoose/typegoose"; -import { NotInQueueError } from "@types"; +import { InteractionNotInGuildError, NotInQueueError } from "@types"; import { Colors, EmbedBuilder } from "discord.js"; export default class QueueInfoCommand extends BaseCommand { @@ -26,7 +26,7 @@ export default class QueueInfoCommand extends BaseCommand { private async loadQueueAndPosition(): Promise<{ queue: DocumentType, position: number }> { if (!this.interaction.guild) { - throw new Error("Interaction is not in a guild"); + throw new InteractionNotInGuildError(this.interaction); } const user = this.interaction.user; const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) diff --git a/src/types/errors/InteractionNotInGuildError.ts b/src/types/errors/InteractionNotInGuildError.ts new file mode 100644 index 0000000..68ee914 --- /dev/null +++ b/src/types/errors/InteractionNotInGuildError.ts @@ -0,0 +1,9 @@ +import { Interaction } from "discord.js"; + +export default class InteractionNotInGuildError extends Error { + public interaction: Interaction; + constructor(interaction: Interaction) { + super(`Interaction ${interaction.id} is not in a guild`) + this.interaction = interaction; + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 02c45bc..fca9e1e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; import CouldNotFindQueueError from "./errors/CouldNotFindGuildError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; +import InteractionNotInGuildError from "./errors/InteractionNotInGuildError"; import MissingOptionError from "./errors/MissingOptionError"; import NotInQueueError from "./errors/NotInQueueError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; @@ -18,4 +19,5 @@ export { RoleNotInDatabaseError, CouldNotFindTypeInFileError, NotInQueueError, + InteractionNotInGuildError, } \ No newline at end of file From c0da9abd4be10a1733465181bcff7a2d5cc2016a Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 8 Mar 2024 17:41:17 +0100 Subject: [PATCH 068/130] Add support for not required options --- src/baseCommand/BaseCommand.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 4811366..7c9cc6b 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -78,7 +78,9 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle return optionValue.value as string } else if (option.default) { return option.default as string + } else if (option.required) { + throw new MissingOptionError(option.name, interaction.commandName) } - throw new MissingOptionError(option.name, interaction.commandName) + return "" } } From 3fe120fd79f66710927597b7009a547913353afc Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 8 Mar 2024 18:29:10 +0100 Subject: [PATCH 069/130] Add queue join command --- .../config/queue/CreateQueueCommand.ts | 2 +- src/commands/queue/QueueJoinCommand.ts | 105 +++++++++ src/events/GuildAddMemberEvent.ts | 2 +- src/models/Queue.ts | 216 ++++++++++++++++-- src/models/User.ts | 11 +- src/types/errors/AlreadyInQueueError.ts | 15 ++ ...uildError.ts => CouldNotFindQueueError.ts} | 0 src/types/errors/QueueLockedError.ts | 15 ++ src/types/errors/UserHasActiveSessionError.ts | 5 + src/types/index.ts | 10 +- src/utils/formatDuration.ts | 6 + 11 files changed, 360 insertions(+), 27 deletions(-) create mode 100644 src/commands/queue/QueueJoinCommand.ts create mode 100644 src/types/errors/AlreadyInQueueError.ts rename src/types/errors/{CouldNotFindGuildError.ts => CouldNotFindQueueError.ts} (100%) create mode 100644 src/types/errors/QueueLockedError.ts create mode 100644 src/types/errors/UserHasActiveSessionError.ts create mode 100644 src/utils/formatDuration.ts diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index bf4c350..6e856c2 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -48,7 +48,7 @@ export default class CreateQueueCommand extends BaseCommand { throw error; } const embed = this.mountCreateQueueEmbed(); - await this.send({ embeds: [embed] }); + await this.send({ embeds: [embed] }); } /** diff --git a/src/commands/queue/QueueJoinCommand.ts b/src/commands/queue/QueueJoinCommand.ts new file mode 100644 index 0000000..def46cb --- /dev/null +++ b/src/commands/queue/QueueJoinCommand.ts @@ -0,0 +1,105 @@ +import { BaseCommand } from "@baseCommand"; +import { Guild as DatabaseGuild } from "@models/Guild"; +import { DocumentType } from "@typegoose/typegoose"; +import { AlreadyInQueueError, CouldNotFindQueueError, InteractionNotInGuildError, QueueLockedError, UserHasActiveSessionError } from "@types"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder, User } from "discord.js"; +import { join } from "path"; + +export default class QueueJoinCommand extends BaseCommand { + public static name = "join"; + public static description = "Joins the queue."; + public static options = [ + { + name: "queue", + description: "The queue to join.", + type: ApplicationCommandOptionType.String, + required: true, + }, + { + name: "intent", + description: "The intent of joining the queue.", + type: ApplicationCommandOptionType.String, + required: false, + } + ]; + /** + * The guild saved in the database. + */ + private dbGuild!: DocumentType; + + public async execute(): Promise { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) + const queueName = this.getOptionValue(QueueJoinCommand.options[0]) + const intent = this.getOptionValue(QueueJoinCommand.options[1]) + const user = this.interaction.user + try { + let joinMessage = await this.joinQueue(queueName, intent, user) + console.log(joinMessage) + const embed = this.mountJoinQueueEmbed(joinMessage); + await this.send({ embeds: [embed] }) + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }) + } else { + throw error; + } + } + } + + private mountJoinQueueEmbed(joinMessage: string): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Queue Joined") + .setDescription(joinMessage) + .setColor(Colors.Green) + return embed + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof AlreadyInQueueError || error instanceof CouldNotFindQueueError || error instanceof QueueLockedError || error instanceof UserHasActiveSessionError) { + const embed = new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red) + return embed + } + throw error; + } + + private async joinQueue(queueName: string, intent: string, user: User): Promise { + const queueData = this.dbGuild.queues.find(x => x.name === queueName); + if (!queueData) { + throw new CouldNotFindQueueError(queueName); + } + + // check if already in queue + const queueWithUser = this.dbGuild.queues.find(x => x.contains(user.id)); + if (queueWithUser) { + throw new AlreadyInQueueError(queueWithUser.name); + } + + // check if user has active tutor session + const userData = await this.app.userManager.getUser(user); + if (await userData.hasActiveSessions()) { + throw new UserHasActiveSessionError(); + } + + // check if queue is locked + if (queueData.locked) { + throw new QueueLockedError(queueData.name); + } + + // join the queue + await queueData.join({ + discord_id: user.id, + joinedAt: Date.now().toString(), + importance: 1, + intent: intent, + }) + + return queueData.getJoinMessage(user.id); + } +} \ No newline at end of file diff --git a/src/events/GuildAddMemberEvent.ts b/src/events/GuildAddMemberEvent.ts index 48928e8..930da00 100644 --- a/src/events/GuildAddMemberEvent.ts +++ b/src/events/GuildAddMemberEvent.ts @@ -2,7 +2,7 @@ import { BaseEvent } from "@baseEvent"; import { Guild as DatabaseGuild } from "@models/Guild"; import { EmbedBuilder, GuildMember } from "discord.js"; import { DocumentType } from "@typegoose/typegoose"; -import { StringReplacements } from "src/types/StringReplacements"; +import { StringReplacements } from "@types"; import { interpolateString } from "@utils/interpolateString"; export default class GuildAddMemberEvent extends BaseEvent { diff --git a/src/models/Queue.ts b/src/models/Queue.ts index f32d201..bea3676 100644 --- a/src/models/Queue.ts +++ b/src/models/Queue.ts @@ -4,6 +4,9 @@ import { QueueEventType } from './Event'; import { QueueSpan } from './QueueSpan'; import { QueueEntry } from './QueueEntry'; import { DocumentType } from '@typegoose/typegoose'; +import { interpolateString } from '@utils/interpolateString'; +import { StringReplacements } from '@types'; +import { formatDuration } from '@utils/formatDuration'; /** * A Queue from the Database @@ -13,102 +16,114 @@ export class Queue { * The Name Of The Queue */ @prop({ required: true }) - name!: string; + name!: string; /** * A Description of the Queue */ @prop() - description?: string; + description?: string; /** * The max Amount of Users that the queue can handle */ @prop() - limit?: number; + limit?: number; /** * The Timeout in Milliseconds if the User disconnects from the Queue (usefull for VC based Queues) */ @prop() - disconnect_timeout?: number; + disconnect_timeout?: number; /** * The Timeout in Milliseconds that the user is kicked off the queue After not accepting a match */ @prop() - match_timeout?: number; + match_timeout?: number; /** * A Custom Join Message for the Queue. Use ${pos} ${total} ${eta} ${user} and so on to create Dynamic Messages. */ @prop() - join_message?: string; + join_message?: string; /** * A Custom Match Found Message for the Queue. Use ${pos} ${total} ${eta} ${user} ${match} ${match_channel} and so on to create Dynamic Messages. */ @prop() - match_found_message?: string; + match_found_message?: string; /** * A Custom Timeout Message. Use ${pos} ${total} ${eta} ${user} ${timeout} and so on to create Dynamic Messages. */ @prop() - timeout_message?: string; + timeout_message?: string; /** * A Custom Leave Message. Use ${pos} ${total} ${eta} ${user} ${timeout} and so on to create Dynamic Messages. */ @prop() - leave_message?: string; + leave_message?: string; /** * A Custom Message that is Displayed when the Room is Left (like Please confirm ur stay) */ @prop() - leave_room_message?: string; + leave_room_message?: string; /** * A Template for spawning in Rooms (if empty default template is used) */ @prop({ type: () => VoiceChannelSpawner }) - room_spawner?: SubDocumentType; + room_spawner?: SubDocumentType; /** * A text Channel to use if dms are disabled */ @prop() - text_channel?: string; + text_channel?: string; /** * Text Channels ids to log queue events */ @prop({ default: [], required: false }) - info_channels!: { - channel_id: string; - events: QueueEventType[]; + info_channels!: { + channel_id: string; + events: QueueEventType[]; }[]; /** * Whether the queue is locked (this also disables the /queue join command for this queue) */ @prop({ default: false }) - locked?: boolean; + locked?: boolean; /** * Whether to automatically lock and unlock the queue according to the opening_times */ @prop({ default: false }) - auto_lock?: boolean; + auto_lock?: boolean; /** * The opening times of the Queue */ @prop({ type: QueueSpan, default: [], required: true }) - opening_times!: mongoose.Types.DocumentArray>; + opening_times!: mongoose.Types.DocumentArray>; /** * The standard time to shift the unlocking of the queue by in milliseconds */ @prop({ default: 0 }) - openShift?: number; + openShift?: number; /** * The standard time to shift the locking of the queue by in milliseconds */ @prop({ default: 0 }) - closeShift?: number; + closeShift?: number; /** * The Entries of the Queue */ @prop({ type: QueueEntry, default: [], required: true }) - entries!: mongoose.Types.DocumentArray>; + entries!: mongoose.Types.DocumentArray>; + /** + * Put an Entry into the Queue + * @param entry The Queue Entry + */ + public async join(this: DocumentType, entry: QueueEntry): Promise { + if (this.entries.find(x => x.discord_id === entry.discord_id)) { + throw new Error("Dublicate Entry"); + } + this.entries.push(entry); + await this.$parent()?.save(); + return this.getEntry(entry.discord_id)!; + } /** * Gets the Sorted Entries with the First ones being the ones with the highest Importance @@ -131,6 +146,14 @@ export class Queue { return this.entries.some(x => x.discord_id === discord_id); } + /** + * Gets The Entry that has the given Discord ID + * @param discord_id The Discord ID of the Entry + */ + public getEntry(this: DocumentType, discord_id: string): DocumentType | null { + return this.entries.find(x => x.discord_id === discord_id) ?? null; + } + /** * Gets the Position in the Current Queue * @param discord_id the Discord ID of the entry @@ -138,7 +161,156 @@ export class Queue { public getPosition(this: DocumentType, discord_id: string): number { return this.getSortedEntries().findIndex(x => x.discord_id === discord_id) + 1; } - + + /** + * Interpolates the Queue String + * @param string The String to Interpolate + */ + public interpolateQueueString(this: DocumentType, string: string): string | null; + /** + * Interpolates the Queue String + * @param string The String to Interpolate + * @param discord_id The Discord ID of the Entry + */ + public interpolateQueueString(this: DocumentType, string: string, discord_id: string): string | null; + /** + * Interpolates the Queue String + * @param string The String to Interpolate + * @param entry The Queue Entry + */ + public interpolateQueueString(this: DocumentType, string: string, entry: QueueEntry): string | null; + /** + * Interpolates the Queue String + * @param string The String to Interpolate + * @param entry_resolvable The Entry Resolvable + */ + public interpolateQueueString(this: DocumentType, string: string, entry_resolvable?: string | QueueEntry | undefined): string | null; + /** + * Interpolates the Queue String + * @param string The String to Interpolate + * @param entry_resolvable The Entry Resolvable + */ + public interpolateQueueString(this: DocumentType, string: string, entry_resolvable?: string | QueueEntry | undefined): string | null { + try { + const replacements: StringReplacements = { + "limit": this.limit, + "name": this.name, + "description": this.description, + "eta": "null", + "timeout": (this.disconnect_timeout ?? 0) / 1000, + "total": this.entries.length, + }; + + if (entry_resolvable) { + let entry: QueueEntry | null; + if (typeof entry_resolvable === "string") { + entry = this.getEntry(entry_resolvable); + } else { + entry = entry_resolvable; + } + if (entry && this.contains(entry.discord_id)) { + const entryReplacements: StringReplacements = { + "member_id": entry.discord_id, + "user": `<@${entry.discord_id}>`, + "pos": this.getPosition(entry.discord_id), + "time_spent": formatDuration(Date.now() - (+entry.joinedAt)), + }; + for (const [key, value] of Object.entries(entryReplacements)) { + replacements[key] = value; + } + } + } + // Interpolate String + return interpolateString(string, replacements); + } catch (error) { + console.log(error); + return null; + } + } + /** + * Gets the leave Message of the Queue + */ + public getLeaveMessage(this: DocumentType): string; + /** + * Gets the leave Message of the Queue + * @param discord_id The Discord ID of the Leaver + */ + public getLeaveMessage(this: DocumentType, discord_id: string): string; + /** + * Gets the leave Message of the Queue + * @param entry The Entry that wants to leave the queue + */ + public getLeaveMessage(this: DocumentType, entry: QueueEntry): string; + /** + * Gets the leave Message of the Queue + * @param entry_resolvable The Entry Resolvable + */ + public getLeaveMessage(this: DocumentType, entry_resolvable?: string | QueueEntry | undefined): string { + const default_leave_message = "You left the Queue."; + if (this.leave_message) { + const leave_msg = this.interpolateQueueString(this.leave_message!, entry_resolvable); + return leave_msg ?? default_leave_message; + } + else { + return default_leave_message; + } + } + /** + * Gets the leave Room Message of the Queue + */ + public getLeaveRoomMessage(this: DocumentType): string; + /** + * Gets the leave Room Message of the Queue + * @param discord_id The Discord ID of the Leaver + */ + public getLeaveRoomMessage(this: DocumentType, discord_id: string): string; + /** + * Gets the leave Room Message of the Queue + * @param entry The Entry that wants to leave the queue + */ + public getLeaveRoomMessage(this: DocumentType, entry: QueueEntry): string; + /** + * Gets the leave Room Message of the Queue + * @param entry_resolvable The Entry Resolvable + */ + public getLeaveRoomMessage(this: DocumentType, entry_resolvable?: string | QueueEntry | undefined): string { + const default_leave_message = `You left the Room. Please confirm your stay or you will be removed from the queue after the Timeout of ${(this.disconnect_timeout ?? 0) / 1000}s.`; + if (this.leave_room_message) { + const leave_msg = this.interpolateQueueString(this.leave_room_message, entry_resolvable); + return leave_msg ?? default_leave_message; + } + else { + return default_leave_message; + } + } + /** + * Gets the join Message of the Queue + */ + public getJoinMessage(this: DocumentType): string; + /** + * Gets the join Message of the Queue + * @param discord_id The Discord ID of the Joiner + */ + public getJoinMessage(this: DocumentType, discord_id: string): string; + /** + * Gets the join Message of the Queue + * @param entry The Entry that wants to join the queue + */ + public getJoinMessage(this: DocumentType, entry: QueueEntry): string; + /** + * Gets the leave Message of the Queue + * @param entry_resolvable The Entry Resolvable + */ + public getJoinMessage(this: DocumentType, entry_resolvable?: string | QueueEntry | undefined): string { + const default_join_message = "You left the Queue."; + if (this.join_message) { + const join_msg = this.interpolateQueueString(this.join_message, entry_resolvable); + return join_msg ?? default_join_message; + } + else { + return default_join_message; + } + } } export const QueueModel = getModelForClass(Queue, { diff --git a/src/models/User.ts b/src/models/User.ts index 08773a2..0e76d62 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -1,5 +1,5 @@ -import { getModelForClass, prop, mongoose, Ref } from "@typegoose/typegoose"; -import { Session } from "./Session"; +import { getModelForClass, prop, mongoose, Ref, DocumentType } from "@typegoose/typegoose"; +import { Session, SessionModel } from "./Session"; import { DBRole } from "./BotRoles"; /** @@ -37,6 +37,13 @@ export class User { */ @prop({ required: true, default: [], ref: () => DBRole }) token_roles!: mongoose.Types.Array>; + + /** + * Checks if the User has Active Sessions + */ + public async hasActiveSessions(this: DocumentType): Promise { + return !!(await SessionModel.findOne({ user: (this._id as string), active: true })); + } } export const UserModel = getModelForClass(User, { diff --git a/src/types/errors/AlreadyInQueueError.ts b/src/types/errors/AlreadyInQueueError.ts new file mode 100644 index 0000000..2083b77 --- /dev/null +++ b/src/types/errors/AlreadyInQueueError.ts @@ -0,0 +1,15 @@ +export default class AlreadyInQueueError extends Error { + /** + * The name of the queue in which the user is already in. + */ + public queueName: string + + /** + * Creates a new already in queue error. + * @param queueName The name of the queue in which the user is already in. + */ + constructor(queueName: string) { + super(`You are already in the queue "${queueName}".`) + this.queueName = queueName + } +} \ No newline at end of file diff --git a/src/types/errors/CouldNotFindGuildError.ts b/src/types/errors/CouldNotFindQueueError.ts similarity index 100% rename from src/types/errors/CouldNotFindGuildError.ts rename to src/types/errors/CouldNotFindQueueError.ts diff --git a/src/types/errors/QueueLockedError.ts b/src/types/errors/QueueLockedError.ts new file mode 100644 index 0000000..24e6d39 --- /dev/null +++ b/src/types/errors/QueueLockedError.ts @@ -0,0 +1,15 @@ +export default class QueueLockedError extends Error { + /** + * The name of the queue which is locked. + */ + public queueName: string + + /** + * Creates a new queue locked error. + * @param queueName The name of the queue which is locked. + */ + constructor(queueName: string) { + super(`Queue "${queueName}" is locked and cannot be joined.`) + this.queueName = queueName + } +} \ No newline at end of file diff --git a/src/types/errors/UserHasActiveSessionError.ts b/src/types/errors/UserHasActiveSessionError.ts new file mode 100644 index 0000000..b94e67f --- /dev/null +++ b/src/types/errors/UserHasActiveSessionError.ts @@ -0,0 +1,5 @@ +export default class UserHasActiveSessionError extends Error { + constructor() { + super(`You have an active session and cannot join a queue.`) + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index fca9e1e..9fb1e14 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,16 +1,21 @@ import OptionRequirement from "./OptionRequirement"; +import { StringReplacements } from "./StringReplacements"; +import AlreadyInQueueError from "./errors/AlreadyInQueueError"; import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; -import CouldNotFindQueueError from "./errors/CouldNotFindGuildError"; +import CouldNotFindQueueError from "./errors/CouldNotFindQueueError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; import InteractionNotInGuildError from "./errors/InteractionNotInGuildError"; import MissingOptionError from "./errors/MissingOptionError"; import NotInQueueError from "./errors/NotInQueueError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; +import QueueLockedError from "./errors/QueueLockedError"; import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; +import UserHasActiveSessionError from "./errors/UserHasActiveSessionError"; export { OptionRequirement, + StringReplacements, QueueAlreadyExistsError, MissingOptionError, CouldNotFindChannelError, @@ -20,4 +25,7 @@ export { CouldNotFindTypeInFileError, NotInQueueError, InteractionNotInGuildError, + AlreadyInQueueError, + UserHasActiveSessionError, + QueueLockedError, } \ No newline at end of file diff --git a/src/utils/formatDuration.ts b/src/utils/formatDuration.ts new file mode 100644 index 0000000..3d46dbc --- /dev/null +++ b/src/utils/formatDuration.ts @@ -0,0 +1,6 @@ +export function formatDuration(duration: number): string { + const hours = Math.floor(duration / 3600); + const minutes = Math.floor((duration % 3600) / 60); + const seconds = duration % 60; + return `${hours}h ${minutes}m ${seconds}s`; +} \ No newline at end of file From 11e27ff9c45d4227056229b6b2de093454b65d3f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 8 Mar 2024 18:51:50 +0100 Subject: [PATCH 070/130] Add queue list command --- src/commands/queue/QueueListCommand.test.ts | 81 +++++++++++++++++++++ src/commands/queue/QueueListCommand.ts | 79 ++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/commands/queue/QueueListCommand.test.ts create mode 100644 src/commands/queue/QueueListCommand.ts diff --git a/src/commands/queue/QueueListCommand.test.ts b/src/commands/queue/QueueListCommand.test.ts new file mode 100644 index 0000000..de266ba --- /dev/null +++ b/src/commands/queue/QueueListCommand.test.ts @@ -0,0 +1,81 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import QueueListCommand from "./QueueListCommand"; +import { container } from "tsyringe"; +import { ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js"; +import { Queue } from "@models/Queue"; +import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; +import { mongoose } from "@typegoose/typegoose"; + +describe("QueueListCommand", () => { + const command = QueueListCommand; + const discord = container.resolve(MockDiscord); + let commandInstance: QueueListCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("list") + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Lists all queues.") + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0) + }) + + it("should reply with all the queues", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queues: FilterOutFunctionKeys[] = [ + { + name: "test", + description: "test description", + entries: new mongoose.Types.DocumentArray([]), + info_channels: [], + opening_times: new mongoose.Types.DocumentArray([]), + }, + { + name: "test2", + description: "another description 2", + entries: new mongoose.Types.DocumentArray([]), + info_channels: [], + opening_times: new mongoose.Types.DocumentArray([]), + }, + ] + dbGuild.queues = new mongoose.Types.DocumentArray(queues); + await dbGuild.save(); + const replySpy = jest.spyOn(interaction, 'reply') + await commandInstance.execute() + + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }) + + const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; + expect(messageContent.embeds).toBeDefined(); + const embeds = messageContent.embeds; + expect(embeds).toHaveLength(1); + const embed = embeds[0]; + const embedData = embed.data; + + expect(embedData).toEqual({ + title: "Queue List", + description: "Here are all the queues available in this server.", + fields: [ + { + name: "test", + value: "test description", + }, + { + name: "test2", + value: "another description 2", + }, + ], + color: Colors.Green, + }) + }) +}) diff --git a/src/commands/queue/QueueListCommand.ts b/src/commands/queue/QueueListCommand.ts new file mode 100644 index 0000000..90d0a56 --- /dev/null +++ b/src/commands/queue/QueueListCommand.ts @@ -0,0 +1,79 @@ +import { BaseCommand } from "@baseCommand"; +import { Queue } from "@models/Queue"; +import { InteractionNotInGuildError } from "@types"; +import { Colors, EmbedBuilder } from "discord.js"; + +export default class QueueListCommand extends BaseCommand { + public static name = "list"; + public static description = "Lists all queues."; + + public async execute(): Promise { + try { + const queues = await this.loadQueues(); + const embed = this.mountListEmbed(queues); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + /** + * Mounts an embed with the list of queues. + * + * @param queues - An array of Queue objects representing the queues available in the server. + * @returns The EmbedBuilder object containing the formatted list of queues. + */ + private mountListEmbed(queues: Queue[]): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Queue List") + .setDescription("Here are all the queues available in this server.") + .addFields( + ...queues.map(queue => { + return { + name: queue.name + (queue.locked ? " (locked)" : ""), + value: queue.description ?? "", + }; + }) + ) + .setColor(Colors.Green); + return embed; + } + + /** + * Mounts an error embed based on the given error. + * If the error is not an instance of InteractionNotInGuildError, it throws the error. + * Otherwise, it creates and returns an embed with the error message and a red color. + * + * @param error - The error object. + * @returns The error embed. + */ + private mountErrorEmbed(error: Error): EmbedBuilder { + if (!(error instanceof InteractionNotInGuildError)) { + throw error; + } + const embed = new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + return embed; + } + + /** + * Loads the queues from the database for the current guild. + * @returns A promise that resolves to an array of Queue objects. + * @throws {InteractionNotInGuildError} If the interaction is not in a guild. + */ + private async loadQueues(): Promise { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) + return dbGuild.queues; + } +} \ No newline at end of file From dd6fc4378eb2e3d9fda68d4e75ce5d4acdf75ddf Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 8 Mar 2024 20:39:04 +0100 Subject: [PATCH 071/130] Add queue join tests --- src/commands/queue/QueueJoinCommand.test.ts | 235 ++++++++++++++++++ src/commands/queue/QueueJoinCommand.ts | 2 - src/types/errors/AlreadyInQueueError.ts | 5 +- src/types/errors/CouldNotFindQueueError.ts | 7 +- .../errors/InteractionNotInGuildError.ts | 3 + src/types/errors/QueueLockedError.ts | 5 +- src/types/errors/UserHasActiveSessionError.ts | 5 +- 7 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 src/commands/queue/QueueJoinCommand.test.ts diff --git a/src/commands/queue/QueueJoinCommand.test.ts b/src/commands/queue/QueueJoinCommand.test.ts new file mode 100644 index 0000000..4eaac14 --- /dev/null +++ b/src/commands/queue/QueueJoinCommand.test.ts @@ -0,0 +1,235 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js"; +import { container } from "tsyringe"; +import QueueJoinCommand from "./QueueJoinCommand"; +import exp from "node:constants"; +import { SessionModel, SessionRole } from "@models/Session"; + +describe("QueueJoinCommand", () => { + const command = QueueJoinCommand; + const discord = container.resolve(MockDiscord); + let commandInstance: QueueJoinCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "queue": + return { value: "test" } + case "intent": + return { value: "" } + default: + return null; + } + }) + }) + + it("should have the correct name", () => { + expect(command.name).toBe("join"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Joins the queue."); + }) + + it("should have the correct options", () => { + expect(command.options).toHaveLength(2); + expect(command.options[0]).toStrictEqual({ + name: "queue", + description: "The queue to join.", + type: 3, + required: true, + }); + expect(command.options[1]).toStrictEqual({ + name: "intent", + description: "The intent of joining the queue.", + type: 3, + required: false, + }); + }) + + it("should join the queue and reply with a success message", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: "test", + description: "test description", + tracks: [], + join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}\n\\> Total Time Spent: ${time_spent}", + } + dbGuild.queues.push(queue) + await dbGuild.save() + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); + + const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; + expect(messageContent.embeds).toBeDefined(); + const embeds = messageContent.embeds; + expect(embeds).toHaveLength(1); + const embed = embeds[0]; + const embedData = embed.data; + + expect(embedData).toEqual({ + title: "Queue Joined", + description: expect.stringContaining(queue.join_message.replace("${name}", queue.name).replace("${pos}", "1").replace("${total}", "1").replace("${time_spent}", "0h 0m")), + color: Colors.Green, + }); + }) + + it("should fail if the queue does not exist", async () => { + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); + + const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; + expect(messageContent.embeds).toBeDefined(); + const embeds = messageContent.embeds; + expect(embeds).toHaveLength(1); + const embed = embeds[0]; + const embedData = embed.data; + + expect(embedData).toEqual({ + title: "Error", + description: `Could not find the queue "${interaction.options.get("queue")!.value}".`, + color: Colors.Red, + }); + }) + + it("should fail if the user is already in the same queue", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: "test", + description: "test description", + tracks: [], + entries: [ { discord_id: interaction.user.id, joinedAt: Date.now().toString() } ] + } + dbGuild.queues.push(queue) + await dbGuild.save() + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); + + const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; + expect(messageContent.embeds).toBeDefined(); + const embeds = messageContent.embeds; + expect(embeds).toHaveLength(1); + const embed = embeds[0]; + const embedData = embed.data; + + expect(embedData).toEqual({ + title: "Error", + description: `You are already in the queue "${queue.name}".`, + color: Colors.Red, + }); + }) + + it("should fail if the user is already in another queue", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: "test", + description: "test description", + tracks: [], + } + const otherQueue = { + name: "another test", + description: "another test description", + tracks: [], + entries: [ { discord_id: interaction.user.id, joinedAt: Date.now().toString() } ] + } + dbGuild.queues.push(queue, otherQueue) + await dbGuild.save() + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); + + const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; + expect(messageContent.embeds).toBeDefined(); + const embeds = messageContent.embeds; + expect(embeds).toHaveLength(1); + const embed = embeds[0]; + const embedData = embed.data; + + expect(embedData).toEqual({ + title: "Error", + description: `You are already in the queue "${otherQueue.name}".`, + color: Colors.Red, + }); + + }) + + it("should fail if the user has an active session", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: "test", + description: "test description", + tracks: [], + } + dbGuild.queues.push(queue) + await dbGuild.save() + + await SessionModel.create({ active: true, user: interaction.user.id, guild: interaction.guild?.id, role: SessionRole.coach, started_at: Date.now(), end_certain: false, rooms: [] }); + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); + + const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; + expect(messageContent.embeds).toBeDefined(); + const embeds = messageContent.embeds; + expect(embeds).toHaveLength(1); + const embed = embeds[0]; + const embedData = embed.data; + + expect(embedData).toEqual({ + title: "Error", + description: `You have an active session and cannot join the queue.`, + color: Colors.Red, + }); + }) + + it("should fail if the queue is locked", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: "test", + description: "test description", + tracks: [], + locked: true, + } + dbGuild.queues.push(queue) + await dbGuild.save() + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); + + const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; + expect(messageContent.embeds).toBeDefined(); + const embeds = messageContent.embeds; + expect(embeds).toHaveLength(1); + const embed = embeds[0]; + const embedData = embed.data; + + expect(embedData).toEqual({ + title: "Error", + description: `The queue "${queue.name}" is locked and cannot be joined.`, + color: Colors.Red, + }); + }) +}) \ No newline at end of file diff --git a/src/commands/queue/QueueJoinCommand.ts b/src/commands/queue/QueueJoinCommand.ts index def46cb..d2313f4 100644 --- a/src/commands/queue/QueueJoinCommand.ts +++ b/src/commands/queue/QueueJoinCommand.ts @@ -3,7 +3,6 @@ import { Guild as DatabaseGuild } from "@models/Guild"; import { DocumentType } from "@typegoose/typegoose"; import { AlreadyInQueueError, CouldNotFindQueueError, InteractionNotInGuildError, QueueLockedError, UserHasActiveSessionError } from "@types"; import { ApplicationCommandOptionType, Colors, EmbedBuilder, User } from "discord.js"; -import { join } from "path"; export default class QueueJoinCommand extends BaseCommand { public static name = "join"; @@ -37,7 +36,6 @@ export default class QueueJoinCommand extends BaseCommand { const user = this.interaction.user try { let joinMessage = await this.joinQueue(queueName, intent, user) - console.log(joinMessage) const embed = this.mountJoinQueueEmbed(joinMessage); await this.send({ embeds: [embed] }) } catch (error) { diff --git a/src/types/errors/AlreadyInQueueError.ts b/src/types/errors/AlreadyInQueueError.ts index 2083b77..8549aea 100644 --- a/src/types/errors/AlreadyInQueueError.ts +++ b/src/types/errors/AlreadyInQueueError.ts @@ -1,3 +1,6 @@ +/** + * Represents an error that occurs when a user is already in a queue. + */ export default class AlreadyInQueueError extends Error { /** * The name of the queue in which the user is already in. @@ -5,7 +8,7 @@ export default class AlreadyInQueueError extends Error { public queueName: string /** - * Creates a new already in queue error. + * Creates a new instance of the AlreadyInQueueError class. * @param queueName The name of the queue in which the user is already in. */ constructor(queueName: string) { diff --git a/src/types/errors/CouldNotFindQueueError.ts b/src/types/errors/CouldNotFindQueueError.ts index 864ca52..152e2c6 100644 --- a/src/types/errors/CouldNotFindQueueError.ts +++ b/src/types/errors/CouldNotFindQueueError.ts @@ -1,3 +1,6 @@ +/** + * Represents an error that occurs when a queue cannot be found. + */ export default class CouldNotFindQueueError extends Error { /** * The name of the queue which could not be found. @@ -5,11 +8,11 @@ export default class CouldNotFindQueueError extends Error { public queueName: string /** - * Creates a new could not find queue error. + * Creates a new CouldNotFindQueueError instance. * @param queueName The name of the queue which could not be found. */ constructor(queueName: string) { - super(`Could not find queue "${queueName}".`) + super(`Could not find the queue "${queueName}".`) this.queueName = queueName } } \ No newline at end of file diff --git a/src/types/errors/InteractionNotInGuildError.ts b/src/types/errors/InteractionNotInGuildError.ts index 68ee914..69a11e8 100644 --- a/src/types/errors/InteractionNotInGuildError.ts +++ b/src/types/errors/InteractionNotInGuildError.ts @@ -1,5 +1,8 @@ import { Interaction } from "discord.js"; +/** + * Error thrown when an interaction is not in a guild. + */ export default class InteractionNotInGuildError extends Error { public interaction: Interaction; constructor(interaction: Interaction) { diff --git a/src/types/errors/QueueLockedError.ts b/src/types/errors/QueueLockedError.ts index 24e6d39..cce90a6 100644 --- a/src/types/errors/QueueLockedError.ts +++ b/src/types/errors/QueueLockedError.ts @@ -1,3 +1,6 @@ +/** + * Represents an error that occurs when attempting to join a locked queue. + */ export default class QueueLockedError extends Error { /** * The name of the queue which is locked. @@ -9,7 +12,7 @@ export default class QueueLockedError extends Error { * @param queueName The name of the queue which is locked. */ constructor(queueName: string) { - super(`Queue "${queueName}" is locked and cannot be joined.`) + super(`The queue "${queueName}" is locked and cannot be joined.`) this.queueName = queueName } } \ No newline at end of file diff --git a/src/types/errors/UserHasActiveSessionError.ts b/src/types/errors/UserHasActiveSessionError.ts index b94e67f..195e594 100644 --- a/src/types/errors/UserHasActiveSessionError.ts +++ b/src/types/errors/UserHasActiveSessionError.ts @@ -1,5 +1,8 @@ +/** + * Error thrown when a user has an active session and cannot join a queue. + */ export default class UserHasActiveSessionError extends Error { constructor() { - super(`You have an active session and cannot join a queue.`) + super(`You have an active session and cannot join the queue.`) } } \ No newline at end of file From 125223173020afe51cd48d094bbe237e1881727f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 8 Mar 2024 20:42:36 +0100 Subject: [PATCH 072/130] Expect correct queue not found message --- src/commands/config/queue/SetWaitingRoomCommand.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/config/queue/SetWaitingRoomCommand.test.ts b/src/commands/config/queue/SetWaitingRoomCommand.test.ts index e5fa6db..e8d34f6 100644 --- a/src/commands/config/queue/SetWaitingRoomCommand.test.ts +++ b/src/commands/config/queue/SetWaitingRoomCommand.test.ts @@ -229,7 +229,7 @@ describe("SetWaitingRoomCommand", () => { const embedData = embed.data expect(embedData).toEqual({ title: "Could Not Set Waiting Room", - description: expect.stringContaining(`:x: Could not find queue "test queue".`), + description: expect.stringContaining(`:x: Could not find the queue "test queue".`), color: Colors.Red }) }) From ad747fe1a631f574af9b26363a1ac05040d3e402 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:48:46 +0100 Subject: [PATCH 073/130] Add tests for BaseCommand getOptionValue --- src/baseCommand/BaseCommand.test.ts | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/baseCommand/BaseCommand.test.ts diff --git a/src/baseCommand/BaseCommand.test.ts b/src/baseCommand/BaseCommand.test.ts new file mode 100644 index 0000000..ec66851 --- /dev/null +++ b/src/baseCommand/BaseCommand.test.ts @@ -0,0 +1,76 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ApplicationCommandOptionType, ChatInputCommandInteraction, CommandInteraction } from "discord.js"; +import BaseCommand from "./BaseCommand"; +import { MissingOptionError, OptionRequirement } from "@types"; + +describe("BaseCommand", () => { + describe.each([ApplicationCommandOptionType.String, ApplicationCommandOptionType.Integer, ApplicationCommandOptionType.Boolean])("getOptionValue with type %p", (optionType) => { + const discord = new MockDiscord(); + let commandInstance: BaseCommand; + let interaction: ChatInputCommandInteraction; + let option: OptionRequirement; + let value: string | number | boolean; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new class extends BaseCommand { + public static name = "test"; + public static description = "test"; + + public execute(): Promise { + throw new Error("Method not implemented."); + } + }(interaction, discord.getApplication()); + + option = { name: "testOption", description: "do not expect this", type: optionType, required: true }; + + if (optionType === ApplicationCommandOptionType.String) { + value = "testValue"; + } else if (optionType === ApplicationCommandOptionType.Integer) { + value = 5; + } else if (optionType === ApplicationCommandOptionType.Boolean) { + value = true; + } + }); + + it.each([true, false])("should return the option value if it exists (option is required: %p)", (required) => { + option.required = required; + interaction.options.get = jest.fn().mockImplementation((optionName: string) => { + return { value: value }; + }) + + let res = (commandInstance as any).getOptionValue(option) as string; + expect(res).toBe(value); + }) + + it.each([true, false])("should return the default value if option does not exist (option is required: %p)", (required) => { + option.required = required; + option.default = value; + interaction.options.get = jest.fn().mockImplementation((optionName: string) => { + return null; + }) + + let res = (commandInstance as any).getOptionValue(option) as string; + expect(res).toBe(value); + }) + + it("should throw MissingOptionError if required option does not exist", () => { + option.required = true; + interaction.options.get = jest.fn().mockImplementation((optionName: string) => { + return null; + }) + + expect(() => (commandInstance as any).getOptionValue(option)).toThrow(MissingOptionError); + }) + + it("should return an empty string if option does not exist and no default value is provided", () => { + option.required = false; + interaction.options.get = jest.fn().mockImplementation((optionName: string) => { + return null; + }) + + let res = (commandInstance as any).getOptionValue(option) as string; + expect(res).toBe(""); + }) + }); +}); \ No newline at end of file From 74d14e536f33c65a023fd251679e34624b90fa10 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:57:54 +0100 Subject: [PATCH 074/130] Make queue parameter case insensitive --- src/commands/queue/QueueJoinCommand.test.ts | 6 ++++-- src/commands/queue/QueueJoinCommand.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/commands/queue/QueueJoinCommand.test.ts b/src/commands/queue/QueueJoinCommand.test.ts index 4eaac14..dab41e8 100644 --- a/src/commands/queue/QueueJoinCommand.test.ts +++ b/src/commands/queue/QueueJoinCommand.test.ts @@ -50,10 +50,12 @@ describe("QueueJoinCommand", () => { }); }) - it("should join the queue and reply with a success message", async () => { + it.each([true, false])("should join the queue and reply with a success message (parameter is lowercase: %p)", async (isLowercase) => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const actualQueueName = interaction.options.get("queue")!.value as string + const queueName = isLowercase ? actualQueueName.toLowerCase() : actualQueueName.toUpperCase(); const queue = { - name: "test", + name: queueName, description: "test description", tracks: [], join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}\n\\> Total Time Spent: ${time_spent}", diff --git a/src/commands/queue/QueueJoinCommand.ts b/src/commands/queue/QueueJoinCommand.ts index d2313f4..57b8bd0 100644 --- a/src/commands/queue/QueueJoinCommand.ts +++ b/src/commands/queue/QueueJoinCommand.ts @@ -68,7 +68,7 @@ export default class QueueJoinCommand extends BaseCommand { } private async joinQueue(queueName: string, intent: string, user: User): Promise { - const queueData = this.dbGuild.queues.find(x => x.name === queueName); + const queueData = this.dbGuild.queues.find(x => x.name.toLowerCase() === queueName.toLowerCase()); if (!queueData) { throw new CouldNotFindQueueError(queueName); } From 5626bda89ee36fa2977a3e7daafeaa67daee6bd7 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 12 Mar 2024 17:04:29 +0100 Subject: [PATCH 075/130] Add documentation --- src/commands/queue/QueueInfoCommand.ts | 52 +++++++++++++++++--------- src/commands/queue/QueueJoinCommand.ts | 29 +++++++++++++- 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/commands/queue/QueueInfoCommand.ts b/src/commands/queue/QueueInfoCommand.ts index 2cfedb3..4705ccc 100644 --- a/src/commands/queue/QueueInfoCommand.ts +++ b/src/commands/queue/QueueInfoCommand.ts @@ -24,23 +24,12 @@ export default class QueueInfoCommand extends BaseCommand { } } - private async loadQueueAndPosition(): Promise<{ queue: DocumentType, position: number }> { - if (!this.interaction.guild) { - throw new InteractionNotInGuildError(this.interaction); - } - const user = this.interaction.user; - const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) - const queueData = dbGuild.queues.find(x => x.contains(user.id)); - if (!queueData) { - this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to get queue info but is not in a queue`); - throw new NotInQueueError(); - } - - const queuePosition = queueData.getPosition(user.id); - return { queue: queueData, position: queuePosition }; - } - - + /** + * Mounts an embed with queue information. + * @param queue - The queue document. + * @param position - The position of the user in the queue. + * @returns The embed with queue information. + */ private mountInfoEmbed(queue: DocumentType, position: number): EmbedBuilder { const embed = new EmbedBuilder() .setTitle("Queue Information") @@ -53,6 +42,12 @@ export default class QueueInfoCommand extends BaseCommand { return embed } + /** + * Creates an error embed based on the given error. + * Throws an error if the given error is not an instance of NotInQueueError. + * @param error - The error object. + * @returns The error embed. + */ private mountErrorEmbed(error: Error): EmbedBuilder { if (!(error instanceof NotInQueueError)) { throw error; @@ -63,4 +58,27 @@ export default class QueueInfoCommand extends BaseCommand { .setColor(Colors.Red) return embed } + + /** + * Loads the queue and position for the current user. + * @returns A promise that resolves to an object containing the queue and position. + * @throws {InteractionNotInGuildError} If the interaction is not in a guild. + * @throws {NotInQueueError} If the user is not in a queue. + */ + private async loadQueueAndPosition(): Promise<{ queue: DocumentType, position: number }> { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + const user = this.interaction.user; + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) + const queueData = dbGuild.queues.find(x => x.contains(user.id)); + if (!queueData) { + this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to get queue info but is not in a queue`); + throw new NotInQueueError(); + } + + const queuePosition = queueData.getPosition(user.id); + return { queue: queueData, position: queuePosition }; + } + } \ No newline at end of file diff --git a/src/commands/queue/QueueJoinCommand.ts b/src/commands/queue/QueueJoinCommand.ts index 57b8bd0..23e0501 100644 --- a/src/commands/queue/QueueJoinCommand.ts +++ b/src/commands/queue/QueueJoinCommand.ts @@ -14,7 +14,7 @@ export default class QueueJoinCommand extends BaseCommand { type: ApplicationCommandOptionType.String, required: true, }, - { + { name: "intent", description: "The intent of joining the queue.", type: ApplicationCommandOptionType.String, @@ -25,7 +25,7 @@ export default class QueueJoinCommand extends BaseCommand { * The guild saved in the database. */ private dbGuild!: DocumentType; - + public async execute(): Promise { if (!this.interaction.guild) { throw new InteractionNotInGuildError(this.interaction); @@ -48,6 +48,12 @@ export default class QueueJoinCommand extends BaseCommand { } } + /** + * Mounts the join queue embed. + * + * @param joinMessage - The message to be displayed in the embed. + * @returns The constructed EmbedBuilder object. + */ private mountJoinQueueEmbed(joinMessage: string): EmbedBuilder { const embed = new EmbedBuilder() .setTitle("Queue Joined") @@ -56,6 +62,13 @@ export default class QueueJoinCommand extends BaseCommand { return embed } + + /** + * Creates an error embed based on the given error. + * @param error - The error object. + * @returns The error embed. + * @throws The error object if it is not an instance of any known error types. + */ private mountErrorEmbed(error: Error): EmbedBuilder { if (error instanceof AlreadyInQueueError || error instanceof CouldNotFindQueueError || error instanceof QueueLockedError || error instanceof UserHasActiveSessionError) { const embed = new EmbedBuilder() @@ -67,6 +80,18 @@ export default class QueueJoinCommand extends BaseCommand { throw error; } + /** + * Joins the specified queue with the given parameters. + * + * @param queueName - The name of the queue to join. + * @param intent - The intent of the user joining the queue. + * @param user - The user joining the queue. + * @returns A promise that resolves to a string representing the join message. + * @throws {CouldNotFindQueueError} if the specified queue does not exist. + * @throws {AlreadyInQueueError} if the user is already in a queue. + * @throws {UserHasActiveSessionError} if the user has an active tutor session. + * @throws {QueueLockedError} if the queue is locked. + */ private async joinQueue(queueName: string, intent: string, user: User): Promise { const queueData = this.dbGuild.queues.find(x => x.name.toLowerCase() === queueName.toLowerCase()); if (!queueData) { From e074a4b2dcc47d83b39faf6eea31df3a4c7677e5 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 12 Mar 2024 17:45:25 +0100 Subject: [PATCH 076/130] Add queue leave command --- src/commands/queue/QueueLeaveCommand.test.ts | 123 +++++++++++++++++++ src/commands/queue/QueueLeaveCommand.ts | 103 ++++++++++++++++ src/types/errors/NotInQueueError.ts | 17 ++- 3 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 src/commands/queue/QueueLeaveCommand.test.ts create mode 100644 src/commands/queue/QueueLeaveCommand.ts diff --git a/src/commands/queue/QueueLeaveCommand.test.ts b/src/commands/queue/QueueLeaveCommand.test.ts new file mode 100644 index 0000000..8fc19cc --- /dev/null +++ b/src/commands/queue/QueueLeaveCommand.test.ts @@ -0,0 +1,123 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, Colors } from "discord.js"; +import { container } from "tsyringe"; +import QueueLeaveCommand from "./QueueLeaveCommand"; + +describe("QueueLeaveCommand", () => { + const command = QueueLeaveCommand; + const discord = container.resolve(MockDiscord); + let commandInstance: QueueLeaveCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "queue": + return { value: "test" } + default: + return null; + } + }) + }); + + it("should have the correct name", () => { + expect(command.name).toBe("leave"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Leaves the queue."); + }) + + it("should have the correct options", () => { + expect(command.options).toHaveLength(1); + expect(command.options[0]).toStrictEqual({ + name: "queue", + description: "The queue to leave.", + type: 3, + required: true, + }); + }) + + it.each([true, false])("should leave the queue and reply with a success message (parameter is lowercase: %p)", async (isLowercase) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const actualQueueName = interaction.options.get("queue")!.value as string + const queueName = isLowercase ? actualQueueName.toLowerCase() : actualQueueName.toUpperCase(); + const queue = { + name: queueName, + description: "test description", + tracks: [], + leave_message: "You left the ${name} queue.", + entries: [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + fetchReply: true, + embeds: [{ + data: { + description: queue.leave_message.replace("${name}", queue.name), + color: Colors.Green, + title: "Queue Left" + } + }] + } + ); + }) + + it("should fail if the queue does not exist", async () => { + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + fetchReply: true, + embeds: [{ + data: { + description: `Could not find the queue "test".`, + color: Colors.Red, + title: "Error" + } + }] + } + ); + }) + + it("should fail if the user is not in the queue", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queueName = interaction.options.get("queue")!.value as string + const queue = { + name: queueName, + description: "test description", + tracks: [], + leave_message: "You left the ${name} queue.", + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + fetchReply: true, + embeds: [{ + data: { + description: `You are currently not in the queue "test".`, + color: Colors.Red, + title: "Error" + } + }] + } + ); + }) +}) diff --git a/src/commands/queue/QueueLeaveCommand.ts b/src/commands/queue/QueueLeaveCommand.ts new file mode 100644 index 0000000..75f6104 --- /dev/null +++ b/src/commands/queue/QueueLeaveCommand.ts @@ -0,0 +1,103 @@ +import { BaseCommand } from "@baseCommand"; +import { Guild as DatabaseGuild } from "@models/Guild"; +import { DocumentType } from "@typegoose/typegoose"; +import { CouldNotFindQueueError, InteractionNotInGuildError, NotInQueueError } from "@types"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder, User } from "discord.js"; + +export default class QueueLeaveCommand extends BaseCommand { + public static name = "leave"; + public static description = "Leaves the queue."; + public static options = [ + { + name: "queue", + description: "The queue to leave.", + type: ApplicationCommandOptionType.String, + required: true, + }, + ]; + + /** + * The guild saved in the database. + */ + private dbGuild!: DocumentType; + + public async execute(): Promise { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) + const queueName = this.getOptionValue(QueueLeaveCommand.options[0]) + const user = this.interaction.user + try { + let leaveMessage = await this.leaveQueue(queueName, user) + const embed = this.mountLeaveQueueEmbed(leaveMessage); + await this.send({ embeds: [embed] }) + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }) + } else { + throw error; + } + } + } + + /** + * Mounts the leave queue embed. + * + * @param leaveMessage - The message to be displayed in the embed. + * @returns The constructed EmbedBuilder object. + */ + private mountLeaveQueueEmbed(leaveMessage: string): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Queue Left") + .setDescription(leaveMessage) + .setColor(Colors.Green) + return embed + } + + /** + * Creates an error embed based on the given error. + * Throws an error if the given error is not an instance of NotInQueueError. + * @param error - The error object. + * @returns The error embed. + */ + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof NotInQueueError || error instanceof CouldNotFindQueueError) { + const embed = new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red) + return embed + } + throw error; + } + + /** + * Leaves the queue. + * + * @param queueName - The name of the queue to leave. + * @param user - The user that is leaving the queue. + * @returns The message to be displayed in the embed. + * @throws {CouldNotFindQueueError} If the queue with the given name does not exist. + * @throws {NotInQueueError} If the user is not in the queue. + */ + private async leaveQueue(queueName: string, user: User): Promise { + const queueData = await this.dbGuild.queues.find(queue => queue.name.toLowerCase() === queueName.toLowerCase()) + if (!queueData) { + throw new CouldNotFindQueueError(queueName); + } + + // check if the user is in the queue + const userIndex = queueData.entries.findIndex(x => x.discord_id === user.id) + if (userIndex === -1) { + throw new NotInQueueError(queueData.name); + } + + // remove the user from the queue + queueData.entries.splice(userIndex, 1) + await this.dbGuild.save() + + return queueData.getLeaveMessage(user.id); + } +} \ No newline at end of file diff --git a/src/types/errors/NotInQueueError.ts b/src/types/errors/NotInQueueError.ts index 95884ad..f8bb88a 100644 --- a/src/types/errors/NotInQueueError.ts +++ b/src/types/errors/NotInQueueError.ts @@ -1,5 +1,18 @@ +/** + * Represents an error that occurs when a user is not in a queue. + */ export default class NotInQueueError extends Error { - constructor() { - super(`You are currently not in a queue.`); + /** + * The name of the queue in which the user is not in. + */ + public queueName: string | undefined; + + /** + * Creates a new instance of the NotInQueueError class. + * @param queueName The name of the queue in which the user is not in. If the user is not in any queue, this parameter should be undefined. + */ + constructor(queueName?: string) { + super(`You are currently not in ${queueName ? `the queue "${queueName}"` : `a queue`}.`); + this.queueName = queueName; } } \ No newline at end of file From 1d2e1bf8c81066a4dfd41a16d5d7d0be382a7d4b Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 12 Mar 2024 17:45:49 +0100 Subject: [PATCH 077/130] Remove unused import --- src/commands/queue/QueueJoinCommand.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/queue/QueueJoinCommand.test.ts b/src/commands/queue/QueueJoinCommand.test.ts index dab41e8..78fda93 100644 --- a/src/commands/queue/QueueJoinCommand.test.ts +++ b/src/commands/queue/QueueJoinCommand.test.ts @@ -2,7 +2,6 @@ import { MockDiscord } from "@tests/mockDiscord"; import { ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js"; import { container } from "tsyringe"; import QueueJoinCommand from "./QueueJoinCommand"; -import exp from "node:constants"; import { SessionModel, SessionRole } from "@models/Session"; describe("QueueJoinCommand", () => { From 4bb373ce47cd53e689d0052e06e5bcd448e6f176 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 12 Mar 2024 17:58:59 +0100 Subject: [PATCH 078/130] Do not require queue parameter to leave queue --- src/commands/queue/QueueLeaveCommand.test.ts | 50 +++----------------- src/commands/queue/QueueLeaveCommand.ts | 24 ++-------- 2 files changed, 12 insertions(+), 62 deletions(-) diff --git a/src/commands/queue/QueueLeaveCommand.test.ts b/src/commands/queue/QueueLeaveCommand.test.ts index 8fc19cc..5ba658b 100644 --- a/src/commands/queue/QueueLeaveCommand.test.ts +++ b/src/commands/queue/QueueLeaveCommand.test.ts @@ -12,14 +12,6 @@ describe("QueueLeaveCommand", () => { beforeEach(() => { interaction = discord.mockInteraction(); commandInstance = new command(interaction, discord.getApplication()); - interaction.options.get = jest.fn().mockImplementation((option: string) => { - switch (option) { - case "queue": - return { value: "test" } - default: - return null; - } - }) }); it("should have the correct name", () => { @@ -30,22 +22,14 @@ describe("QueueLeaveCommand", () => { expect(command.description).toBe("Leaves the queue."); }) - it("should have the correct options", () => { - expect(command.options).toHaveLength(1); - expect(command.options[0]).toStrictEqual({ - name: "queue", - description: "The queue to leave.", - type: 3, - required: true, - }); + it("should have no options", () => { + expect(command.options).toHaveLength(0); }) - it.each([true, false])("should leave the queue and reply with a success message (parameter is lowercase: %p)", async (isLowercase) => { + it("should leave the queue and reply with a success message", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const actualQueueName = interaction.options.get("queue")!.value as string - const queueName = isLowercase ? actualQueueName.toLowerCase() : actualQueueName.toUpperCase(); const queue = { - name: queueName, + name: "test", description: "test description", tracks: [], leave_message: "You left the ${name} queue.", @@ -72,30 +56,10 @@ describe("QueueLeaveCommand", () => { ); }) - it("should fail if the queue does not exist", async () => { - const replySpy = jest.spyOn(interaction, 'reply'); - await commandInstance.execute(); - - expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith( - { - fetchReply: true, - embeds: [{ - data: { - description: `Could not find the queue "test".`, - color: Colors.Red, - title: "Error" - } - }] - } - ); - }) - - it("should fail if the user is not in the queue", async () => { + it("should fail if the user is not in a queue", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queueName = interaction.options.get("queue")!.value as string const queue = { - name: queueName, + name: "test", description: "test description", tracks: [], leave_message: "You left the ${name} queue.", @@ -112,7 +76,7 @@ describe("QueueLeaveCommand", () => { fetchReply: true, embeds: [{ data: { - description: `You are currently not in the queue "test".`, + description: `You are currently not in a queue.`, color: Colors.Red, title: "Error" } diff --git a/src/commands/queue/QueueLeaveCommand.ts b/src/commands/queue/QueueLeaveCommand.ts index 75f6104..df19a0b 100644 --- a/src/commands/queue/QueueLeaveCommand.ts +++ b/src/commands/queue/QueueLeaveCommand.ts @@ -7,14 +7,6 @@ import { ApplicationCommandOptionType, Colors, EmbedBuilder, User } from "discor export default class QueueLeaveCommand extends BaseCommand { public static name = "leave"; public static description = "Leaves the queue."; - public static options = [ - { - name: "queue", - description: "The queue to leave.", - type: ApplicationCommandOptionType.String, - required: true, - }, - ]; /** * The guild saved in the database. @@ -26,10 +18,9 @@ export default class QueueLeaveCommand extends BaseCommand { throw new InteractionNotInGuildError(this.interaction); } this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) - const queueName = this.getOptionValue(QueueLeaveCommand.options[0]) const user = this.interaction.user try { - let leaveMessage = await this.leaveQueue(queueName, user) + let leaveMessage = await this.leaveQueue(user) const embed = this.mountLeaveQueueEmbed(leaveMessage); await this.send({ embeds: [embed] }) } catch (error) { @@ -82,19 +73,14 @@ export default class QueueLeaveCommand extends BaseCommand { * @throws {CouldNotFindQueueError} If the queue with the given name does not exist. * @throws {NotInQueueError} If the user is not in the queue. */ - private async leaveQueue(queueName: string, user: User): Promise { - const queueData = await this.dbGuild.queues.find(queue => queue.name.toLowerCase() === queueName.toLowerCase()) + private async leaveQueue(user: User): Promise { + const queueData = await this.dbGuild.queues.find(queue => queue.contains(user.id)) if (!queueData) { - throw new CouldNotFindQueueError(queueName); - } - - // check if the user is in the queue - const userIndex = queueData.entries.findIndex(x => x.discord_id === user.id) - if (userIndex === -1) { - throw new NotInQueueError(queueData.name); + throw new NotInQueueError(); } // remove the user from the queue + const userIndex = queueData.entries.findIndex(entry => entry.discord_id === user.id) queueData.entries.splice(userIndex, 1) await this.dbGuild.save() From 5d1480a0359d81b824eebdb4298d42a6a547ec21 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 12 Mar 2024 18:17:42 +0100 Subject: [PATCH 079/130] Fix problems with queue leave message --- src/commands/queue/QueueLeaveCommand.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/queue/QueueLeaveCommand.ts b/src/commands/queue/QueueLeaveCommand.ts index df19a0b..8725cb8 100644 --- a/src/commands/queue/QueueLeaveCommand.ts +++ b/src/commands/queue/QueueLeaveCommand.ts @@ -79,11 +79,13 @@ export default class QueueLeaveCommand extends BaseCommand { throw new NotInQueueError(); } + const leaveMessage = queueData.getLeaveMessage(user.id); + // remove the user from the queue const userIndex = queueData.entries.findIndex(entry => entry.discord_id === user.id) queueData.entries.splice(userIndex, 1) await this.dbGuild.save() - return queueData.getLeaveMessage(user.id); + return leaveMessage; } } \ No newline at end of file From 9182356e446507f8fb927e7205dce9fa5e878403 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 12 Mar 2024 18:17:52 +0100 Subject: [PATCH 080/130] Format duration with milliseconds --- src/utils/formatDuration.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/utils/formatDuration.ts b/src/utils/formatDuration.ts index 3d46dbc..665a7b2 100644 --- a/src/utils/formatDuration.ts +++ b/src/utils/formatDuration.ts @@ -1,4 +1,12 @@ +/** + * Formats a duration in milliseconds into a string representation. + * The format is "Xh Ym Zs", where X represents hours, Y represents minutes, and Z represents seconds. + * + * @param duration - The duration in milliseconds to format. + * @returns A string representation of the formatted duration. + */ export function formatDuration(duration: number): string { + duration = duration / 1000; const hours = Math.floor(duration / 3600); const minutes = Math.floor((duration % 3600) / 60); const seconds = duration % 60; From bfab10467b084b15fdcbc15792094e129813d303 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 13 Mar 2024 17:31:34 +0100 Subject: [PATCH 081/130] Add queue manager --- src/Application.ts | 9 +- .../config/queue/CreateQueueCommand.ts | 43 +---- src/commands/queue/QueueInfoCommand.ts | 2 +- src/commands/queue/QueueJoinCommand.ts | 32 +--- src/commands/queue/QueueLeaveCommand.ts | 36 +---- src/managers/QueueManager.ts | 148 ++++++++++++++++++ src/models/Queue.ts | 13 -- src/models/QueueEntry.ts | 8 +- 8 files changed, 175 insertions(+), 116 deletions(-) create mode 100644 src/managers/QueueManager.ts diff --git a/src/Application.ts b/src/Application.ts index 296dea8..59c6f5b 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -12,6 +12,7 @@ import CommandsLoader from "@utils/CommandsLoader" import { BaseEvent } from "@baseEvent" import { BaseCommandOrSubcommandsHandler } from "@baseCommand" import EventsLoader from "@utils/EventsLoader" +import QueueManager from "./managers/QueueManager" /** * The main `Application` class. @@ -30,6 +31,11 @@ export class Application { */ public configManager: ConfigManager + /** + * The queue manager responsible for managing the queues in the database. + */ + public queueManager: QueueManager + /** * The user manager responsible for managing the users in the database. */ @@ -77,12 +83,13 @@ export class Application { * @param client The Discord client. * @param token The bot token. */ - constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager, @inject(delay(() => UserManager)) userManager: UserManager) { + constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager, @inject(delay(() => QueueManager)) queueManager: QueueManager, @inject(delay(() => UserManager)) userManager: UserManager) { this.client = new Client(options) this.token = token this.logger = createConsola({ level: Environment.logLevel }) this.commandsManager = commandsManager this.configManager = configManager + this.queueManager = queueManager this.userManager = userManager } diff --git a/src/commands/config/queue/CreateQueueCommand.ts b/src/commands/config/queue/CreateQueueCommand.ts index 6e856c2..ffe529a 100644 --- a/src/commands/config/queue/CreateQueueCommand.ts +++ b/src/commands/config/queue/CreateQueueCommand.ts @@ -38,7 +38,7 @@ export default class CreateQueueCommand extends BaseCommand { const queueName = this.getOptionValue(CreateQueueCommand.options[0]); const queueDescription = this.getOptionValue(CreateQueueCommand.options[1]); try { - await this.createQueue(queueName, queueDescription); + await this.app.queueManager.createQueue(this.dbGuild, queueName, queueDescription); } catch (error) { if (error instanceof QueueAlreadyExistsError) { const embed = this.mountCreateQueueFailedEmbed(error.queueName); @@ -75,45 +75,4 @@ export default class CreateQueueCommand extends BaseCommand { .setColor(Colors.Red) return embed } - - /** - * Creates a queue on the database. - * @param queueName The queue name. - * @param queueDescription The queue description. - */ - private async createQueue(queueName: string, queueDescription: string): Promise { - if (this.checkQueueName(queueName)) { - this.app.logger.info(`Queue "${queueName}" already exists on guild "${this.interaction.guild?.name}" (id: ${this.interaction.guild?.id}). Aborting.`) - throw new QueueAlreadyExistsError(queueName); - } - const queue: FilterOutFunctionKeys = { - name: queueName, - description: queueDescription, - disconnect_timeout: 60000, - match_timeout: 120000, - limit: 150, - join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}\n\\> Total Time Spent: ${time_spent}", - match_found_message: "You have found a Match with ${match}. Please Join ${match_channel} if you are not moved automatically. If you don't join in ${timeout} seconds, your position in the queue is dropped.", - timeout_message: "Your queue Timed out after ${timeout} seconds.", - leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", - entries: new mongoose.Types.DocumentArray([]), - opening_times: new mongoose.Types.DocumentArray([]), - info_channels: [], - } - this.app.logger.debug(`Creating queue "${queueName}" on guild "${this.interaction.guild?.name}" (id: ${this.interaction.guild?.id})`) - this.dbGuild.queues.push(queue); - await this.dbGuild.save(); - this.app.logger.info(`Queue "${queueName}" created on guild "${this.interaction.guild?.name}" (id: ${this.interaction.guild?.id})`) - } - - /** - * Returns whether the queue name already exists on this guild. - * - * The check is case insensitive. - * @param queueName The queue name to check. - * @returns Whether the queue name already exists on this guild. - */ - private checkQueueName(queueName: string): boolean { - return this.dbGuild.queues.some((queue) => queue.name.toLowerCase === queueName.toLowerCase); - } } \ No newline at end of file diff --git a/src/commands/queue/QueueInfoCommand.ts b/src/commands/queue/QueueInfoCommand.ts index 4705ccc..a28ea30 100644 --- a/src/commands/queue/QueueInfoCommand.ts +++ b/src/commands/queue/QueueInfoCommand.ts @@ -71,7 +71,7 @@ export default class QueueInfoCommand extends BaseCommand { } const user = this.interaction.user; const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) - const queueData = dbGuild.queues.find(x => x.contains(user.id)); + const queueData = this.app.queueManager.getQueueOfUser(dbGuild, user); if (!queueData) { this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to get queue info but is not in a queue`); throw new NotInQueueError(); diff --git a/src/commands/queue/QueueJoinCommand.ts b/src/commands/queue/QueueJoinCommand.ts index 23e0501..af2e1b6 100644 --- a/src/commands/queue/QueueJoinCommand.ts +++ b/src/commands/queue/QueueJoinCommand.ts @@ -21,16 +21,11 @@ export default class QueueJoinCommand extends BaseCommand { required: false, } ]; - /** - * The guild saved in the database. - */ - private dbGuild!: DocumentType; public async execute(): Promise { if (!this.interaction.guild) { throw new InteractionNotInGuildError(this.interaction); } - this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) const queueName = this.getOptionValue(QueueJoinCommand.options[0]) const intent = this.getOptionValue(QueueJoinCommand.options[1]) const user = this.interaction.user @@ -93,36 +88,23 @@ export default class QueueJoinCommand extends BaseCommand { * @throws {QueueLockedError} if the queue is locked. */ private async joinQueue(queueName: string, intent: string, user: User): Promise { - const queueData = this.dbGuild.queues.find(x => x.name.toLowerCase() === queueName.toLowerCase()); - if (!queueData) { - throw new CouldNotFindQueueError(queueName); - } + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!) + const queueData = this.app.queueManager.getQueue(dbGuild, queueName); - // check if already in queue - const queueWithUser = this.dbGuild.queues.find(x => x.contains(user.id)); + // check if already in any queue + const queueWithUser = this.app.queueManager.getQueueOfUser(dbGuild, user); if (queueWithUser) { + this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to join queue ${queueData.name} but is already in queue ${queueWithUser.name}`); throw new AlreadyInQueueError(queueWithUser.name); } // check if user has active tutor session const userData = await this.app.userManager.getUser(user); if (await userData.hasActiveSessions()) { + this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to join queue ${queueData.name} but has an active tutor session`); throw new UserHasActiveSessionError(); } - // check if queue is locked - if (queueData.locked) { - throw new QueueLockedError(queueData.name); - } - - // join the queue - await queueData.join({ - discord_id: user.id, - joinedAt: Date.now().toString(), - importance: 1, - intent: intent, - }) - - return queueData.getJoinMessage(user.id); + return this.app.queueManager.joinQueue(queueData, user, intent); } } \ No newline at end of file diff --git a/src/commands/queue/QueueLeaveCommand.ts b/src/commands/queue/QueueLeaveCommand.ts index 8725cb8..5b4e065 100644 --- a/src/commands/queue/QueueLeaveCommand.ts +++ b/src/commands/queue/QueueLeaveCommand.ts @@ -8,19 +8,14 @@ export default class QueueLeaveCommand extends BaseCommand { public static name = "leave"; public static description = "Leaves the queue."; - /** - * The guild saved in the database. - */ - private dbGuild!: DocumentType; - public async execute(): Promise { if (!this.interaction.guild) { throw new InteractionNotInGuildError(this.interaction); } - this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) const user = this.interaction.user try { - let leaveMessage = await this.leaveQueue(user) + let leaveMessage = await this.app.queueManager.leaveQueue(dbGuild, user) const embed = this.mountLeaveQueueEmbed(leaveMessage); await this.send({ embeds: [embed] }) } catch (error) { @@ -54,7 +49,7 @@ export default class QueueLeaveCommand extends BaseCommand { * @returns The error embed. */ private mountErrorEmbed(error: Error): EmbedBuilder { - if (error instanceof NotInQueueError || error instanceof CouldNotFindQueueError) { + if (error instanceof NotInQueueError) { const embed = new EmbedBuilder() .setTitle("Error") .setDescription(error.message) @@ -63,29 +58,4 @@ export default class QueueLeaveCommand extends BaseCommand { } throw error; } - - /** - * Leaves the queue. - * - * @param queueName - The name of the queue to leave. - * @param user - The user that is leaving the queue. - * @returns The message to be displayed in the embed. - * @throws {CouldNotFindQueueError} If the queue with the given name does not exist. - * @throws {NotInQueueError} If the user is not in the queue. - */ - private async leaveQueue(user: User): Promise { - const queueData = await this.dbGuild.queues.find(queue => queue.contains(user.id)) - if (!queueData) { - throw new NotInQueueError(); - } - - const leaveMessage = queueData.getLeaveMessage(user.id); - - // remove the user from the queue - const userIndex = queueData.entries.findIndex(entry => entry.discord_id === user.id) - queueData.entries.splice(userIndex, 1) - await this.dbGuild.save() - - return leaveMessage; - } } \ No newline at end of file diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts new file mode 100644 index 0000000..2601d55 --- /dev/null +++ b/src/managers/QueueManager.ts @@ -0,0 +1,148 @@ +import { delay, inject, injectable, singleton } from "tsyringe"; +import { Application } from "@application"; +import { Queue } from "@models/Queue"; +import { DocumentType, mongoose } from "@typegoose/typegoose"; +import { AlreadyInQueueError, CouldNotFindQueueError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError } from "@types"; +import { Guild as DatabaseGuild } from "@models/Guild"; +import { QueueEntry, QueueEntryModel } from "@models/QueueEntry"; +import { User } from "discord.js"; +import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; + +@injectable() +@singleton() +export default class QueueManager { + protected app: Application; + + constructor(@inject(delay(() => Application)) app: Application) { + this.app = app; + } + + /** + * Creates a new queue for a guild. + * + * @param dbGuild - The database guild object. + * @param queueName - The name of the queue. + * @param queueDescription - The description of the queue. + * @throws {QueueAlreadyExistsError} If a queue with the same name already exists in the guild. + */ + public async createQueue(dbGuild: DocumentType, queueName: string, queueDescription: string): Promise { + if (dbGuild.queues.some(queue => queue.name === queueName)) { + this.app.logger.info(`Queue with name ${queueName} already exists in guild ${dbGuild.name} (id: ${dbGuild._id})`); + throw new QueueAlreadyExistsError(queueName); + } + const queue: FilterOutFunctionKeys = { + name: queueName, + description: queueDescription, + disconnect_timeout: 60000, + match_timeout: 120000, + limit: 150, + join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}", + match_found_message: "You have found a Match with ${match}. Please Join ${match_channel} if you are not moved automatically. If you don't join in ${timeout} seconds, your position in the queue is dropped.", + timeout_message: "Your queue Timed out after ${timeout} seconds.", + leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", + entries: new mongoose.Types.DocumentArray([]), + opening_times: new mongoose.Types.DocumentArray([]), + info_channels: [], + } + this.app.logger.debug(`Creating queue "${queueName}" on guild "${dbGuild.name}" (id: ${dbGuild._id})`) + dbGuild.queues.push(queue); + await dbGuild.save(); + this.app.logger.info(`Queue "${queueName}" created on guild "${dbGuild.name}" (id: ${dbGuild._id})`) + } + + /** + * Retrieves a queue from the specified guild's database. The comparison is case-insensitive. + * + * @param dbGuild - The database guild object. + * @param queueName - The name of the queue to retrieve. + * @returns The retrieved queue. + * @throws {CouldNotFindQueueError} if the specified queue cannot be found. + */ + public getQueue(dbGuild: DatabaseGuild, queueName: string): DocumentType { + const queue = dbGuild.queues.find(queue => queue.name.toLowerCase() === queueName.toLowerCase()); + if (!queue) { + this.app.logger.debug(`Queue ${queueName} not found in guild ${dbGuild.name} (id: ${dbGuild._id})`); + throw new CouldNotFindQueueError(queueName); + } + this.app.logger.debug(`Queue ${queueName} found in guild ${dbGuild.name} (id: ${dbGuild._id})`); + return queue; + } + + /** + * Retrieves the queue associated with a specific user. + * + * @param dbGuild - The database guild object. + * @param userId - The ID of the user. + * @returns The Queue object associated with the user, or undefined if not found. + */ + public getQueueOfUser(dbGuild: DatabaseGuild, user: User): DocumentType | undefined { + this.app.logger.debug(`Retrieving queue of user ${user.username} (id: ${user.id}) in guild ${dbGuild.name} (id: ${dbGuild._id})`); + return dbGuild.queues.find(queue => queue.contains(user.id)); + } + + /** + * Adds a user to the specified queue. + * + * @param queue - The queue to join. + * @param userId - The ID of the user to add to the queue. + * @param intent - The intent of the user joining the queue. + * @returns A promise that resolves to a string representing the join message. + * @throws {AlreadyInQueueError} If the user is already in the queue. + * @throws {QueueLockedError} If the queue is locked. + */ + public async joinQueue(queue: DocumentType, user: User, intent: string): Promise { + // Check if the user is already in the queue they are trying to join. + if (queue.contains(user.id)) { + this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to join queue ${queue.name} but is already in it`); + throw new AlreadyInQueueError(queue.name); + } + + // Check if the queue is locked. + if (queue.locked) { + this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to join queue ${queue.name} but it is locked`); + throw new QueueLockedError(queue.name); + } + + // Add the user to the queue. + const newEntry = new QueueEntryModel({ + discord_id: user.id, + joinedAt: Date.now().toString(), + importance: 1, + intent: intent, + }); + queue.entries.push(newEntry); + await queue.$parent()?.save(); + this.app.logger.info(`User ${user.username} (id: ${user.id}) joined queue ${queue.name}`); + + // Return the join message. + return queue.getJoinMessage(user.id); + } + + /** + * Removes a user from the queue he is in and returns the leave message. + * + * @param guild - The database guild. + * @param user - The user to remove from the queue. + * @returns The leave message. + * @throws {NotInQueueError} If the user is not in a queue. + */ + public async leaveQueue(guild: DatabaseGuild, user: User): Promise { + const queue = this.getQueueOfUser(guild, user); + if (!queue) { + this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to leave queue but is not in a queue`); + throw new NotInQueueError(); + } + + const leaveMessage = queue.getLeaveMessage(user.id); + + // remove the user from the queue + const userIndex = queue.entries.findIndex(entry => entry.discord_id === user.id) + queue.entries.splice(userIndex, 1) + await queue.$parent()?.save() + this.app.logger.info(`User ${user.username} (id: ${user.id}) left queue ${queue.name}`); + + return leaveMessage; + } + + +} \ No newline at end of file diff --git a/src/models/Queue.ts b/src/models/Queue.ts index bea3676..d1975b4 100644 --- a/src/models/Queue.ts +++ b/src/models/Queue.ts @@ -112,19 +112,6 @@ export class Queue { @prop({ type: QueueEntry, default: [], required: true }) entries!: mongoose.Types.DocumentArray>; - /** - * Put an Entry into the Queue - * @param entry The Queue Entry - */ - public async join(this: DocumentType, entry: QueueEntry): Promise { - if (this.entries.find(x => x.discord_id === entry.discord_id)) { - throw new Error("Dublicate Entry"); - } - this.entries.push(entry); - await this.$parent()?.save(); - return this.getEntry(entry.discord_id)!; - } - /** * Gets the Sorted Entries with the First ones being the ones with the highest Importance * @param limit How many entries should we get at most? diff --git a/src/models/QueueEntry.ts b/src/models/QueueEntry.ts index f423019..8680034 100644 --- a/src/models/QueueEntry.ts +++ b/src/models/QueueEntry.ts @@ -24,4 +24,10 @@ export class QueueEntry { */ @prop({ required: false }) intent?: string; -} \ No newline at end of file +} + +export const QueueEntryModel = getModelForClass(QueueEntry, { + schemaOptions: { + autoCreate: false, + }, +}); \ No newline at end of file From 32cefbad51837fcaa9bb5dbdc27e4f76a4bbac54 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 13 Mar 2024 17:34:20 +0100 Subject: [PATCH 082/130] Test time spent --- src/commands/queue/QueueLeaveCommand.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/queue/QueueLeaveCommand.test.ts b/src/commands/queue/QueueLeaveCommand.test.ts index 5ba658b..56ce255 100644 --- a/src/commands/queue/QueueLeaveCommand.test.ts +++ b/src/commands/queue/QueueLeaveCommand.test.ts @@ -32,7 +32,7 @@ describe("QueueLeaveCommand", () => { name: "test", description: "test description", tracks: [], - leave_message: "You left the ${name} queue.", + leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", entries: [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }], } dbGuild.queues.push(queue); @@ -47,7 +47,7 @@ describe("QueueLeaveCommand", () => { fetchReply: true, embeds: [{ data: { - description: queue.leave_message.replace("${name}", queue.name), + description: expect.stringContaining(queue.leave_message.replace("${name}", queue.name).replace("${time_spent}", "0h 0m")), color: Colors.Green, title: "Queue Left" } @@ -62,7 +62,7 @@ describe("QueueLeaveCommand", () => { name: "test", description: "test description", tracks: [], - leave_message: "You left the ${name} queue.", + leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", } dbGuild.queues.push(queue); await dbGuild.save(); From e2913f44e874664515d07a1f9b715616152a092a Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 13 Mar 2024 20:23:08 +0100 Subject: [PATCH 083/130] Add queueinfo add command --- src/baseCommand/BaseCommand.test.ts | 10 +- .../queue/AddQueueInfoChannelCommand.test.ts | 293 ++++++++++++++++++ .../queue/AddQueueInfoChannelCommand.ts | 104 +++++++ src/managers/QueueManager.ts | 53 +++- .../errors/ChannelAlreadyInfoChannelError.ts | 25 ++ src/types/errors/CouldNotFindChannelError.ts | 2 +- src/types/errors/InvalidEventError.ts | 25 ++ src/types/index.ts | 4 + 8 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 src/commands/config/queue/AddQueueInfoChannelCommand.test.ts create mode 100644 src/commands/config/queue/AddQueueInfoChannelCommand.ts create mode 100644 src/types/errors/ChannelAlreadyInfoChannelError.ts create mode 100644 src/types/errors/InvalidEventError.ts diff --git a/src/baseCommand/BaseCommand.test.ts b/src/baseCommand/BaseCommand.test.ts index ec66851..da8a83f 100644 --- a/src/baseCommand/BaseCommand.test.ts +++ b/src/baseCommand/BaseCommand.test.ts @@ -1,10 +1,10 @@ import { MockDiscord } from "@tests/mockDiscord"; -import { ApplicationCommandOptionType, ChatInputCommandInteraction, CommandInteraction } from "discord.js"; +import { ApplicationCommandOptionType, Channel, ChatInputCommandInteraction, CommandInteraction, TextChannel } from "discord.js"; import BaseCommand from "./BaseCommand"; import { MissingOptionError, OptionRequirement } from "@types"; describe("BaseCommand", () => { - describe.each([ApplicationCommandOptionType.String, ApplicationCommandOptionType.Integer, ApplicationCommandOptionType.Boolean])("getOptionValue with type %p", (optionType) => { + describe.each([ApplicationCommandOptionType.String, ApplicationCommandOptionType.Integer, ApplicationCommandOptionType.Boolean, ApplicationCommandOptionType.Channel])("getOptionValue with type %p", (optionType) => { const discord = new MockDiscord(); let commandInstance: BaseCommand; let interaction: ChatInputCommandInteraction; @@ -30,7 +30,11 @@ describe("BaseCommand", () => { value = 5; } else if (optionType === ApplicationCommandOptionType.Boolean) { value = true; - } + } else if (optionType === ApplicationCommandOptionType.Channel) { + value = discord.mockChannel().id; + } else { + throw new Error("Invalid option type"); + } }); it.each([true, false])("should return the option value if it exists (option is required: %p)", (required) => { diff --git a/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts new file mode 100644 index 0000000..b71ddb4 --- /dev/null +++ b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts @@ -0,0 +1,293 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ApplicationCommandOptionType, ChannelType, ChatInputCommandInteraction, Colors } from "discord.js"; +import { container } from "tsyringe"; +import AddQueueInfoChannelCommand from "./AddQueueInfoChannelCommand"; +import { QueueEventType } from "@models/Event"; +import { eventNames } from "process"; + +describe("AddQueueInfoChannelCommand", () => { + const command = AddQueueInfoChannelCommand; + const discord = container.resolve(MockDiscord); + let commandInstance: AddQueueInfoChannelCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "channel": + return { value: "test channel" } + case "queue": + return { value: "test queue" } + case "events": + return { value: `${Object.values(QueueEventType).join(", ")}` } + default: + return null; + } + }) + interaction.guild!.channels.cache.get = jest.fn().mockImplementation((key: string) => { + if (key == "test channel") { + return { + id: "test channel", + type: ChannelType.GuildText, + } + } else if (key == "another channel") { + return { + id: "another channel", + type: ChannelType.GuildVoice, + } + } else { + return undefined; + } + }) + }); + + it("should have the correct name", () => { + expect(command.name).toBe("add_info_channel"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Adds a channel to the queue info channels."); + }) + + it("should have the correct options", () => { + expect(command.options).toHaveLength(3); + expect(command.options[0]).toStrictEqual({ + name: "channel", + description: "The channel to be added to the queue info channels.", + type: ApplicationCommandOptionType.Channel, + required: true, + }); + expect(command.options[1]).toStrictEqual({ + name: "queue", + description: "The queue for which the info channel will be set.", + type: ApplicationCommandOptionType.String, + required: true, + }); + expect(command.options[2]).toStrictEqual({ + name: "events", + description: `${Object.values(QueueEventType).join(", ")} (defaults to all)`, + type: ApplicationCommandOptionType.String, + required: false, + }); + }) + + it("should defer the interaction", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: interaction.options.get("queue")!.value as string, + description: "test description", + tracks: [], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + const deferSpy = jest.spyOn(interaction, 'deferReply') + await commandInstance.execute() + + expect(deferSpy).toHaveBeenCalledTimes(1) + }) + + it("should set the queue info channel and reply with a success message", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: interaction.options.get("queue")!.value as string, + description: "test description", + tracks: [], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + const channelName = interaction.options.get("channel")!.value as string; + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Queue Info Channel Added", + description: `The channel ${channelName} was added to the queue ${queue.name} info channels.`, + color: Colors.Green, + fields: [{ + name: "Events", + value: Object.values(QueueEventType).join(", ") + }] + } + }] + } + ); + }) + + it("should fail if the channel is not found", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: interaction.options.get("queue")!.value as string, + description: "test description", + tracks: [], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "channel": + return { value: "this channel does not exist" } + case "queue": + return { value: "test queue" } + case "events": + return { value: `${Object.values(QueueEventType).join(", ")}` } + default: + return null; + } + }) + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + const channelName = interaction.options.get("channel")!.value as string; + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Error", + description: `Could not find channel "${channelName}".`, + color: Colors.Red + } + }] + } + ); + }) + + it("should fail if the queue is not found", async () => { + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Error", + description: `Could not find the queue "${interaction.options.get("queue")!.value}".`, + color: Colors.Red + } + }] + } + ); + }) + + it("should fail if the event is not valid", async () => { + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "channel": + return { value: "test channel" } + case "queue": + return { value: "test queue" } + case "events": + return { value: "invalidevent" } + default: + return null; + } + }) + + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: interaction.options.get("queue")!.value as string, + description: "test description", + tracks: [], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + const eventNames = interaction.options.get("events")!.value as string; + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Error", + description: `Invalid event: "${eventNames}". Valid events: "${Object.values(QueueEventType).join(`", "`)}".`, + color: Colors.Red + } + }] + } + ); + }) + + it("should fail if the channel is not a text channel", async () => { + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "channel": + return { value: "another channel" } + case "queue": + return { value: "test queue" } + case "events": + return { value: `${Object.values(QueueEventType).join(", ")}` } + default: + return null; + } + }) + + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: interaction.options.get("queue")!.value as string, + description: "test description", + tracks: [], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + const channelName = interaction.options.get("channel")!.value as string; + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Error", + description: `Could not find channel "${channelName}" with type "${ChannelType[ChannelType.GuildText]}".`, + color: Colors.Red + } + }] + } + ); + }) + + it("should fail if the channel is already a queue info channel", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: interaction.options.get("queue")!.value as string, + description: "test description", + tracks: [], + info_channels: [{ channel_id: "test channel", events: Object.values(QueueEventType) }] + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + const channelName = interaction.options.get("channel")!.value as string; + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Error", + description: `The channel "${channelName}" is already a queue info channel for the queue "${queue.name}".`, + color: Colors.Red + } + }] + } + ); + }) +}) \ No newline at end of file diff --git a/src/commands/config/queue/AddQueueInfoChannelCommand.ts b/src/commands/config/queue/AddQueueInfoChannelCommand.ts new file mode 100644 index 0000000..10ffe8f --- /dev/null +++ b/src/commands/config/queue/AddQueueInfoChannelCommand.ts @@ -0,0 +1,104 @@ +import { BaseCommand } from "@baseCommand"; +import { QueueEventType } from "@models/Event"; +import { ChannelAlreadyInfoChannelError, CouldNotFindChannelError, CouldNotFindQueueError, InteractionNotInGuildError, InvalidEventError } from "@types"; +import { ApplicationCommandOptionType, ChannelType, Colors, EmbedBuilder, TextChannel } from "discord.js"; + +export default class AddQueueInfoChannelCommand extends BaseCommand { + public static name: string = "add_info_channel"; + public static description: string = "Adds a channel to the queue info channels."; + public static options = [ + { + name: "channel", + description: "The channel to be added to the queue info channels.", + type: ApplicationCommandOptionType.Channel, + required: true, + }, + { + name: "queue", + description: "The queue for which the info channel will be set.", + type: ApplicationCommandOptionType.String, + required: true, + }, + { + name: "events", + description: `${Object.values(QueueEventType).join(", ")} (defaults to all)`, + type: ApplicationCommandOptionType.String, + required: false, + } + ] + + public async execute(): Promise { + await this.defer() + + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction) + } + try { + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) + + // Get the options + const channelId = this.getOptionValue(AddQueueInfoChannelCommand.options[0]) + const channel = this.interaction.guild.channels.cache.get(channelId) + if (!channel) { + throw new CouldNotFindChannelError(channelId) + } else if (channel.type != ChannelType.GuildText) { + throw new CouldNotFindChannelError(channelId, ChannelType.GuildText) + } + const queueName = this.getOptionValue(AddQueueInfoChannelCommand.options[1]) + const eventsString = this.getOptionValue(AddQueueInfoChannelCommand.options[2]) + const events = (!eventsString || eventsString === "*" || eventsString === "all") + ? Object.values(QueueEventType) + : eventsString.replace(/\s/g, "").split(","); + + // Add the channel to the queue info channels + await this.app.queueManager.addQueueInfoChannel(dbGuild, queueName, channel, events) + const embed = this.mountAddQueueInfoChannelEmbed(channel, queueName, events); + await this.send({ embeds: [embed] }) + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }) + } else { + throw error; + } + } + } + + /** + * Builds and returns an embed for adding a queue info channel. + * + * @param channel - The channel to be added. + * @param queueName - The name of the queue. + * @param events - The events associated with the queue. + * @returns - The built embed. + */ + private mountAddQueueInfoChannelEmbed(channel: TextChannel, queueName: string, events: string[]): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Queue Info Channel Added") + .setDescription(`The channel ${channel.name ?? channel.id} was added to the queue ${queueName} info channels.`) + .addFields({ + name: "Events", + value: events.join(", ") + }) + .setColor(Colors.Green) + return embed + } + + /** + * Mounts an error embed based on the given error. + * + * @param error - The error object. + * @returns The error embed. + * @throws The error object if it is not an instance of CouldNotFindChannelError, CouldNotFindQueueError, or ChannelAlreadyInfoChannelError. + */ + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof CouldNotFindChannelError || error instanceof CouldNotFindQueueError || error instanceof ChannelAlreadyInfoChannelError || error instanceof InvalidEventError) { + const embed = new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red) + return embed + } + throw error; + } +} \ No newline at end of file diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index 2601d55..d3e57e8 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -2,11 +2,13 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { Application } from "@application"; import { Queue } from "@models/Queue"; import { DocumentType, mongoose } from "@typegoose/typegoose"; -import { AlreadyInQueueError, CouldNotFindQueueError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError } from "@types"; +import { AlreadyInQueueError, ChannelAlreadyInfoChannelError, CouldNotFindQueueError, InvalidEventError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; -import { QueueEntry, QueueEntryModel } from "@models/QueueEntry"; -import { User } from "discord.js"; +import { QueueEntryModel } from "@models/QueueEntry"; +import { TextChannel, User } from "discord.js"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; +import { QueueEventType } from "@models/Event"; +import events from "events"; @injectable() @singleton() @@ -144,5 +146,50 @@ export default class QueueManager { return leaveMessage; } + /** + * Adds a queue info channel to the specified database guild. + * + * @param dbGuild - The database guild to add the queue info channel to. + * @param queueName - The name of the queue. + * @param channel - The text channel to set as the info channel. + * @param eventStrings - An array of event strings. + * @throws {ChannelAlreadyInfoChannelError} If the channel is already an info channel for the queue. + * @throws {InvalidEventError} If an invalid event string is encountered. + */ + public async addQueueInfoChannel(dbGuild: DocumentType, queueName: string, channel: TextChannel, eventStrings: string[]): Promise { + const queue = this.getQueue(dbGuild, queueName); + try { + const events = this.validateAndConvertEventStrings(eventStrings); + if (queue.info_channels.some(infoChannel => infoChannel.channel_id === channel.id)) { + this.app.logger.debug(`Channel ${channel.name} (id: ${channel.id}) is already an info channel for queue ${queue.name}`); + throw new ChannelAlreadyInfoChannelError(queue.name, channel.name ?? channel.id); + } + queue.info_channels.push({ channel_id: channel.id, events: events }); + await dbGuild.save(); + this.app.logger.info(`Channel ${channel.name} (id: ${channel.id}) set as info channel for queue ${queue.name}`); + + } catch (error) { + throw error + } + + } + + /** + * Validates and converts an array of event strings to an array of QueueEventType. + * + * @param eventStrings - The array of event strings to validate and convert. + * @returns An array of QueueEventType. + * @throws {InvalidEventError} If an invalid event string is encountered. + */ + private validateAndConvertEventStrings(eventStrings: string[]): QueueEventType[] { + return eventStrings.map(eventString => { + const eventKey = eventString.toUpperCase(); + if (eventKey in QueueEventType) { + return QueueEventType[eventKey as keyof typeof QueueEventType]; + } + this.app.logger.info(`Invalid event: ${eventString}`); + throw new InvalidEventError(eventString, Object.values(QueueEventType)); + }); + } } \ No newline at end of file diff --git a/src/types/errors/ChannelAlreadyInfoChannelError.ts b/src/types/errors/ChannelAlreadyInfoChannelError.ts new file mode 100644 index 0000000..56167a4 --- /dev/null +++ b/src/types/errors/ChannelAlreadyInfoChannelError.ts @@ -0,0 +1,25 @@ +/** + * Represents an error that occurs when trying to set a channel as an info channel, but it is already an info channel. + */ +export default class ChannelAlreadyInfoChannelError extends Error { + /** + * The name of the queue which is already an info channel. + */ + public queueName: string + + /** + * The name of the channel which is already an info channel. + */ + public channelName: string + + /** + * Creates a new ChannelAlreadyInfoChannelError instance. + * @param queueName The name of the queue which is already an info channel. + * @param channelName The name of the channel which is already an info channel. + */ + constructor(queueName: string, channelName: string) { + super(`The channel "${channelName}" is already a queue info channel for the queue "${queueName}".`) + this.queueName = queueName + this.channelName = channelName + } +} \ No newline at end of file diff --git a/src/types/errors/CouldNotFindChannelError.ts b/src/types/errors/CouldNotFindChannelError.ts index 421a3de..b6c39c5 100644 --- a/src/types/errors/CouldNotFindChannelError.ts +++ b/src/types/errors/CouldNotFindChannelError.ts @@ -16,7 +16,7 @@ export default class CouldNotFindChannelError extends Error { * @param channelType The type of the channel which was expected. */ constructor(channelNameOrId: string, channelType?: ChannelType) { - super(`Could not find channel "${channelNameOrId}"${channelType ? ` with type "${ChannelType[channelType]}"` : ""}.`) + super(`Could not find channel "${channelNameOrId}"${channelType != undefined ? ` with type "${ChannelType[channelType]}"` : ""}.`) this.channelNameOrId = channelNameOrId this.channelType = channelType } diff --git a/src/types/errors/InvalidEventError.ts b/src/types/errors/InvalidEventError.ts new file mode 100644 index 0000000..123eb10 --- /dev/null +++ b/src/types/errors/InvalidEventError.ts @@ -0,0 +1,25 @@ +/** + * Represents an error that occurs when an invalid event is encountered. + */ +export default class InvalidEventError extends Error { + /** + * The name of the event which is invalid. + */ + public eventName: string + + /** + * The valid events. + */ + public validEvents: string[] + + /** + * Creates a new InvalidEventError instance. + * @param eventName The name of the event which is invalid. + * @param validEvents The valid events. + */ + constructor(eventName: string, validEvents: string[]) { + super(`Invalid event: "${eventName}". Valid events: "${validEvents.join(`", "`)}".`) + this.eventName = eventName + this.validEvents = validEvents + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 9fb1e14..92c1e9b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,11 +1,13 @@ import OptionRequirement from "./OptionRequirement"; import { StringReplacements } from "./StringReplacements"; import AlreadyInQueueError from "./errors/AlreadyInQueueError"; +import ChannelAlreadyInfoChannelError from "./errors/ChannelAlreadyInfoChannelError"; import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; import CouldNotFindQueueError from "./errors/CouldNotFindQueueError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; import InteractionNotInGuildError from "./errors/InteractionNotInGuildError"; +import InvalidEventError from "./errors/InvalidEventError"; import MissingOptionError from "./errors/MissingOptionError"; import NotInQueueError from "./errors/NotInQueueError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; @@ -28,4 +30,6 @@ export { AlreadyInQueueError, UserHasActiveSessionError, QueueLockedError, + InvalidEventError, + ChannelAlreadyInfoChannelError, } \ No newline at end of file From e932bf5017b71990cfc462046bdaf4790e291d62 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 14 Mar 2024 14:11:47 +0100 Subject: [PATCH 084/130] Add remove info channel command and improve add command --- .../queue/AddQueueInfoChannelCommand.test.ts | 11 +- .../queue/AddQueueInfoChannelCommand.ts | 6 +- .../RemoveQueueInfoChannelCommand.test.ts | 230 ++++++++++++++++++ .../queue/RemoveQueueInfoChannelCommand.ts | 73 ++++++ src/managers/QueueManager.ts | 67 +++-- .../errors/ChannelAlreadyInfoChannelError.ts | 2 +- .../errors/ChannelNotInfoChannelError.ts | 18 ++ src/types/index.ts | 2 + 8 files changed, 372 insertions(+), 37 deletions(-) create mode 100644 src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts create mode 100644 src/commands/config/queue/RemoveQueueInfoChannelCommand.ts create mode 100644 src/types/errors/ChannelNotInfoChannelError.ts diff --git a/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts index b71ddb4..82f83cc 100644 --- a/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts +++ b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts @@ -38,7 +38,7 @@ describe("AddQueueInfoChannelCommand", () => { type: ChannelType.GuildVoice, } } else { - return undefined; + return null; } }) }); @@ -74,15 +74,6 @@ describe("AddQueueInfoChannelCommand", () => { }) it("should defer the interaction", async () => { - const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: interaction.options.get("queue")!.value as string, - description: "test description", - tracks: [], - } - dbGuild.queues.push(queue); - await dbGuild.save(); - const deferSpy = jest.spyOn(interaction, 'deferReply') await commandInstance.execute() diff --git a/src/commands/config/queue/AddQueueInfoChannelCommand.ts b/src/commands/config/queue/AddQueueInfoChannelCommand.ts index 10ffe8f..6c5a2e0 100644 --- a/src/commands/config/queue/AddQueueInfoChannelCommand.ts +++ b/src/commands/config/queue/AddQueueInfoChannelCommand.ts @@ -73,7 +73,7 @@ export default class AddQueueInfoChannelCommand extends BaseCommand { * @returns - The built embed. */ private mountAddQueueInfoChannelEmbed(channel: TextChannel, queueName: string, events: string[]): EmbedBuilder { - const embed = new EmbedBuilder() + return new EmbedBuilder() .setTitle("Queue Info Channel Added") .setDescription(`The channel ${channel.name ?? channel.id} was added to the queue ${queueName} info channels.`) .addFields({ @@ -81,7 +81,6 @@ export default class AddQueueInfoChannelCommand extends BaseCommand { value: events.join(", ") }) .setColor(Colors.Green) - return embed } /** @@ -93,11 +92,10 @@ export default class AddQueueInfoChannelCommand extends BaseCommand { */ private mountErrorEmbed(error: Error): EmbedBuilder { if (error instanceof CouldNotFindChannelError || error instanceof CouldNotFindQueueError || error instanceof ChannelAlreadyInfoChannelError || error instanceof InvalidEventError) { - const embed = new EmbedBuilder() + return new EmbedBuilder() .setTitle("Error") .setDescription(error.message) .setColor(Colors.Red) - return embed } throw error; } diff --git a/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts b/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts new file mode 100644 index 0000000..265769d --- /dev/null +++ b/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts @@ -0,0 +1,230 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChannelType, ChatInputCommandInteraction, Colors } from "discord.js"; +import { container } from "tsyringe"; +import RemoveQueueInfoChannelCommand from "./RemoveQueueInfoChannelCommand"; +import { QueueEventType } from "@models/Event"; + +describe("RemoveQueueInfoChannelCommand", () => { + const command = RemoveQueueInfoChannelCommand; + const discord = container.resolve(MockDiscord); + let commandInstance: RemoveQueueInfoChannelCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "channel": + return { value: "test channel" } + case "queue": + return { value: "test queue" } + default: + return null + } + }) + interaction.guild!.channels.cache.get = jest.fn().mockImplementation((key: string) => { + switch (key) { + case "test channel": + return { id: "test channel", type: ChannelType.GuildText } + case "another channel": + return { id: "another channel", type: ChannelType.GuildVoice } + default: + return null + } + }) + }); + + it("should have the correct name", () => { + expect(command.name).toBe("remove_info_channel"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Removes a channel from the queue info channels."); + }) + + it("should have the correct options", () => { + expect(command.options).toHaveLength(2); + expect(command.options[0]).toStrictEqual({ + name: "channel", + description: "The channel to be removed from the queue info channels.", + type: 7, + required: true, + }); + expect(command.options[1]).toStrictEqual({ + name: "queue", + description: "The queue for which the info channel will be removed.", + type: 3, + required: true, + }); + }) + + it("should defer the interaction", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply') + await commandInstance.execute(); + + expect(deferSpy).toHaveBeenCalledTimes(1); + }) + + it("should remove the channel from the queue info channels and reply with a success message", async () => { + const channelName = interaction.options.get("channel")!.value as string; + const queueName = interaction.options.get("queue")!.value as string; + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: queueName, + description: "test description", + tracks: [], + info_channels: [{ channel_id: channelName, events: Object.values(QueueEventType) }], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Queue Info Channel Removed", + description: `The channel "${channelName}" has been removed from the queue info channels for the queue "${queueName}".`, + color: Colors.Green, + } + }] + } + ); + }) + + it("should fail if the channel is not found", async () => { + const channelName = "this channel does not exist"; + const queueName = interaction.options.get("queue")!.value as string; + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: queueName, + description: "test description", + tracks: [], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "channel": + return { value: channelName } + case "queue": + return { value: queueName } + default: + return null + } + }) + + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Error", + description: `Could not find channel "${channelName}".`, + color: Colors.Red, + } + }] + } + ); + }) + + it("should fail if the channel is not a text channel", async () => { + const channelName = "another channel"; + const queueName = interaction.options.get("queue")!.value as string; + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: queueName, + description: "test description", + tracks: [], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + interaction.options.get = jest.fn().mockImplementation((option: string) => { + switch (option) { + case "channel": + return { value: channelName } + case "queue": + return { value: queueName } + default: + return null + } + }) + + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Error", + description: `Could not find channel "${channelName}" with type "${ChannelType[ChannelType.GuildText]}".`, + color: Colors.Red, + } + }] + } + ); + + }) + + it("should fail if the queue is not found", async () => { + const channelName = interaction.options.get("channel")!.value as string; + const queueName = interaction.options.get("queue")!.value as string; + + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Error", + description: `Could not find the queue "${queueName}".`, + color: Colors.Red, + } + }] + } + ); + }) + + it("should fail if the channel is not an info channel for the queue", async () => { + const channelName = interaction.options.get("channel")!.value as string; + const queueName = interaction.options.get("queue")!.value as string; + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = { + name: queueName, + description: "test description", + tracks: [], + } + dbGuild.queues.push(queue); + await dbGuild.save(); + + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith( + { + embeds: [{ + data: { + title: "Error", + description: `The channel "${channelName}" is not a queue info channel for the queue "${queueName}".`, + color: Colors.Red, + } + }] + } + ); + }) + +}) \ No newline at end of file diff --git a/src/commands/config/queue/RemoveQueueInfoChannelCommand.ts b/src/commands/config/queue/RemoveQueueInfoChannelCommand.ts new file mode 100644 index 0000000..510d7b3 --- /dev/null +++ b/src/commands/config/queue/RemoveQueueInfoChannelCommand.ts @@ -0,0 +1,73 @@ +import { BaseCommand } from "@baseCommand"; +import { InteractionNotInGuildError, CouldNotFindChannelError, CouldNotFindQueueError, ChannelNotInfoChannelError } from "@types"; +import { ApplicationCommandOptionType, ChannelType, Colors, EmbedBuilder, TextChannel } from "discord.js"; + +export default class RemoveQueueInfoChannelCommand extends BaseCommand { + public static name: string = "remove_info_channel"; + public static description: string = "Removes a channel from the queue info channels."; + public static options = [ + { + name: "channel", + description: "The channel to be removed from the queue info channels.", + type: ApplicationCommandOptionType.Channel, + required: true, + }, + { + name: "queue", + description: "The queue for which the info channel will be removed.", + type: ApplicationCommandOptionType.String, + required: true, + }, + ] + + public async execute(): Promise { + await this.defer() + + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction) + } + try { + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild) + + // Get the options + const channelId = this.getOptionValue(RemoveQueueInfoChannelCommand.options[0]) + const channel = this.interaction.guild.channels.cache.get(channelId) + if (!channel) { + throw new CouldNotFindChannelError(channelId) + } else if (channel.type != ChannelType.GuildText) { + throw new CouldNotFindChannelError(channelId, ChannelType.GuildText) + } + const queueName = this.getOptionValue(RemoveQueueInfoChannelCommand.options[1]) + + // Remove the channel from the queue info channels + await this.app.queueManager.removeQueueInfoChannel(dbGuild, queueName, channel) + const embed = this.mountRemoveQueueInfoChannelEmbed(channel, queueName); + await this.send({ embeds: [embed] }) + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }) + } else { + throw error; + } + } + } + + private mountRemoveQueueInfoChannelEmbed(channel: TextChannel, queueName: string): EmbedBuilder { + return new EmbedBuilder() + .setTitle("Queue Info Channel Removed") + .setColor(Colors.Green) + .setDescription(`The channel "${channel.name ?? channel.id}" has been removed from the queue info channels for the queue "${queueName}".`) + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof CouldNotFindChannelError || error instanceof CouldNotFindQueueError || error instanceof ChannelNotInfoChannelError) { + return new EmbedBuilder() + .setTitle("Error") + .setColor(Colors.Red) + .setDescription(error.message) + } + throw error + } + +} \ No newline at end of file diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index d3e57e8..198ea2a 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -2,13 +2,12 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { Application } from "@application"; import { Queue } from "@models/Queue"; import { DocumentType, mongoose } from "@typegoose/typegoose"; -import { AlreadyInQueueError, ChannelAlreadyInfoChannelError, CouldNotFindQueueError, InvalidEventError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError } from "@types"; +import { AlreadyInQueueError, ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, CouldNotFindQueueError, InvalidEventError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; import { QueueEntryModel } from "@models/QueueEntry"; import { TextChannel, User } from "discord.js"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEventType } from "@models/Event"; -import events from "events"; @injectable() @singleton() @@ -63,10 +62,10 @@ export default class QueueManager { public getQueue(dbGuild: DatabaseGuild, queueName: string): DocumentType { const queue = dbGuild.queues.find(queue => queue.name.toLowerCase() === queueName.toLowerCase()); if (!queue) { - this.app.logger.debug(`Queue ${queueName} not found in guild ${dbGuild.name} (id: ${dbGuild._id})`); + this.app.logger.debug(`Queue "${queueName}" not found in guild "${dbGuild.name}" (id: ${dbGuild._id})`); throw new CouldNotFindQueueError(queueName); } - this.app.logger.debug(`Queue ${queueName} found in guild ${dbGuild.name} (id: ${dbGuild._id})`); + this.app.logger.debug(`Queue "${queueName}" found in guild "${dbGuild.name}" (id: ${dbGuild._id})`); return queue; } @@ -78,7 +77,7 @@ export default class QueueManager { * @returns The Queue object associated with the user, or undefined if not found. */ public getQueueOfUser(dbGuild: DatabaseGuild, user: User): DocumentType | undefined { - this.app.logger.debug(`Retrieving queue of user ${user.username} (id: ${user.id}) in guild ${dbGuild.name} (id: ${dbGuild._id})`); + this.app.logger.debug(`Retrieving queue of user "${user.username}" (id: ${user.id}) in guild "${dbGuild.name}" (id: ${dbGuild._id})`); return dbGuild.queues.find(queue => queue.contains(user.id)); } @@ -95,13 +94,13 @@ export default class QueueManager { public async joinQueue(queue: DocumentType, user: User, intent: string): Promise { // Check if the user is already in the queue they are trying to join. if (queue.contains(user.id)) { - this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to join queue ${queue.name} but is already in it`); + this.app.logger.info(`User "${user.username}" (id: ${user.id}) tried to join queue "${queue.name}" but is already in it`); throw new AlreadyInQueueError(queue.name); } // Check if the queue is locked. if (queue.locked) { - this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to join queue ${queue.name} but it is locked`); + this.app.logger.info(`User "${user.username}" (id: ${user.id}) tried to join queue "${queue.name}" but it is locked`); throw new QueueLockedError(queue.name); } @@ -114,7 +113,7 @@ export default class QueueManager { }); queue.entries.push(newEntry); await queue.$parent()?.save(); - this.app.logger.info(`User ${user.username} (id: ${user.id}) joined queue ${queue.name}`); + this.app.logger.info(`User "${user.username}" (id: ${user.id}) joined queue "${queue.name}"`); // Return the join message. return queue.getJoinMessage(user.id); @@ -131,7 +130,7 @@ export default class QueueManager { public async leaveQueue(guild: DatabaseGuild, user: User): Promise { const queue = this.getQueueOfUser(guild, user); if (!queue) { - this.app.logger.info(`User ${user.username} (id: ${user.id}) tried to leave queue but is not in a queue`); + this.app.logger.info(`User "${user.username}" (id: ${user.id}) tried to leave queue but is not in a queue`); throw new NotInQueueError(); } @@ -141,7 +140,7 @@ export default class QueueManager { const userIndex = queue.entries.findIndex(entry => entry.discord_id === user.id) queue.entries.splice(userIndex, 1) await queue.$parent()?.save() - this.app.logger.info(`User ${user.username} (id: ${user.id}) left queue ${queue.name}`); + this.app.logger.info(`User "${user.username}" (id: ${user.id}) left queue "${queue.name}"`); return leaveMessage; } @@ -155,23 +154,22 @@ export default class QueueManager { * @param eventStrings - An array of event strings. * @throws {ChannelAlreadyInfoChannelError} If the channel is already an info channel for the queue. * @throws {InvalidEventError} If an invalid event string is encountered. + * @throws {CouldNotFindQueueError} If the specified queue cannot be found. */ public async addQueueInfoChannel(dbGuild: DocumentType, queueName: string, channel: TextChannel, eventStrings: string[]): Promise { const queue = this.getQueue(dbGuild, queueName); - try { - const events = this.validateAndConvertEventStrings(eventStrings); - if (queue.info_channels.some(infoChannel => infoChannel.channel_id === channel.id)) { - this.app.logger.debug(`Channel ${channel.name} (id: ${channel.id}) is already an info channel for queue ${queue.name}`); - throw new ChannelAlreadyInfoChannelError(queue.name, channel.name ?? channel.id); - } - queue.info_channels.push({ channel_id: channel.id, events: events }); - await dbGuild.save(); - this.app.logger.info(`Channel ${channel.name} (id: ${channel.id}) set as info channel for queue ${queue.name}`); + const events = this.validateAndConvertEventStrings(eventStrings); - } catch (error) { - throw error + // Check if the channel is already an info channel for the queue + if (queue.info_channels.some(infoChannel => infoChannel.channel_id === channel.id)) { + this.app.logger.debug(`Channel "${channel.name}" (id: ${channel.id}) is already an info channel for queue "${queue.name}"`); + throw new ChannelAlreadyInfoChannelError(queue.name, channel.name ?? channel.id); } + // Add the channel to the queue info channels + queue.info_channels.push({ channel_id: channel.id, events: events }); + await dbGuild.save(); + this.app.logger.info(`Channel "${channel.name}" (id: ${channel.id}) set as info channel for queue "${queue.name}"`); } /** @@ -187,9 +185,34 @@ export default class QueueManager { if (eventKey in QueueEventType) { return QueueEventType[eventKey as keyof typeof QueueEventType]; } - this.app.logger.info(`Invalid event: ${eventString}`); + this.app.logger.info(`Invalid event: "${eventString}"`); throw new InvalidEventError(eventString, Object.values(QueueEventType)); }); } + /** + * Removes a queue info channel from the specified database guild. + * + * @param dbGuild - The database guild to remove the queue info channel from. + * @param queueName - The name of the queue. + * @param channel - The text channel to remove from the info channels. + * @throws {ChannelNotInfoChannelError} If the channel is not an info channel for the queue. + * @throws {CouldNotFindQueueError} If the specified queue cannot be found. + */ + public async removeQueueInfoChannel(dbGuild: DocumentType, queueName: string, channel: TextChannel): Promise { + const queue = this.getQueue(dbGuild, queueName); + const channelIndex = queue.info_channels.findIndex(infoChannel => infoChannel.channel_id === channel.id); + + // Check if the channel is an info channel for the queue + if (channelIndex === -1) { + this.app.logger.debug(`Channel "${channel.name}" (id: ${channel.id}) is not an info channel for queue "${queue.name}"`); + throw new ChannelNotInfoChannelError(queue.name, channel.name ?? channel.id); + } + + // Remove the channel from the queue info channels + queue.info_channels.splice(channelIndex, 1); + await dbGuild.save(); + this.app.logger.info(`Channel "${channel.name}" (id: ${channel.id}) removed from info channels for queue "${queue.name}"`); + } + } \ No newline at end of file diff --git a/src/types/errors/ChannelAlreadyInfoChannelError.ts b/src/types/errors/ChannelAlreadyInfoChannelError.ts index 56167a4..c2149e7 100644 --- a/src/types/errors/ChannelAlreadyInfoChannelError.ts +++ b/src/types/errors/ChannelAlreadyInfoChannelError.ts @@ -3,7 +3,7 @@ */ export default class ChannelAlreadyInfoChannelError extends Error { /** - * The name of the queue which is already an info channel. + * The name of the queue for which the channel is already an info channel. */ public queueName: string diff --git a/src/types/errors/ChannelNotInfoChannelError.ts b/src/types/errors/ChannelNotInfoChannelError.ts new file mode 100644 index 0000000..9799d59 --- /dev/null +++ b/src/types/errors/ChannelNotInfoChannelError.ts @@ -0,0 +1,18 @@ +export default class ChannelNotInfoChannelError extends Error { + /* + * The name of the queue for which the channel is not an info channel. + */ + public queueName: string + + /* + * The name of the channel which is not an info channel. + */ + public channelName: string + + constructor(queueName: string, channelName: string) { + super(`The channel "${channelName}" is not a queue info channel for the queue "${queueName}".`) + this.queueName = queueName + this.channelName = channelName + } + +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 92c1e9b..017d565 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,7 @@ import OptionRequirement from "./OptionRequirement"; import { StringReplacements } from "./StringReplacements"; import AlreadyInQueueError from "./errors/AlreadyInQueueError"; import ChannelAlreadyInfoChannelError from "./errors/ChannelAlreadyInfoChannelError"; +import ChannelNotInfoChannelError from "./errors/ChannelNotInfoChannelError"; import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; import CouldNotFindQueueError from "./errors/CouldNotFindQueueError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; @@ -32,4 +33,5 @@ export { QueueLockedError, InvalidEventError, ChannelAlreadyInfoChannelError, + ChannelNotInfoChannelError, } \ No newline at end of file From 382067221de29ef71f90af236c536de90c528dd1 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Thu, 14 Mar 2024 15:08:32 +0100 Subject: [PATCH 085/130] Log queue activity --- src/Application.ts | 3 +- src/commands/queue/QueueInfoCommand.test.ts | 1 + src/managers/QueueManager.ts | 79 ++++++++++++++++++++- src/managers/index.ts | 2 + 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/Application.ts b/src/Application.ts index 59c6f5b..afd6ec8 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -3,7 +3,7 @@ import 'dotenv/config' import { Client, Partials, ClientOptions, Interaction } from 'discord.js' import { CronJob } from 'cron' import { ConsolaInstance, createConsola } from 'consola' -import { CommandsManager, ConfigManager, UserManager } from "./managers" +import { CommandsManager, ConfigManager, QueueManager, UserManager } from "./managers" import { container, delay, inject, injectable, singleton } from "tsyringe" import Environment from "./Environment" import mongoose from "mongoose" @@ -12,7 +12,6 @@ import CommandsLoader from "@utils/CommandsLoader" import { BaseEvent } from "@baseEvent" import { BaseCommandOrSubcommandsHandler } from "@baseCommand" import EventsLoader from "@utils/EventsLoader" -import QueueManager from "./managers/QueueManager" /** * The main `Application` class. diff --git a/src/commands/queue/QueueInfoCommand.test.ts b/src/commands/queue/QueueInfoCommand.test.ts index 05b311f..82bbda9 100644 --- a/src/commands/queue/QueueInfoCommand.test.ts +++ b/src/commands/queue/QueueInfoCommand.test.ts @@ -6,6 +6,7 @@ import { EmbedBuilder } from "@discordjs/builders"; import { ChatInputCommandInteraction, Colors } from "discord.js"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEntry } from "@models/QueueEntry"; +import { QueueManager } from "@managers"; describe("InfoCommand", () => { const command = QueueInfoCommand; diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index 198ea2a..750d99b 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -5,9 +5,12 @@ import { DocumentType, mongoose } from "@typegoose/typegoose"; import { AlreadyInQueueError, ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, CouldNotFindQueueError, InvalidEventError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; import { QueueEntryModel } from "@models/QueueEntry"; -import { TextChannel, User } from "discord.js"; +import { EmbedBuilder, TextChannel, User, Guild as DiscordGuild } from "discord.js"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEventType } from "@models/Event"; +import { Session } from "inspector"; +import { InternalRoles } from "@models/BotRoles"; +import { SessionModel } from "@models/Session"; @injectable() @singleton() @@ -115,6 +118,7 @@ export default class QueueManager { await queue.$parent()?.save(); this.app.logger.info(`User "${user.username}" (id: ${user.id}) joined queue "${queue.name}"`); + this.logQueueActivity(queue, QueueEventType.JOIN, user); // Return the join message. return queue.getJoinMessage(user.id); } @@ -142,6 +146,7 @@ export default class QueueManager { await queue.$parent()?.save() this.app.logger.info(`User "${user.username}" (id: ${user.id}) left queue "${queue.name}"`); + this.logQueueActivity(queue, QueueEventType.LEAVE, user); return leaveMessage; } @@ -215,4 +220,76 @@ export default class QueueManager { this.app.logger.info(`Channel "${channel.name}" (id: ${channel.id}) removed from info channels for queue "${queue.name}"`); } + + /** + * Logs the activity of a queue. + * + * @param queue - The queue on which the activity occurred. + * @param event - The type of queue event. + * @param user - The user associated with the event. + * @param targets - Optional array of users to target with the event. + * @returns A promise that resolves when the activity is logged. + */ + private async logQueueActivity(queue: DocumentType, event: QueueEventType, user: User, targets?: User[]): Promise { + const dbGuild = queue.$parent() as DocumentType; + const activeSessionRole = dbGuild.guild_settings.roles?.find(role => role.internal_name === InternalRoles.ACTIVE_SESSION); + const queueSessions: DocumentType[] = await SessionModel.find({ queue: queue._id, active: true }); + + for (const infoChannel of queue.info_channels) { + if (infoChannel.events.includes(event)) { + const discordChannel = this.app.client.channels.cache.get(infoChannel.channel_id)! as TextChannel; + if (!discordChannel) { + this.app.logger.debug(`Channel with id ${infoChannel.channel_id} not found in guild ${dbGuild.name} (id: ${dbGuild._id})`); + continue; + } + const emebed = this.getEventEmbed(user, event, queue, queueSessions, targets); + const message = await discordChannel.send(`<@&${activeSessionRole?.role_id}>`); + await message.edit({ embeds: [emebed] }); + } + } + } + + /** + * Generates an embed message for a queue event. + * + * @param user - The user associated with the event. + * @param event - The type of queue event. + * @param queue - The queue on which the event occurred. + * @param sessions - The array of active sessions. + * @param targets - Optional. The array of users affected by the event. + * @returns The generated EmbedBuilder object. + */ + private getEventEmbed(user: User, event: QueueEventType, queue: DocumentType, sessions: DocumentType[], targets?: User[]): EmbedBuilder { + let eventDescription: string = ""; + switch (event) { + case QueueEventType.JOIN: + eventDescription = `${user} joined the queue ${queue.name}.`; + break; + case QueueEventType.LEAVE: + eventDescription = `${user} left the queue ${queue.name}.`; + break; + case QueueEventType.NEXT: + eventDescription = `${user} picked ${targets?.join(", ")} from the queue ${queue.name}.`; + break; + case QueueEventType.TUTOR_SESSION_START: + eventDescription = `${user} started a new tutor session on the queue ${queue.name}.`; + break; + case QueueEventType.TUTOR_SESSION_QUIT: + eventDescription = `${user} ended the tutor session on the queue ${queue.name}.`; + break; + case QueueEventType.KICK: + eventDescription = `${targets?.join(", ")} were kicked from the queue ${queue.name} by ${user}.`; + break; + } + let sessionsDescription = `There ${sessions.length === 1 ? "is" : "are"} ${sessions.length} active session${sessions.length === 1 ? "" : "s"} in the queue ${queue.name}.`; + let membersDescription = `There ${queue.entries.length === 1 ? "is" : "are"} ${queue.entries.length} member${queue.entries.length === 1 ? "" : "s"} in the queue ${queue.name}.`; + + return new EmbedBuilder() + .setTitle("Queue Activity") + .addFields( + { name: "❯ Event", value: eventDescription }, + { name: "❯ Active Sessions", value: sessionsDescription }, + { name: "❯ Members", value: membersDescription }, + ) + } } \ No newline at end of file diff --git a/src/managers/index.ts b/src/managers/index.ts index 062e1be..a66ab92 100644 --- a/src/managers/index.ts +++ b/src/managers/index.ts @@ -1,9 +1,11 @@ import CommandsManager from "./CommandsManager"; import ConfigManager from "./ConfigManager"; +import QueueManager from "./QueueManager"; import UserManager from "./UserManager"; export { CommandsManager, ConfigManager, UserManager, + QueueManager, } \ No newline at end of file From 7294a5aadb9f745302c71576c7d51ad7bed88b59 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 15 Mar 2024 19:53:49 +0100 Subject: [PATCH 086/130] Add tutor session start command --- .../session/TutorSessionStartCommand.test.ts | 185 ++++++++++++++++++ .../tutor/session/TutorSessionStartCommand.ts | 84 ++++++++ src/managers/QueueManager.ts | 31 ++- src/managers/UserManager.ts | 47 +++++ src/types/errors/CouldNotAssignRoleError.ts | 28 +++ src/types/errors/GuildHasNoQueueError.ts | 13 ++ src/types/errors/UserHasActiveSessionError.ts | 2 +- src/types/index.ts | 4 + tests/mockDiscord.ts | 7 +- tests/testutils.ts | 8 +- 10 files changed, 401 insertions(+), 8 deletions(-) create mode 100644 src/commands/tutor/session/TutorSessionStartCommand.test.ts create mode 100644 src/commands/tutor/session/TutorSessionStartCommand.ts create mode 100644 src/types/errors/CouldNotAssignRoleError.ts create mode 100644 src/types/errors/GuildHasNoQueueError.ts diff --git a/src/commands/tutor/session/TutorSessionStartCommand.test.ts b/src/commands/tutor/session/TutorSessionStartCommand.test.ts new file mode 100644 index 0000000..24fb2a9 --- /dev/null +++ b/src/commands/tutor/session/TutorSessionStartCommand.test.ts @@ -0,0 +1,185 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, Colors, GuildMemberRoleManager } from "discord.js"; +import TutorSessionStartCommand from "./TutorSessionStartCommand"; +import { InternalRoles, RoleScopes } from "@models/BotRoles"; +import { createQueue, createRole } from "@tests/testutils"; +import { Session, SessionModel, SessionRole } from "@models/Session"; + +describe("TutorSessionStartCommand", () => { + const command = TutorSessionStartCommand; + const discord = new MockDiscord(); + let commandInstance: TutorSessionStartCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(async () => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + await createRole(dbGuild, `active session ${interaction.guild}`, InternalRoles.ACTIVE_SESSION); + const role = discord.mockRole(interaction.guild!, { id: InternalRoles.ACTIVE_SESSION.toString() }) + interaction.guild!.roles.resolve = jest.fn().mockReturnValue(role); + GuildMemberRoleManager.prototype.add = jest.fn().mockResolvedValue(role); + jest.clearAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("start"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Starts a tutor session."); + }) + + it("should have the correct options", () => { + expect(command.options).toHaveLength(1); + expect(command.options[0]).toStrictEqual({ + name: "queue", + description: "The queue to start the tutor session for.", + type: 3, + required: false, + }); + }) + + it("should defer the interaction", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply'); + await commandInstance.execute(); + expect(deferSpy).toHaveBeenCalledTimes(1); + }) + + it.each([true, false])("should reply with the started tutor session (queue parameter is provided: %p)", async (parameterSet) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + + if (parameterSet) { + interaction.options.get = jest.fn().mockReturnValue({ value: queue.name }); + } + + const replySpy = jest.spyOn(interaction, 'editReply'); + const saveSpy = jest.spyOn(SessionModel.prototype as any, 'save'); + await commandInstance.execute(); + + expect(saveSpy).toHaveBeenCalledTimes(1); + const saveSpyRes = await saveSpy.mock.results[0].value + expect(saveSpyRes).toMatchObject({ + user: interaction.user.id, + queue: queue._id, + guild: interaction.guild!.id, + role: SessionRole.coach, + active: true, + end_certain: false, + rooms: [] + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Tutor Session Started", + description: `You have started a tutor session for queue "test".`, + color: Colors.Green, + } + }] + }) + }) + + it.each([true, false])("should reply with the started tutor session (queue parameter is provided: %p)", async (parameterSet) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + + if (parameterSet) { + interaction.options.get = jest.fn().mockReturnValue({ value: queue.name }); + } + + const replySpy = jest.spyOn(interaction, 'editReply'); + const saveSpy = jest.spyOn(SessionModel.prototype as any, 'save'); + await commandInstance.execute(); + + expect(saveSpy).toHaveBeenCalledTimes(1); + const saveSpyRes = await saveSpy.mock.results[0].value as Session; + expect(saveSpyRes.user).toBe(interaction.user.id); + expect(saveSpyRes.queue).toStrictEqual(queue._id); + expect(saveSpyRes.guild).toBe(interaction.guild!.id); + expect(saveSpyRes.role).toBe(SessionRole.coach); + expect(saveSpyRes.active).toBe(true); + expect(saveSpyRes.end_certain).toBe(false); + expect(saveSpyRes.rooms).toStrictEqual([]); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Tutor Session Started", + description: `You have started a tutor session for queue "test".`, + color: Colors.Green, + } + }] + }) + }) + + it("should fail if the guild has no queue", async () => { + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Error", + description: `The guild has no queue.`, + color: Colors.Red, + } + }] + }) + }) + + it("should fail if the queue does not exist", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + await createQueue(dbGuild, "test", "test description"); + + interaction.options.get = jest.fn().mockReturnValue({ value: "nonexistent" }); + + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Error", + description: `Could not find the queue "nonexistent".`, + color: Colors.Red, + } + }] + }) + }) + + it("should fail if the user has an active session", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + + await SessionModel.create({ + queue: queue, + user: interaction.user.id, + guild: interaction.guild!.id, + role: SessionRole.coach, + active: true, + started_at: new Date(), + end_certain: false, + rooms: [], + }) + + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Error", + description: `You have an active session and cannot perform this action.`, + color: Colors.Red, + } + }] + }) + }) +}) \ No newline at end of file diff --git a/src/commands/tutor/session/TutorSessionStartCommand.ts b/src/commands/tutor/session/TutorSessionStartCommand.ts new file mode 100644 index 0000000..8c5c65d --- /dev/null +++ b/src/commands/tutor/session/TutorSessionStartCommand.ts @@ -0,0 +1,84 @@ +import { BaseCommand } from "@baseCommand"; +import { Queue } from "@models/Queue"; +import { CouldNotAssignRoleError, CouldNotFindQueueError, CouldNotFindRoleError, GuildHasNoQueueError, InteractionNotInGuildError, UserHasActiveSessionError } from "@types"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { InternalRoles } from "@models/BotRoles"; + +export default class TutorSessionStartCommand extends BaseCommand { + public static name = "start"; + public static description = "Starts a tutor session."; + public static options = [ + { + name: "queue", + description: "The queue to start the tutor session for.", + type: ApplicationCommandOptionType.String, + required: false, + }, + ]; + + public async execute(): Promise { + await this.defer(); + try { + const queue = await this.startTutorSession(); + const embed = this.mountStartTutorSessionEmbed(queue); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountStartTutorSessionEmbed(queue: DocumentType): EmbedBuilder { + return new EmbedBuilder() + .setTitle("Tutor Session Started") + .setDescription(`You have started a tutor session for queue "${queue.name}".`) + .setColor(Colors.Green) + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof GuildHasNoQueueError || error instanceof CouldNotFindQueueError || error instanceof CouldNotAssignRoleError || error instanceof CouldNotFindRoleError || error instanceof UserHasActiveSessionError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red) + } + throw error + } + + private async startTutorSession(): Promise> { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild); + + // check if guild has a queue + if (!dbGuild.queues || dbGuild.queues.length === 0) { + this.app.logger.info(`Guild ${dbGuild.name} (id: ${dbGuild.id}) has no queue.`); + throw new GuildHasNoQueueError(); + } + + const queueName = this.getOptionValue(TutorSessionStartCommand.options[0]) + + // get the queue or the first queue available if no queue name is provided + let queue: DocumentType; + if (queueName != "") { + this.app.logger.info(`Looking for queue ${queueName} in guild ${dbGuild.name} (id: ${dbGuild.id})`); + queue = this.app.queueManager.getQueue(dbGuild, queueName); + } else { + this.app.logger.info(`No queue name provided, using the first queue available in guild ${dbGuild.name} (id: ${dbGuild.id})`); + queue = dbGuild.queues[0]; + } + + // set active session role + await this.app.userManager.assignRoleToUser(dbGuild, this.interaction.user, InternalRoles.ACTIVE_SESSION); + + // start tutor session + await this.app.queueManager.startTutorSession(queue, this.interaction.user); + return queue; + } +} \ No newline at end of file diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index 750d99b..2559619 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -2,7 +2,7 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { Application } from "@application"; import { Queue } from "@models/Queue"; import { DocumentType, mongoose } from "@typegoose/typegoose"; -import { AlreadyInQueueError, ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, CouldNotFindQueueError, InvalidEventError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError } from "@types"; +import { AlreadyInQueueError, ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, CouldNotFindQueueError, InvalidEventError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError, UserHasActiveSessionError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; import { QueueEntryModel } from "@models/QueueEntry"; import { EmbedBuilder, TextChannel, User, Guild as DiscordGuild } from "discord.js"; @@ -10,7 +10,7 @@ import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEventType } from "@models/Event"; import { Session } from "inspector"; import { InternalRoles } from "@models/BotRoles"; -import { SessionModel } from "@models/Session"; +import { SessionModel, SessionRole } from "@models/Session"; @injectable() @singleton() @@ -150,6 +150,33 @@ export default class QueueManager { return leaveMessage; } + public async startTutorSession(queue: DocumentType, user: User): Promise { + const dbGuild = queue.$parent() as DocumentType; + const dbUser = await this.app.userManager.getUser(user); + + // Check if user has active session + if (await dbUser.hasActiveSessions()) { + this.app.logger.info(`User "${user.username}" (id: ${user.id}) tried to start a tutor session but has an active session`); + throw new UserHasActiveSessionError(); + } + + const userSession = await SessionModel.create({ + user: user.id, + queue: queue._id, + guild: dbGuild._id, + role: SessionRole.coach, + active: true, + started_at: Date.now(), + end_certain: false, + rooms: [], + }); + await userSession.save(); + dbUser.sessions.push(userSession); + await dbUser.save(); + this.app.logger.info(`User "${user.username}" (id: ${user.id}) started a tutor session on queue "${queue.name}"`); + this.logQueueActivity(queue, QueueEventType.TUTOR_SESSION_START, user); + } + /** * Adds a queue info channel to the specified database guild. * diff --git a/src/managers/UserManager.ts b/src/managers/UserManager.ts index 0c6c086..cbc1a59 100644 --- a/src/managers/UserManager.ts +++ b/src/managers/UserManager.ts @@ -3,16 +3,32 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { User as DiscordUser } from "discord.js"; import { DocumentType } from "@typegoose/typegoose"; import { User, UserModel } from "@models/User"; +import { InternalRoles } from "@models/BotRoles"; +import { CouldNotFindRoleError, CouldNotAssignRoleError } from "@types"; +import { Guild as DatabaseGuild } from "@models/Guild"; +/** + * Manages user-related operations such as retrieving users, creating new users, and assigning roles to users. + */ @injectable() @singleton() export default class UserManager { protected app: Application; + /** + * Constructs a new instance of the UserManager class. + * @param app The application instance. + */ constructor(@inject(delay(() => Application)) app: Application) { this.app = app; } + /** + * Retrieves a user from the database based on the provided Discord user. + * If the user does not exist, a new user will be created and returned. + * @param user The Discord user. + * @returns A Promise that resolves to the retrieved or created user. + */ public async getUser(user: DiscordUser): Promise> { var userModel = await UserModel.findById(user.id); if (!userModel) { @@ -23,6 +39,11 @@ export default class UserManager { return userModel; } + /** + * Creates a new user in the database based on the provided Discord user. + * @param user The Discord user. + * @returns A Promise that resolves to the created user. + */ public async getDefaultUser(user: DiscordUser): Promise> { const newUser = new UserModel({ _id: user.id, @@ -31,4 +52,30 @@ export default class UserManager { this.app.logger.info(`Created new User "${user.tag}" (id: ${user.id})`); return newUser; } + + /** + * Assigns a role to a user in a guild. + * @param dbGuild The database guild. + * @param user The Discord user. + * @param roleName The internal name of the role to assign. + * @throws {CouldNotFindRoleError} if the specified role cannot be found in the guild. + * @throws {CouldNotAssignRoleError} if the role cannot be assigned to the user. + */ + public async assignRoleToUser(dbGuild: DocumentType, user: DiscordUser, roleName: InternalRoles): Promise { + const dbActiveSessionRole = dbGuild.guild_settings.roles?.find(role => role.internal_name == roleName); + if (!dbActiveSessionRole) { + this.app.logger.info(`Role "${roleName}" not found in guild "${dbGuild.name}" (id: ${dbGuild._id})`); + throw new CouldNotFindRoleError(roleName); + } + const guild = this.app.client.guilds.resolve(dbGuild._id)!; + const role = guild.roles.resolve(dbActiveSessionRole.role_id!); + const member = guild.members.resolve(user); + if (role && member && !member.roles.cache.has(role.id)) { + await member.roles.add(role); + this.app.logger.info(`Assigned role "${role.name}" to user "${user.tag}" (id: ${user.id})`); + } else { + this.app.logger.info(`Could not assign role "${roleName}" to user "${user.tag}" (id: ${user.id})`); + throw new CouldNotAssignRoleError(roleName, user); + } + } } \ No newline at end of file diff --git a/src/types/errors/CouldNotAssignRoleError.ts b/src/types/errors/CouldNotAssignRoleError.ts new file mode 100644 index 0000000..ed029b3 --- /dev/null +++ b/src/types/errors/CouldNotAssignRoleError.ts @@ -0,0 +1,28 @@ +import { User } from "discord.js"; + +/** + * Represents an error that occurs when a role cannot be assigned to a user. + */ +export default class CouldNotAssignRoleError extends Error { + /* + * The name of the role that could not be assigned to the user. + */ + public roleName: string; + + /* + * The user that could not be assigned the role. + */ + public user: User; + + /** + * Creates a new instance of CouldNotAssignRoleError. + * + * @param roleName The name of the role that could not be assigned to the user. + * @param user The user that could not be assigned the role. + */ + constructor(roleName: string, user: User) { + super(`Could not assign role: ${roleName} to user: ${user.tag}`); + this.roleName = roleName; + this.user = user; + } +} \ No newline at end of file diff --git a/src/types/errors/GuildHasNoQueueError.ts b/src/types/errors/GuildHasNoQueueError.ts new file mode 100644 index 0000000..68e1111 --- /dev/null +++ b/src/types/errors/GuildHasNoQueueError.ts @@ -0,0 +1,13 @@ +import { Guild } from "discord.js"; + +/** + * Custom error class representing an error when a guild has no queue. + */ +export default class GuildHasNoQueueError extends Error { + /** + * Creates an instance of GuildHasNoQueueError. + */ + constructor() { + super(`The guild has no queue.`); + } +} \ No newline at end of file diff --git a/src/types/errors/UserHasActiveSessionError.ts b/src/types/errors/UserHasActiveSessionError.ts index 195e594..55a4e00 100644 --- a/src/types/errors/UserHasActiveSessionError.ts +++ b/src/types/errors/UserHasActiveSessionError.ts @@ -3,6 +3,6 @@ */ export default class UserHasActiveSessionError extends Error { constructor() { - super(`You have an active session and cannot join the queue.`) + super(`You have an active session and cannot perform this action.`) } } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 017d565..39f8b7d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,10 +3,12 @@ import { StringReplacements } from "./StringReplacements"; import AlreadyInQueueError from "./errors/AlreadyInQueueError"; import ChannelAlreadyInfoChannelError from "./errors/ChannelAlreadyInfoChannelError"; import ChannelNotInfoChannelError from "./errors/ChannelNotInfoChannelError"; +import CouldNotAssignRoleError from "./errors/CouldNotAssignRoleError"; import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; import CouldNotFindQueueError from "./errors/CouldNotFindQueueError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; +import GuildHasNoQueueError from "./errors/GuildHasNoQueueError"; import InteractionNotInGuildError from "./errors/InteractionNotInGuildError"; import InvalidEventError from "./errors/InvalidEventError"; import MissingOptionError from "./errors/MissingOptionError"; @@ -24,6 +26,7 @@ export { CouldNotFindChannelError, CouldNotFindQueueError, CouldNotFindRoleError, + CouldNotAssignRoleError, RoleNotInDatabaseError, CouldNotFindTypeInFileError, NotInQueueError, @@ -34,4 +37,5 @@ export { InvalidEventError, ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, + GuildHasNoQueueError, } \ No newline at end of file diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 591a491..f2a9f01 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -5,10 +5,11 @@ import { mockGuildMember, mockTextChannel, mockUser, + mockRole, mockChatInputCommandInteraction } from '@shoginn/discordjs-mock'; import "reflect-metadata" -import { ChatInputCommandInteraction, Guild, GuildMember, TextBasedChannel, TextChannel, User } from 'discord.js'; +import { APIRole, ChatInputCommandInteraction, Guild, GuildMember, Role, TextBasedChannel, TextChannel, User } from 'discord.js'; import { container, singleton } from 'tsyringe'; import { randomInt } from 'crypto'; import assert from 'assert'; @@ -51,6 +52,10 @@ export class MockDiscord { return mockUser(this.app.client, { id: userId, username: userId, global_name: userId, discriminator: randomInt(9999).toString() }); } + public mockRole(guild: Guild = this.mockGuild(), role: Partial): Role { + return mockRole(this.app.client, "0", guild, role); + } + public mockGuildMember(user: User = this.mockUser(), guild: Guild = this.mockGuild()): GuildMember { return mockGuildMember({ client: this.app.client, diff --git a/tests/testutils.ts b/tests/testutils.ts index a04f1b9..f9e89b1 100644 --- a/tests/testutils.ts +++ b/tests/testutils.ts @@ -1,4 +1,4 @@ -import { DBRole, DBRoleModel, RoleScopes } from "@models/BotRoles"; +import { DBRole, DBRoleModel, InternalRoles, RoleScopes } from "@models/BotRoles"; import { Guild } from "@models/Guild" import { Queue, QueueModel } from "@models/Queue"; import { VoiceChannel, VoiceChannelModel } from "@models/VoiceChannel"; @@ -12,7 +12,7 @@ export const config = { Database: 'test' } -export async function createQueue(guild: DocumentType, name: string, description: string): Promise { +export async function createQueue(guild: DocumentType, name: string, description: string): Promise> { const queue = new QueueModel({ name: name, description: description, @@ -32,10 +32,10 @@ export async function createQueue(guild: DocumentType, name: string, desc return queue; } -export async function createRole(guild: DocumentType, name: string): Promise { +export async function createRole(guild: DocumentType, name: string, internalName: string = "tutor"): Promise { if (!guild.guild_settings.roles) guild.guild_settings.roles = new mongoose.Types.DocumentArray([]); const role = new DBRoleModel({ - internal_name: "tutor", + internal_name: internalName, role_id: name, scope: RoleScopes.SERVER, server_id: guild.id, From eb8a92409a17a6a47f8958c3d02b49e1fdccc290 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 15 Mar 2024 19:54:32 +0100 Subject: [PATCH 087/130] Update node modules --- package-lock.json | 335 +++++++++++++++++++++++++--------------------- 1 file changed, 182 insertions(+), 153 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe79530..3adda35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,13 +34,13 @@ } }, "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -140,9 +140,9 @@ } }, "node_modules/@babel/core": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", - "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", + "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -150,11 +150,11 @@ "@babel/generator": "^7.23.6", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.7", - "@babel/parser": "^7.23.6", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6", + "@babel/helpers": "^7.24.0", + "@babel/parser": "^7.24.0", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.0", + "@babel/types": "^7.24.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -284,9 +284,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", + "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", "dev": true, "engines": { "node": ">=6.9.0" @@ -344,14 +344,14 @@ } }, "node_modules/@babel/helpers": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.8.tgz", - "integrity": "sha512-KDqYz4PiOWvDFrdHLPhKtCThtIcKVy6avWD2oG4GEvyQ+XDZwHD4YQd+H2vNMnq2rkdxsDkU82T+Vk8U/WXHRQ==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.0.tgz", + "integrity": "sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA==", "dev": true, "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6" + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.0", + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -443,9 +443,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", + "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -632,23 +632,23 @@ } }, "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", - "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.0.tgz", + "integrity": "sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw==", "dev": true, "dependencies": { "@babel/code-frame": "^7.23.5", @@ -657,8 +657,8 @@ "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -667,9 +667,9 @@ } }, "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.23.4", @@ -807,9 +807,9 @@ } }, "node_modules/@fastify/busboy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", - "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", "engines": { "node": ">=14" } @@ -1118,32 +1118,32 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" @@ -1156,9 +1156,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1166,9 +1166,9 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.4.tgz", - "integrity": "sha512-8zJ8N1x51xo9hwPh6AWnKdLGEC5N3lDa6kms1YHmFBoRhTpJR6HG8wWk0td1MVCu9cD4YBrvjZEtd5Obw0Fbnw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.5.tgz", + "integrity": "sha512-XLNOMH66KhJzUJNwT/qlMnS4WsNDWD5ASdyaSH3EtK+F4r/CFGa3jT4GNi4mfOitGvWXtdLgQJkQjxSVrio+jA==", "dependencies": { "sparse-bitfield": "^3.0.3" } @@ -1266,21 +1266,21 @@ "dev": true }, "node_modules/@typegoose/typegoose": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-12.1.0.tgz", - "integrity": "sha512-RhqsFvTCTshtYxuzsHCGwPLJXgX1sc5aguZJ4w3ax6shVHaVQSG4VZddo/BowfP+0CSjp4J8XeCtrunkzkhJOg==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-12.2.0.tgz", + "integrity": "sha512-6gC5aIfccXw4IZOMe3oSef63M6leDyMvuycfwzQdVi8M9C5UnpaV8tO1xzmxiOxUHDST0GdDW/aWCCX6MtOQxg==", "dependencies": { "lodash": "^4.17.20", - "loglevel": "^1.8.1", + "loglevel": "^1.9.1", "reflect-metadata": "^0.2.1", - "semver": "^7.5.4", + "semver": "^7.6.0", "tslib": "^2.6.2" }, "engines": { "node": ">=16.20.1" }, "peerDependencies": { - "mongoose": "~8.1.0" + "mongoose": "~8.2.0" } }, "node_modules/@types/babel__core": { @@ -1358,9 +1358,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.11", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz", - "integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==", + "version": "29.5.12", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", + "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -1373,9 +1373,9 @@ "integrity": "sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ==" }, "node_modules/@types/node": { - "version": "20.11.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.6.tgz", - "integrity": "sha512-+EOokTnksGVgip2PbYbr3xnR7kZigh4LbybAfBAw5BpnQ+FqBYUsvCEjYd70IXKlbohQ64mzEYmMtlWUY8q//Q==", + "version": "20.11.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.28.tgz", + "integrity": "sha512-M/GPWVS2wLkSkNHVeLkrF2fD5Lx5UC4PxA0uZcKc6QqbIQUJyW1jVjueJYi1z8n0I5PxYrtpnPnWglE+y9A0KA==", "dependencies": { "undici-types": "~5.26.4" } @@ -1543,9 +1543,9 @@ } }, "node_modules/b4a": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", - "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", + "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==", "dev": true }, "node_modules/babel-jest": { @@ -1670,6 +1670,13 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bare-events": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.1.tgz", + "integrity": "sha512-9GYPpsPFvrWBkelIhOhTWtkeZxVxZOdb3VnFTCzlOo3OjvmTvzLoZFUT8kNFACx0vJej6QPney1Cf9BvzCNE/A==", + "dev": true, + "optional": true + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1693,9 +1700,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "dev": true, "funding": [ { @@ -1712,8 +1719,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, @@ -1788,9 +1795,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001580", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001580.tgz", - "integrity": "sha512-mtj5ur2FFPZcCEpXFy8ADXbDACuNFXg6mxVDqp7tqooX6l3zwm+d8EPoeOSIFRDvHs8qu7/SLFOGniULkcH2iA==", + "version": "1.0.30001597", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz", + "integrity": "sha512-7LjJvmQU6Sj7bL0j5b5WY/3n7utXUJvAe1lxhsHDbLmwX9mdL86Yjtr+5SRCyf8qME4M7pU2hswj0FpyBVCv9w==", "dev": true, "funding": [ { @@ -2082,20 +2089,20 @@ } }, "node_modules/dotenv": { - "version": "16.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", - "integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==", + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" + "url": "https://dotenvx.com" } }, "node_modules/electron-to-chromium": { - "version": "1.4.645", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.645.tgz", - "integrity": "sha512-EeS1oQDCmnYsRDRy2zTeC336a/4LZ6WKqvSaM1jLocEk5ZuyszkQtCpsqvuvaIXGOUjwtvF6LTcS8WueibXvSw==", + "version": "1.4.707", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.707.tgz", + "integrity": "sha512-qRq74Mo7ChePOU6GHdfAJ0NREXU8vQTlVlfWz3wNygFay6xrd/fY2J7oGHwrhFeU30OVctGLdTh/FcnokTWpng==", "dev": true }, "node_modules/emittery": { @@ -2126,9 +2133,9 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, "engines": { "node": ">=6" @@ -2306,9 +2313,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "dev": true, "funding": [ { @@ -2464,9 +2471,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "dependencies": { "function-bind": "^1.1.2" @@ -2482,9 +2489,9 @@ "dev": true }, "node_modules/https-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", - "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "dev": true, "dependencies": { "agent-base": "^7.0.2", @@ -2553,10 +2560,23 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, - "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "devOptional": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "devOptional": true }, "node_modules/is-arrayish": { @@ -2632,14 +2652,14 @@ } }, "node_modules/istanbul-lib-instrument": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", - "integrity": "sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.2.tgz", + "integrity": "sha512-1WUsZ9R1lA0HtBSohTkm39WTPlNKSJ5iFk7UwqXkBLoHQT+hfqPsfsTDVuZdKGaBwn7din9bS7SsnoAr943hvw==", "dev": true, "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" }, @@ -2676,9 +2696,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "dev": true, "dependencies": { "html-escaper": "^2.0.0", @@ -3262,6 +3282,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "devOptional": true + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -3363,9 +3389,9 @@ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" }, "node_modules/loglevel": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", - "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", "engines": { "node": ">= 0.6.0" }, @@ -3392,9 +3418,9 @@ } }, "node_modules/magic-bytes.js": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.8.0.tgz", - "integrity": "sha512-lyWpfvNGVb5lu8YUAbER0+UMBTdR63w2mcSUlhhBTyVbxJvjgqwyAf3AZD6MprgK0uHuBoWXSDAMWLupX83o3Q==" + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.10.0.tgz", + "integrity": "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==" }, "node_modules/make-dir": { "version": "4.0.0", @@ -3540,13 +3566,13 @@ } }, "node_modules/mongodb-memory-server": { - "version": "9.1.6", - "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-9.1.6.tgz", - "integrity": "sha512-gzcpgGYlPhuKmria37W+bvYy6W+OkX2UVG7MoP41OWFvQv2Hn7A+fLXkV+lsMmhog1lMQprdV6AR+gixgheLaw==", + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-9.1.7.tgz", + "integrity": "sha512-Yxw1cUMoCKTK6jxk4cKG07P+Z/qOmuCVyt3ScIDaoHeOCbOlg2sEtXYO9vEK/tzpj/1KHdDStU2oYrsJ8Fvm0A==", "dev": true, "hasInstallScript": true, "dependencies": { - "mongodb-memory-server-core": "9.1.6", + "mongodb-memory-server-core": "9.1.7", "tslib": "^2.6.2" }, "engines": { @@ -3554,9 +3580,9 @@ } }, "node_modules/mongodb-memory-server-core": { - "version": "9.1.6", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-9.1.6.tgz", - "integrity": "sha512-3H/dq5II+XcSbK80hicMw4zFlDxcpjt4oWJq76RlOVuLoaf3AFqVheR6Vqx9ymlIqER4Jni58FMCIIRbesia1A==", + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-9.1.7.tgz", + "integrity": "sha512-q8geqCmt5hGuxaDhRo03ZUB0ITr6lnJ3jffdNiC4nDq13WbHUfY2A1RQq3OHDbdrY6aRYvZphx2bcXYBFRis3A==", "dev": true, "dependencies": { "async-mutex": "^0.4.0", @@ -3589,9 +3615,9 @@ } }, "node_modules/mongoose": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.1.1.tgz", - "integrity": "sha512-DbLb0NsiEXmaqLOpEz+AtAsgwhRw6f25gwa1dF5R7jj6lS1D8X6uTdhBSC8GDVtOwe5Tfw2EL7nTn6hiJT3Bgg==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.2.1.tgz", + "integrity": "sha512-UgZZbXSJH0pdU936qj3FyVI+sBsMoGowFnL5R/RYrA50ayn6+ZYdVr8ehsRgNxRcMYwoNld5XzHIfkFRJTePEw==", "peer": true, "dependencies": { "bson": "^6.2.0", @@ -3620,9 +3646,9 @@ } }, "node_modules/mongoose/node_modules/bson": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.2.0.tgz", - "integrity": "sha512-ID1cI+7bazPDyL9wYy9GaQ8gEEohWvcUl/Yf0dIdutJxnmInEEyCsb4awy/OiBfall7zBA179Pahi3vCdFze3Q==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.5.0.tgz", + "integrity": "sha512-DXf1BTAS8vKyR90BO4x5v3rKVarmkdkzwOrnYDFdjAY694ILNDkmA3uRh1xXJEl+C1DAh8XCvAQ+Gh3kzubtpg==", "peer": true, "engines": { "node": ">=16.20.1" @@ -4100,9 +4126,9 @@ } }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -4188,16 +4214,16 @@ } }, "node_modules/socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", + "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", "devOptional": true, "dependencies": { - "ip": "^2.0.0", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { - "node": ">= 10.13.0", + "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, @@ -4247,13 +4273,16 @@ } }, "node_modules/streamx": { - "version": "2.15.6", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.6.tgz", - "integrity": "sha512-q+vQL4AAz+FdfT137VF69Cc/APqUbxy+MDOImRrMvchJpigHj9GksgDU2LYbO9rx7RX6osWgxJB2WxhYv4SZAw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.16.1.tgz", + "integrity": "sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==", "dev": true, "dependencies": { "fast-fifo": "^1.1.0", "queue-tick": "^1.0.1" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" } }, "node_modules/string-length": { @@ -4457,9 +4486,9 @@ } }, "node_modules/ts-mixer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz", - "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==" + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==" }, "node_modules/ts-node": { "version": "10.9.2", @@ -4588,9 +4617,9 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4600,9 +4629,9 @@ } }, "node_modules/typescript-transform-paths": { - "version": "3.4.6", - "resolved": "https://registry.npmjs.org/typescript-transform-paths/-/typescript-transform-paths-3.4.6.tgz", - "integrity": "sha512-qdgpCk9oRHkIBhznxaHAapCFapJt5e4FbFik7Y4qdqtp6VyC3smAIPoDEIkjZ2eiF7x5+QxUPYNwJAtw0thsTw==", + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/typescript-transform-paths/-/typescript-transform-paths-3.4.7.tgz", + "integrity": "sha512-1Us1kdkdfKd2unbkBAOV2HHRmbRBYpSuk9nJ7cLD2hP4QmfToiM/VpxNlhJc1eezVwVqSKSBjNSzZsK/fWR/9A==", "dev": true, "dependencies": { "minimatch": "^3.0.4" From 20c29b494b5c823303efcb636f9627ac21c5321d Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 15 Mar 2024 19:54:40 +0100 Subject: [PATCH 088/130] Remove unused import --- tests/testSetup.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/testSetup.ts b/tests/testSetup.ts index 17f6310..675d24f 100644 --- a/tests/testSetup.ts +++ b/tests/testSetup.ts @@ -4,7 +4,6 @@ import { Severity, setGlobalOptions } from "@typegoose/typegoose" setGlobalOptions({ options: { allowMixed: Severity.ALLOW } }); import { mongoose } from "@typegoose/typegoose"; -import { after } from "node:test"; beforeAll(async () => { // put your client connection code here, example with mongoose: From 4b4028da6ec0d1e21b45f0534eb20bca971cf268 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 15 Mar 2024 19:57:10 +0100 Subject: [PATCH 089/130] Fix problems in tests through new package versions --- src/commands/admin/UpdateBotRolesCommand.test.ts | 2 +- src/commands/queue/QueueJoinCommand.test.ts | 2 +- src/events/GuildAddMemberEvent.test.ts | 4 ++-- src/events/GuildCreateEvent.test.ts | 2 +- src/events/GuildUpdateEvent.test.ts | 2 +- src/events/ReadyEvent.test.ts | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands/admin/UpdateBotRolesCommand.test.ts b/src/commands/admin/UpdateBotRolesCommand.test.ts index b4ba70e..03f219f 100644 --- a/src/commands/admin/UpdateBotRolesCommand.test.ts +++ b/src/commands/admin/UpdateBotRolesCommand.test.ts @@ -202,7 +202,7 @@ describe("UpdateBotRolesCommand", () => { } await dbGuild.save() - const guildSaveSpy = jest.spyOn(GuildModel.prototype, 'save') + const guildSaveSpy = jest.spyOn(GuildModel.prototype as any, 'save') await commandInstance.execute() dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) diff --git a/src/commands/queue/QueueJoinCommand.test.ts b/src/commands/queue/QueueJoinCommand.test.ts index 78fda93..62c4f8d 100644 --- a/src/commands/queue/QueueJoinCommand.test.ts +++ b/src/commands/queue/QueueJoinCommand.test.ts @@ -198,7 +198,7 @@ describe("QueueJoinCommand", () => { expect(embedData).toEqual({ title: "Error", - description: `You have an active session and cannot join the queue.`, + description: `You have an active session and cannot perform this action.`, color: Colors.Red, }); }) diff --git a/src/events/GuildAddMemberEvent.test.ts b/src/events/GuildAddMemberEvent.test.ts index 4ad63dd..b8b5b15 100644 --- a/src/events/GuildAddMemberEvent.test.ts +++ b/src/events/GuildAddMemberEvent.test.ts @@ -33,7 +33,7 @@ describe("GuildAddMemberEvent", () => { }) it("should create a new user in the database if it doesn't exist", async () => { - const saveSpy = jest.spyOn(UserModel.prototype, 'save') + const saveSpy = jest.spyOn(UserModel.prototype as any, 'save') await eventInstance.execute(member) expect(saveSpy).toHaveBeenCalledTimes(1) @@ -44,7 +44,7 @@ describe("GuildAddMemberEvent", () => { it("should not create a new user in the database if it already exists", async () => { await discord.getApplication().userManager.getUser(member.user) jest.clearAllMocks() // seems to be necessary here because the saveSpy is still set from the previous test - const saveSpy = jest.spyOn(UserModel.prototype, 'save') + const saveSpy = jest.spyOn(UserModel.prototype as any, 'save') await eventInstance.execute(member) diff --git a/src/events/GuildCreateEvent.test.ts b/src/events/GuildCreateEvent.test.ts index 1dfeada..b9fc043 100644 --- a/src/events/GuildCreateEvent.test.ts +++ b/src/events/GuildCreateEvent.test.ts @@ -31,7 +31,7 @@ describe("GuildCreateEvent", () => { it("should create a new guild in the database", async () => { const findSpy = jest.spyOn(GuildModel, 'findById') - const saveSpy = jest.spyOn(GuildModel.prototype, 'save') + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save') await eventInstance.execute(guild) expect(findSpy).toHaveBeenCalledTimes(1) diff --git a/src/events/GuildUpdateEvent.test.ts b/src/events/GuildUpdateEvent.test.ts index d4c2223..5290ad5 100644 --- a/src/events/GuildUpdateEvent.test.ts +++ b/src/events/GuildUpdateEvent.test.ts @@ -32,7 +32,7 @@ describe("GuildUpdateEvent", () => { it ("should update the guild name in the database if the name changed", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(oldGuild) - const saveSpy = jest.spyOn(GuildModel.prototype, 'save') + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save') const newGuild = { ...oldGuild } as Guild newGuild.name = "new name" await eventInstance.execute(oldGuild, newGuild) diff --git a/src/events/ReadyEvent.test.ts b/src/events/ReadyEvent.test.ts index 52e4cad..c81e8e3 100644 --- a/src/events/ReadyEvent.test.ts +++ b/src/events/ReadyEvent.test.ts @@ -37,7 +37,7 @@ describe("ReadyEvent", () => { await discord.getApplication().configManager.getGuildConfig(guild) const findSpy = jest.spyOn(GuildModel, 'findById') - const saveSpy = jest.spyOn(GuildModel.prototype, 'save') + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save') await eventInstance.execute() expect(findSpy).toHaveBeenCalledWith(guild.id) @@ -47,7 +47,7 @@ describe("ReadyEvent", () => { it("should create a new guild entry in the database if it does not exist", async () => { const findSpy = jest.spyOn(GuildModel, 'findById') - const saveSpy = jest.spyOn(GuildModel.prototype, 'save') + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save') await eventInstance.execute() expect(findSpy).toHaveBeenCalledWith(guild.id) From 6562ea6f2a2c777c605fa49653c1915def2a8628 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 15 Mar 2024 19:59:43 +0100 Subject: [PATCH 090/130] Add documentation --- .../tutor/session/TutorSessionStartCommand.ts | 20 +++++++++++++++++++ src/managers/QueueManager.ts | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/src/commands/tutor/session/TutorSessionStartCommand.ts b/src/commands/tutor/session/TutorSessionStartCommand.ts index 8c5c65d..10a7c48 100644 --- a/src/commands/tutor/session/TutorSessionStartCommand.ts +++ b/src/commands/tutor/session/TutorSessionStartCommand.ts @@ -5,6 +5,9 @@ import { ApplicationCommandOptionType, Colors, EmbedBuilder } from "discord.js"; import { DocumentType } from "@typegoose/typegoose"; import { InternalRoles } from "@models/BotRoles"; +/** + * Represents a command to start a tutor session. + */ export default class TutorSessionStartCommand extends BaseCommand { public static name = "start"; public static description = "Starts a tutor session."; @@ -33,6 +36,12 @@ export default class TutorSessionStartCommand extends BaseCommand { } } + /** + * Mounts the embed for a successful tutor session start. + * + * @param queue - The queue for the tutor session. + * @returns The embed builder for the tutor session start. + */ private mountStartTutorSessionEmbed(queue: DocumentType): EmbedBuilder { return new EmbedBuilder() .setTitle("Tutor Session Started") @@ -40,6 +49,12 @@ export default class TutorSessionStartCommand extends BaseCommand { .setColor(Colors.Green) } + /** + * Mounts the embed for an error during tutor session start. + * + * @param error - The error that occurred. + * @returns The embed builder for the error. + */ private mountErrorEmbed(error: Error): EmbedBuilder { if (error instanceof GuildHasNoQueueError || error instanceof CouldNotFindQueueError || error instanceof CouldNotAssignRoleError || error instanceof CouldNotFindRoleError || error instanceof UserHasActiveSessionError) { return new EmbedBuilder() @@ -50,6 +65,11 @@ export default class TutorSessionStartCommand extends BaseCommand { throw error } + /** + * Starts the tutor session. + * + * @returns The queue for the tutor session. + */ private async startTutorSession(): Promise> { if (!this.interaction.guild) { throw new InteractionNotInGuildError(this.interaction); diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index 2559619..9e34703 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -150,6 +150,13 @@ export default class QueueManager { return leaveMessage; } + /** + * Starts a tutor session for a user in the specified queue. + * + * @param queue - The queue for which the tutor session is started. + * @param user - The user starting the tutor session. + * @throws {UserHasActiveSessionError} if the user already has an active session. + */ public async startTutorSession(queue: DocumentType, user: User): Promise { const dbGuild = queue.$parent() as DocumentType; const dbUser = await this.app.userManager.getUser(user); From 9d3ba558161964e4ffbb8f244545c49f1d5102d3 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 15 Mar 2024 20:04:19 +0100 Subject: [PATCH 091/130] Move loaders to their own directory --- src/Application.ts | 4 ++-- src/{utils => loaders}/CommandsLoader.ts | 0 src/{utils => loaders}/EventsLoader.ts | 0 src/{utils => loaders}/Loader.ts | 0 tsconfig.json | 1 + 5 files changed, 3 insertions(+), 2 deletions(-) rename src/{utils => loaders}/CommandsLoader.ts (100%) rename src/{utils => loaders}/EventsLoader.ts (100%) rename src/{utils => loaders}/Loader.ts (100%) diff --git a/src/Application.ts b/src/Application.ts index afd6ec8..ab2024a 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -8,10 +8,10 @@ import { container, delay, inject, injectable, singleton } from "tsyringe" import Environment from "./Environment" import mongoose from "mongoose" import path from "path" -import CommandsLoader from "@utils/CommandsLoader" import { BaseEvent } from "@baseEvent" import { BaseCommandOrSubcommandsHandler } from "@baseCommand" -import EventsLoader from "@utils/EventsLoader" +import CommandsLoader from "@loaders/CommandsLoader" +import EventsLoader from "@loaders/EventsLoader" /** * The main `Application` class. diff --git a/src/utils/CommandsLoader.ts b/src/loaders/CommandsLoader.ts similarity index 100% rename from src/utils/CommandsLoader.ts rename to src/loaders/CommandsLoader.ts diff --git a/src/utils/EventsLoader.ts b/src/loaders/EventsLoader.ts similarity index 100% rename from src/utils/EventsLoader.ts rename to src/loaders/EventsLoader.ts diff --git a/src/utils/Loader.ts b/src/loaders/Loader.ts similarity index 100% rename from src/utils/Loader.ts rename to src/loaders/Loader.ts diff --git a/tsconfig.json b/tsconfig.json index fdb6e76..c5e4703 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "@managers": ["src/managers"], "@types": ["src/types"], "@utils/*": ["src/utils/*"], + "@loaders/*": ["src/loaders/*"], "@tests/*": ["tests/*"], "@models/*": ["src/models/*"], "@commands/*": ["src/commands/*"], From e1de8aa1bf473cf913583333cf675b96f39ea6b2 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 15 Mar 2024 21:11:04 +0100 Subject: [PATCH 092/130] Improve tests --- src/commands/PingCommand.test.ts | 40 ++-- .../admin/UpdateBotRolesCommand.test.ts | 21 +- .../queue/AddQueueInfoChannelCommand.test.ts | 180 +++++++-------- .../config/queue/CreateQueueCommand.test.ts | 91 +++----- .../RemoveQueueInfoChannelCommand.test.ts | 44 ++-- .../queue/SetWaitingRoomCommand.test.ts | 131 +++++------ src/commands/queue/QueueInfoCommand.test.ts | 93 ++++---- src/commands/queue/QueueJoinCommand.test.ts | 205 +++++++----------- src/commands/queue/QueueLeaveCommand.test.ts | 30 +-- src/commands/queue/QueueListCommand.test.ts | 44 ++-- src/events/GuildAddMemberEvent.test.ts | 17 +- tests/testutils.ts | 12 +- 12 files changed, 376 insertions(+), 532 deletions(-) diff --git a/src/commands/PingCommand.test.ts b/src/commands/PingCommand.test.ts index a05a232..5fbdb2a 100644 --- a/src/commands/PingCommand.test.ts +++ b/src/commands/PingCommand.test.ts @@ -38,28 +38,24 @@ describe("PingCommand", () => { await commandInstance.execute() expect(editSpy).toHaveBeenCalledTimes(1) - expect(editSpy).toHaveBeenCalledWith({ content: "Pong.", embeds: expect.anything() }) - - const messageContent = editSpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - - expect(embedData).toEqual({ - title: "__Response Times__", - fields: expect.arrayContaining([ - expect.objectContaining({ - name: "Bot Latency:", - value: expect.stringContaining(":hourglass_flowing_sand:"), - }), - expect.objectContaining({ - name: "API Latency:", - value: expect.stringContaining(":hourglass_flowing_sand:"), - }), - ]), - color: interaction.guild?.members.me?.roles.highest.color || 0x7289da + expect(editSpy).toHaveBeenCalledWith({ + content: "Pong.", + embeds: [{ + data: { + title: "__Response Times__", + fields: expect.arrayContaining([ + expect.objectContaining({ + name: "Bot Latency:", + value: expect.stringContaining(":hourglass_flowing_sand:"), + }), + expect.objectContaining({ + name: "API Latency:", + value: expect.stringContaining(":hourglass_flowing_sand:"), + }), + ]), + color: interaction.guild?.members.me?.roles.highest.color || 0x7289da + } + }] }) }) diff --git a/src/commands/admin/UpdateBotRolesCommand.test.ts b/src/commands/admin/UpdateBotRolesCommand.test.ts index 03f219f..1770dca 100644 --- a/src/commands/admin/UpdateBotRolesCommand.test.ts +++ b/src/commands/admin/UpdateBotRolesCommand.test.ts @@ -221,18 +221,13 @@ describe("UpdateBotRolesCommand", () => { const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute() - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - expect(embedData.title).toBe("Administration") - expect(embedData.description).toContain("Done generating internal Roles. Internal Roles:") - expect(embedData.description).toContain("Unassigned Roles:") - for (const internalRole of InternalGuildRoles) { - expect(embedData.description).toContain(internalRole) - } + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Administration", + description: expect.stringContaining("Done generating internal Roles. Internal Roles:") + } + }] + }) }) }); diff --git a/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts index 82f83cc..da07aa5 100644 --- a/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts +++ b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts @@ -4,6 +4,8 @@ import { container } from "tsyringe"; import AddQueueInfoChannelCommand from "./AddQueueInfoChannelCommand"; import { QueueEventType } from "@models/Event"; import { eventNames } from "process"; +import { createQueue } from "@tests/testutils"; +import { GuildModel } from "@models/Guild"; describe("AddQueueInfoChannelCommand", () => { const command = AddQueueInfoChannelCommand; @@ -82,45 +84,40 @@ describe("AddQueueInfoChannelCommand", () => { it("should set the queue info channel and reply with a success message", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: interaction.options.get("queue")!.value as string, - description: "test description", - tracks: [], - } - dbGuild.queues.push(queue); - await dbGuild.save(); + const queue = await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description"); + jest.clearAllMocks(); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute(); + expect(saveSpy).toHaveBeenCalledTimes(1); + const saveSpyRes = await saveSpy.mock.results[0].value; + expect(saveSpyRes.queues[0].info_channels).toHaveLength(1); + expect(saveSpyRes.queues[0].info_channels[0]).toMatchObject({ + channel_id: "test channel", + events: Object.values(QueueEventType) + }); const channelName = interaction.options.get("channel")!.value as string; expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith( - { - embeds: [{ - data: { - title: "Queue Info Channel Added", - description: `The channel ${channelName} was added to the queue ${queue.name} info channels.`, - color: Colors.Green, - fields: [{ - name: "Events", - value: Object.values(QueueEventType).join(", ") - }] - } - }] - } - ); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Queue Info Channel Added", + description: `The channel ${channelName} was added to the queue ${queue.name} info channels.`, + color: Colors.Green, + fields: [{ + name: "Events", + value: Object.values(QueueEventType).join(", ") + }] + } + }] + }); }) it("should fail if the channel is not found", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: interaction.options.get("queue")!.value as string, - description: "test description", - tracks: [], - } - dbGuild.queues.push(queue); - await dbGuild.save(); + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description"); interaction.options.get = jest.fn().mockImplementation((option: string) => { switch (option) { @@ -140,17 +137,15 @@ describe("AddQueueInfoChannelCommand", () => { const channelName = interaction.options.get("channel")!.value as string; expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith( - { - embeds: [{ - data: { - title: "Error", - description: `Could not find channel "${channelName}".`, - color: Colors.Red - } - }] - } - ); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Error", + description: `Could not find channel "${channelName}".`, + color: Colors.Red + } + }] + }); }) it("should fail if the queue is not found", async () => { @@ -158,17 +153,15 @@ describe("AddQueueInfoChannelCommand", () => { await commandInstance.execute(); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith( - { - embeds: [{ - data: { - title: "Error", - description: `Could not find the queue "${interaction.options.get("queue")!.value}".`, - color: Colors.Red - } - }] - } - ); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Error", + description: `Could not find the queue "${interaction.options.get("queue")!.value}".`, + color: Colors.Red + } + }] + }); }) it("should fail if the event is not valid", async () => { @@ -186,30 +179,22 @@ describe("AddQueueInfoChannelCommand", () => { }) const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: interaction.options.get("queue")!.value as string, - description: "test description", - tracks: [], - } - dbGuild.queues.push(queue); - await dbGuild.save(); + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description"); const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute(); const eventNames = interaction.options.get("events")!.value as string; expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith( - { - embeds: [{ - data: { - title: "Error", - description: `Invalid event: "${eventNames}". Valid events: "${Object.values(QueueEventType).join(`", "`)}".`, - color: Colors.Red - } - }] - } - ); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Error", + description: `Invalid event: "${eventNames}". Valid events: "${Object.values(QueueEventType).join(`", "`)}".`, + color: Colors.Red + } + }] + }); }) it("should fail if the channel is not a text channel", async () => { @@ -227,58 +212,41 @@ describe("AddQueueInfoChannelCommand", () => { }) const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: interaction.options.get("queue")!.value as string, - description: "test description", - tracks: [], - } - dbGuild.queues.push(queue); - await dbGuild.save(); + await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description"); const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute(); const channelName = interaction.options.get("channel")!.value as string; expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith( - { - embeds: [{ - data: { - title: "Error", - description: `Could not find channel "${channelName}" with type "${ChannelType[ChannelType.GuildText]}".`, - color: Colors.Red - } - }] - } - ); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Error", + description: `Could not find channel "${channelName}" with type "${ChannelType[ChannelType.GuildText]}".`, + color: Colors.Red + } + }] + }); }) it("should fail if the channel is already a queue info channel", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: interaction.options.get("queue")!.value as string, - description: "test description", - tracks: [], - info_channels: [{ channel_id: "test channel", events: Object.values(QueueEventType) }] - } - dbGuild.queues.push(queue); - await dbGuild.save(); + const queue = await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description", [], false, [{ channel_id: "test channel", events: Object.values(QueueEventType) }]); const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute(); const channelName = interaction.options.get("channel")!.value as string; expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith( - { - embeds: [{ - data: { - title: "Error", - description: `The channel "${channelName}" is already a queue info channel for the queue "${queue.name}".`, - color: Colors.Red - } - }] - } - ); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Error", + description: `The channel "${channelName}" is already a queue info channel for the queue "${queue.name}".`, + color: Colors.Red + } + }] + }); }) }) \ No newline at end of file diff --git a/src/commands/config/queue/CreateQueueCommand.test.ts b/src/commands/config/queue/CreateQueueCommand.test.ts index b698749..e1e9a75 100644 --- a/src/commands/config/queue/CreateQueueCommand.test.ts +++ b/src/commands/config/queue/CreateQueueCommand.test.ts @@ -3,6 +3,7 @@ import CreateQueueCommand from "./CreateQueueCommand" import { container } from "tsyringe" import { Application, ApplicationCommandOptionType, BaseMessageOptions, ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js" import { OptionRequirement } from "@types" +import { createQueue } from "@tests/testutils" describe("CreateQueueCommand", () => { const command = CreateQueueCommand @@ -16,7 +17,7 @@ describe("CreateQueueCommand", () => { interaction.options.get = jest.fn().mockImplementation((option: string) => { switch (option) { case "name": - return { value: "test name" } + return { value: "test name" } case "description": return { value: "test description" } default: @@ -61,18 +62,14 @@ describe("CreateQueueCommand", () => { await commandInstance.execute() expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - - expect(embedData).toEqual({ - title: "Queue Created", - description: expect.stringContaining(`Queue "${interaction.options.get("name")?.value}" created.`), - color: Colors.Green + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Queue Created", + description: `Queue "${interaction.options.get("name")?.value}" created.`, + color: Colors.Green + } + }] }) }) @@ -82,71 +79,55 @@ describe("CreateQueueCommand", () => { dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) expect(dbGuild.queues).toHaveLength(1) - expect(dbGuild.queues[0].name).toBe("test name") - expect(dbGuild.queues[0].description).toBe("test description") + expect(dbGuild.queues[0]).toMatchObject({ + name: "test name", + description: "test description", + }) }) it("should fail if the queue name is already taken on the same guild", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - const queue = { - name: "test name", - description: "test description", - tracks: [] - } - dbGuild.queues.push(queue) - await dbGuild.save() + const queue = await createQueue(dbGuild, "test name", "test description") const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute() expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - expect(embedData).toEqual({ - title: "Queue Creation Failed", - description: expect.stringContaining(`Queue with name "${queue.name}" already exists.`), - color: Colors.Red + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Queue Creation Failed", + description: `Queue with name "${queue.name}" already exists.`, + color: Colors.Red + } + }] }) }) it("should create a queue if the queue name is already taken on another guild", async () => { - const queue = { - name: "test name", - description: "test description", - tracks: [] - } const otherGuild = discord.mockGuild() let dbGuild = await discord.getApplication().configManager.getGuildConfig(otherGuild) - dbGuild.queues.push(queue) - await dbGuild.save() + await createQueue(dbGuild, "test name", "test description") const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute() dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) expect(dbGuild.queues).toHaveLength(1) - expect(dbGuild.queues[0].name).toBe("test name") - expect(dbGuild.queues[0].description).toBe("test description") + expect(dbGuild.queues[0]).toMatchObject({ + name: "test name", + description: "test description", + }) expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - - expect(embedData).toEqual({ - title: "Queue Created", - description: expect.stringContaining("Queue"), - color: Colors.Green + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Queue Created", + description: `Queue "${interaction.options.get("name")?.value}" created.`, + color: Colors.Green + } + }] }) }) diff --git a/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts b/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts index 265769d..cf7028d 100644 --- a/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts +++ b/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts @@ -3,6 +3,8 @@ import { ChannelType, ChatInputCommandInteraction, Colors } from "discord.js"; import { container } from "tsyringe"; import RemoveQueueInfoChannelCommand from "./RemoveQueueInfoChannelCommand"; import { QueueEventType } from "@models/Event"; +import { createQueue } from "@tests/testutils"; +import { GuildModel } from "@models/Guild"; describe("RemoveQueueInfoChannelCommand", () => { const command = RemoveQueueInfoChannelCommand; @@ -70,18 +72,17 @@ describe("RemoveQueueInfoChannelCommand", () => { const channelName = interaction.options.get("channel")!.value as string; const queueName = interaction.options.get("queue")!.value as string; const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: queueName, - description: "test description", - tracks: [], - info_channels: [{ channel_id: channelName, events: Object.values(QueueEventType) }], - } - dbGuild.queues.push(queue); - await dbGuild.save(); + await createQueue(dbGuild, queueName, "test description", [], false, [{ channel_id: channelName, events: Object.values(QueueEventType) }]); + jest.clearAllMocks(); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); const replySpy = jest.spyOn(interaction, 'editReply'); await commandInstance.execute(); + expect(saveSpy).toHaveBeenCalledTimes(1); + const saveSpyRes = await saveSpy.mock.results[0].value; + const queueInfoChannel = saveSpyRes.queues.find((q: any) => q.name === queueName).info_channels.find((c: any) => c.channel_id === channelName); + expect(queueInfoChannel).toBeUndefined(); expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledWith( { @@ -100,13 +101,7 @@ describe("RemoveQueueInfoChannelCommand", () => { const channelName = "this channel does not exist"; const queueName = interaction.options.get("queue")!.value as string; const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: queueName, - description: "test description", - tracks: [], - } - dbGuild.queues.push(queue); - await dbGuild.save(); + await createQueue(dbGuild, queueName, "test description"); interaction.options.get = jest.fn().mockImplementation((option: string) => { switch (option) { @@ -140,13 +135,7 @@ describe("RemoveQueueInfoChannelCommand", () => { const channelName = "another channel"; const queueName = interaction.options.get("queue")!.value as string; const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: queueName, - description: "test description", - tracks: [], - } - dbGuild.queues.push(queue); - await dbGuild.save(); + await createQueue(dbGuild, queueName, "test description"); interaction.options.get = jest.fn().mockImplementation((option: string) => { switch (option) { @@ -178,9 +167,8 @@ describe("RemoveQueueInfoChannelCommand", () => { }) it("should fail if the queue is not found", async () => { - const channelName = interaction.options.get("channel")!.value as string; const queueName = interaction.options.get("queue")!.value as string; - + const replySpy = jest.spyOn(interaction, 'editReply'); await commandInstance.execute(); @@ -202,13 +190,7 @@ describe("RemoveQueueInfoChannelCommand", () => { const channelName = interaction.options.get("channel")!.value as string; const queueName = interaction.options.get("queue")!.value as string; const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: queueName, - description: "test description", - tracks: [], - } - dbGuild.queues.push(queue); - await dbGuild.save(); + await createQueue(dbGuild, queueName, "test description"); const replySpy = jest.spyOn(interaction, 'editReply'); await commandInstance.execute(); diff --git a/src/commands/config/queue/SetWaitingRoomCommand.test.ts b/src/commands/config/queue/SetWaitingRoomCommand.test.ts index e8d34f6..dd5c4f2 100644 --- a/src/commands/config/queue/SetWaitingRoomCommand.test.ts +++ b/src/commands/config/queue/SetWaitingRoomCommand.test.ts @@ -3,6 +3,7 @@ import SetWaitingRoomCommand from "./SetWaitingRoomCommand" import { container } from "tsyringe" import { BaseMessageOptions, ChannelType, ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js" import { createQueue, createRole, createWaitingRoom } from "@tests/testutils" +import { GuildModel } from "@models/Guild" describe("SetWaitingRoomCommand", () => { const command = SetWaitingRoomCommand @@ -92,22 +93,28 @@ describe("SetWaitingRoomCommand", () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) + + jest.clearAllMocks(); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute() + expect(saveSpy).toHaveBeenCalledTimes(1) + const saveSpyRes = await saveSpy.mock.results[0].value + expect(saveSpyRes.voice_channels).toHaveLength(1) + expect(saveSpyRes.voice_channels[0]).toMatchObject({ + id: "test channel", + supervisors: [`test supervisor ${interaction.guild}`], + }) expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - - expect(embedData).toEqual({ - title: "Waiting Room Set", - description: expect.stringContaining(`:white_check_mark: Waiting room [object Object] set for queue "${interaction.options.get("queue")?.value}".`), - color: Colors.Green + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Waiting Room Set", + description: expect.stringContaining(`:white_check_mark: Waiting room [object Object] set for queue "${interaction.options.get("queue")?.value}".`), + color: Colors.Green + } + }] }) }) @@ -166,18 +173,14 @@ describe("SetWaitingRoomCommand", () => { await commandInstance.execute() expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - expect(embedData).toEqual({ - title: "Could Not Set Waiting Room", - description: expect.stringContaining(`Could not find channel "test channel" with type "GuildVoice".`), - color: Colors.Red + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Could Not Set Waiting Room", + description: `:x: Could not find channel "test channel" with type "GuildVoice".`, + color: Colors.Red + } + }] }) }) @@ -196,18 +199,14 @@ describe("SetWaitingRoomCommand", () => { await commandInstance.execute() expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - expect(embedData).toEqual({ - title: "Could Not Set Waiting Room", - description: expect.stringContaining(`:x: Could not find channel "test channel" with type "GuildVoice".`), - color: Colors.Red + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Could Not Set Waiting Room", + description: `:x: Could not find channel "test channel" with type "GuildVoice".`, + color: Colors.Red + } + }] }) }) @@ -219,18 +218,14 @@ describe("SetWaitingRoomCommand", () => { await commandInstance.execute() expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - expect(embedData).toEqual({ - title: "Could Not Set Waiting Room", - description: expect.stringContaining(`:x: Could not find the queue "test queue".`), - color: Colors.Red + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Could Not Set Waiting Room", + description: `:x: Could not find the queue "test queue".`, + color: Colors.Red + } + }] }) }) @@ -243,18 +238,14 @@ describe("SetWaitingRoomCommand", () => { await commandInstance.execute() expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - expect(embedData).toEqual({ - title: "Could Not Set Waiting Room", - description: expect.stringContaining(`:x: Could not find role "test supervisor ${interaction.guild}".`), - color: Colors.Red + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Could Not Set Waiting Room", + description: `:x: Could not find role "test supervisor ${interaction.guild}".`, + color: Colors.Red + } + }] }) }) @@ -266,18 +257,14 @@ describe("SetWaitingRoomCommand", () => { await commandInstance.execute() expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - - const messageContent = replySpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - expect(embedData).toEqual({ - title: "Could Not Set Waiting Room", - description: expect.stringContaining(`:x: Role [object Object] is not an internal role. Try running \`/admin update_bot_roles\` to update the internal roles.`), - color: Colors.Red + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Could Not Set Waiting Room", + description: `:x: Role [object Object] is not an internal role. Try running \`/admin update_bot_roles\` to update the internal roles.`, + color: Colors.Red + } + }] }) }) }) \ No newline at end of file diff --git a/src/commands/queue/QueueInfoCommand.test.ts b/src/commands/queue/QueueInfoCommand.test.ts index 82bbda9..a313f14 100644 --- a/src/commands/queue/QueueInfoCommand.test.ts +++ b/src/commands/queue/QueueInfoCommand.test.ts @@ -1,12 +1,10 @@ import QueueInfoCommand from "./QueueInfoCommand"; import { MockDiscord } from "@tests/mockDiscord"; -import { Queue } from "@models/Queue"; -import { mongoose } from "@typegoose/typegoose"; import { EmbedBuilder } from "@discordjs/builders"; import { ChatInputCommandInteraction, Colors } from "discord.js"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEntry } from "@models/QueueEntry"; -import { QueueManager } from "@managers"; +import { createQueue } from "@tests/testutils"; describe("InfoCommand", () => { const command = QueueInfoCommand; @@ -36,70 +34,55 @@ describe("InfoCommand", () => { await commandInstance.execute(); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); - - const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; - expect(messageContent.embeds).toBeDefined(); - const embeds = messageContent.embeds; - expect(embeds).toHaveLength(1); - const embed = embeds[0]; - const embedData = embed.data; - - expect(embedData).toEqual({ - title: "Error", - description: "You are currently not in a queue.", - color: Colors.Red, + expect(replySpy).toHaveBeenCalledWith({ + fetchReply: true, + embeds: [{ + data: { + title: "Error", + description: "You are currently not in a queue.", + color: Colors.Red, + } + }] }); }) it("should reply with the queue info if the user is in a queue", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queueEntry: FilterOutFunctionKeys = { + const queueEntry: FilterOutFunctionKeys = { discord_id: interaction.user.id, joinedAt: Date.now().toString(), }; - const queue: FilterOutFunctionKeys = { - name: "test", - description: "test description", - entries: new mongoose.Types.DocumentArray([queueEntry]), - info_channels: [], - opening_times: new mongoose.Types.DocumentArray([]), - }; - dbGuild.queues.push(queue); - await dbGuild.save(); + const queue = await createQueue(dbGuild, "test", "test description", [queueEntry]); + const replySpy = jest.spyOn(interaction, 'reply'); await commandInstance.execute(); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); - - const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; - expect(messageContent.embeds).toBeDefined(); - const embeds = messageContent.embeds; - expect(embeds).toHaveLength(1); - const embed = embeds[0]; - const embedData = embed.data; - - expect(embedData).toEqual({ - title: "Queue Information", - fields: expect.arrayContaining([ - expect.objectContaining({ - name: "❯ Name", - value: "test", - }), - expect.objectContaining({ - name: "❯ Description", - value: "test description", - }), - expect.objectContaining({ - name: "❯ Active Entries", - value: "1", - }), - expect.objectContaining({ - name: "❯ Your Position", - value: "1/1", - }), - ]), + expect(replySpy).toHaveBeenCalledWith({ + fetchReply: true, + embeds: [{ + data: { + title: "Queue Information", + fields: expect.arrayContaining([ + expect.objectContaining({ + name: "❯ Name", + value: queue.name, + }), + expect.objectContaining({ + name: "❯ Description", + value: queue.description, + }), + expect.objectContaining({ + name: "❯ Active Entries", + value: queue.entries.length.toString(), + }), + expect.objectContaining({ + name: "❯ Your Position", + value: `${queue.entries.findIndex(e => e.discord_id === interaction.user.id) + 1}/${queue.entries.length}`, + }), + ]), + } + }] }); }) }); \ No newline at end of file diff --git a/src/commands/queue/QueueJoinCommand.test.ts b/src/commands/queue/QueueJoinCommand.test.ts index 62c4f8d..6ce9ec7 100644 --- a/src/commands/queue/QueueJoinCommand.test.ts +++ b/src/commands/queue/QueueJoinCommand.test.ts @@ -3,6 +3,8 @@ import { ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js"; import { container } from "tsyringe"; import QueueJoinCommand from "./QueueJoinCommand"; import { SessionModel, SessionRole } from "@models/Session"; +import { createQueue } from "@tests/testutils"; +import { GuildModel } from "@models/Guild"; describe("QueueJoinCommand", () => { const command = QueueJoinCommand; @@ -53,32 +55,27 @@ describe("QueueJoinCommand", () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); const actualQueueName = interaction.options.get("queue")!.value as string const queueName = isLowercase ? actualQueueName.toLowerCase() : actualQueueName.toUpperCase(); - const queue = { - name: queueName, - description: "test description", - tracks: [], - join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}\n\\> Total Time Spent: ${time_spent}", - } - dbGuild.queues.push(queue) - await dbGuild.save() + const queue = await createQueue(dbGuild, queueName, "test description"); + jest.clearAllMocks(); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); const replySpy = jest.spyOn(interaction, 'reply'); await commandInstance.execute(); + expect(saveSpy).toHaveBeenCalledTimes(1); + const saveSpyRes = await saveSpy.mock.results[0].value; + expect(saveSpyRes.queues[0].entries).toHaveLength(1); + expect(saveSpyRes.queues[0].entries[0].discord_id).toBe(interaction.user.id); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); - - const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; - expect(messageContent.embeds).toBeDefined(); - const embeds = messageContent.embeds; - expect(embeds).toHaveLength(1); - const embed = embeds[0]; - const embedData = embed.data; - - expect(embedData).toEqual({ - title: "Queue Joined", - description: expect.stringContaining(queue.join_message.replace("${name}", queue.name).replace("${pos}", "1").replace("${total}", "1").replace("${time_spent}", "0h 0m")), - color: Colors.Green, + expect(replySpy).toHaveBeenCalledWith({ + fetchReply: true, + embeds: [{ + data: { + description: expect.stringContaining(queue.join_message!.replace("${name}", queue.name).replace("${pos}", "1").replace("${total}", "1").replace("${time_spent}", "0h 0m")), + color: Colors.Green, + title: "Queue Joined" + } + }] }); }) @@ -87,150 +84,110 @@ describe("QueueJoinCommand", () => { await commandInstance.execute(); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); - - const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; - expect(messageContent.embeds).toBeDefined(); - const embeds = messageContent.embeds; - expect(embeds).toHaveLength(1); - const embed = embeds[0]; - const embedData = embed.data; - - expect(embedData).toEqual({ - title: "Error", - description: `Could not find the queue "${interaction.options.get("queue")!.value}".`, - color: Colors.Red, + expect(replySpy).toHaveBeenCalledWith({ + fetchReply: true, + embeds: [{ + data: { + title: "Error", + description: `Could not find the queue "${interaction.options.get("queue")!.value}".`, + color: Colors.Red, + } + }] }); }) it("should fail if the user is already in the same queue", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: "test", - description: "test description", - tracks: [], - entries: [ { discord_id: interaction.user.id, joinedAt: Date.now().toString() } ] - } - dbGuild.queues.push(queue) - await dbGuild.save() + const queue = await createQueue(dbGuild, "test", "test description", [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }]); + jest.clearAllMocks(); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); const replySpy = jest.spyOn(interaction, 'reply'); await commandInstance.execute(); + expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); - - const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; - expect(messageContent.embeds).toBeDefined(); - const embeds = messageContent.embeds; - expect(embeds).toHaveLength(1); - const embed = embeds[0]; - const embedData = embed.data; - - expect(embedData).toEqual({ - title: "Error", - description: `You are already in the queue "${queue.name}".`, - color: Colors.Red, + expect(replySpy).toHaveBeenCalledWith({ + fetchReply: true, + embeds: [{ + data: { + title: "Error", + description: `You are already in the queue "${queue.name}".`, + color: Colors.Red, + } + }] }); }) it("should fail if the user is already in another queue", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: "test", - description: "test description", - tracks: [], - } - const otherQueue = { - name: "another test", - description: "another test description", - tracks: [], - entries: [ { discord_id: interaction.user.id, joinedAt: Date.now().toString() } ] - } - dbGuild.queues.push(queue, otherQueue) - await dbGuild.save() + const queue = await createQueue(dbGuild, "test", "test description"); + const otherQueue = await createQueue(dbGuild, "another test", "another test description", [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }]); + jest.clearAllMocks(); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); const replySpy = jest.spyOn(interaction, 'reply'); await commandInstance.execute(); + expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); - - const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; - expect(messageContent.embeds).toBeDefined(); - const embeds = messageContent.embeds; - expect(embeds).toHaveLength(1); - const embed = embeds[0]; - const embedData = embed.data; - - expect(embedData).toEqual({ - title: "Error", - description: `You are already in the queue "${otherQueue.name}".`, - color: Colors.Red, + expect(replySpy).toHaveBeenCalledWith({ + fetchReply: true, + embeds: [{ + data: { + title: "Error", + description: `You are already in the queue "${otherQueue.name}".`, + color: Colors.Red, + } + }] }); - }) it("should fail if the user has an active session", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: "test", - description: "test description", - tracks: [], - } - dbGuild.queues.push(queue) - await dbGuild.save() + const queue = await createQueue(dbGuild, "test", "test description"); await SessionModel.create({ active: true, user: interaction.user.id, guild: interaction.guild?.id, role: SessionRole.coach, started_at: Date.now(), end_certain: false, rooms: [] }); + jest.clearAllMocks(); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); const replySpy = jest.spyOn(interaction, 'reply'); await commandInstance.execute(); + expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); - - const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; - expect(messageContent.embeds).toBeDefined(); - const embeds = messageContent.embeds; - expect(embeds).toHaveLength(1); - const embed = embeds[0]; - const embedData = embed.data; - - expect(embedData).toEqual({ - title: "Error", - description: `You have an active session and cannot perform this action.`, - color: Colors.Red, + expect(replySpy).toHaveBeenCalledWith({ + fetchReply: true, + embeds: [{ + data: { + title: "Error", + description: `You have an active session and cannot perform this action.`, + color: Colors.Red, + } + }] }); }) it("should fail if the queue is locked", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: "test", - description: "test description", - tracks: [], - locked: true, - } - dbGuild.queues.push(queue) - await dbGuild.save() - + const queue = await createQueue(dbGuild, "test", "test description", [], true); + + jest.clearAllMocks(); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); const replySpy = jest.spyOn(interaction, 'reply'); await commandInstance.execute(); + expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }); - - const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; - expect(messageContent.embeds).toBeDefined(); - const embeds = messageContent.embeds; - expect(embeds).toHaveLength(1); - const embed = embeds[0]; - const embedData = embed.data; - - expect(embedData).toEqual({ - title: "Error", - description: `The queue "${queue.name}" is locked and cannot be joined.`, - color: Colors.Red, + expect(replySpy).toHaveBeenCalledWith({ + fetchReply: true, + embeds: [{ + data: { + title: "Error", + description: `The queue "${queue.name}" is locked and cannot be joined.`, + color: Colors.Red, + } + }] }); }) }) \ No newline at end of file diff --git a/src/commands/queue/QueueLeaveCommand.test.ts b/src/commands/queue/QueueLeaveCommand.test.ts index 56ce255..63519ed 100644 --- a/src/commands/queue/QueueLeaveCommand.test.ts +++ b/src/commands/queue/QueueLeaveCommand.test.ts @@ -2,6 +2,8 @@ import { MockDiscord } from "@tests/mockDiscord"; import { ChatInputCommandInteraction, Colors } from "discord.js"; import { container } from "tsyringe"; import QueueLeaveCommand from "./QueueLeaveCommand"; +import { GuildModel } from "@models/Guild"; +import { createQueue } from "@tests/testutils"; describe("QueueLeaveCommand", () => { const command = QueueLeaveCommand; @@ -28,26 +30,22 @@ describe("QueueLeaveCommand", () => { it("should leave the queue and reply with a success message", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: "test", - description: "test description", - tracks: [], - leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", - entries: [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }], - } - dbGuild.queues.push(queue); - await dbGuild.save(); + const queue = await createQueue(dbGuild, "test", "test description", [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }]); const replySpy = jest.spyOn(interaction, 'reply'); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); await commandInstance.execute(); + expect(saveSpy).toHaveBeenCalledTimes(1); + const saveSpyRes = await saveSpy.mock.results[0].value; + expect(saveSpyRes.queues.entries).toHaveLength(0); expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledWith( { fetchReply: true, embeds: [{ data: { - description: expect.stringContaining(queue.leave_message.replace("${name}", queue.name).replace("${time_spent}", "0h 0m")), + description: expect.stringContaining(queue.leave_message!.replace("${name}", queue.name).replace("${time_spent}", "0h 0m")), color: Colors.Green, title: "Queue Left" } @@ -58,18 +56,14 @@ describe("QueueLeaveCommand", () => { it("should fail if the user is not in a queue", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = { - name: "test", - description: "test description", - tracks: [], - leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", - } - dbGuild.queues.push(queue); - await dbGuild.save(); + await createQueue(dbGuild, "test", "test description"); + jest.clearAllMocks(); const replySpy = jest.spyOn(interaction, 'reply'); + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); await commandInstance.execute(); + expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledWith( { diff --git a/src/commands/queue/QueueListCommand.test.ts b/src/commands/queue/QueueListCommand.test.ts index de266ba..797ae45 100644 --- a/src/commands/queue/QueueListCommand.test.ts +++ b/src/commands/queue/QueueListCommand.test.ts @@ -53,29 +53,25 @@ describe("QueueListCommand", () => { await commandInstance.execute() expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ fetchReply: true, embeds: expect.anything() }) - - const messageContent = replySpy.mock.calls[0][0] as { embeds: EmbedBuilder[] }; - expect(messageContent.embeds).toBeDefined(); - const embeds = messageContent.embeds; - expect(embeds).toHaveLength(1); - const embed = embeds[0]; - const embedData = embed.data; - - expect(embedData).toEqual({ - title: "Queue List", - description: "Here are all the queues available in this server.", - fields: [ - { - name: "test", - value: "test description", - }, - { - name: "test2", - value: "another description 2", - }, - ], - color: Colors.Green, - }) + expect(replySpy).toHaveBeenCalledWith({ + fetchReply: true, + embeds: [{ + data: { + title: "Queue List", + description: "Here are all the queues available in this server.", + fields: [ + { + name: "test", + value: "test description", + }, + { + name: "test2", + value: "another description 2", + }, + ], + color: Colors.Green, + } + }] + }); }) }) diff --git a/src/events/GuildAddMemberEvent.test.ts b/src/events/GuildAddMemberEvent.test.ts index b8b5b15..84fe6da 100644 --- a/src/events/GuildAddMemberEvent.test.ts +++ b/src/events/GuildAddMemberEvent.test.ts @@ -61,15 +61,14 @@ describe("GuildAddMemberEvent", () => { await eventInstance.execute(member) expect(sendSpy).toHaveBeenCalledTimes(1) - expect(sendSpy).toHaveBeenCalledWith({ embeds: expect.anything() }) - const messageContent = sendSpy.mock.calls[0][0] as BaseMessageOptions - expect(messageContent.embeds).toBeDefined() - const embeds = messageContent.embeds as EmbedBuilder[] - expect(embeds).toHaveLength(1) - const embed = embeds[0] - const embedData = embed.data - expect(embedData.title).toBe(`Welcome to ${discordGuild.name}!`) - expect(embedData.description).toBe(`Welcome to ${discordGuild.name}, ${member}!`) + expect(sendSpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: `Welcome to ${discordGuild.name}!`, + description: `Welcome to ${discordGuild.name}, ${member}!` + } + }] + }) }) it("should not send a welcome message to the new member, when it is not set for the guild", async () => { diff --git a/tests/testutils.ts b/tests/testutils.ts index f9e89b1..e35a330 100644 --- a/tests/testutils.ts +++ b/tests/testutils.ts @@ -1,6 +1,8 @@ import { DBRole, DBRoleModel, InternalRoles, RoleScopes } from "@models/BotRoles"; +import { QueueEventType } from "@models/Event"; import { Guild } from "@models/Guild" import { Queue, QueueModel } from "@models/Queue"; +import { QueueEntry } from "@models/QueueEntry"; import { VoiceChannel, VoiceChannelModel } from "@models/VoiceChannel"; import { DocumentType, mongoose } from "@typegoose/typegoose" import { ChannelType } from "discord.js"; @@ -12,7 +14,10 @@ export const config = { Database: 'test' } -export async function createQueue(guild: DocumentType, name: string, description: string): Promise> { +export async function createQueue(guild: DocumentType, name: string, description: string, entries: QueueEntry[] = [], locked: boolean = false, info_channels: { + channel_id: string; + events: QueueEventType[]; +}[] = []): Promise> { const queue = new QueueModel({ name: name, description: description, @@ -23,9 +28,10 @@ export async function createQueue(guild: DocumentType, name: string, desc match_found_message: "You have found a Match with ${match}. Please Join ${match_channel} if you are not moved automatically. If you don't join in ${timeout} seconds, your position in the queue is dropped.", timeout_message: "Your queue Timed out after ${timeout} seconds.", leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", - entries: [], + entries: entries, opening_times: [], - info_channels: [], + info_channels: info_channels, + locked: locked, }); guild.queues.push(queue); await guild.save(); From ac4ff2ece841d2c698ec215e9d2c1ebe86b549a8 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 16 Mar 2024 15:57:47 +0100 Subject: [PATCH 093/130] Check session in command not manager --- src/commands/tutor/session/TutorSessionStartCommand.ts | 8 ++++++++ src/managers/QueueManager.ts | 7 ------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/commands/tutor/session/TutorSessionStartCommand.ts b/src/commands/tutor/session/TutorSessionStartCommand.ts index 10a7c48..818d82b 100644 --- a/src/commands/tutor/session/TutorSessionStartCommand.ts +++ b/src/commands/tutor/session/TutorSessionStartCommand.ts @@ -75,6 +75,14 @@ export default class TutorSessionStartCommand extends BaseCommand { throw new InteractionNotInGuildError(this.interaction); } const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild); + const user = this.interaction.user; + const dbUser = await this.app.userManager.getUser(user); + + // Check if user has active session + if (await dbUser.hasActiveSessions()) { + this.app.logger.info(`User "${user.username}" (id: ${user.id}) tried to start a tutor session but has an active session`); + throw new UserHasActiveSessionError(); + } // check if guild has a queue if (!dbGuild.queues || dbGuild.queues.length === 0) { diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index 9e34703..d5c9551 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -155,18 +155,11 @@ export default class QueueManager { * * @param queue - The queue for which the tutor session is started. * @param user - The user starting the tutor session. - * @throws {UserHasActiveSessionError} if the user already has an active session. */ public async startTutorSession(queue: DocumentType, user: User): Promise { const dbGuild = queue.$parent() as DocumentType; const dbUser = await this.app.userManager.getUser(user); - // Check if user has active session - if (await dbUser.hasActiveSessions()) { - this.app.logger.info(`User "${user.username}" (id: ${user.id}) tried to start a tutor session but has an active session`); - throw new UserHasActiveSessionError(); - } - const userSession = await SessionModel.create({ user: user.id, queue: queue._id, From 684ba6ebca8c792d1e73e77e16c7dc3bd782006d Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 16 Mar 2024 16:44:13 +0100 Subject: [PATCH 094/130] Remove duplicate test --- .../session/TutorSessionStartCommand.test.ts | 36 +------------------ 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/src/commands/tutor/session/TutorSessionStartCommand.test.ts b/src/commands/tutor/session/TutorSessionStartCommand.test.ts index 24fb2a9..5e62644 100644 --- a/src/commands/tutor/session/TutorSessionStartCommand.test.ts +++ b/src/commands/tutor/session/TutorSessionStartCommand.test.ts @@ -46,7 +46,7 @@ describe("TutorSessionStartCommand", () => { expect(deferSpy).toHaveBeenCalledTimes(1); }) - it.each([true, false])("should reply with the started tutor session (queue parameter is provided: %p)", async (parameterSet) => { + it.each([true, false])("should start a tutor session and reply with it (queue parameter is provided: %p)", async (parameterSet) => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); const queue = await createQueue(dbGuild, "test", "test description"); @@ -82,40 +82,6 @@ describe("TutorSessionStartCommand", () => { }) }) - it.each([true, false])("should reply with the started tutor session (queue parameter is provided: %p)", async (parameterSet) => { - const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); - - if (parameterSet) { - interaction.options.get = jest.fn().mockReturnValue({ value: queue.name }); - } - - const replySpy = jest.spyOn(interaction, 'editReply'); - const saveSpy = jest.spyOn(SessionModel.prototype as any, 'save'); - await commandInstance.execute(); - - expect(saveSpy).toHaveBeenCalledTimes(1); - const saveSpyRes = await saveSpy.mock.results[0].value as Session; - expect(saveSpyRes.user).toBe(interaction.user.id); - expect(saveSpyRes.queue).toStrictEqual(queue._id); - expect(saveSpyRes.guild).toBe(interaction.guild!.id); - expect(saveSpyRes.role).toBe(SessionRole.coach); - expect(saveSpyRes.active).toBe(true); - expect(saveSpyRes.end_certain).toBe(false); - expect(saveSpyRes.rooms).toStrictEqual([]); - - expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - embeds: [{ - data: { - title: "Tutor Session Started", - description: `You have started a tutor session for queue "test".`, - color: Colors.Green, - } - }] - }) - }) - it("should fail if the guild has no queue", async () => { const replySpy = jest.spyOn(interaction, 'editReply'); await commandInstance.execute(); From 8022d09fe41ba1c6414bb2776104d2d56381cf3d Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 16 Mar 2024 17:04:09 +0100 Subject: [PATCH 095/130] Improve tests --- .../session/TutorSessionStartCommand.test.ts | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/src/commands/tutor/session/TutorSessionStartCommand.test.ts b/src/commands/tutor/session/TutorSessionStartCommand.test.ts index 5e62644..26b4e59 100644 --- a/src/commands/tutor/session/TutorSessionStartCommand.test.ts +++ b/src/commands/tutor/session/TutorSessionStartCommand.test.ts @@ -2,7 +2,7 @@ import { MockDiscord } from "@tests/mockDiscord"; import { ChatInputCommandInteraction, Colors, GuildMemberRoleManager } from "discord.js"; import TutorSessionStartCommand from "./TutorSessionStartCommand"; import { InternalRoles, RoleScopes } from "@models/BotRoles"; -import { createQueue, createRole } from "@tests/testutils"; +import { createQueue, createRole, createSession } from "@tests/testutils"; import { Session, SessionModel, SessionRole } from "@models/Session"; describe("TutorSessionStartCommand", () => { @@ -55,6 +55,28 @@ describe("TutorSessionStartCommand", () => { } const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Tutor Session Started", + description: `You have started a tutor session for queue "test".`, + color: Colors.Green, + } + }] + }) + }) + + it.each([true, false])("should start a tutor session save it in the database (queue parameter is provided: %p)", async (parameterSet) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + + if (parameterSet) { + interaction.options.get = jest.fn().mockReturnValue({ value: queue.name }); + } + const saveSpy = jest.spyOn(SessionModel.prototype as any, 'save'); await commandInstance.execute(); @@ -69,17 +91,20 @@ describe("TutorSessionStartCommand", () => { end_certain: false, rooms: [] }); + }) - expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - embeds: [{ - data: { - title: "Tutor Session Started", - description: `You have started a tutor session for queue "test".`, - color: Colors.Green, - } - }] - }) + it.each([true, false])("should add the active session role to the user (queue parameter is provided: %p)", async (parameterSet) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + + if (parameterSet) { + interaction.options.get = jest.fn().mockReturnValue({ value: queue.name }); + } + + const addSpy = jest.spyOn(GuildMemberRoleManager.prototype, 'add'); + await commandInstance.execute(); + expect(addSpy).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ id: InternalRoles.ACTIVE_SESSION.toString() })); }) it("should fail if the guild has no queue", async () => { @@ -122,17 +147,7 @@ describe("TutorSessionStartCommand", () => { it("should fail if the user has an active session", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); const queue = await createQueue(dbGuild, "test", "test description"); - - await SessionModel.create({ - queue: queue, - user: interaction.user.id, - guild: interaction.guild!.id, - role: SessionRole.coach, - active: true, - started_at: new Date(), - end_certain: false, - rooms: [], - }) + await createSession(queue, interaction.user.id, interaction.guild!.id); const replySpy = jest.spyOn(interaction, 'editReply'); await commandInstance.execute(); From 757720eed996f2f9b5471f25003cba4ed21b29b2 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 16 Mar 2024 18:02:36 +0100 Subject: [PATCH 096/130] Add tutor session end command --- .../session/TutorSessionEndCommand.test.ts | 109 ++++++++++++++++++ .../tutor/session/TutorSessionEndCommand.ts | 97 ++++++++++++++++ src/managers/QueueManager.ts | 43 ++++++- src/managers/UserManager.ts | 56 +++++++-- src/models/User.ts | 7 ++ .../CouldNotFindQueueForSessionError.ts | 5 + src/types/errors/CouldNotRemoveRoleError.ts | 28 +++++ .../errors/UserHasNoActiveSessionError.ts | 8 ++ src/types/index.ts | 6 + tests/mockDiscord.ts | 7 +- tests/testutils.ts | 17 ++- 11 files changed, 363 insertions(+), 20 deletions(-) create mode 100644 src/commands/tutor/session/TutorSessionEndCommand.test.ts create mode 100644 src/commands/tutor/session/TutorSessionEndCommand.ts create mode 100644 src/types/errors/CouldNotFindQueueForSessionError.ts create mode 100644 src/types/errors/CouldNotRemoveRoleError.ts create mode 100644 src/types/errors/UserHasNoActiveSessionError.ts diff --git a/src/commands/tutor/session/TutorSessionEndCommand.test.ts b/src/commands/tutor/session/TutorSessionEndCommand.test.ts new file mode 100644 index 0000000..309dc3d --- /dev/null +++ b/src/commands/tutor/session/TutorSessionEndCommand.test.ts @@ -0,0 +1,109 @@ +import { InternalRoles } from "@models/BotRoles"; +import { MockDiscord } from "@tests/mockDiscord"; +import { createQueue, createRole, createSession } from "@tests/testutils"; +import { ChatInputCommandInteraction, Colors, DataManager, GuildMember, GuildMemberRoleManager, Role } from "discord.js"; +import TutorSessionEndCommand from "./TutorSessionEndCommand"; +import { SessionModel } from "@models/Session"; + +describe("TutorSessionEndCommand", () => { + const command = TutorSessionEndCommand; + const discord = new MockDiscord(); + let commandInstance: TutorSessionEndCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(async () => { + // mock role + const guild = discord.mockGuild(); + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild); + await createRole(dbGuild, `active session ${guild}`, InternalRoles.ACTIVE_SESSION); + const role = discord.mockRole(guild!, { id: InternalRoles.ACTIVE_SESSION.toString(), name: `active session ${guild}` }) + guild!.roles.resolve = jest.fn().mockReturnValue(role); + GuildMemberRoleManager.prototype.remove = jest.fn().mockResolvedValue(role); + + interaction = discord.mockInteraction(undefined, undefined, discord.mockGuildMember(undefined, guild, [role.id])); + commandInstance = new command(interaction, discord.getApplication()); + jest.clearAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("end"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Ends a tutor session."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + it("should defer the interaction", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply'); + await commandInstance.execute(); + expect(deferSpy).toHaveBeenCalledTimes(1); + }) + + it("should end the tutor session and reply with it", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Tutor Session Ended", + description: `You have ended the tutor session for queue "${queue.name}".`, + color: Colors.Green + } + }] + }) + }) + + it("should end the tutor session on the database", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const saveSpy = jest.spyOn(SessionModel.prototype as any, 'save'); + await commandInstance.execute(); + + expect(saveSpy).toHaveBeenCalledTimes(1); + const saveSpyRes = await saveSpy.mock.results[0].value; + expect(saveSpyRes).toMatchObject({ + active: false, + end_certain: true, + }) + }) + + it("should remove the active session role from the user", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const removeSpy = jest.spyOn(GuildMemberRoleManager.prototype, 'remove'); + await commandInstance.execute(); + + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith(expect.objectContaining({ id: InternalRoles.ACTIVE_SESSION.toString() })); + }) + + it("should fail if the user doesn't have an active session", async () => { + const replySpy = jest.spyOn(interaction, 'editReply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith({ + embeds: [{ + data: { + title: "Error", + description: `You do not have an active session.`, + color: Colors.Red + } + }] + }) + }) +}) \ No newline at end of file diff --git a/src/commands/tutor/session/TutorSessionEndCommand.ts b/src/commands/tutor/session/TutorSessionEndCommand.ts new file mode 100644 index 0000000..d6f30f7 --- /dev/null +++ b/src/commands/tutor/session/TutorSessionEndCommand.ts @@ -0,0 +1,97 @@ +import { BaseCommand } from "@baseCommand"; +import { Queue } from "@models/Queue"; +import { Colors, EmbedBuilder } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { CouldNotFindQueueError, CouldNotFindQueueForSessionError, CouldNotFindRoleError, CouldNotRemoveRoleError, InteractionNotInGuildError, UserHasNoActiveSessionError } from "@types"; +import { InternalRoles } from "@models/BotRoles"; + +/** + * Represents a command to end a tutor session. + */ +export default class TutorSessionEndCommand extends BaseCommand { + public static name = "end"; + public static description = "Ends a tutor session."; + + public async execute(): Promise { + await this.defer(); + try { + const queue = await this.endTutorSession(); + const embed = this.mountEndTutorSessionEmbed(queue); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + /** + * Mounts the embed for the end of the tutor session. + * + * @param queue - The queue for the tutor session. + * @returns The embed builder for the end of the tutor session. + */ + private mountEndTutorSessionEmbed(queue: DocumentType): EmbedBuilder { + return new EmbedBuilder() + .setTitle("Tutor Session Ended") + .setDescription(`You have ended the tutor session for queue "${queue.name}".`) + .setColor(Colors.Green); + } + + /** + * Mounts the error embed for the tutor session end command. + * + * @param error - The error object. + * @returns The embed builder for the error. + * @throws The error object if it is not an instance of the expected errors. + */ + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof UserHasNoActiveSessionError || error instanceof CouldNotFindQueueForSessionError || error instanceof CouldNotFindQueueError || error instanceof CouldNotFindRoleError || error instanceof CouldNotRemoveRoleError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + /** + * Ends the tutor session. + * + * @returns The queue on which the tutor session was ended. + * @throws {InteractionNotInGuildError} if the interaction is not in a guild. + * @throws {UserHasNoActiveSessionError} if the user does not have an active session. + * @throws {CouldNotFindQueueForSessionError} if the queue for the session could not be found. + */ + private async endTutorSession(): Promise> { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild); + const user = this.interaction.user; + const dbUser = await this.app.userManager.getUser(user); + const session = (await dbUser.getActiveSessions()).find(session => session.guild === dbGuild.id); + + // Check if the user has an active session + if (!session) { + throw new UserHasNoActiveSessionError(); + } + + // Get the queue + if (!session.queue) { + throw new CouldNotFindQueueForSessionError(); + } + const queue = this.app.queueManager.getQueueById(dbGuild, session.queue._id); + + // remove active session role + await this.app.userManager.removeRoleFromUser(dbGuild, this.interaction.user, InternalRoles.ACTIVE_SESSION); + + // end tutor session + await this.app.queueManager.endTutorSession(queue, session, this.interaction.user); + + return queue; + } +} diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index d5c9551..aeffd71 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -8,9 +8,8 @@ import { QueueEntryModel } from "@models/QueueEntry"; import { EmbedBuilder, TextChannel, User, Guild as DiscordGuild } from "discord.js"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEventType } from "@models/Event"; -import { Session } from "inspector"; import { InternalRoles } from "@models/BotRoles"; -import { SessionModel, SessionRole } from "@models/Session"; +import { SessionModel, SessionRole, Session } from "@models/Session"; @injectable() @singleton() @@ -72,6 +71,24 @@ export default class QueueManager { return queue; } + /** + * Retrieves a queue by its ID from the specified guild. + * + * @param dbGuild - The database guild object. + * @param queueId - The ID of the queue to retrieve. + * @returns The queue object with the specified ID. + * @throws {CouldNotFindQueueError} if the queue with the specified ID is not found. + */ + public getQueueById(dbGuild: DatabaseGuild, queueId: mongoose.Types.ObjectId): DocumentType { + const queue = dbGuild.queues.find(queue => queue._id.equals(queueId)); + if (!queue) { + this.app.logger.debug(`Queue with id "${queueId}" not found in guild "${dbGuild.name}" (id: ${dbGuild._id})`); + throw new CouldNotFindQueueError(queueId.toString()); + } + this.app.logger.debug(`Queue with id "${queueId}" found in guild "${dbGuild.name}" (id: ${dbGuild._id})`); + return queue; + } + /** * Retrieves the queue associated with a specific user. * @@ -118,7 +135,7 @@ export default class QueueManager { await queue.$parent()?.save(); this.app.logger.info(`User "${user.username}" (id: ${user.id}) joined queue "${queue.name}"`); - this.logQueueActivity(queue, QueueEventType.JOIN, user); + await this.logQueueActivity(queue, QueueEventType.JOIN, user); // Return the join message. return queue.getJoinMessage(user.id); } @@ -146,7 +163,7 @@ export default class QueueManager { await queue.$parent()?.save() this.app.logger.info(`User "${user.username}" (id: ${user.id}) left queue "${queue.name}"`); - this.logQueueActivity(queue, QueueEventType.LEAVE, user); + await this.logQueueActivity(queue, QueueEventType.LEAVE, user); return leaveMessage; } @@ -174,7 +191,23 @@ export default class QueueManager { dbUser.sessions.push(userSession); await dbUser.save(); this.app.logger.info(`User "${user.username}" (id: ${user.id}) started a tutor session on queue "${queue.name}"`); - this.logQueueActivity(queue, QueueEventType.TUTOR_SESSION_START, user); + await this.logQueueActivity(queue, QueueEventType.TUTOR_SESSION_START, user); + } + + /** + * Ends a tutor session. + * + * @param queue - The queue for which the tutor session is ended. + * @param session - The session to end. + * @param user - The user ending the tutor session. + */ + public async endTutorSession(queue: DocumentType, session: DocumentType, user: User): Promise { + session.active = false; + session.ended_at = Date.now().toString(); + session.end_certain = true; + await session.save(); + this.app.logger.info(`Tutor session for user "${session.user}" on queue "${session.queue}" ended`); + await this.logQueueActivity(queue, QueueEventType.TUTOR_SESSION_QUIT, user); } /** diff --git a/src/managers/UserManager.ts b/src/managers/UserManager.ts index cbc1a59..f664a6c 100644 --- a/src/managers/UserManager.ts +++ b/src/managers/UserManager.ts @@ -1,10 +1,10 @@ import { Application } from "@application"; import { delay, inject, injectable, singleton } from "tsyringe"; -import { User as DiscordUser } from "discord.js"; +import { User as DiscordUser, Guild, GuildMember, Role } from "discord.js"; import { DocumentType } from "@typegoose/typegoose"; import { User, UserModel } from "@models/User"; import { InternalRoles } from "@models/BotRoles"; -import { CouldNotFindRoleError, CouldNotAssignRoleError } from "@types"; +import { CouldNotFindRoleError, CouldNotAssignRoleError, CouldNotRemoveRoleError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; /** @@ -14,7 +14,7 @@ import { Guild as DatabaseGuild } from "@models/Guild"; @singleton() export default class UserManager { protected app: Application; - + /** * Constructs a new instance of the UserManager class. * @param app The application instance. @@ -62,14 +62,7 @@ export default class UserManager { * @throws {CouldNotAssignRoleError} if the role cannot be assigned to the user. */ public async assignRoleToUser(dbGuild: DocumentType, user: DiscordUser, roleName: InternalRoles): Promise { - const dbActiveSessionRole = dbGuild.guild_settings.roles?.find(role => role.internal_name == roleName); - if (!dbActiveSessionRole) { - this.app.logger.info(`Role "${roleName}" not found in guild "${dbGuild.name}" (id: ${dbGuild._id})`); - throw new CouldNotFindRoleError(roleName); - } - const guild = this.app.client.guilds.resolve(dbGuild._id)!; - const role = guild.roles.resolve(dbActiveSessionRole.role_id!); - const member = guild.members.resolve(user); + const { role, member } = this.loadRoleAndMemberFor(dbGuild, user, roleName); if (role && member && !member.roles.cache.has(role.id)) { await member.roles.add(role); this.app.logger.info(`Assigned role "${role.name}" to user "${user.tag}" (id: ${user.id})`); @@ -78,4 +71,45 @@ export default class UserManager { throw new CouldNotAssignRoleError(roleName, user); } } + + /** + * Removes a role from a user. + * + * @param dbGuild - The database guild object. + * @param user - The Discord user object. + * @param roleName - The name of the role to remove. + * @throws {CouldNotFindRoleError} if the specified role is not found in the guild. + * @throws {CouldNotRemoveRoleError} if the role cannot be removed from the user. + */ + public async removeRoleFromUser(dbGuild: DocumentType, user: DiscordUser, roleName: InternalRoles): Promise { + const { role, member } = this.loadRoleAndMemberFor(dbGuild, user, roleName); + if (role && member && member.roles.cache.has(role.id)) { + await member.roles.remove(role); + this.app.logger.info(`Removed role "${role.name}" from user "${user.tag}" (id: ${user.id})`); + } else { + this.app.logger.info(`Could not remove role "${roleName}" from user "${user.tag}" (id: ${user.id})`); + throw new CouldNotRemoveRoleError(roleName, user); + } + } + + /** + * Loads the guild, role, and member information for a given user and role name. + * + * @param dbGuild - The database guild object. + * @param user - The Discord user object. + * @param roleName - The internal role name. + * @returns An object containing the guild, role, and member information. + * @throws {CouldNotFindRoleError} if the specified role is not found in the guild. + */ + private loadRoleAndMemberFor(dbGuild: DocumentType, user: DiscordUser, roleName: InternalRoles): { role: Role | null, member: GuildMember | null } { + const dbRole = dbGuild.guild_settings.roles?.find(role => role.internal_name == roleName); + if (!dbRole) { + this.app.logger.info(`Role "${roleName}" not found in guild "${dbGuild.name}" (id: ${dbGuild._id})`); + throw new CouldNotFindRoleError(roleName); + } + const guild = this.app.client.guilds.resolve(dbGuild._id)!; + const role = guild.roles.resolve(dbRole.role_id!); + const member = guild.members.resolve(user); + return { role, member } + } } \ No newline at end of file diff --git a/src/models/User.ts b/src/models/User.ts index 0e76d62..b42f5b0 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -44,6 +44,13 @@ export class User { public async hasActiveSessions(this: DocumentType): Promise { return !!(await SessionModel.findOne({ user: (this._id as string), active: true })); } + + /** + * Returns the Active Sessions + */ + public async getActiveSessions(this: DocumentType): Promise[]> { + return SessionModel.find({ user: (this._id as string), active: true }); + } } export const UserModel = getModelForClass(User, { diff --git a/src/types/errors/CouldNotFindQueueForSessionError.ts b/src/types/errors/CouldNotFindQueueForSessionError.ts new file mode 100644 index 0000000..eafddc4 --- /dev/null +++ b/src/types/errors/CouldNotFindQueueForSessionError.ts @@ -0,0 +1,5 @@ +export default class CouldNotFindQueueForSessionError extends Error { + public constructor() { + super("Could not find the queue for the session."); + } +} \ No newline at end of file diff --git a/src/types/errors/CouldNotRemoveRoleError.ts b/src/types/errors/CouldNotRemoveRoleError.ts new file mode 100644 index 0000000..42a9b7c --- /dev/null +++ b/src/types/errors/CouldNotRemoveRoleError.ts @@ -0,0 +1,28 @@ +import { User } from "discord.js"; + +/** + * Represents an error that occurs when a role cannot be assigned to a user. + */ +export default class CouldNotRemoveRoleError extends Error { + /* + * The name of the role that could not be assigned to the user. + */ + public roleName: string; + + /* + * The user that could not be assigned the role. + */ + public user: User; + + /** + * Creates a new instance of CouldNotAssignRoleError. + * + * @param roleName The name of the role that could not be assigned to the user. + * @param user The user that could not be assigned the role. + */ + constructor(roleName: string, user: User) { + super(`Could not remove role: ${roleName} from user: ${user.tag}`); + this.roleName = roleName; + this.user = user; + } +} \ No newline at end of file diff --git a/src/types/errors/UserHasNoActiveSessionError.ts b/src/types/errors/UserHasNoActiveSessionError.ts new file mode 100644 index 0000000..57f7506 --- /dev/null +++ b/src/types/errors/UserHasNoActiveSessionError.ts @@ -0,0 +1,8 @@ +/** + * Error thrown when a user does not have an active session. + */ +export default class UserHasNoActiveSessionError extends Error { + public constructor() { + super("You do not have an active session."); + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 39f8b7d..b94b824 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,8 +6,10 @@ import ChannelNotInfoChannelError from "./errors/ChannelNotInfoChannelError"; import CouldNotAssignRoleError from "./errors/CouldNotAssignRoleError"; import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; import CouldNotFindQueueError from "./errors/CouldNotFindQueueError"; +import CouldNotFindQueueForSessionError from "./errors/CouldNotFindQueueForSessionError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; +import CouldNotRemoveRoleError from "./errors/CouldNotRemoveRoleError"; import GuildHasNoQueueError from "./errors/GuildHasNoQueueError"; import InteractionNotInGuildError from "./errors/InteractionNotInGuildError"; import InvalidEventError from "./errors/InvalidEventError"; @@ -17,6 +19,7 @@ import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; import QueueLockedError from "./errors/QueueLockedError"; import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; import UserHasActiveSessionError from "./errors/UserHasActiveSessionError"; +import UserHasNoActiveSessionError from "./errors/UserHasNoActiveSessionError"; export { OptionRequirement, @@ -25,14 +28,17 @@ export { MissingOptionError, CouldNotFindChannelError, CouldNotFindQueueError, + CouldNotFindQueueForSessionError, CouldNotFindRoleError, CouldNotAssignRoleError, + CouldNotRemoveRoleError, RoleNotInDatabaseError, CouldNotFindTypeInFileError, NotInQueueError, InteractionNotInGuildError, AlreadyInQueueError, UserHasActiveSessionError, + UserHasNoActiveSessionError, QueueLockedError, InvalidEventError, ChannelAlreadyInfoChannelError, diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index f2a9f01..8720937 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -26,7 +26,7 @@ export class MockDiscord { public constructor() { this.app = this.mockApplication(); } - + private mockApplication(): Application { const clientOptions = { intents: [] }; container.register("options", { useValue: clientOptions }) @@ -56,16 +56,17 @@ export class MockDiscord { return mockRole(this.app.client, "0", guild, role); } - public mockGuildMember(user: User = this.mockUser(), guild: Guild = this.mockGuild()): GuildMember { + public mockGuildMember(user: User = this.mockUser(), guild: Guild = this.mockGuild(), roles?: string[]): GuildMember { return mockGuildMember({ client: this.app.client, user: user, guild: guild, + data: roles ? { roles: roles } : undefined }); } public mockInteraction(commandName: string = "ping", channel?: TextChannel, guildMember?: GuildMember): ChatInputCommandInteraction { - const guild = this.mockGuild(); + const guild = guildMember?.guild ?? this.mockGuild(); channel = channel ? channel : this.mockChannel(guild); guildMember = guildMember ? guildMember : this.mockGuildMember(this.mockUser(), guild); assert(guildMember.guild === guild); diff --git a/tests/testutils.ts b/tests/testutils.ts index e35a330..f2a1166 100644 --- a/tests/testutils.ts +++ b/tests/testutils.ts @@ -3,9 +3,10 @@ import { QueueEventType } from "@models/Event"; import { Guild } from "@models/Guild" import { Queue, QueueModel } from "@models/Queue"; import { QueueEntry } from "@models/QueueEntry"; +import { SessionModel, SessionRole, Session } from "@models/Session"; import { VoiceChannel, VoiceChannelModel } from "@models/VoiceChannel"; import { DocumentType, mongoose } from "@typegoose/typegoose" -import { ChannelType } from "discord.js"; +import { ChannelType, } from "discord.js"; export const config = { Memory: true, @@ -38,6 +39,20 @@ export async function createQueue(guild: DocumentType, name: string, desc return queue; } +export async function createSession(queue: DocumentType, userId: string, guildId: string, active: boolean = true): Promise> { + const session = await SessionModel.create({ + queue: queue, + user: userId, + guild: guildId, + role: SessionRole.coach, + active: active, + started_at: new Date(), + end_certain: !active, + rooms: [], + }) + return session; +} + export async function createRole(guild: DocumentType, name: string, internalName: string = "tutor"): Promise { if (!guild.guild_settings.roles) guild.guild_settings.roles = new mongoose.Types.DocumentArray([]); const role = new DBRoleModel({ From f27b4bfb148772407dc05952c5a4900f466a2cac Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 16 Mar 2024 18:08:29 +0100 Subject: [PATCH 097/130] Make get default methods private --- src/managers/ConfigManager.ts | 2 +- src/managers/UserManager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index 2aec8f0..057cfb1 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -23,7 +23,7 @@ export default class ConfigManager { return guildModel; } - public async getDefaultGuildConfig(guild: DiscordGuild): Promise> { + private async getDefaultGuildConfig(guild: DiscordGuild): Promise> { const newGuildData = new GuildModel({ _id: guild.id, name: guild.name, diff --git a/src/managers/UserManager.ts b/src/managers/UserManager.ts index f664a6c..9eda8cd 100644 --- a/src/managers/UserManager.ts +++ b/src/managers/UserManager.ts @@ -44,7 +44,7 @@ export default class UserManager { * @param user The Discord user. * @returns A Promise that resolves to the created user. */ - public async getDefaultUser(user: DiscordUser): Promise> { + private async getDefaultUser(user: DiscordUser): Promise> { const newUser = new UserModel({ _id: user.id, }); From 5e22f507c8e746457b409d8f115b3bb1dea0df0f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 16 Mar 2024 18:17:29 +0100 Subject: [PATCH 098/130] Change command responses to be ephemeral by default --- src/baseCommand/BaseCommand.ts | 8 ++-- src/commands/PingCommand.test.ts | 2 +- src/commands/queue/QueueInfoCommand.test.ts | 10 ++--- src/commands/queue/QueueJoinCommand.test.ts | 30 ++++++-------- src/commands/queue/QueueLeaveCommand.test.ts | 42 +++++++++----------- src/commands/queue/QueueListCommand.test.ts | 7 ++-- 6 files changed, 42 insertions(+), 57 deletions(-) diff --git a/src/baseCommand/BaseCommand.ts b/src/baseCommand/BaseCommand.ts index 7c9cc6b..a5ea4f8 100644 --- a/src/baseCommand/BaseCommand.ts +++ b/src/baseCommand/BaseCommand.ts @@ -19,7 +19,7 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle * @param content The message content. * @returns The sent message.1 */ - protected async send(content: BaseMessageOptions | string): Promise { + protected async send(content: BaseMessageOptions | string, ephemeral: boolean = true): Promise { try { const interaction = this.interaction as CommandInteraction const messageContent = typeof content === "string" ? { content } : content @@ -31,7 +31,7 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle return sentContent as Message } else { this.app.logger.debug(`Replying to interaction ${interaction.id}`) - const sentContent = await interaction.reply({ ...messageContent, fetchReply: true }) + const sentContent = await interaction.reply({ ...messageContent, fetchReply: true, ephemeral: ephemeral }) this.app.logger.debug(`Finished reply to interaction ${interaction.id}`) return sentContent as Message } @@ -48,11 +48,11 @@ export default abstract class BaseCommand extends BaseCommandOrSubcommandsHandle /** * Defers the reply to the interaction. */ - protected async defer(): Promise { + protected async defer(ephemeral: boolean = true): Promise { try { this.app.logger.debug(`Deferring reply to interaction ${this.interaction.id}`) const interaction = this.interaction as CommandInteraction - await interaction.deferReply() + await interaction.deferReply({ ephemeral: ephemeral }) } catch (error) { if (error instanceof Error) { handleInteractionError(error, this.interaction, this.app.logger) diff --git a/src/commands/PingCommand.test.ts b/src/commands/PingCommand.test.ts index 5fbdb2a..342fbd4 100644 --- a/src/commands/PingCommand.test.ts +++ b/src/commands/PingCommand.test.ts @@ -30,7 +30,7 @@ describe("PingCommand", () => { const replySpy = jest.spyOn(interaction, 'reply') await commandInstance.execute() - expect(replySpy).toHaveBeenCalledWith({ content: "Pinging...", fetchReply: true }) + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ content: "Pinging..." })) }) it("should edit the reply with pong and message embed", async () => { diff --git a/src/commands/queue/QueueInfoCommand.test.ts b/src/commands/queue/QueueInfoCommand.test.ts index a313f14..a612534 100644 --- a/src/commands/queue/QueueInfoCommand.test.ts +++ b/src/commands/queue/QueueInfoCommand.test.ts @@ -34,8 +34,7 @@ describe("InfoCommand", () => { await commandInstance.execute(); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - fetchReply: true, + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ embeds: [{ data: { title: "Error", @@ -43,7 +42,7 @@ describe("InfoCommand", () => { color: Colors.Red, } }] - }); + })); }) it("should reply with the queue info if the user is in a queue", async () => { @@ -58,8 +57,7 @@ describe("InfoCommand", () => { await commandInstance.execute(); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - fetchReply: true, + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ embeds: [{ data: { title: "Queue Information", @@ -83,6 +81,6 @@ describe("InfoCommand", () => { ]), } }] - }); + })); }) }); \ No newline at end of file diff --git a/src/commands/queue/QueueJoinCommand.test.ts b/src/commands/queue/QueueJoinCommand.test.ts index 6ce9ec7..bb16081 100644 --- a/src/commands/queue/QueueJoinCommand.test.ts +++ b/src/commands/queue/QueueJoinCommand.test.ts @@ -67,8 +67,7 @@ describe("QueueJoinCommand", () => { expect(saveSpyRes.queues[0].entries).toHaveLength(1); expect(saveSpyRes.queues[0].entries[0].discord_id).toBe(interaction.user.id); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - fetchReply: true, + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ embeds: [{ data: { description: expect.stringContaining(queue.join_message!.replace("${name}", queue.name).replace("${pos}", "1").replace("${total}", "1").replace("${time_spent}", "0h 0m")), @@ -76,7 +75,7 @@ describe("QueueJoinCommand", () => { title: "Queue Joined" } }] - }); + })); }) it("should fail if the queue does not exist", async () => { @@ -84,8 +83,7 @@ describe("QueueJoinCommand", () => { await commandInstance.execute(); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - fetchReply: true, + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ embeds: [{ data: { title: "Error", @@ -93,7 +91,7 @@ describe("QueueJoinCommand", () => { color: Colors.Red, } }] - }); + })); }) it("should fail if the user is already in the same queue", async () => { @@ -107,8 +105,7 @@ describe("QueueJoinCommand", () => { expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - fetchReply: true, + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ embeds: [{ data: { title: "Error", @@ -116,7 +113,7 @@ describe("QueueJoinCommand", () => { color: Colors.Red, } }] - }); + })); }) it("should fail if the user is already in another queue", async () => { @@ -131,8 +128,7 @@ describe("QueueJoinCommand", () => { expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - fetchReply: true, + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ embeds: [{ data: { title: "Error", @@ -140,7 +136,7 @@ describe("QueueJoinCommand", () => { color: Colors.Red, } }] - }); + })); }) it("should fail if the user has an active session", async () => { @@ -156,8 +152,7 @@ describe("QueueJoinCommand", () => { expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - fetchReply: true, + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ embeds: [{ data: { title: "Error", @@ -165,7 +160,7 @@ describe("QueueJoinCommand", () => { color: Colors.Red, } }] - }); + })); }) it("should fail if the queue is locked", async () => { @@ -179,8 +174,7 @@ describe("QueueJoinCommand", () => { expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith({ - fetchReply: true, + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ embeds: [{ data: { title: "Error", @@ -188,6 +182,6 @@ describe("QueueJoinCommand", () => { color: Colors.Red, } }] - }); + })); }) }) \ No newline at end of file diff --git a/src/commands/queue/QueueLeaveCommand.test.ts b/src/commands/queue/QueueLeaveCommand.test.ts index 63519ed..7733213 100644 --- a/src/commands/queue/QueueLeaveCommand.test.ts +++ b/src/commands/queue/QueueLeaveCommand.test.ts @@ -40,18 +40,15 @@ describe("QueueLeaveCommand", () => { const saveSpyRes = await saveSpy.mock.results[0].value; expect(saveSpyRes.queues.entries).toHaveLength(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith( - { - fetchReply: true, - embeds: [{ - data: { - description: expect.stringContaining(queue.leave_message!.replace("${name}", queue.name).replace("${time_spent}", "0h 0m")), - color: Colors.Green, - title: "Queue Left" - } - }] - } - ); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + description: expect.stringContaining(queue.leave_message!.replace("${name}", queue.name).replace("${time_spent}", "0h 0m")), + color: Colors.Green, + title: "Queue Left" + } + }] + })); }) it("should fail if the user is not in a queue", async () => { @@ -65,17 +62,14 @@ describe("QueueLeaveCommand", () => { expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith( - { - fetchReply: true, - embeds: [{ - data: { - description: `You are currently not in a queue.`, - color: Colors.Red, - title: "Error" - } - }] - } - ); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + description: `You are currently not in a queue.`, + color: Colors.Red, + title: "Error" + } + }] + })); }) }) diff --git a/src/commands/queue/QueueListCommand.test.ts b/src/commands/queue/QueueListCommand.test.ts index 797ae45..b00794a 100644 --- a/src/commands/queue/QueueListCommand.test.ts +++ b/src/commands/queue/QueueListCommand.test.ts @@ -53,9 +53,8 @@ describe("QueueListCommand", () => { await commandInstance.execute() expect(replySpy).toHaveBeenCalledTimes(1) - expect(replySpy).toHaveBeenCalledWith({ - fetchReply: true, - embeds: [{ + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ data: { title: "Queue List", description: "Here are all the queues available in this server.", @@ -72,6 +71,6 @@ describe("QueueListCommand", () => { color: Colors.Green, } }] - }); + })); }) }) From 859d8b1fe292742d281d4dfa477f1b5abb5c5f7f Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 16 Mar 2024 20:49:07 +0100 Subject: [PATCH 099/130] Fix Javascript problems --- .../admin/UpdateBotRolesCommand.test.ts | 2 +- .../queue/AddQueueInfoChannelCommand.test.ts | 3 +- .../RemoveQueueInfoChannelCommand.test.ts | 2 +- .../queue/SetWaitingRoomCommand.test.ts | 2 +- .../config/queue/SetWaitingRoomCommand.ts | 2 +- src/commands/queue/QueueJoinCommand.test.ts | 4 +- src/commands/queue/QueueLeaveCommand.test.ts | 2 +- .../session/TutorSessionEndCommand.test.ts | 2 +- .../session/TutorSessionStartCommand.test.ts | 3 +- src/events/GuildAddMemberEvent.test.ts | 6 +- src/events/GuildCreateEvent.test.ts | 2 +- src/events/GuildUpdateEvent.test.ts | 2 +- src/events/ReadyEvent.test.ts | 3 +- src/managers/ConfigManager.ts | 3 +- src/managers/QueueManager.ts | 4 +- src/managers/UserManager.ts | 3 +- src/models/BotRoles.ts | 5 -- src/models/Guild.ts | 8 +-- src/models/Models.ts | 57 +++++++++++++++++++ src/models/Queue.ts | 8 +-- src/models/QueueEntry.ts | 8 +-- src/models/Room.ts | 54 +++++++++++++++--- src/models/Session.ts | 24 +++++--- src/models/User.ts | 28 ++++++--- src/models/VoiceChannel.ts | 8 +-- src/types/EventDate.ts | 19 +++++++ src/types/index.ts | 2 + src/utils/formatDuration.ts | 16 +++++- tests/testutils.ts | 9 +-- 29 files changed, 208 insertions(+), 83 deletions(-) create mode 100644 src/models/Models.ts create mode 100644 src/types/EventDate.ts diff --git a/src/commands/admin/UpdateBotRolesCommand.test.ts b/src/commands/admin/UpdateBotRolesCommand.test.ts index 1770dca..cdc8f50 100644 --- a/src/commands/admin/UpdateBotRolesCommand.test.ts +++ b/src/commands/admin/UpdateBotRolesCommand.test.ts @@ -4,7 +4,7 @@ import UpdateBotRolesCommand from "./UpdateBotRolesCommand"; import { container } from "tsyringe"; import { mockRole } from "@shoginn/discordjs-mock"; import { InternalGuildRoles } from "@models/BotRoles"; -import { GuildModel } from "@models/Guild"; +import { GuildModel } from "@models/Models"; describe("UpdateBotRolesCommand", () => { const command = UpdateBotRolesCommand diff --git a/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts index da07aa5..5ce7070 100644 --- a/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts +++ b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts @@ -3,9 +3,8 @@ import { ApplicationCommandOptionType, ChannelType, ChatInputCommandInteraction, import { container } from "tsyringe"; import AddQueueInfoChannelCommand from "./AddQueueInfoChannelCommand"; import { QueueEventType } from "@models/Event"; -import { eventNames } from "process"; import { createQueue } from "@tests/testutils"; -import { GuildModel } from "@models/Guild"; +import { GuildModel } from "@models/Models"; describe("AddQueueInfoChannelCommand", () => { const command = AddQueueInfoChannelCommand; diff --git a/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts b/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts index cf7028d..2e2168c 100644 --- a/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts +++ b/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts @@ -4,7 +4,7 @@ import { container } from "tsyringe"; import RemoveQueueInfoChannelCommand from "./RemoveQueueInfoChannelCommand"; import { QueueEventType } from "@models/Event"; import { createQueue } from "@tests/testutils"; -import { GuildModel } from "@models/Guild"; +import { GuildModel } from "@models/Models"; describe("RemoveQueueInfoChannelCommand", () => { const command = RemoveQueueInfoChannelCommand; diff --git a/src/commands/config/queue/SetWaitingRoomCommand.test.ts b/src/commands/config/queue/SetWaitingRoomCommand.test.ts index dd5c4f2..eaa47ff 100644 --- a/src/commands/config/queue/SetWaitingRoomCommand.test.ts +++ b/src/commands/config/queue/SetWaitingRoomCommand.test.ts @@ -3,7 +3,7 @@ import SetWaitingRoomCommand from "./SetWaitingRoomCommand" import { container } from "tsyringe" import { BaseMessageOptions, ChannelType, ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js" import { createQueue, createRole, createWaitingRoom } from "@tests/testutils" -import { GuildModel } from "@models/Guild" +import { GuildModel } from "@models/Models" describe("SetWaitingRoomCommand", () => { const command = SetWaitingRoomCommand diff --git a/src/commands/config/queue/SetWaitingRoomCommand.ts b/src/commands/config/queue/SetWaitingRoomCommand.ts index eb7db7b..30c4c82 100644 --- a/src/commands/config/queue/SetWaitingRoomCommand.ts +++ b/src/commands/config/queue/SetWaitingRoomCommand.ts @@ -4,7 +4,7 @@ import { CouldNotFindChannelError, CouldNotFindQueueError, CouldNotFindRoleError import { Guild as DatabaseGuild } from "@models/Guild"; import { ArraySubDocumentType, DocumentType, mongoose } from "@typegoose/typegoose"; import { ApplicationCommandOptionType, ChannelType, Colors, EmbedBuilder, Role, VoiceChannel } from "discord.js"; -import { VoiceChannelModel } from "@models/VoiceChannel"; +import { VoiceChannelModel } from "@models/Models"; export default class SetWaitingRoomCommand extends BaseCommand { public static name = "set_waiting_room"; diff --git a/src/commands/queue/QueueJoinCommand.test.ts b/src/commands/queue/QueueJoinCommand.test.ts index bb16081..6a83077 100644 --- a/src/commands/queue/QueueJoinCommand.test.ts +++ b/src/commands/queue/QueueJoinCommand.test.ts @@ -2,9 +2,9 @@ import { MockDiscord } from "@tests/mockDiscord"; import { ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js"; import { container } from "tsyringe"; import QueueJoinCommand from "./QueueJoinCommand"; -import { SessionModel, SessionRole } from "@models/Session"; +import { SessionRole } from "@models/Session"; import { createQueue } from "@tests/testutils"; -import { GuildModel } from "@models/Guild"; +import { GuildModel, SessionModel } from "@models/Models"; describe("QueueJoinCommand", () => { const command = QueueJoinCommand; diff --git a/src/commands/queue/QueueLeaveCommand.test.ts b/src/commands/queue/QueueLeaveCommand.test.ts index 7733213..9a70ef7 100644 --- a/src/commands/queue/QueueLeaveCommand.test.ts +++ b/src/commands/queue/QueueLeaveCommand.test.ts @@ -2,8 +2,8 @@ import { MockDiscord } from "@tests/mockDiscord"; import { ChatInputCommandInteraction, Colors } from "discord.js"; import { container } from "tsyringe"; import QueueLeaveCommand from "./QueueLeaveCommand"; -import { GuildModel } from "@models/Guild"; import { createQueue } from "@tests/testutils"; +import { GuildModel } from "@models/Models"; describe("QueueLeaveCommand", () => { const command = QueueLeaveCommand; diff --git a/src/commands/tutor/session/TutorSessionEndCommand.test.ts b/src/commands/tutor/session/TutorSessionEndCommand.test.ts index 309dc3d..e380e57 100644 --- a/src/commands/tutor/session/TutorSessionEndCommand.test.ts +++ b/src/commands/tutor/session/TutorSessionEndCommand.test.ts @@ -3,7 +3,7 @@ import { MockDiscord } from "@tests/mockDiscord"; import { createQueue, createRole, createSession } from "@tests/testutils"; import { ChatInputCommandInteraction, Colors, DataManager, GuildMember, GuildMemberRoleManager, Role } from "discord.js"; import TutorSessionEndCommand from "./TutorSessionEndCommand"; -import { SessionModel } from "@models/Session"; +import { SessionModel } from "@models/Models"; describe("TutorSessionEndCommand", () => { const command = TutorSessionEndCommand; diff --git a/src/commands/tutor/session/TutorSessionStartCommand.test.ts b/src/commands/tutor/session/TutorSessionStartCommand.test.ts index 26b4e59..067e34d 100644 --- a/src/commands/tutor/session/TutorSessionStartCommand.test.ts +++ b/src/commands/tutor/session/TutorSessionStartCommand.test.ts @@ -3,7 +3,8 @@ import { ChatInputCommandInteraction, Colors, GuildMemberRoleManager } from "dis import TutorSessionStartCommand from "./TutorSessionStartCommand"; import { InternalRoles, RoleScopes } from "@models/BotRoles"; import { createQueue, createRole, createSession } from "@tests/testutils"; -import { Session, SessionModel, SessionRole } from "@models/Session"; +import { Session, SessionRole } from "@models/Session"; +import { SessionModel } from "@models/Models"; describe("TutorSessionStartCommand", () => { const command = TutorSessionStartCommand; diff --git a/src/events/GuildAddMemberEvent.test.ts b/src/events/GuildAddMemberEvent.test.ts index 84fe6da..ee74e46 100644 --- a/src/events/GuildAddMemberEvent.test.ts +++ b/src/events/GuildAddMemberEvent.test.ts @@ -1,10 +1,10 @@ import { MockDiscord } from "@tests/mockDiscord" import GuildAddMemberEvent from "./GuildAddMemberEvent" import { container } from "tsyringe" -import { BaseMessageOptions, Guild as DiscordGuild, EmbedBuilder, GuildMember } from "discord.js" +import { Guild as DiscordGuild, GuildMember } from "discord.js" import { Guild as DatabaseGuild } from "@models/Guild" -import { UserModel } from "@models/User" import { DocumentType } from "@typegoose/typegoose" +import { UserModel } from "@models/Models" describe("GuildAddMemberEvent", () => { const event = GuildAddMemberEvent @@ -18,7 +18,7 @@ describe("GuildAddMemberEvent", () => { eventInstance = new event(discord.getApplication()) discordGuild = discord.mockGuild() member = discord.mockGuildMember(discord.mockUser(), discordGuild) - databaseGuild = await discord.getApplication().configManager.getDefaultGuildConfig(discordGuild) + databaseGuild = await discord.getApplication().configManager.getGuildConfig(discordGuild) }) it("should have the correct name", () => { diff --git a/src/events/GuildCreateEvent.test.ts b/src/events/GuildCreateEvent.test.ts index b9fc043..8f474bf 100644 --- a/src/events/GuildCreateEvent.test.ts +++ b/src/events/GuildCreateEvent.test.ts @@ -3,7 +3,7 @@ import GuildCreateEvent from "./GuildCreateEvent" import { MockDiscord } from "@tests/mockDiscord" import { Guild } from "discord.js" import { CommandsManager } from "@managers" -import { GuildModel } from "@models/Guild" +import { GuildModel } from "@models/Models" describe("GuildCreateEvent", () => { const event = GuildCreateEvent diff --git a/src/events/GuildUpdateEvent.test.ts b/src/events/GuildUpdateEvent.test.ts index 5290ad5..a3fdbf8 100644 --- a/src/events/GuildUpdateEvent.test.ts +++ b/src/events/GuildUpdateEvent.test.ts @@ -2,7 +2,7 @@ import { container } from "tsyringe" import GuildUpdateEvent from "./GuildUpdateEvent" import { MockDiscord } from "@tests/mockDiscord" import { Guild } from "discord.js" -import { GuildModel } from "@models/Guild" +import { GuildModel } from "@models/Models" describe("GuildUpdateEvent", () => { const event = GuildUpdateEvent diff --git a/src/events/ReadyEvent.test.ts b/src/events/ReadyEvent.test.ts index c81e8e3..57db283 100644 --- a/src/events/ReadyEvent.test.ts +++ b/src/events/ReadyEvent.test.ts @@ -2,8 +2,7 @@ import { container } from "tsyringe" import ReadyEvent from "./ReadyEvent" import { MockDiscord } from "@tests/mockDiscord" import { ActivityType, Guild } from "discord.js" -import { GuildModel } from "@models/Guild" - +import { GuildModel } from "@models/Models" describe("ReadyEvent", () => { const event = ReadyEvent diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index 057cfb1..ed5bd22 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -1,8 +1,9 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { Guild as DiscordGuild } from "discord.js"; -import { Guild, GuildModel } from "@models/Guild"; +import { Guild } from "@models/Guild"; import { DocumentType } from "@typegoose/typegoose"; import { Application } from "@application"; +import { GuildModel } from "@models/Models"; @injectable() @singleton() diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index aeffd71..fdba991 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -4,12 +4,12 @@ import { Queue } from "@models/Queue"; import { DocumentType, mongoose } from "@typegoose/typegoose"; import { AlreadyInQueueError, ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, CouldNotFindQueueError, InvalidEventError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError, UserHasActiveSessionError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; -import { QueueEntryModel } from "@models/QueueEntry"; import { EmbedBuilder, TextChannel, User, Guild as DiscordGuild } from "discord.js"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEventType } from "@models/Event"; import { InternalRoles } from "@models/BotRoles"; -import { SessionModel, SessionRole, Session } from "@models/Session"; +import { SessionRole, Session } from "@models/Session"; +import { QueueEntryModel, SessionModel } from "@models/Models"; @injectable() @singleton() diff --git a/src/managers/UserManager.ts b/src/managers/UserManager.ts index 9eda8cd..a782650 100644 --- a/src/managers/UserManager.ts +++ b/src/managers/UserManager.ts @@ -2,10 +2,11 @@ import { Application } from "@application"; import { delay, inject, injectable, singleton } from "tsyringe"; import { User as DiscordUser, Guild, GuildMember, Role } from "discord.js"; import { DocumentType } from "@typegoose/typegoose"; -import { User, UserModel } from "@models/User"; +import { User } from "@models/User"; import { InternalRoles } from "@models/BotRoles"; import { CouldNotFindRoleError, CouldNotAssignRoleError, CouldNotRemoveRoleError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; +import { UserModel } from "@models/Models"; /** * Manages user-related operations such as retrieving users, creating new users, and assigning roles to users. diff --git a/src/models/BotRoles.ts b/src/models/BotRoles.ts index 4243bf4..e47747d 100644 --- a/src/models/BotRoles.ts +++ b/src/models/BotRoles.ts @@ -63,8 +63,3 @@ export interface BotRole extends DBRole { scope: RoleScopes.GLOBAL, } -export const DBRoleModel = getModelForClass(DBRole, { - schemaOptions: { - autoCreate: false, - }, -}); \ No newline at end of file diff --git a/src/models/Guild.ts b/src/models/Guild.ts index 12de723..3a270e5 100644 --- a/src/models/Guild.ts +++ b/src/models/Guild.ts @@ -53,10 +53,4 @@ export class Guild { */ @prop() welcome_title?: string; -} - -export const GuildModel = getModelForClass(Guild, { - schemaOptions: { - autoCreate: true, - }, -}); \ No newline at end of file +} \ No newline at end of file diff --git a/src/models/Models.ts b/src/models/Models.ts new file mode 100644 index 0000000..d035905 --- /dev/null +++ b/src/models/Models.ts @@ -0,0 +1,57 @@ +import { getModelForClass } from "@typegoose/typegoose"; +import { DBRole } from "./BotRoles"; +import { Queue } from "./Queue"; +import { QueueEntry } from "./QueueEntry"; +import { Room } from "./Room"; +import { Session } from "./Session"; +import { User } from "./User"; +import { Guild } from "./Guild"; +import { VoiceChannel } from "./VoiceChannel"; + +export const DBRoleModel = getModelForClass(DBRole, { + schemaOptions: { + autoCreate: false, + }, +}); + +export const GuildModel = getModelForClass(Guild, { + schemaOptions: { + autoCreate: true, + }, +}); + +export const QueueModel = getModelForClass(Queue, { + schemaOptions: { + autoCreate: false, + }, +}); + +export const QueueEntryModel = getModelForClass(QueueEntry, { + schemaOptions: { + autoCreate: false, + }, +}); + +export const RoomModel = getModelForClass(Room, { + schemaOptions: { + autoCreate: true, + }, +}); + +export const SessionModel = getModelForClass(Session, { + schemaOptions: { + autoCreate: true, + }, +}); + +export const UserModel = getModelForClass(User, { + schemaOptions: { + autoCreate: true, + }, +}); + +export const VoiceChannelModel = getModelForClass(VoiceChannel, { + schemaOptions: { + autoCreate: false, + }, +}); \ No newline at end of file diff --git a/src/models/Queue.ts b/src/models/Queue.ts index d1975b4..da8cabc 100644 --- a/src/models/Queue.ts +++ b/src/models/Queue.ts @@ -298,10 +298,4 @@ export class Queue { return default_join_message; } } -} - -export const QueueModel = getModelForClass(Queue, { - schemaOptions: { - autoCreate: false, - }, -}); \ No newline at end of file +} \ No newline at end of file diff --git a/src/models/QueueEntry.ts b/src/models/QueueEntry.ts index 8680034..f423019 100644 --- a/src/models/QueueEntry.ts +++ b/src/models/QueueEntry.ts @@ -24,10 +24,4 @@ export class QueueEntry { */ @prop({ required: false }) intent?: string; -} - -export const QueueEntryModel = getModelForClass(QueueEntry, { - schemaOptions: { - autoCreate: false, - }, -}); \ No newline at end of file +} \ No newline at end of file diff --git a/src/models/Room.ts b/src/models/Room.ts index 7fdc995..e5e3860 100644 --- a/src/models/Room.ts +++ b/src/models/Room.ts @@ -1,5 +1,8 @@ -import { ArraySubDocumentType, getModelForClass, mongoose, prop } from "@typegoose/typegoose"; -import { VoiceChannelEvent } from "./Event"; +import { ArraySubDocumentType, mongoose, prop, DocumentType } from "@typegoose/typegoose"; +import { VoiceChannelEvent, VoiceChannelEventType } from "./Event"; +import { SessionRole } from "./Session"; +import { EventDate } from "@types"; +import { RoomModel, UserModel } from "./Models"; export class Room { /** @@ -32,10 +35,45 @@ export class Room { */ @prop({ required: true, type: () => [VoiceChannelEvent], default: [] }) events!: mongoose.Types.DocumentArray>; -} -export const RoomModel = getModelForClass(Room, { - schemaOptions: { - autoCreate: true, - }, -}); + public async getFirstJoinTimes(this: DocumentType): Promise { + // We Assume that the Role Does not change During the Rooms Lifetime + return (await RoomModel.aggregate<{ _id: string, timestamp: string }>([ + { $match: { _id: this._id } }, + { $unwind: { path: "$events" } }, + { $match: { "events.type": { $in: [VoiceChannelEventType.user_join, VoiceChannelEventType.move_member] } } }, + { $group: { _id: { $cond: { if: { $eq: ["$events.type", VoiceChannelEventType.user_join] }, then: "$events.emitted_by", else: { $ifNull: ["$events.target", "NULL"] } } }, timestamp: { $min: "$events.timestamp" } } }, + { $match: { _id: { $ne: "NULL" } } }, + ])).map(x => { return { target_id: x._id, timestamp: x.timestamp } as EventDate; }); + } + + public async getUserRoles(this: DocumentType): Promise<{ + userID: string; + role: SessionRole | null; + }[]> { + const users = await this.getFirstJoinTimes(); + const userRoles: { + userID: string; + role: SessionRole | null; + }[] = []; + for (const user of users) { + const userModel = await UserModel.findById(user.target_id); + if (!userModel) { + userRoles.push({ userID: user.target_id, role: null }); + continue; + } + const role = await userModel.getRole(this.guild, (+user.timestamp)); + const b = { + userID: user.target_id, + role: role, + }; + userRoles.push(b); + } + return userRoles; + } + + public async getNumberOfParticipants(this: DocumentType): Promise { + const roles = await this.getUserRoles(); + return roles.filter(x => x.role === SessionRole.participant || x.role == null).length; + } +} \ No newline at end of file diff --git a/src/models/Session.ts b/src/models/Session.ts index 9d3c871..f726fbd 100644 --- a/src/models/Session.ts +++ b/src/models/Session.ts @@ -1,4 +1,5 @@ -import { getModelForClass, prop, mongoose } from "@typegoose/typegoose"; +import { DocumentType, prop, mongoose } from "@typegoose/typegoose"; +import { RoomModel } from "./Models"; export enum SessionRole { "participant" = "participant", @@ -52,10 +53,19 @@ export class Session { */ @prop({ required: true, type: () => [String], default: [] }) rooms!: mongoose.Types.Array; -} -export const SessionModel = getModelForClass(Session, { - schemaOptions: { - autoCreate: true, - }, -}); + public getNumberOfRooms(this: DocumentType): number { + return this.rooms.length; + } + + public async getNumberOfParticipants(this: DocumentType): Promise { + let count = 0; + for (const room of this.rooms) { + const roomData = await RoomModel.findById(room); + if (roomData) { + count += (await roomData.getNumberOfParticipants()); + } + } + return count; + } +} \ No newline at end of file diff --git a/src/models/User.ts b/src/models/User.ts index b42f5b0..a2b05b2 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -1,6 +1,7 @@ import { getModelForClass, prop, mongoose, Ref, DocumentType } from "@typegoose/typegoose"; -import { Session, SessionModel } from "./Session"; +import { Session, SessionRole } from "./Session"; import { DBRole } from "./BotRoles"; +import { SessionModel } from "./Models"; /** * A User from the Database @@ -51,10 +52,23 @@ export class User { public async getActiveSessions(this: DocumentType): Promise[]> { return SessionModel.find({ user: (this._id as string), active: true }); } -} -export const UserModel = getModelForClass(User, { - schemaOptions: { - autoCreate: true, - }, -}); + /** + * Gets The Role a User had at the given Time + * @param guildID The Guild That is associated With the Session + * @param timestamp The Timestamp (defaults to Date.now()) + */ + public async getRole(this: DocumentType, guildID: string, timestamp?: number): Promise { + if (!timestamp) { + timestamp = Date.now(); + } + // Get Session(s) at Timestamp + const sessions = await SessionModel.find({ guild: guildID, user: (this._id as string), started_at: { $lte: timestamp.toString() }, $or: [{ ended_at: { $exists: false } }, { ended_at: { $gte: timestamp.toString() } }] }); + // We assume that there is at most One Session per guild at a time + if (!sessions.length) { + return null; + } else { + return sessions[0].role; + } + } +} \ No newline at end of file diff --git a/src/models/VoiceChannel.ts b/src/models/VoiceChannel.ts index b9e65c1..dcf9991 100644 --- a/src/models/VoiceChannel.ts +++ b/src/models/VoiceChannel.ts @@ -60,10 +60,4 @@ export class VoiceChannel implements Channel { */ @prop({ type: String, default: [] }) supervisors?: mongoose.Types.Array; -} - -export const VoiceChannelModel = getModelForClass(VoiceChannel, { - schemaOptions: { - autoCreate: false, - }, -}); \ No newline at end of file +} \ No newline at end of file diff --git a/src/types/EventDate.ts b/src/types/EventDate.ts new file mode 100644 index 0000000..b7ff8d5 --- /dev/null +++ b/src/types/EventDate.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; + +/** + * Simple Type To Export Event Dates + */ +export type EventDate = { + /** + * The Event ID + */ + event_id?: mongoose.Types.ObjectId, + /** + * The Target ID + */ + target_id: string, + /** + * The Timestamp + */ + timestamp: string, +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index b94b824..82833e8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +import { EventDate } from "./EventDate"; import OptionRequirement from "./OptionRequirement"; import { StringReplacements } from "./StringReplacements"; import AlreadyInQueueError from "./errors/AlreadyInQueueError"; @@ -44,4 +45,5 @@ export { ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, GuildHasNoQueueError, + EventDate, } \ No newline at end of file diff --git a/src/utils/formatDuration.ts b/src/utils/formatDuration.ts index 665a7b2..24511a9 100644 --- a/src/utils/formatDuration.ts +++ b/src/utils/formatDuration.ts @@ -7,8 +7,20 @@ */ export function formatDuration(duration: number): string { duration = duration / 1000; - const hours = Math.floor(duration / 3600); + const days = Math.floor(duration / 86400); + const hours = Math.floor((duration % 86400) / 3600); const minutes = Math.floor((duration % 3600) / 60); const seconds = duration % 60; - return `${hours}h ${minutes}m ${seconds}s`; + + const parts = []; + + if (days > 1) { + parts.push(`${days}d`); + } + + parts.push(`${hours}h`); + parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + + return parts.join(' '); } \ No newline at end of file diff --git a/tests/testutils.ts b/tests/testutils.ts index f2a1166..21dcfa9 100644 --- a/tests/testutils.ts +++ b/tests/testutils.ts @@ -1,10 +1,11 @@ -import { DBRole, DBRoleModel, InternalRoles, RoleScopes } from "@models/BotRoles"; +import { DBRole, RoleScopes } from "@models/BotRoles"; import { QueueEventType } from "@models/Event"; import { Guild } from "@models/Guild" -import { Queue, QueueModel } from "@models/Queue"; +import { QueueModel, SessionModel, DBRoleModel, VoiceChannelModel } from "@models/Models"; +import { Queue } from "@models/Queue"; import { QueueEntry } from "@models/QueueEntry"; -import { SessionModel, SessionRole, Session } from "@models/Session"; -import { VoiceChannel, VoiceChannelModel } from "@models/VoiceChannel"; +import {SessionRole, Session } from "@models/Session"; +import { VoiceChannel } from "@models/VoiceChannel"; import { DocumentType, mongoose } from "@typegoose/typegoose" import { ChannelType, } from "discord.js"; From fca77a3922e653082e80cd38d8783fafb5ba3385 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 16 Mar 2024 21:00:16 +0100 Subject: [PATCH 100/130] Add session summary command --- .../TutorSessionSummaryCommand.test.ts | 80 ++++++++++++++++++ .../session/TutorSesssionSummaryCommand.ts | 81 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/commands/tutor/session/TutorSessionSummaryCommand.test.ts create mode 100644 src/commands/tutor/session/TutorSesssionSummaryCommand.ts diff --git a/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts b/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts new file mode 100644 index 0000000..c6c4161 --- /dev/null +++ b/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts @@ -0,0 +1,80 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, Colors } from "discord.js"; +import TutorSessionSummaryCommand from "./TutorSesssionSummaryCommand"; +import { createQueue, createSession } from "@tests/testutils"; + +describe("TutorSessionSummaryCommand", () => { + const command = TutorSessionSummaryCommand; + const discord = new MockDiscord(); + let commandInstance: TutorSessionSummaryCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("summary"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Shows a summary of the current session."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + it("should reply with a summary of the current session", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Session Summary", + fields: expect.arrayContaining([ + { + name: "Time Spent", + value: expect.any(String), + inline: true, + }, + { + name: "Channels Visited", + value: expect.any(String), + inline: true, + }, + { + name: "Participants", + value: expect.any(String), + inline: true, + } + ]), + color: Colors.Green, + } + }] + })); + }) + + it("should fail if the user has no active session", async () => { + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: `You do not have an active session.`, + color: Colors.Red, + } + }] + })); + }) +}) \ No newline at end of file diff --git a/src/commands/tutor/session/TutorSesssionSummaryCommand.ts b/src/commands/tutor/session/TutorSesssionSummaryCommand.ts new file mode 100644 index 0000000..9a633b0 --- /dev/null +++ b/src/commands/tutor/session/TutorSesssionSummaryCommand.ts @@ -0,0 +1,81 @@ +import { BaseCommand } from "@baseCommand"; +import { InteractionNotInGuildError, UserHasNoActiveSessionError } from "@types"; +import { EmbedBuilder, Colors } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { Session } from "@models/Session"; +import { formatDuration } from "@utils/formatDuration"; + +export default class TutorSessionSummaryCommand extends BaseCommand { + public static name = "summary"; + public static description = "Shows a summary of the current session."; + + public async execute(): Promise { + try { + const { timeSpent, channelsVisited, participants } = await this.getTutorSessionSummary(); + const embed = this.mountTutorSessionSummaryEmbed(timeSpent, channelsVisited, participants); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountTutorSessionSummaryEmbed(timeSpent: string, channelsVisited: number, participants: number): EmbedBuilder { + return new EmbedBuilder() + .setTitle("Session Summary") + .addFields({ + name: "Time Spent", + value: timeSpent, + inline: true + }, { + name: "Channels Visited", + value: channelsVisited.toString(), + inline: true + }, { + name: "Participants", + value: participants.toString(), + inline: true + }) + .setColor(Colors.Green); + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof UserHasNoActiveSessionError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private async getTutorSessionSummary(): Promise<{timeSpent: string, channelsVisited: number, participants: number}> { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild); + const dbUser = await this.app.userManager.getUser(this.interaction.user); + const session = (await dbUser.getActiveSessions()).find(session => session.guild === dbGuild.id); + + // Check if the user has an active session + if (!session) { + throw new UserHasNoActiveSessionError(); + } + + const timeSpent = this.getTimeSpent(session); + const channelsVisited = session.rooms.length; + const participants = await session.getNumberOfParticipants(); + + return { timeSpent, channelsVisited, participants }; + } + + private getTimeSpent(session: DocumentType): string { + const start = session.started_at!; + const diff = Date.now() - (+start); + return formatDuration(diff); + } +} \ No newline at end of file From f328bd198c13f4d7cfdc536bae47343ad06006e1 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 16 Mar 2024 21:00:25 +0100 Subject: [PATCH 101/130] Improve session end command --- .../session/TutorSessionEndCommand.test.ts | 17 +++++++++ .../tutor/session/TutorSessionEndCommand.ts | 35 ++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/commands/tutor/session/TutorSessionEndCommand.test.ts b/src/commands/tutor/session/TutorSessionEndCommand.test.ts index e380e57..a83cf06 100644 --- a/src/commands/tutor/session/TutorSessionEndCommand.test.ts +++ b/src/commands/tutor/session/TutorSessionEndCommand.test.ts @@ -57,6 +57,23 @@ describe("TutorSessionEndCommand", () => { data: { title: "Tutor Session Ended", description: `You have ended the tutor session for queue "${queue.name}".`, + fields: expect.arrayContaining([ + { + name: "Time Spent", + value: expect.any(String), + inline: true, + }, + { + name: "Channels Visited", + value: expect.any(String), + inline: true, + }, + { + name: "Participants", + value: expect.any(String), + inline: true, + } + ]), color: Colors.Green } }] diff --git a/src/commands/tutor/session/TutorSessionEndCommand.ts b/src/commands/tutor/session/TutorSessionEndCommand.ts index d6f30f7..1f96497 100644 --- a/src/commands/tutor/session/TutorSessionEndCommand.ts +++ b/src/commands/tutor/session/TutorSessionEndCommand.ts @@ -4,6 +4,8 @@ import { Colors, EmbedBuilder } from "discord.js"; import { DocumentType } from "@typegoose/typegoose"; import { CouldNotFindQueueError, CouldNotFindQueueForSessionError, CouldNotFindRoleError, CouldNotRemoveRoleError, InteractionNotInGuildError, UserHasNoActiveSessionError } from "@types"; import { InternalRoles } from "@models/BotRoles"; +import { formatDuration } from "@utils/formatDuration"; +import { Session } from "@models/Session"; /** * Represents a command to end a tutor session. @@ -15,8 +17,8 @@ export default class TutorSessionEndCommand extends BaseCommand { public async execute(): Promise { await this.defer(); try { - const queue = await this.endTutorSession(); - const embed = this.mountEndTutorSessionEmbed(queue); + const { queue, timeSpent, channelsVisited, participants } = await this.endTutorSession(); + const embed = this.mountEndTutorSessionEmbed(queue, timeSpent, channelsVisited, participants); await this.send({ embeds: [embed] }); } catch (error) { if (error instanceof Error) { @@ -34,10 +36,23 @@ export default class TutorSessionEndCommand extends BaseCommand { * @param queue - The queue for the tutor session. * @returns The embed builder for the end of the tutor session. */ - private mountEndTutorSessionEmbed(queue: DocumentType): EmbedBuilder { + private mountEndTutorSessionEmbed(queue: DocumentType, timeSpent: string, channelsVisited: number, participants: number): EmbedBuilder { return new EmbedBuilder() .setTitle("Tutor Session Ended") .setDescription(`You have ended the tutor session for queue "${queue.name}".`) + .addFields({ + name: "Time Spent", + value: timeSpent, + inline: true + }, { + name: "Channels Visited", + value: channelsVisited.toString(), + inline: true + }, { + name: "Participants", + value: participants.toString(), + inline: true + }) .setColor(Colors.Green); } @@ -66,7 +81,7 @@ export default class TutorSessionEndCommand extends BaseCommand { * @throws {UserHasNoActiveSessionError} if the user does not have an active session. * @throws {CouldNotFindQueueForSessionError} if the queue for the session could not be found. */ - private async endTutorSession(): Promise> { + private async endTutorSession(): Promise<{queue: DocumentType, timeSpent: string, channelsVisited: number, participants: number}> { if (!this.interaction.guild) { throw new InteractionNotInGuildError(this.interaction); } @@ -92,6 +107,16 @@ export default class TutorSessionEndCommand extends BaseCommand { // end tutor session await this.app.queueManager.endTutorSession(queue, session, this.interaction.user); - return queue; + const timeSpent = this.getTimeSpent(session); + const channelsVisited = session.rooms.length; + const participants = await session.getNumberOfParticipants(); + + return { queue, timeSpent, channelsVisited, participants }; + } + + private getTimeSpent(session: DocumentType): string { + const start = session.started_at!; + const diff = Date.now() - (+start); + return formatDuration(diff); } } From 956a58de6577c7a7879ed4c8e534808295580c2c Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 17 Mar 2024 16:44:54 +0100 Subject: [PATCH 102/130] Improve event embeds --- src/managers/QueueManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index fdba991..811c907 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -293,7 +293,7 @@ export default class QueueManager { private async logQueueActivity(queue: DocumentType, event: QueueEventType, user: User, targets?: User[]): Promise { const dbGuild = queue.$parent() as DocumentType; const activeSessionRole = dbGuild.guild_settings.roles?.find(role => role.internal_name === InternalRoles.ACTIVE_SESSION); - const queueSessions: DocumentType[] = await SessionModel.find({ queue: queue._id, active: true }); + const numberOfQueueSessions = await SessionModel.find({ queue: queue._id, active: true }).countDocuments(); for (const infoChannel of queue.info_channels) { if (infoChannel.events.includes(event)) { @@ -302,7 +302,7 @@ export default class QueueManager { this.app.logger.debug(`Channel with id ${infoChannel.channel_id} not found in guild ${dbGuild.name} (id: ${dbGuild._id})`); continue; } - const emebed = this.getEventEmbed(user, event, queue, queueSessions, targets); + const emebed = this.getEventEmbed(user, event, queue, numberOfQueueSessions, targets); const message = await discordChannel.send(`<@&${activeSessionRole?.role_id}>`); await message.edit({ embeds: [emebed] }); } @@ -319,7 +319,7 @@ export default class QueueManager { * @param targets - Optional. The array of users affected by the event. * @returns The generated EmbedBuilder object. */ - private getEventEmbed(user: User, event: QueueEventType, queue: DocumentType, sessions: DocumentType[], targets?: User[]): EmbedBuilder { + private getEventEmbed(user: User, event: QueueEventType, queue: DocumentType, numberOfQueueSessions: number, targets?: User[]): EmbedBuilder { let eventDescription: string = ""; switch (event) { case QueueEventType.JOIN: @@ -341,7 +341,7 @@ export default class QueueManager { eventDescription = `${targets?.join(", ")} were kicked from the queue ${queue.name} by ${user}.`; break; } - let sessionsDescription = `There ${sessions.length === 1 ? "is" : "are"} ${sessions.length} active session${sessions.length === 1 ? "" : "s"} in the queue ${queue.name}.`; + let sessionsDescription = `There ${numberOfQueueSessions === 1 ? "is" : "are"} ${numberOfQueueSessions} active session${numberOfQueueSessions === 1 ? "" : "s"} in the queue ${queue.name}.`; let membersDescription = `There ${queue.entries.length === 1 ? "is" : "are"} ${queue.entries.length} member${queue.entries.length === 1 ? "" : "s"} in the queue ${queue.name}.`; return new EmbedBuilder() From 74ba4fd2264ed7b8839d1128f6a1edda269a0ce7 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 17 Mar 2024 16:45:02 +0100 Subject: [PATCH 103/130] Add queue summary command --- .../queue/TutorQueueSummaryCommand.test.ts | 101 ++++++++++++++++++ .../tutor/queue/TutorQueueSummaryCommand.ts | 81 ++++++++++++++ src/types/errors/SessionHasNoQueueError.ts | 21 ++++ src/types/index.ts | 2 + tests/testutils.ts | 2 +- 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/commands/tutor/queue/TutorQueueSummaryCommand.test.ts create mode 100644 src/commands/tutor/queue/TutorQueueSummaryCommand.ts create mode 100644 src/types/errors/SessionHasNoQueueError.ts diff --git a/src/commands/tutor/queue/TutorQueueSummaryCommand.test.ts b/src/commands/tutor/queue/TutorQueueSummaryCommand.test.ts new file mode 100644 index 0000000..b4ecc7f --- /dev/null +++ b/src/commands/tutor/queue/TutorQueueSummaryCommand.test.ts @@ -0,0 +1,101 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, Colors } from "discord.js"; +import TutorQueueSummaryCommand from "./TutorQueueSummaryCommand"; +import { createQueue, createSession } from "@tests/testutils"; + +describe("TutorQueueSummaryCommand", () => { + const command = TutorQueueSummaryCommand; + const discord = new MockDiscord(); + let commandInstance: TutorQueueSummaryCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("summary"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Shows a summary of the current queue."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + it("should reply with a summary of the current queue", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Queue Summary", + fields: expect.arrayContaining([ + { + name: "Name", + value: queue.name, + }, + { + name: "Description", + value: queue.description, + }, + { + name: "Entries", + value: "0", + inline: true, + }, + { + name: "Tutor Sessions", + value: "1", + inline: true, + } + ]), + color: Colors.Green, + } + }] + })); + }) + + it("should fail if the user has no active session", async () => { + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You do not have an active session.", + color: Colors.Red, + } + }] + })); + }) + + it("should fail if the session has no queue", async () => { + await createSession(null, interaction.user.id, interaction.guild!.id); + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "Your session has no queue.", + color: Colors.Red, + } + }] + })); + }) +}) \ No newline at end of file diff --git a/src/commands/tutor/queue/TutorQueueSummaryCommand.ts b/src/commands/tutor/queue/TutorQueueSummaryCommand.ts new file mode 100644 index 0000000..a51819d --- /dev/null +++ b/src/commands/tutor/queue/TutorQueueSummaryCommand.ts @@ -0,0 +1,81 @@ +import { BaseCommand } from "@baseCommand"; +import { SessionModel } from "@models/Models"; +import { InteractionNotInGuildError, SessionHasNoQueueError, UserHasNoActiveSessionError } from "@types"; +import { EmbedBuilder, Colors } from "discord.js"; + +export default class TutorQueueSummaryCommand extends BaseCommand { + public static name = "summary"; + public static description = "Shows a summary of the current queue."; + + public async execute(): Promise { + try { + const { name, description, entries, tutorSessions } = await this.getTutorQueueSummary(); + const embed = this.mountTutorQueueSummaryEmbed(name, description, entries, tutorSessions); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountTutorQueueSummaryEmbed(name: string, description: string, entries: number, tutorSessions: number): EmbedBuilder { + return new EmbedBuilder() + .setTitle("Queue Summary") + .addFields({ + name: "Name", + value: name, + }, { + name: "Description", + value: description, + }, { + name: "Entries", + value: entries.toString(), + inline: true + }, { + name: "Tutor Sessions", + value: tutorSessions.toString(), + inline: true + }) + .setColor(Colors.Green); + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof UserHasNoActiveSessionError || error instanceof SessionHasNoQueueError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private async getTutorQueueSummary(): Promise<{name: string, description: string, entries: number, tutorSessions: number}> { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild); + const dbUser = await this.app.userManager.getUser(this.interaction.user); + const session = (await dbUser.getActiveSessions()).find(session => session.guild === dbGuild.id); + + // Check if the user has an active session + if (!session) { + throw new UserHasNoActiveSessionError(); + } else if (!session.queue) { + throw new SessionHasNoQueueError(session); + } + + const queue = this.app.queueManager.getQueueById(dbGuild, session.queue); + const numberOfQueueSessions = await SessionModel.find({ queue: queue._id, active: true }).countDocuments(); + + return { + name: queue.name, + description: queue.description ?? "No description", + entries: queue.entries.length, + tutorSessions: numberOfQueueSessions + }; + } +} \ No newline at end of file diff --git a/src/types/errors/SessionHasNoQueueError.ts b/src/types/errors/SessionHasNoQueueError.ts new file mode 100644 index 0000000..b2c6e37 --- /dev/null +++ b/src/types/errors/SessionHasNoQueueError.ts @@ -0,0 +1,21 @@ +import { DocumentType } from "@typegoose/typegoose"; +import { Session } from "@models/Session"; + +/** + * Error thrown when a session has no queue. + */ +export default class SessionHasNoQueueError extends Error { + /** + * The session that has no queue. + */ + public session: DocumentType; + + /** + * Creates an instance of SessionHasNoQueueError. + * @param session - The session that has no queue. + */ + constructor(session: DocumentType) { + super("Your session has no queue."); + this.session = session; + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 82833e8..5390dff 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,7 @@ import NotInQueueError from "./errors/NotInQueueError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; import QueueLockedError from "./errors/QueueLockedError"; import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; +import SessionHasNoQueueError from "./errors/SessionHasNoQueueError"; import UserHasActiveSessionError from "./errors/UserHasActiveSessionError"; import UserHasNoActiveSessionError from "./errors/UserHasNoActiveSessionError"; @@ -40,6 +41,7 @@ export { AlreadyInQueueError, UserHasActiveSessionError, UserHasNoActiveSessionError, + SessionHasNoQueueError, QueueLockedError, InvalidEventError, ChannelAlreadyInfoChannelError, diff --git a/tests/testutils.ts b/tests/testutils.ts index 21dcfa9..c485b61 100644 --- a/tests/testutils.ts +++ b/tests/testutils.ts @@ -40,7 +40,7 @@ export async function createQueue(guild: DocumentType, name: string, desc return queue; } -export async function createSession(queue: DocumentType, userId: string, guildId: string, active: boolean = true): Promise> { +export async function createSession(queue: DocumentType | null, userId: string, guildId: string, active: boolean = true): Promise> { const session = await SessionModel.create({ queue: queue, user: userId, From eba19964fd1b5320deeec7d30f96512d4a0aa0e3 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 17 Mar 2024 18:05:29 +0100 Subject: [PATCH 104/130] Add tutor queue list command --- .../tutor/queue/TutorQueueListCommand.test.ts | 106 ++++++++++++++++++ .../tutor/queue/TutorQueueListCommand.ts | 97 ++++++++++++++++ src/types/QueueListItem.ts | 7 ++ src/types/index.ts | 2 + 4 files changed, 212 insertions(+) create mode 100644 src/commands/tutor/queue/TutorQueueListCommand.test.ts create mode 100644 src/commands/tutor/queue/TutorQueueListCommand.ts create mode 100644 src/types/QueueListItem.ts diff --git a/src/commands/tutor/queue/TutorQueueListCommand.test.ts b/src/commands/tutor/queue/TutorQueueListCommand.test.ts new file mode 100644 index 0000000..85824c6 --- /dev/null +++ b/src/commands/tutor/queue/TutorQueueListCommand.test.ts @@ -0,0 +1,106 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, ApplicationCommandOptionType, GuildMemberManager, Guild, GuildMember, Colors } from "discord.js"; +import TutorQueueListCommand from "./TutorQueueListCommand"; +import { createQueue, createSession } from "@tests/testutils"; + +describe("TutorQueueListCommand", () => { + const command = TutorQueueListCommand; + const discord = new MockDiscord(); + let commandInstance: TutorQueueListCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("list"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Lists the first entries of the current queue."); + }) + + it("should have one option", () => { + expect(command.options).toHaveLength(1); + expect(command.options[0]).toEqual({ + name: "amount", + description: "The amount of entries to list.", + type: ApplicationCommandOptionType.Integer, + required: false, + default: 5, + }); + }) + + it.each([null, 1, 3, 5])(`should reply with a list of the first %s entries of the current queue`, async (amount) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + var members: GuildMember[] = []; + for (let i = 0; i < 4; i++) { + const member = discord.mockGuildMember(undefined, interaction.guild!); + members.push(member); + } + const queueEntries = members.map(member => ({ + discord_id: member.id, + joinedAt: Date.now().toString(), + })); + const queue = await createQueue(dbGuild, "test", "test description", queueEntries); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + if (amount) { + interaction.options.get = jest.fn().mockReturnValue({ value: amount }); + } + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Queue Information", + description: `The queue ${queue.name} has 4 entries.`, + fields: queueEntries.slice(0, amount ?? 5).map((_, index) => ({ + name: members[index].displayName, + value: expect.stringContaining(`Position: ${index + 1}`), + })), + color: Colors.Green, + }, + }], + })); + }) + + it("should fail if the user has no active session", async () => { + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You do not have an active session.", + color: Colors.Red, + } + }] + })); + }) + + it("should fail if the session has no queue", async () => { + await createSession(null, interaction.user.id, interaction.guild!.id); + + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "Your session has no queue.", + color: Colors.Red, + } + }] + })); + }); +}) \ No newline at end of file diff --git a/src/commands/tutor/queue/TutorQueueListCommand.ts b/src/commands/tutor/queue/TutorQueueListCommand.ts new file mode 100644 index 0000000..3ebb473 --- /dev/null +++ b/src/commands/tutor/queue/TutorQueueListCommand.ts @@ -0,0 +1,97 @@ +import { BaseCommand } from "@baseCommand"; +import { UserHasNoActiveSessionError, SessionHasNoQueueError, InteractionNotInGuildError, QueueListItem } from "@types"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder, GuildMember } from "discord.js"; + +export default class TutorQueueListCommand extends BaseCommand { + public static name = "list"; + public static description = "Lists the first entries of the current queue."; + public static options = [{ + name: "amount", + description: "The amount of entries to list.", + type: ApplicationCommandOptionType.Integer, + required: false, + default: 5, + }]; + + public async execute(): Promise { + try { + const { queueName, entries, totalNumberOfEntries } = await this.getTutorQueueList(); + const embed = this.mountTutorQueueListEmbed(queueName, entries, totalNumberOfEntries); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountTutorQueueListEmbed(queueName: string, entries: QueueListItem[], totalNumberOfEntries: number): EmbedBuilder { + return new EmbedBuilder() + .setTitle("Queue Information") + .setDescription(`The queue ${queueName} has ${totalNumberOfEntries} ${totalNumberOfEntries == 1 ? "entry" : "entries"}.`) + .addFields(entries.map((entry, index) => ({ + name: entry.member?.displayName ?? "Unknown", + value: + `-Mention: ${entry.member ?? "Unknown"}\n-Position: ${index + 1}\n-Joined At: ${entry.joinedAt}${entry.intent ? `\n-Intent: ${entry.intent}` : ""}`, + }))) + .setColor(Colors.Green); + } + + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof UserHasNoActiveSessionError || error instanceof SessionHasNoQueueError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private async getTutorQueueList(): Promise<{ queueName: string, entries: QueueListItem[], totalNumberOfEntries: number }> { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild); + const dbUser = await this.app.userManager.getUser(this.interaction.user); + const session = (await dbUser.getActiveSessions()).find(session => session.guild === dbGuild.id); + + // Check if the user has an active session + if (!session) { + this.app.logger.info(`User ${this.interaction.user.displayName} (id: ${this.interaction.user.id}) has no active session.`); + throw new UserHasNoActiveSessionError(); + } else if (!session.queue) { + this.app.logger.info(`Session ${session.id} has no queue.`); + throw new SessionHasNoQueueError(session); + } + + // Get the number of entries option + const numberOfEntries = parseInt(this.getOptionValue(TutorQueueListCommand.options[0])); + + const queue = this.app.queueManager.getQueueById(dbGuild, session.queue); + const entries = queue.getSortedEntries(numberOfEntries); + const firstEntries = await Promise.all(entries.map(async (entry) => { + let member: GuildMember | null; + try { + member = await this.interaction.guild!.members.fetch(entry.discord_id); + } catch (error) { + this.app.logger.info(`Member ${entry.discord_id} not found in guild ${this.interaction.guild!.id} but is in the queue ${queue.name}!`); + member = null; + } + return { + member: member, + joinedAt: ``, + intent: entry.intent + } + })); + + return { + queueName: queue.name, + entries: firstEntries, + totalNumberOfEntries: queue.entries.length + }; + } +} \ No newline at end of file diff --git a/src/types/QueueListItem.ts b/src/types/QueueListItem.ts new file mode 100644 index 0000000..c3586fc --- /dev/null +++ b/src/types/QueueListItem.ts @@ -0,0 +1,7 @@ +import { GuildMember } from "discord.js"; + +export type QueueListItem = { + member: GuildMember | null; + joinedAt: string; + intent?: string; +}; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 5390dff..d63d603 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ import { EventDate } from "./EventDate"; import OptionRequirement from "./OptionRequirement"; +import { QueueListItem } from "./QueueListItem"; import { StringReplacements } from "./StringReplacements"; import AlreadyInQueueError from "./errors/AlreadyInQueueError"; import ChannelAlreadyInfoChannelError from "./errors/ChannelAlreadyInfoChannelError"; @@ -48,4 +49,5 @@ export { ChannelNotInfoChannelError, GuildHasNoQueueError, EventDate, + QueueListItem, } \ No newline at end of file From 4050bd9b7997da0f3985ac90fe202512d3ca9b94 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 17 Mar 2024 18:07:04 +0100 Subject: [PATCH 105/130] Add logging --- src/commands/tutor/queue/TutorQueueSummaryCommand.ts | 2 ++ src/commands/tutor/session/TutorSessionEndCommand.ts | 2 ++ src/commands/tutor/session/TutorSesssionSummaryCommand.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/src/commands/tutor/queue/TutorQueueSummaryCommand.ts b/src/commands/tutor/queue/TutorQueueSummaryCommand.ts index a51819d..0e8383e 100644 --- a/src/commands/tutor/queue/TutorQueueSummaryCommand.ts +++ b/src/commands/tutor/queue/TutorQueueSummaryCommand.ts @@ -63,8 +63,10 @@ export default class TutorQueueSummaryCommand extends BaseCommand { // Check if the user has an active session if (!session) { + this.app.logger.info(`User ${this.interaction.user.displayName} (id: ${this.interaction.user.id}) has no active session.`); throw new UserHasNoActiveSessionError(); } else if (!session.queue) { + this.app.logger.info(`Session ${session.id} has no queue.`); throw new SessionHasNoQueueError(session); } diff --git a/src/commands/tutor/session/TutorSessionEndCommand.ts b/src/commands/tutor/session/TutorSessionEndCommand.ts index 1f96497..1f1f163 100644 --- a/src/commands/tutor/session/TutorSessionEndCommand.ts +++ b/src/commands/tutor/session/TutorSessionEndCommand.ts @@ -92,11 +92,13 @@ export default class TutorSessionEndCommand extends BaseCommand { // Check if the user has an active session if (!session) { + this.app.logger.info(`User ${user.displayName} (id: ${user.id}) has no active session.`); throw new UserHasNoActiveSessionError(); } // Get the queue if (!session.queue) { + this.app.logger.info(`Session ${session.id} has no queue.`); throw new CouldNotFindQueueForSessionError(); } const queue = this.app.queueManager.getQueueById(dbGuild, session.queue._id); diff --git a/src/commands/tutor/session/TutorSesssionSummaryCommand.ts b/src/commands/tutor/session/TutorSesssionSummaryCommand.ts index 9a633b0..134fd8c 100644 --- a/src/commands/tutor/session/TutorSesssionSummaryCommand.ts +++ b/src/commands/tutor/session/TutorSesssionSummaryCommand.ts @@ -63,6 +63,7 @@ export default class TutorSessionSummaryCommand extends BaseCommand { // Check if the user has an active session if (!session) { + this.app.logger.error(`User ${this.interaction.user.displayName} (id: ${this.interaction.user.id}) has no active session.`); throw new UserHasNoActiveSessionError(); } From a90ab43f9523f7bd6bef5993a0e75129c4a6286e Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 17 Mar 2024 18:37:17 +0100 Subject: [PATCH 106/130] Add tutor summary command --- .../tutor/TutorSummaryCommand.test.ts | 83 +++++++++++++++++++ src/commands/tutor/TutorSummaryCommand.ts | 82 ++++++++++++++++++ src/models/User.ts | 10 +++ 3 files changed, 175 insertions(+) create mode 100644 src/commands/tutor/TutorSummaryCommand.test.ts create mode 100644 src/commands/tutor/TutorSummaryCommand.ts diff --git a/src/commands/tutor/TutorSummaryCommand.test.ts b/src/commands/tutor/TutorSummaryCommand.test.ts new file mode 100644 index 0000000..a8e5758 --- /dev/null +++ b/src/commands/tutor/TutorSummaryCommand.test.ts @@ -0,0 +1,83 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, Colors } from "discord.js"; +import TutorSummaryCommand from "./TutorSummaryCommand"; +import { createQueue, createSession } from "@tests/testutils"; + +describe("TutorSummaryCommand", () => { + const command = TutorSummaryCommand; + const discord = new MockDiscord(); + let commandInstance: TutorSummaryCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("summary"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Shows a summary of all your Sessions."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + it("should reply with a summary of all your Sessions when you had no session", async () => { + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Summary", + description: "You had 0 sessions.", + color: Colors.Green, + } + }] + })); + }) + + it.each([1, 2, 3])(`should reply with a summary of all your Sessions when you had %s sessions`, async (numberOfSessions) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild, "test", "test description"); + const sessions = []; + for (let i = 0; i < numberOfSessions; i++) { + const session = await createSession(queue, interaction.user.id, interaction.guild!.id); + sessions.push(session); + } + const replySpy = jest.spyOn(interaction, 'reply'); + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Summary", + description: `You had ${numberOfSessions} ${numberOfSessions === 1 ? "session" : "sessions"}.`, + color: Colors.Green, + fields: expect.arrayContaining([ + { + name: "Time Spent", + value: expect.any(String), + }, + { + name: "Channels Visited", + value: expect.any(String), + inline: true, + }, + { + name: "Participants", + value: expect.any(String), + inline: true, + } + ]), + } + }] + })); + }) +}) \ No newline at end of file diff --git a/src/commands/tutor/TutorSummaryCommand.ts b/src/commands/tutor/TutorSummaryCommand.ts new file mode 100644 index 0000000..39321e1 --- /dev/null +++ b/src/commands/tutor/TutorSummaryCommand.ts @@ -0,0 +1,82 @@ +import { BaseCommand } from "@baseCommand"; +import { InteractionNotInGuildError } from "@types"; +import { formatDuration } from "@utils/formatDuration"; +import { EmbedBuilder, Colors } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { Session } from "@models/Session"; + +export default class TutorSummaryCommand extends BaseCommand { + public static name = "summary"; + public static description = "Shows a summary of all your Sessions."; + + public async execute(): Promise { + const { numberOfSessions, timeSpent, channelsVisited, participants } = await this.getTutorSummary(); + const embed = this.mountTutorSummaryEmbed(numberOfSessions, timeSpent, channelsVisited, participants); + await this.send({ embeds: [embed] }); + } + + private mountTutorSummaryEmbed(numberOfSessions: number, timeSpent?: string, channelsVisited?: number, participants?: number): EmbedBuilder { + var embed = new EmbedBuilder() + .setTitle("Summary") + .setDescription(`You had ${numberOfSessions} ${numberOfSessions === 1 ? "session" : "sessions"}.`) + .setColor(Colors.Green); + + if (timeSpent !== undefined && channelsVisited !== undefined && participants !== undefined) { + embed = embed.addFields({ + name: "Time Spent", + value: timeSpent, + }, { + name: "Channels Visited", + value: channelsVisited.toString(), + inline: true + }, { + name: "Participants", + value: participants.toString(), + inline: true + }); + } + + return embed; + } + + private async getTutorSummary(): Promise<{ numberOfSessions: number, timeSpent?: string, channelsVisited?: number, participants?: number }> { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild); + const dbUser = await this.app.userManager.getUser(this.interaction.user); + + const sessions = await dbUser.getSessions(dbGuild._id); + const numberOfSessions = sessions.length; + + if (numberOfSessions === 0) { + this.app.logger.info(`No sessions found for user ${this.interaction.user.displayName} (id: ${this.interaction.user.id}) in guild ${dbGuild.name} (id: ${dbGuild.id})`) + return { numberOfSessions }; + } + + let timeSpent = 0; + let channelsVisited = 0; + let participants = 0; + + for (const session of sessions) { + timeSpent += this.getTimeSpent(session); + channelsVisited += session.getNumberOfRooms(); + participants += await session.getNumberOfParticipants(); + } + + return { + numberOfSessions, + timeSpent: formatDuration(timeSpent), + channelsVisited, + participants + }; + + } + + private getTimeSpent(session: DocumentType): number { + const start = session.started_at!; + const end = session.ended_at ?? Date.now(); + const diff = (+end) - (+start); + return diff + } +} \ No newline at end of file diff --git a/src/models/User.ts b/src/models/User.ts index a2b05b2..c314371 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -53,6 +53,16 @@ export class User { return SessionModel.find({ user: (this._id as string), active: true }); } + /** + * Retrieves the sessions associated with the user in a specific guild. + * + * @param guildID - The ID of the guild. + * @returns A promise that resolves to an array of sessions. + */ + public async getSessions(this: DocumentType, guildID: string): Promise[]> { + return SessionModel.find({ user: (this._id as string), guild: guildID }); + } + /** * Gets The Role a User had at the given Time * @param guildID The Guild That is associated With the Session From 1efff2b02ffe7430abf956f2165a64c54fad7951 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 17 Mar 2024 18:37:29 +0100 Subject: [PATCH 107/130] Change tutor session summary embed --- src/commands/tutor/session/TutorSessionSummaryCommand.test.ts | 1 - src/commands/tutor/session/TutorSesssionSummaryCommand.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts b/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts index c6c4161..19a7559 100644 --- a/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts +++ b/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts @@ -43,7 +43,6 @@ describe("TutorSessionSummaryCommand", () => { { name: "Time Spent", value: expect.any(String), - inline: true, }, { name: "Channels Visited", diff --git a/src/commands/tutor/session/TutorSesssionSummaryCommand.ts b/src/commands/tutor/session/TutorSesssionSummaryCommand.ts index 134fd8c..f652bd2 100644 --- a/src/commands/tutor/session/TutorSesssionSummaryCommand.ts +++ b/src/commands/tutor/session/TutorSesssionSummaryCommand.ts @@ -30,7 +30,6 @@ export default class TutorSessionSummaryCommand extends BaseCommand { .addFields({ name: "Time Spent", value: timeSpent, - inline: true }, { name: "Channels Visited", value: channelsVisited.toString(), From 9a2290c062e340c3b9337b0fbb3b11533dc2003d Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 17 Mar 2024 18:38:50 +0100 Subject: [PATCH 108/130] Rename queue info to queue summary --- ...eueInfoCommand.test.ts => QueueSummaryCommand.test.ts} | 8 ++++---- .../queue/{QueueInfoCommand.ts => QueueSummaryCommand.ts} | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) rename src/commands/queue/{QueueInfoCommand.test.ts => QueueSummaryCommand.test.ts} (94%) rename src/commands/queue/{QueueInfoCommand.ts => QueueSummaryCommand.ts} (97%) diff --git a/src/commands/queue/QueueInfoCommand.test.ts b/src/commands/queue/QueueSummaryCommand.test.ts similarity index 94% rename from src/commands/queue/QueueInfoCommand.test.ts rename to src/commands/queue/QueueSummaryCommand.test.ts index a612534..b18c9cd 100644 --- a/src/commands/queue/QueueInfoCommand.test.ts +++ b/src/commands/queue/QueueSummaryCommand.test.ts @@ -1,4 +1,4 @@ -import QueueInfoCommand from "./QueueInfoCommand"; +import QueueSummaryCommand from "./QueueSummaryCommand"; import { MockDiscord } from "@tests/mockDiscord"; import { EmbedBuilder } from "@discordjs/builders"; import { ChatInputCommandInteraction, Colors } from "discord.js"; @@ -7,9 +7,9 @@ import { QueueEntry } from "@models/QueueEntry"; import { createQueue } from "@tests/testutils"; describe("InfoCommand", () => { - const command = QueueInfoCommand; + const command = QueueSummaryCommand; const discord = new MockDiscord(); - let commandInstance: QueueInfoCommand; + let commandInstance: QueueSummaryCommand; let interaction: ChatInputCommandInteraction; beforeEach(() => { @@ -18,7 +18,7 @@ describe("InfoCommand", () => { }); it("should have the correct name", () => { - expect(command.name).toBe("info"); + expect(command.name).toBe("summary"); }) it("should have the correct description", () => { diff --git a/src/commands/queue/QueueInfoCommand.ts b/src/commands/queue/QueueSummaryCommand.ts similarity index 97% rename from src/commands/queue/QueueInfoCommand.ts rename to src/commands/queue/QueueSummaryCommand.ts index a28ea30..a908ea4 100644 --- a/src/commands/queue/QueueInfoCommand.ts +++ b/src/commands/queue/QueueSummaryCommand.ts @@ -4,8 +4,8 @@ import { DocumentType } from "@typegoose/typegoose"; import { InteractionNotInGuildError, NotInQueueError } from "@types"; import { Colors, EmbedBuilder } from "discord.js"; -export default class QueueInfoCommand extends BaseCommand { - public static name = "info"; +export default class QueueSummaryCommand extends BaseCommand { + public static name = "summary"; public static description = "Displays information about the queue."; public static options = []; From 53ac5838a22461304d13e1312945db42db4f934e Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:54:26 +0100 Subject: [PATCH 109/130] Use queue manager --- .../config/queue/SetWaitingRoomCommand.ts | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/commands/config/queue/SetWaitingRoomCommand.ts b/src/commands/config/queue/SetWaitingRoomCommand.ts index 30c4c82..d95ca16 100644 --- a/src/commands/config/queue/SetWaitingRoomCommand.ts +++ b/src/commands/config/queue/SetWaitingRoomCommand.ts @@ -57,7 +57,7 @@ export default class SetWaitingRoomCommand extends BaseCommand { } } - private mountSetWaitingRoomEmbed(channel: VoiceChannel, queue: ArraySubDocumentType, supervisor: Role): EmbedBuilder { + private mountSetWaitingRoomEmbed(channel: VoiceChannel, queue: DocumentType, supervisor: Role): EmbedBuilder { const embed = new EmbedBuilder() .setTitle("Waiting Room Set") .setDescription(`:white_check_mark: Waiting room ${channel} set for queue "${queue.name}".`) @@ -77,7 +77,7 @@ export default class SetWaitingRoomCommand extends BaseCommand { return embed } - private async createWaitingRoom(channel: VoiceChannel, queue: ArraySubDocumentType, supervisor: Role): Promise { + private async createWaitingRoom(channel: VoiceChannel, queue: DocumentType, supervisor: Role): Promise { const existingWaitingRoom = this.dbGuild.voice_channels.find(voiceChannel => voiceChannel.queue == queue.id) if (existingWaitingRoom) { this.app.logger.debug(`Found existing waiting room for queue ${queue.name}. Overwriting.`) @@ -101,11 +101,11 @@ export default class SetWaitingRoomCommand extends BaseCommand { } } - private getOptionValues(): { channel: VoiceChannel, queue: ArraySubDocumentType, supervisor: Role } { + private getOptionValues(): { channel: VoiceChannel, queue: DocumentType, supervisor: Role } { const channelId = this.getOptionValue(SetWaitingRoomCommand.options[0]); const channel = this.getVoiceChannel(channelId); - const queueId = this.getOptionValue(SetWaitingRoomCommand.options[1]); - const queue = this.getQueue(queueId); + const queueName = this.getOptionValue(SetWaitingRoomCommand.options[1]); + const queue = this.app.queueManager.getQueue(this.dbGuild, queueName); const supervisorId = this.getOptionValue(SetWaitingRoomCommand.options[2]); const supervisor = this.getRole(supervisorId); return { channel, queue, supervisor }; @@ -121,16 +121,6 @@ export default class SetWaitingRoomCommand extends BaseCommand { return channel; } - private getQueue(queueName: string): ArraySubDocumentType { - const queue = this.dbGuild.queues.find(x => x.name.toLowerCase() === queueName.toLowerCase()); - if (!queue) { - const error = new CouldNotFindQueueError(queueName); - this.app.logger.debug(error.message); - throw error; - } - return queue; - } - private getRole(roleId: string): Role { const role = this.interaction.guild?.roles.cache.get(roleId); if (!role) { From 5c227379c26568b2ce3a49a67dc603584940c688 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 19 Mar 2024 17:36:00 +0100 Subject: [PATCH 110/130] Add voice channel update event --- src/Application.ts | 9 +- src/events/VoiceChannelUpdateEvent.ts | 161 +++++++++++++++++++++++ src/managers/DmManager.test.ts | 182 ++++++++++++++++++++++++++ src/managers/DmManager.ts | 115 ++++++++++++++++ src/managers/QueueManager.ts | 49 ++++++- tests/mockDiscord.ts | 19 ++- 6 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 src/events/VoiceChannelUpdateEvent.ts create mode 100644 src/managers/DmManager.test.ts create mode 100644 src/managers/DmManager.ts diff --git a/src/Application.ts b/src/Application.ts index ab2024a..d3f72dd 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -12,6 +12,7 @@ import { BaseEvent } from "@baseEvent" import { BaseCommandOrSubcommandsHandler } from "@baseCommand" import CommandsLoader from "@loaders/CommandsLoader" import EventsLoader from "@loaders/EventsLoader" +import DmManager from "./managers/DmManager" /** * The main `Application` class. @@ -40,6 +41,11 @@ export class Application { */ public userManager: UserManager + /** + * The DM manager responsible for managing the direct messages. + */ + public dmManager: DmManager + /** * The commands manager responsible for managing the bot commands. */ @@ -82,7 +88,7 @@ export class Application { * @param client The Discord client. * @param token The bot token. */ - constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager, @inject(delay(() => QueueManager)) queueManager: QueueManager, @inject(delay(() => UserManager)) userManager: UserManager) { + constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager, @inject(delay(() => QueueManager)) queueManager: QueueManager, @inject(delay(() => UserManager)) userManager: UserManager, @inject(delay(() => DmManager)) dmManager: DmManager){ this.client = new Client(options) this.token = token this.logger = createConsola({ level: Environment.logLevel }) @@ -90,6 +96,7 @@ export class Application { this.configManager = configManager this.queueManager = queueManager this.userManager = userManager + this.dmManager = dmManager } private loadEvents(): void { diff --git a/src/events/VoiceChannelUpdateEvent.ts b/src/events/VoiceChannelUpdateEvent.ts new file mode 100644 index 0000000..4550886 --- /dev/null +++ b/src/events/VoiceChannelUpdateEvent.ts @@ -0,0 +1,161 @@ +import { BaseEvent } from "@baseEvent" +import { InternalRoles } from "@models/BotRoles"; +import { VoiceChannelEventType } from "@models/Event"; +import { RoomModel } from "@models/Models"; +import { Guild as DatabaseGuild } from "@models/Guild"; +import { VoiceChannel } from "@models/VoiceChannel"; +import { GuildMember, VoiceBasedChannel, VoiceState } from "discord.js"; +import { ArraySubDocumentType, DocumentType } from "@typegoose/typegoose"; +import assert from "assert"; +import { Room } from "@models/Room"; + +export default class VoiceChannelUpdateEvent extends BaseEvent { + public static name = "voiceStateUpdate"; + + private dbGuild!: DocumentType; + + public async execute(oldState: VoiceState, newState: VoiceState) { + this.app.logger.info(`Voice state updated for user ${newState.member?.user.tag} (id: ${newState.member?.id}) in guild ${newState.guild.name} (id: ${newState.guild.id})`); + this.handleVoiceStateUpdate(oldState, newState); + } + + private async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState): Promise { + const oldUserChannel = oldState.channel; + const newUserChannel = newState.channel; + + if (newUserChannel && newUserChannel.guild && newUserChannel.id != oldUserChannel?.id) { + await this.handleVoiceJoin(newState) + } else if (oldUserChannel && oldUserChannel.guild && oldUserChannel.id != newUserChannel?.id) { + await this.handleVoiceLeave(oldState) + } + } + + private async handleVoiceJoin(newState: VoiceState): Promise { + const newUserChannel = newState.channel!; + this.app.logger.info(`User ${newState.member?.user.tag} (id: ${newState.member?.id}) joined voice channel ${newUserChannel.name} (id: ${newUserChannel.id}) in guild ${newState.guild.name} (id: ${newState.guild.id})`); + + this.dbGuild = await this.app.configManager.getGuildConfig(newState.guild); + const dbChannel = this.dbGuild.voice_channels.find(channel => channel.id === newUserChannel.id) + + if (dbChannel && dbChannel.queue) { + await this.handleQueueJoin(newUserChannel, dbChannel, newState.member!); + return + } + + const dbRoom = await RoomModel.findById(newUserChannel.id); + if (dbRoom) { + await this.handleRoomEvent(newUserChannel, dbRoom, newState.member!, VoiceChannelEventType.user_join); + } + } + + private async handleQueueJoin(userChannel: VoiceBasedChannel, dbChannel: ArraySubDocumentType, member: GuildMember): Promise { + assert(dbChannel.queue, "Queue is not defined in voice channel"); + + const queue = this.app.queueManager.getQueueById(this.dbGuild, dbChannel.queue._id); + + if (!queue) { + this.app.logger.error(`Could not find queue ${dbChannel.queue.id} referenced by channel ${userChannel.name} (id: ${userChannel.id}) in guild ${this.dbGuild.name} (id: ${this.dbGuild.id})`); + return + } + + try { + if (queue.contains(member!.id)) { + this.app.queueManager.stayInQueue(queue, member.user); + this.app.dmManager.sendQueueStayMessage(member.user, queue); + return + } + + if (this.memberIsTutor(member)) { + this.app.logger.info(`User ${member.user.tag} (id: ${member.id}) is a tutor in guild ${this.dbGuild.name} (id: ${this.dbGuild.id}). Not adding to queue.`); + return + } + + if (queue.locked && !queue.contains(member.id)) { + await member.voice.setChannel(null); + this.app.dmManager.sendQueueLockedMessage(member.user, queue); + } + + const joinMessage = await this.app.queueManager.joinQueue(queue, member.user); + this.app.dmManager.sendQueueJoinMessage(member.user, queue, joinMessage); + } catch (error) { + if (error instanceof Error) { + this.app.logger.error(`Could not add user ${member.user.tag} (id: ${member.id}) to queue ${queue.name} (id: ${dbChannel.queue.id}) in guild ${this.dbGuild.name} (id: ${this.dbGuild.id}). Error: ${error}`); + this.app.dmManager.sendErrorMessage(member.user, error); + return + } + this.app.logger.error(`Could not add user ${member.user.tag} (id: ${member.id}) to queue ${queue.name} (id: ${dbChannel.queue.id}) in guild ${this.dbGuild.name} (id: ${this.dbGuild.id}). Error: ${error}`); + this.app.dmManager.sendErrorMessage(member.user, new Error("An unknown error occurred while adding you to the queue. Please try again later.")); + return + } + } + + + private async handleVoiceLeave(oldState: VoiceState): Promise { + const oldUserChannel = oldState.channel!; + this.app.logger.info(`User ${oldState.member?.user.tag} (id: ${oldState.member?.id}) left voice channel ${oldUserChannel.name} (id: ${oldUserChannel.id}) in guild ${oldState.guild.name} (id: ${oldState.guild.id})`); + + this.dbGuild = await this.app.configManager.getGuildConfig(oldState.guild); + const dbChannel = this.dbGuild.voice_channels.find(channel => channel.id === oldUserChannel.id) + + if (dbChannel && dbChannel.temporary && oldUserChannel.members.size === 0) { + await this.removeTemporaryChannel(oldUserChannel, dbChannel); + return + } else if (dbChannel && dbChannel.queue) { + await this.handleQueueLeave(oldUserChannel, dbChannel, oldState.member!); + return + } + + const dbRoom = await RoomModel.findById(oldUserChannel.id); + if (dbRoom) { + await this.handleRoomEvent(oldUserChannel, dbRoom, oldState.member!, VoiceChannelEventType.user_leave); + } + } + + private async removeTemporaryChannel(channel: VoiceBasedChannel, dbChannel: ArraySubDocumentType): Promise { + if (channel.deletable) { + await channel.delete(); + this.app.logger.info(`Removed temporary channel ${channel.name} (id: ${channel.id}) in guild ${channel.guild.name} (id: ${channel.guild.id})`); + } else { + this.app.logger.error(`Could not remove temporary channel ${channel.name} (id: ${channel.id}) in guild ${channel.guild.name} (id: ${channel.guild.id})`); + } + } + + private async handleQueueLeave(userChannel: VoiceBasedChannel, dbChannel: ArraySubDocumentType, member: GuildMember): Promise { + assert(dbChannel.queue, "Queue is not defined in voice channel"); + + const queue = this.app.queueManager.getQueueById(this.dbGuild, dbChannel.queue._id); + + if (!queue) { + this.app.logger.error(`Could not find queue ${dbChannel.queue.id} referenced by channel ${userChannel.name} (id: ${userChannel.id}) in guild ${this.dbGuild.name} (id: ${this.dbGuild.id})`); + return + } + + if (queue.contains(member.id)) { + if (queue.disconnect_timeout) { + const leaveMessage = await this.app.queueManager.leaveQueueWithTimeout(this.dbGuild, member.user); + this.app.dmManager.sendActuallyLeaveQueueMessage(member.user, queue, leaveMessage); + } else { + const leaveMessage = await this.app.queueManager.leaveQueue(this.dbGuild, member.user); + this.app.dmManager.sendQueueLeaveMessage(member.user, queue, leaveMessage); + } + } + } + + private async handleRoomEvent(userChannel: VoiceBasedChannel, dbRoom: DocumentType, member: GuildMember, event: VoiceChannelEventType): Promise { + dbRoom.events.push({ + emitted_by: member.id, + type: event, + timestamp: Date.now().toString() + }); + await dbRoom.save(); + this.app.logger.info(`User ${member.user.tag} (id: ${member.id}) joined room ${userChannel.name} (id: ${userChannel.id}) in guild ${member.guild.name} (id: ${member.guild.id})`); + } + + private memberIsTutor(member: GuildMember): boolean { + const dbTutorRole = this.dbGuild.guild_settings.roles?.find(role => role.internal_name = InternalRoles.TUTOR); + if (dbTutorRole) { + return member.roles.cache.some(role => role.id === dbTutorRole.id); + } + return false + } +} \ No newline at end of file diff --git a/src/managers/DmManager.test.ts b/src/managers/DmManager.test.ts new file mode 100644 index 0000000..57a247c --- /dev/null +++ b/src/managers/DmManager.test.ts @@ -0,0 +1,182 @@ +import { MockDiscord } from "@tests/mockDiscord" +import { container } from "tsyringe" +import DmManager from "./DmManager" +import { createQueue } from "@tests/testutils" +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, DMChannel, MessageCreateOptions, MessagePayload, User } from "discord.js" +import { Queue } from "@models/Queue" +import { DocumentType } from "@typegoose/typegoose" + +describe("DmManager", () => { + const discord = container.resolve(MockDiscord) + let dmManager = container.resolve(DmManager) + let dmChannel: DMChannel + let user: User + let queue: DocumentType + + beforeEach(async () => { + dmChannel = discord.mockDMChannel() + user = discord.mockUser() + const guild = discord.mockGuild() + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + queue = await createQueue(dbGuild, "test", "test description") + }) + + describe("sendQueueJoinMessage", () => { + it("should send a message to the user", async () => { + const joinMessage = "You have joined the queue!" + + const dmSpy = jest.spyOn(user, "createDM").mockResolvedValue(dmChannel); + const sendSpy = jest.spyOn(dmChannel, "send").mockImplementation(() => Promise.resolve({} as any)) + + await dmManager.sendQueueJoinMessage(user, queue, joinMessage) + + expect(dmSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Queue Update", + description: joinMessage + } + }], + components: [{ + data: { type: 1 }, + components: [{ + data: { custom_id: "queue_refresh", label: "Refresh", style: ButtonStyle.Primary, type: 2 }, + }, + { + data: { custom_id: "queue_leave", label: "Leave Queue", style: ButtonStyle.Danger, type: 2 }, + } + ] + }] + })); + }) + }) + + describe("sendQueueStayMessage", () => { + it("should send a message to the user", async () => { + const dmSpy = jest.spyOn(user, "createDM").mockResolvedValue(dmChannel); + const sendSpy = jest.spyOn(dmChannel, "send").mockImplementation(() => Promise.resolve({} as any)) + + await dmManager.sendQueueStayMessage(user, queue) + + expect(dmSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Queue Update", + description: `You stayed in the queue "${queue.name}"` + } + }], + components: [{ + data: { type: 1 }, + components: [{ + data: { custom_id: "queue_refresh", label: "Refresh", style: ButtonStyle.Primary, type: 2 }, + }, + { + data: { custom_id: "queue_leave", label: "Leave Queue", style: ButtonStyle.Danger, type: 2 }, + } + ] + }] + })); + }) + }) + + describe("sendActuallyLeaveQueueMessage", () => { + it("should send a message to the user", async () => { + const leaveMessage = "You have left the queue!" + + const dmSpy = jest.spyOn(user, "createDM").mockResolvedValue(dmChannel); + const sendSpy = jest.spyOn(dmChannel, "send").mockImplementation(() => Promise.resolve({} as any)) + + await dmManager.sendActuallyLeaveQueueMessage(user, queue, leaveMessage) + + expect(dmSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Queue Update", + description: leaveMessage + } + }], + components: [{ + data: { type: 1 }, + components: [{ + data: { custom_id: "queue_stay", label: "Stay in Queue", style: ButtonStyle.Primary, type: 2 }, + }, + { + data: { custom_id: "queue_leave", label: "Leave Queue", style: ButtonStyle.Danger, type: 2 }, + } + ] + }] + })); + }) + }) + + describe("sendQueueLeaveMessage", () => { + it("should send a message to the user", async () => { + const leaveMessage = "You have left the queue!" + + const dmSpy = jest.spyOn(user, "createDM").mockResolvedValue(dmChannel); + const sendSpy = jest.spyOn(dmChannel, "send").mockImplementation(() => Promise.resolve({} as any)) + + await dmManager.sendQueueLeaveMessage(user, queue, leaveMessage) + + expect(dmSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Queue Update", + description: leaveMessage + } + }], + })); + }) + }) + + describe("sendQueueLockedMessage", () => { + it("should send a message to the user", async () => { + const dmSpy = jest.spyOn(user, "createDM").mockResolvedValue(dmChannel); + const sendSpy = jest.spyOn(dmChannel, "send").mockImplementation(() => Promise.resolve({} as any)) + + await dmManager.sendQueueLockedMessage(user, queue) + + expect(dmSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Queue Update", + description: `The queue "${queue.name}" is currently locked. You can't join it at the moment.` + } + }], + })); + }) + }) + + describe("sendErrorMessage", () => { + it("should send a description of the error to the user", async () => { + const error = new Error("My Error") + + const dmSpy = jest.spyOn(user, "createDM").mockResolvedValue(dmChannel); + const sendSpy = jest.spyOn(dmChannel, "send").mockImplementation(() => Promise.resolve({} as any)) + + await dmManager.sendErrorMessage(user, error) + + expect(dmSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: `An error occurred: ${error.message}` + } + }], + })); + }) + }) + +}) diff --git a/src/managers/DmManager.ts b/src/managers/DmManager.ts new file mode 100644 index 0000000..263cc57 --- /dev/null +++ b/src/managers/DmManager.ts @@ -0,0 +1,115 @@ +import { Application } from "@application"; +import { Queue } from "@models/Queue"; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, User } from "discord.js"; +import { delay, inject, injectable, singleton } from "tsyringe"; +import { DocumentType } from "@typegoose/typegoose"; + +@injectable() +@singleton() +export default class DmManager { + protected app: Application; + + constructor(@inject(delay(() => Application)) app: Application) { + this.app = app; + } + + private queueRefreshButton = new ButtonBuilder() + .setCustomId("queue_refresh") + .setLabel("Refresh") + .setStyle(ButtonStyle.Primary); + + private queueStayButton = new ButtonBuilder() + .setCustomId("queue_stay") + .setLabel("Stay in Queue") + .setStyle(ButtonStyle.Primary); + + private queueLeaveButton = new ButtonBuilder() + .setCustomId("queue_leave") + .setLabel("Leave Queue") + .setStyle(ButtonStyle.Danger); + + + public async sendQueueJoinMessage(user: User, queue: DocumentType, joinMessage: string): Promise { + this.app.logger.debug(`Sending join message to user "${user.tag}" (id: ${user.id}) for queue "${queue.name}" (id: ${queue._id})`); + const dmChannel = await user.createDM(); + + const embed = new EmbedBuilder() + .setTitle("Queue Update") + .setDescription(joinMessage) + + const components = new ActionRowBuilder() + .addComponents( + this.queueRefreshButton, + this.queueLeaveButton + ) + + await dmChannel.send({ embeds: [embed], components: [components] }); + } + + public async sendQueueStayMessage(user: User, queue: DocumentType): Promise { + this.app.logger.debug(`Sending stay message to user "${user.tag}" (id: ${user.id}) for queue "${queue.name}" (id: ${queue._id})`); + const dmChannel = await user.createDM(); + + const embed = new EmbedBuilder() + .setTitle("Queue Update") + .setDescription(`You stayed in the queue "${queue.name}"`) + + const components = new ActionRowBuilder() + .addComponents( + this.queueRefreshButton, + this.queueLeaveButton + ) + + await dmChannel.send({ embeds: [embed], components: [components] }); + } + + public async sendActuallyLeaveQueueMessage(user: User, queue: DocumentType, leaveMessage: string): Promise { + this.app.logger.debug(`Sending actually want to leave queue message to user "${user.tag}" (id: ${user.id}) for queue "${queue.name}" (id: ${queue._id})`); + const dmChannel = await user.createDM(); + + const embed = new EmbedBuilder() + .setTitle("Queue Update") + .setDescription(leaveMessage) + + const components = new ActionRowBuilder() + .addComponents( + this.queueStayButton, + this.queueLeaveButton + ) + + await dmChannel.send({ embeds: [embed], components: [components] }); + } + + public async sendQueueLeaveMessage(user: User, queue: DocumentType, leaveMessage: string): Promise { + this.app.logger.debug(`Sending leave message to user "${user.tag}" (id: ${user.id}) for queue "${queue.name}" (id: ${queue._id})`); + const dmChannel = await user.createDM(); + + const embed = new EmbedBuilder() + .setTitle("Queue Update") + .setDescription(leaveMessage) + + await dmChannel.send({ embeds: [embed] }); + } + + public async sendQueueLockedMessage(user: User, queue: DocumentType): Promise { + this.app.logger.debug(`Sending lock message to user "${user.tag}" (id: ${user.id}) for queue "${queue.name}" (id: ${queue._id})`); + const dmChannel = await user.createDM(); + + const embed = new EmbedBuilder() + .setTitle("Queue Update") + .setDescription(`The queue "${queue.name}" is currently locked. You can't join it at the moment.`) + + await dmChannel.send({ embeds: [embed] }); + } + + public async sendErrorMessage(user: User, error: Error): Promise { + this.app.logger.debug(`Sending error message to user "${user.tag}" (id: ${user.id})`); + const dmChannel = await user.createDM(); + + const embed = new EmbedBuilder() + .setTitle("Error") + .setDescription(`An error occurred: ${error.message}`) + + await dmChannel.send({ embeds: [embed] }); + } +} \ No newline at end of file diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index 811c907..c3d376e 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -4,7 +4,7 @@ import { Queue } from "@models/Queue"; import { DocumentType, mongoose } from "@typegoose/typegoose"; import { AlreadyInQueueError, ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, CouldNotFindQueueError, InvalidEventError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError, UserHasActiveSessionError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; -import { EmbedBuilder, TextChannel, User, Guild as DiscordGuild } from "discord.js"; +import { EmbedBuilder, TextChannel, User, Guild as DiscordGuild, Collection } from "discord.js"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEventType } from "@models/Event"; import { InternalRoles } from "@models/BotRoles"; @@ -15,6 +15,10 @@ import { QueueEntryModel, SessionModel } from "@models/Models"; @singleton() export default class QueueManager { protected app: Application; + /** + * A collection of pending queue stays. The key is the queue ID and the value is an array of user IDs. + */ + private pendingQueueStays: Collection> = new Collection(); constructor(@inject(delay(() => Application)) app: Application) { this.app = app; @@ -39,7 +43,7 @@ export default class QueueManager { disconnect_timeout: 60000, match_timeout: 120000, limit: 150, - join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}", + join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}\n\\> Total Time Spent: ${time_spent}", match_found_message: "You have found a Match with ${match}. Please Join ${match_channel} if you are not moved automatically. If you don't join in ${timeout} seconds, your position in the queue is dropped.", timeout_message: "Your queue Timed out after ${timeout} seconds.", leave_message: "You Left the `${name}` queue.\nTotal Time Spent: ${time_spent}", @@ -111,7 +115,7 @@ export default class QueueManager { * @throws {AlreadyInQueueError} If the user is already in the queue. * @throws {QueueLockedError} If the queue is locked. */ - public async joinQueue(queue: DocumentType, user: User, intent: string): Promise { + public async joinQueue(queue: DocumentType, user: User, intent?: string): Promise { // Check if the user is already in the queue they are trying to join. if (queue.contains(user.id)) { this.app.logger.info(`User "${user.username}" (id: ${user.id}) tried to join queue "${queue.name}" but is already in it`); @@ -140,6 +144,37 @@ export default class QueueManager { return queue.getJoinMessage(user.id); } + public async leaveQueueWithTimeout(guild: DatabaseGuild, user: User): Promise { + const queue = this.getQueueOfUser(guild, user); + if (!queue) { + this.app.logger.info(`User "${user.username}" (id: ${user.id}) tried to leave queue with timeout but is not in a queue`); + throw new NotInQueueError(); + } + + // Add the user to the pending queue stays + let pendingQueueStays = this.pendingQueueStays.get(queue.id); + if (!pendingQueueStays) { + this.pendingQueueStays.set(queue.id, new Collection([[user.id, undefined]])); + pendingQueueStays = this.pendingQueueStays.get(queue.id)!; + } + pendingQueueStays.set(user.id, undefined); + + const leftRoomMessage = queue.getLeaveRoomMessage(user.id); + + setTimeout(async () => { + let pendingQueueStays = this.pendingQueueStays.get(queue.id); + if (pendingQueueStays && pendingQueueStays.has(user.id)) { + await this.leaveQueue(guild, user); + + if (pendingQueueStays && pendingQueueStays.has(user.id)) { + pendingQueueStays.delete(user.id); + } + } + }, queue.disconnect_timeout); + + return leftRoomMessage; + } + /** * Removes a user from the queue he is in and returns the leave message. * @@ -167,6 +202,14 @@ export default class QueueManager { return leaveMessage; } + public async stayInQueue(queue: DocumentType, user: User): Promise { + const pendingQueueStays = this.pendingQueueStays.get(queue.id); + if (pendingQueueStays && pendingQueueStays.has(user.id)) { + pendingQueueStays.delete(user.id); + this.app.logger.info(`User "${user.username}" (id: ${user.id}) stayed in queue "${queue.name}"`); + } + } + /** * Starts a tutor session for a user in the specified queue. * diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 8720937..8786d99 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -9,7 +9,7 @@ import { mockChatInputCommandInteraction } from '@shoginn/discordjs-mock'; import "reflect-metadata" -import { APIRole, ChatInputCommandInteraction, Guild, GuildMember, Role, TextBasedChannel, TextChannel, User } from 'discord.js'; +import { APIRole, ChatInputCommandInteraction, DMChannel, Guild, GuildMember, Role, TextChannel, User, VoiceState } from 'discord.js'; import { container, singleton } from 'tsyringe'; import { randomInt } from 'crypto'; import assert from 'assert'; @@ -47,6 +47,10 @@ export class MockDiscord { return mockTextChannel(this.app.client, guild); } + public mockDMChannel(): DMChannel { + return Reflect.construct(DMChannel, [this.app.client, {}]) as DMChannel; + } + public mockUser(): User { const userId = randomInt(281474976710655).toString(); return mockUser(this.app.client, { id: userId, username: userId, global_name: userId, discriminator: randomInt(9999).toString() }); @@ -72,4 +76,17 @@ export class MockDiscord { assert(guildMember.guild === guild); return mockChatInputCommandInteraction({ client: this.app.client, name: commandName, id: "test", channel: channel, member: guildMember }) } + + // public mockVoiceState(guild: Guild = this.mockGuild(), channelID: string | null = "123", member: GuildMember = this.mockGuildMember(this.mockUser(), guild)): VoiceState { + public mockVoiceState({ + guild = this.mockGuild(), + channelID = "123", + member = this.mockGuildMember(this.mockUser(), guild) + }: { + guild?: Guild, + channelID?: string | null, + member?: GuildMember + }): VoiceState { + return Reflect.construct(VoiceState, [guild, { channelID: channelID, member: member }]); + } } \ No newline at end of file From 797d33cf29f16e6234da164663f2b57148f69404 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 19 Mar 2024 17:36:08 +0100 Subject: [PATCH 111/130] Improvements --- src/commands/queue/QueueJoinCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/queue/QueueJoinCommand.ts b/src/commands/queue/QueueJoinCommand.ts index af2e1b6..0bb3ffc 100644 --- a/src/commands/queue/QueueJoinCommand.ts +++ b/src/commands/queue/QueueJoinCommand.ts @@ -30,7 +30,7 @@ export default class QueueJoinCommand extends BaseCommand { const intent = this.getOptionValue(QueueJoinCommand.options[1]) const user = this.interaction.user try { - let joinMessage = await this.joinQueue(queueName, intent, user) + const joinMessage = await this.joinQueue(queueName, intent, user) const embed = this.mountJoinQueueEmbed(joinMessage); await this.send({ embeds: [embed] }) } catch (error) { From 48a76ae17fe153ffd97efc68962a9286e657094a Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 20 Mar 2024 21:28:58 +0100 Subject: [PATCH 112/130] Add voice state tests --- src/events/VoiceChannelUpdateEvent.test.ts | 405 +++++++++++++++++++++ src/events/VoiceChannelUpdateEvent.ts | 16 +- src/managers/QueueManager.ts | 25 +- tests/mockDiscord.ts | 69 +++- tests/testutils.ts | 74 +++- 5 files changed, 551 insertions(+), 38 deletions(-) create mode 100644 src/events/VoiceChannelUpdateEvent.test.ts diff --git a/src/events/VoiceChannelUpdateEvent.test.ts b/src/events/VoiceChannelUpdateEvent.test.ts new file mode 100644 index 0000000..8a582c9 --- /dev/null +++ b/src/events/VoiceChannelUpdateEvent.test.ts @@ -0,0 +1,405 @@ +import { MockDiscord } from "@tests/mockDiscord" +import { Collection, Guild, GuildChannel, VoiceChannel, VoiceState } from "discord.js" +import { container } from "tsyringe" +import VoiceChannelUpdateEvent from "./VoiceChannelUpdateEvent" +import { createQueue, createRole, createRoom, createVoiceChannel } from "@tests/testutils" +import { QueueEventType } from "@models/Event" +import { GuildModel, RoomModel } from "@models/Models" +import { randomInt } from "crypto" + +describe("VoiceChannelUpdateEvent", () => { + const event = VoiceChannelUpdateEvent + const discord = container.resolve(MockDiscord) + let eventInstance: VoiceChannelUpdateEvent + let guild: Guild + + beforeEach(() => { + eventInstance = new event(discord.getApplication()) + guild = discord.mockGuild() + + }) + + it("should have the correct name", () => { + expect(event.name).toBe("voiceStateUpdate") + }) + + it("should not react if the channel stays the same", async () => { + const oldState = discord.mockVoiceState(guild, { channelID: "123" }) + const newState = discord.mockVoiceState(guild, { channelID: "123" }) + + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(0) + expect(leaveSpy).toHaveBeenCalledTimes(0) + }) + + describe.each([null, randomInt(281474976710655).toString()])("when a user joins a voice channel from channel: %p", (oldChannelId) => { + it("should not do anything when it a normal voice channel", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + const channelID = randomInt(281474976710655).toString() + const oldState = discord.mockVoiceState(guild, { channelID: oldChannelId, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const joinQueueSpy = jest.spyOn(eventInstance as any, "handleQueueJoin") + const joinRoomSpy = jest.spyOn(eventInstance as any, "handleRoomEvent") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(1) + expect(joinQueueSpy).toHaveBeenCalledTimes(0) + expect(joinRoomSpy).toHaveBeenCalledTimes(0) + expect(leaveSpy).toHaveBeenCalledTimes(oldChannelId ? 1 : 0) + }) + + it("should add the user to the queue when the voice channel has a queue", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + const channelID = randomInt(281474976710655).toString() + const oldState = discord.mockVoiceState(guild, { channelID: oldChannelId, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + const queue = await createQueue(dbGuild, { info_channels: [{ channel_id: channelID, events: Object.values(QueueEventType) }] }) + await createVoiceChannel(dbGuild, { queue: queue, channelID: channelID, supervisor: "tutor"}) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const joinQueueSpy = jest.spyOn(eventInstance as any, "handleQueueJoin") + const joinRoomSpy = jest.spyOn(eventInstance as any, "handleRoomEvent") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + const sendQueueJoinMessageSpy = jest.spyOn(discord.getApplication().dmManager, "sendQueueJoinMessage").mockResolvedValue() + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(1) + expect(joinQueueSpy).toHaveBeenCalledTimes(1) + expect(joinRoomSpy).toHaveBeenCalledTimes(0) + expect(leaveSpy).toHaveBeenCalledTimes(oldChannelId ? 1 : 0) + expect(saveSpy).toHaveBeenCalledTimes(1); + const saveSpyRes = await saveSpy.mock.results[0].value; + expect(saveSpyRes.queues[0].entries).toHaveLength(1); + expect(saveSpyRes.queues[0].entries[0].discord_id).toBe(member.user.id); + expect(sendQueueJoinMessageSpy).toHaveBeenCalledTimes(1); + }) + + it("should not add the user to the queue when the user is a tutor", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + const tutorRole = await createRole(dbGuild, `tutor ${guild.id}`) + const discordTutorRole = discord.mockRole(guild!, { id: tutorRole.server_role_name, name: `tutor ${guild}` }) + + const member = discord.mockGuildMember(discord.mockUser(), guild, [discordTutorRole.id]) + + const channelID = randomInt(281474976710655).toString() + const queue = await createQueue(dbGuild, { info_channels: [{ channel_id: channelID, events: Object.values(QueueEventType) }] }) + await createVoiceChannel(dbGuild, { queue: queue, channelID: channelID, supervisor: "tutor"}) + + const oldState = discord.mockVoiceState(guild, { channelID: oldChannelId, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + + discord.getApplication().dmManager.sendQueueJoinMessage = jest.fn() + + jest.clearAllMocks(); + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const joinQueueSpy = jest.spyOn(eventInstance as any, "handleQueueJoin") + const joinRoomSpy = jest.spyOn(eventInstance as any, "handleRoomEvent") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(1) + expect(joinQueueSpy).toHaveBeenCalledTimes(1) + expect(joinRoomSpy).toHaveBeenCalledTimes(0) + expect(leaveSpy).toHaveBeenCalledTimes(oldChannelId ? 1 : 0) + expect(saveSpy).toHaveBeenCalledTimes(0); + }) + + it("should keep the user in the queue if he is in the pending queue stays for the same guild", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + + const channelID = randomInt(281474976710655).toString() + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + const queue = await createQueue(dbGuild, { + info_channels: [{ channel_id: channelID, events: Object.values(QueueEventType) }], + entries: [{ discord_id: member.id, joinedAt: Date.now().toString() }] + }) + await createVoiceChannel(dbGuild, { queue: queue, channelID: channelID, supervisor: "tutor"}) + + const queueManager = discord.getApplication().queueManager + Object.defineProperty(queueManager, "pendingQueueStays", { value: new Collection([[queue.id, [member.id]]]) }) + + const oldState = discord.mockVoiceState(guild, { channelID: oldChannelId, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const joinQueueSpy = jest.spyOn(eventInstance as any, "handleQueueJoin") + const joinRoomSpy = jest.spyOn(eventInstance as any, "handleRoomEvent") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + const sendQueueStayMessageSpy = jest.spyOn(discord.getApplication().dmManager, "sendQueueStayMessage").mockResolvedValue() + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(1) + expect(joinQueueSpy).toHaveBeenCalledTimes(1) + expect(joinRoomSpy).toHaveBeenCalledTimes(0) + expect(leaveSpy).toHaveBeenCalledTimes(oldChannelId ? 1 : 0) + expect(saveSpy).toHaveBeenCalledTimes(0); + expect(sendQueueStayMessageSpy).toHaveBeenCalledTimes(1); + const pendingQueueStays = (queueManager as any).pendingQueueStays + expect(pendingQueueStays).toMatchObject(new Collection([[queue.id, []]])) + }) + + it("should send the queue locked message if the queue is locked", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + + const channelID = randomInt(281474976710655).toString() + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + const queue = await createQueue(dbGuild, { + info_channels: [{ channel_id: channelID, events: Object.values(QueueEventType) }], + locked: true + }) + await createVoiceChannel(dbGuild, { queue: queue, channelID: channelID, supervisor: "tutor"}) + + const oldState = discord.mockVoiceState(guild, { channelID: oldChannelId, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const joinQueueSpy = jest.spyOn(eventInstance as any, "handleQueueJoin") + const joinRoomSpy = jest.spyOn(eventInstance as any, "handleRoomEvent") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + const sendQueueLockedMessageSpy = jest.spyOn(discord.getApplication().dmManager, "sendQueueLockedMessage").mockResolvedValue() + const setChannelSpy = jest.spyOn(VoiceState.prototype, "setChannel").mockImplementation() + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(1) + expect(joinQueueSpy).toHaveBeenCalledTimes(1) + expect(joinRoomSpy).toHaveBeenCalledTimes(0) + expect(leaveSpy).toHaveBeenCalledTimes(oldChannelId ? 1 : 0) + expect(saveSpy).toHaveBeenCalledTimes(0); + expect(sendQueueLockedMessageSpy).toHaveBeenCalledTimes(1); + expect(setChannelSpy).toHaveBeenCalledTimes(1); + + }) + + it("should add the event to the room if the voice channel is a room model", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + + const channelID = randomInt(281474976710655).toString() + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + const dbRoom = await createRoom(dbGuild, { roomId: channelID }) + + const oldState = discord.mockVoiceState(guild, { channelID: oldChannelId, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const joinQueueSpy = jest.spyOn(eventInstance as any, "handleQueueJoin") + const joinRoomSpy = jest.spyOn(eventInstance as any, "handleRoomEvent") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveGuildSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + const saveRoomSpy = jest.spyOn(RoomModel.prototype as any, 'save'); + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(1) + expect(joinQueueSpy).toHaveBeenCalledTimes(0) + expect(joinRoomSpy).toHaveBeenCalledTimes(1) + expect(leaveSpy).toHaveBeenCalledTimes(oldChannelId ? 1 : 0) + expect(saveGuildSpy).toHaveBeenCalledTimes(0); + expect(saveRoomSpy).toHaveBeenCalledTimes(1); + const saveRoomSpyRes = await saveRoomSpy.mock.results[0].value; + expect(saveRoomSpyRes.events).toHaveLength(1); + expect(saveRoomSpyRes.events[0].type).toBe("user_join"); + }) + }) + + describe.each([null, randomInt(281474976710655).toString()])("when a user leaves a voice channel to %p", (newChannelId) => { + + it("should not do anything when it a normal voice channel", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + const channelID = randomInt(281474976710655).toString() + const oldState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: newChannelId, member: member }) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const joinQueueSpy = jest.spyOn(eventInstance as any, "handleQueueJoin") + const joinRoomSpy = jest.spyOn(eventInstance as any, "handleRoomEvent") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(newChannelId ? 1 : 0) + expect(leaveSpy).toHaveBeenCalledTimes(1) + }) + + it("should remove the user from the queue when the voice channel has a queue and no timeout", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + const channelID = randomInt(281474976710655).toString() + const oldState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: newChannelId, member: member }) + + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + const queue = await createQueue(dbGuild, { + info_channels: [{ channel_id: channelID, events: Object.values(QueueEventType) }], + entries: [{ discord_id: member.id, joinedAt: Date.now().toString() }], + disconnect_timeout: 0 + }) + await createVoiceChannel(dbGuild, { queue: queue, channelID: channelID, supervisor: "tutor"}) + + // call restore to restore the original implementation of the function which was changed in another test + jest.restoreAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + const sendQueueLeaveMessage = jest.spyOn(discord.getApplication().dmManager, "sendQueueLeaveMessage").mockResolvedValue() + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(newChannelId ? 1 : 0) + expect(leaveSpy).toHaveBeenCalledTimes(1) + expect(saveSpy).toHaveBeenCalledTimes(1); + const saveSpyRes = await saveSpy.mock.results[0].value; + expect(saveSpyRes.queues[0].entries).toHaveLength(0); + expect(sendQueueLeaveMessage).toHaveBeenCalledTimes(1); + }) + + it("should not directly remove the user from the queue not send a actually want to leave message when the voice channel has a queue and a timeout", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + const channelID = randomInt(281474976710655).toString() + const oldState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: newChannelId, member: member }) + + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + const queue = await createQueue(dbGuild, { + info_channels: [{ channel_id: channelID, events: Object.values(QueueEventType) }], + entries: [{ discord_id: member.id, joinedAt: Date.now().toString() }], + disconnect_timeout: 1 + }) + await createVoiceChannel(dbGuild, { queue: queue, channelID: channelID, supervisor: "tutor"}) + + // mock leave queue because otherwise we get mongo connection errors + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const sendQueueLeaveMessage = jest.spyOn(discord.getApplication().dmManager, "sendActuallyLeaveQueueMessage").mockResolvedValue() + const leaveQueueSpy = jest.spyOn(discord.getApplication().queueManager, "leaveQueue").mockResolvedValue("leave message") + + await eventInstance.execute(oldState, newState) + // wait so leave queue can be called + await new Promise(resolve => setTimeout(resolve, 1)) + + expect(joinSpy).toHaveBeenCalledTimes(newChannelId ? 1 : 0) + expect(leaveSpy).toHaveBeenCalledTimes(1) + expect(leaveQueueSpy).toHaveBeenCalledTimes(1); + expect(sendQueueLeaveMessage).toHaveBeenCalledTimes(1); + }) + + it("should remove the room when the voice channel is temporary and empty", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + const channelID = randomInt(281474976710655).toString() + const oldState = discord.mockVoiceState(guild, { channelID: channelID, member: member, numberOfMembersOfChannel: 0}) + const newState = discord.mockVoiceState(guild, { channelID: newChannelId, member: member }) + + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + await createVoiceChannel(dbGuild, { channelID: channelID, temporary: true }) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + const deleteSpy = jest.spyOn(oldState.channel!, "delete") + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(newChannelId ? 1 : 0) + expect(leaveSpy).toHaveBeenCalledTimes(1) + expect(saveSpy).toHaveBeenCalledTimes(0); + expect(deleteSpy).toHaveBeenCalledTimes(1); + }) + + it("should not remove the room when the voice channel is temporary but not empty", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + const channelID = randomInt(281474976710655).toString() + const oldState = discord.mockVoiceState(guild, { channelID: channelID, member: member, numberOfMembersOfChannel: 2}) + const newState = discord.mockVoiceState(guild, { channelID: newChannelId, member: member }) + + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + await createVoiceChannel(dbGuild, { channelID: channelID, temporary: true }) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + const deleteSpy = jest.spyOn(oldState.channel!, "delete") + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(newChannelId ? 1 : 0) + expect(leaveSpy).toHaveBeenCalledTimes(1) + expect(saveSpy).toHaveBeenCalledTimes(0); + expect(deleteSpy).toHaveBeenCalledTimes(0); + }) + + it("should not remove the room when the voice channel is not temporary", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + const channelID = randomInt(281474976710655).toString() + const oldState = discord.mockVoiceState(guild, { channelID: channelID, member: member, numberOfMembersOfChannel: 0}) + const newState = discord.mockVoiceState(guild, { channelID: newChannelId, member: member }) + + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + await createVoiceChannel(dbGuild, { channelID: channelID, temporary: false }) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + const deleteSpy = jest.spyOn(oldState.channel!, "delete") + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(newChannelId ? 1 : 0) + expect(leaveSpy).toHaveBeenCalledTimes(1) + expect(saveSpy).toHaveBeenCalledTimes(0); + expect(deleteSpy).toHaveBeenCalledTimes(0); + }) + + it("should add the event to the room if the voice channel is a room model", async () => { + const member = discord.mockGuildMember(discord.mockUser(), guild) + + const channelID = randomInt(281474976710655).toString() + const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) + const dbRoom = await createRoom(dbGuild, { roomId: channelID }) + + const oldState = discord.mockVoiceState(guild, { channelID: channelID, member: member }) + const newState = discord.mockVoiceState(guild, { channelID: newChannelId, member: member }) + + jest.clearAllMocks() + const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") + const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") + const saveGuildSpy = jest.spyOn(GuildModel.prototype as any, 'save'); + const saveRoomSpy = jest.spyOn(RoomModel.prototype as any, 'save'); + + await eventInstance.execute(oldState, newState) + + expect(joinSpy).toHaveBeenCalledTimes(newChannelId ? 1 : 0) + expect(leaveSpy).toHaveBeenCalledTimes(1) + expect(saveGuildSpy).toHaveBeenCalledTimes(0); + expect(saveRoomSpy).toHaveBeenCalledTimes(1); + const saveRoomSpyRes = await saveRoomSpy.mock.results[0].value; + expect(saveRoomSpyRes.events).toHaveLength(1); + expect(saveRoomSpyRes.events[0].type).toBe("user_leave"); + }) + }) +}) \ No newline at end of file diff --git a/src/events/VoiceChannelUpdateEvent.ts b/src/events/VoiceChannelUpdateEvent.ts index 4550886..d81ff9d 100644 --- a/src/events/VoiceChannelUpdateEvent.ts +++ b/src/events/VoiceChannelUpdateEvent.ts @@ -16,16 +16,17 @@ export default class VoiceChannelUpdateEvent extends BaseEvent { public async execute(oldState: VoiceState, newState: VoiceState) { this.app.logger.info(`Voice state updated for user ${newState.member?.user.tag} (id: ${newState.member?.id}) in guild ${newState.guild.name} (id: ${newState.guild.id})`); - this.handleVoiceStateUpdate(oldState, newState); + await this.handleVoiceStateUpdate(oldState, newState); } private async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState): Promise { const oldUserChannel = oldState.channel; const newUserChannel = newState.channel; - if (newUserChannel && newUserChannel.guild && newUserChannel.id != oldUserChannel?.id) { + if (newUserChannel && newUserChannel.guild && newUserChannel.id && newUserChannel.id != oldUserChannel?.id) { await this.handleVoiceJoin(newState) - } else if (oldUserChannel && oldUserChannel.guild && oldUserChannel.id != newUserChannel?.id) { + } + if (oldUserChannel && oldUserChannel.guild && oldUserChannel.id && oldUserChannel.id != newUserChannel?.id) { await this.handleVoiceLeave(oldState) } } @@ -73,6 +74,7 @@ export default class VoiceChannelUpdateEvent extends BaseEvent { if (queue.locked && !queue.contains(member.id)) { await member.voice.setChannel(null); this.app.dmManager.sendQueueLockedMessage(member.user, queue); + return } const joinMessage = await this.app.queueManager.joinQueue(queue, member.user); @@ -82,7 +84,7 @@ export default class VoiceChannelUpdateEvent extends BaseEvent { this.app.logger.error(`Could not add user ${member.user.tag} (id: ${member.id}) to queue ${queue.name} (id: ${dbChannel.queue.id}) in guild ${this.dbGuild.name} (id: ${this.dbGuild.id}). Error: ${error}`); this.app.dmManager.sendErrorMessage(member.user, error); return - } + } this.app.logger.error(`Could not add user ${member.user.tag} (id: ${member.id}) to queue ${queue.name} (id: ${dbChannel.queue.id}) in guild ${this.dbGuild.name} (id: ${this.dbGuild.id}). Error: ${error}`); this.app.dmManager.sendErrorMessage(member.user, new Error("An unknown error occurred while adding you to the queue. Please try again later.")); return @@ -98,7 +100,7 @@ export default class VoiceChannelUpdateEvent extends BaseEvent { const dbChannel = this.dbGuild.voice_channels.find(channel => channel.id === oldUserChannel.id) if (dbChannel && dbChannel.temporary && oldUserChannel.members.size === 0) { - await this.removeTemporaryChannel(oldUserChannel, dbChannel); + await this.removeTemporaryChannel(oldUserChannel); return } else if (dbChannel && dbChannel.queue) { await this.handleQueueLeave(oldUserChannel, dbChannel, oldState.member!); @@ -111,7 +113,7 @@ export default class VoiceChannelUpdateEvent extends BaseEvent { } } - private async removeTemporaryChannel(channel: VoiceBasedChannel, dbChannel: ArraySubDocumentType): Promise { + private async removeTemporaryChannel(channel: VoiceBasedChannel): Promise { if (channel.deletable) { await channel.delete(); this.app.logger.info(`Removed temporary channel ${channel.name} (id: ${channel.id}) in guild ${channel.guild.name} (id: ${channel.guild.id})`); @@ -154,7 +156,7 @@ export default class VoiceChannelUpdateEvent extends BaseEvent { private memberIsTutor(member: GuildMember): boolean { const dbTutorRole = this.dbGuild.guild_settings.roles?.find(role => role.internal_name = InternalRoles.TUTOR); if (dbTutorRole) { - return member.roles.cache.some(role => role.id === dbTutorRole.id); + return member.roles.cache.some(role => role.id === dbTutorRole.role_id); } return false } diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index c3d376e..eee2c53 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -18,7 +18,7 @@ export default class QueueManager { /** * A collection of pending queue stays. The key is the queue ID and the value is an array of user IDs. */ - private pendingQueueStays: Collection> = new Collection(); + private pendingQueueStays: Collection = new Collection(); constructor(@inject(delay(() => Application)) app: Application) { this.app = app; @@ -152,23 +152,20 @@ export default class QueueManager { } // Add the user to the pending queue stays - let pendingQueueStays = this.pendingQueueStays.get(queue.id); - if (!pendingQueueStays) { - this.pendingQueueStays.set(queue.id, new Collection([[user.id, undefined]])); - pendingQueueStays = this.pendingQueueStays.get(queue.id)!; + if (!this.pendingQueueStays.get(queue.id)) { + this.pendingQueueStays.set(queue.id, [user.id]); } - pendingQueueStays.set(user.id, undefined); + this.pendingQueueStays.get(queue.id)!.push(user.id); const leftRoomMessage = queue.getLeaveRoomMessage(user.id); setTimeout(async () => { - let pendingQueueStays = this.pendingQueueStays.get(queue.id); - if (pendingQueueStays && pendingQueueStays.has(user.id)) { + const pendingQueueStays = this.pendingQueueStays.get(queue.id); + if (pendingQueueStays && pendingQueueStays.includes(user.id)) { await this.leaveQueue(guild, user); - - if (pendingQueueStays && pendingQueueStays.has(user.id)) { - pendingQueueStays.delete(user.id); - } + // Remove the user from the pending queue stays + this.pendingQueueStays.set(queue.id, this.pendingQueueStays.get(queue.id)!.filter(id => id !== user.id)); + this.app.logger.info(`User "${user.username}" (id: ${user.id}) left queue "${queue.name}" after disconnect timeout`); } }, queue.disconnect_timeout); @@ -204,8 +201,8 @@ export default class QueueManager { public async stayInQueue(queue: DocumentType, user: User): Promise { const pendingQueueStays = this.pendingQueueStays.get(queue.id); - if (pendingQueueStays && pendingQueueStays.has(user.id)) { - pendingQueueStays.delete(user.id); + if (pendingQueueStays && pendingQueueStays.includes(user.id)) { + this.pendingQueueStays.set(queue.id, pendingQueueStays.filter(id => id !== user.id)); this.app.logger.info(`User "${user.username}" (id: ${user.id}) stayed in queue "${queue.name}"`); } } diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 8786d99..d7e53e3 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -9,7 +9,7 @@ import { mockChatInputCommandInteraction } from '@shoginn/discordjs-mock'; import "reflect-metadata" -import { APIRole, ChatInputCommandInteraction, DMChannel, Guild, GuildMember, Role, TextChannel, User, VoiceState } from 'discord.js'; +import { APIGuildMember, APIRole, APIUser, ChannelType, ChatInputCommandInteraction, DMChannel, Guild, GuildMember, Role, TextChannel, User, VoiceState } from 'discord.js'; import { container, singleton } from 'tsyringe'; import { randomInt } from 'crypto'; import assert from 'assert'; @@ -77,16 +77,67 @@ export class MockDiscord { return mockChatInputCommandInteraction({ client: this.app.client, name: commandName, id: "test", channel: channel, member: guildMember }) } - // public mockVoiceState(guild: Guild = this.mockGuild(), channelID: string | null = "123", member: GuildMember = this.mockGuildMember(this.mockUser(), guild)): VoiceState { - public mockVoiceState({ - guild = this.mockGuild(), - channelID = "123", - member = this.mockGuildMember(this.mockUser(), guild) + public mockVoiceState(guild: Guild, { + channelID = randomInt(281474976710655).toString(), + member = this.mockGuildMember(this.mockUser(), this.mockGuild()), + numberOfMembersOfChannel = 1 }: { - guild?: Guild, channelID?: string | null, - member?: GuildMember + member?: GuildMember, + numberOfMembersOfChannel?: number }): VoiceState { - return Reflect.construct(VoiceState, [guild, { channelID: channelID, member: member }]); + const apiMember = this.guildMemberToAPIGuildMember(member); + const voiceState = Reflect.construct(VoiceState, [member.guild, { + channel_id: channelID, + user_id: member.id, + member: apiMember, + session_id: "test", + deaf: false, + mute: false, + self_deaf: false, + self_mute: false, + self_video: false, + suppress: false, + }]) as VoiceState; + Object.defineProperty(voiceState, "channel", { + value: { + type: ChannelType.GuildVoice, + guild: guild, + id: channelID, + name: "test", + members: { size: numberOfMembersOfChannel }, + deletable: true, + delete: jest.fn(() => Promise.resolve()) + } + }) + return voiceState; + } + + private guildMemberToAPIGuildMember(member: GuildMember): APIGuildMember { + return { + user: this.userToAPIUser(member.user), + nick: member.nickname, + avatar: member.avatar, + roles: member.roles.cache.map(role => role.id), + joined_at: member.joinedAt!.toString(), + premium_since: member.premiumSince?.toString(), + deaf: member.voice.deaf ?? false, + mute: member.voice.mute ?? false, + flags: member.flags.bitfield, + } + } + + private userToAPIUser(user: User): APIUser { + return { + id: user.id, + username: user.username, + discriminator: user.discriminator, + global_name: user.globalName, + avatar: user.avatar, + bot: user.bot, + system: user.system, + banner: user.banner, + accent_color: user.accentColor, + } } } \ No newline at end of file diff --git a/tests/testutils.ts b/tests/testutils.ts index c485b61..c0ea250 100644 --- a/tests/testutils.ts +++ b/tests/testutils.ts @@ -1,13 +1,16 @@ import { DBRole, RoleScopes } from "@models/BotRoles"; import { QueueEventType } from "@models/Event"; import { Guild } from "@models/Guild" -import { QueueModel, SessionModel, DBRoleModel, VoiceChannelModel } from "@models/Models"; +import { QueueModel, SessionModel, DBRoleModel, VoiceChannelModel, RoomModel } from "@models/Models"; import { Queue } from "@models/Queue"; import { QueueEntry } from "@models/QueueEntry"; +import { Room } from "@models/Room"; import {SessionRole, Session } from "@models/Session"; import { VoiceChannel } from "@models/VoiceChannel"; import { DocumentType, mongoose } from "@typegoose/typegoose" +import { randomInt } from "crypto"; import { ChannelType, } from "discord.js"; +import events from "events"; export const config = { Memory: true, @@ -16,14 +19,28 @@ export const config = { Database: 'test' } -export async function createQueue(guild: DocumentType, name: string, description: string, entries: QueueEntry[] = [], locked: boolean = false, info_channels: { - channel_id: string; - events: QueueEventType[]; -}[] = []): Promise> { +export async function createQueue(guild: DocumentType, { + name = "test queue", + description = "test description", + entries = [], + locked = false, + disconnect_timeout = 1, + info_channels = [] +}: { + name?: string, + description?: string, + entries?: QueueEntry[], + locked?: boolean, + disconnect_timeout?: number, + info_channels?: { + channel_id: string; + events: QueueEventType[]; + }[] +} = {}): Promise> { const queue = new QueueModel({ name: name, description: description, - disconnect_timeout: 60000, + disconnect_timeout: disconnect_timeout, match_timeout: 120000, limit: 150, join_message: "You joined the ${name} queue.\n\\> Your Position: ${pos}/${total}\n\\> Total Time Spent: ${time_spent}", @@ -68,17 +85,58 @@ export async function createRole(guild: DocumentType, name: string, inter return role; } -export async function createWaitingRoom(guild: DocumentType, channel: string, queue: Queue, supervisor: string): Promise { +// export async function createVoiceChannel(guild: DocumentType, channelId: string, queue: Queue, supervisor: string): Promise { +export async function createVoiceChannel(guild: DocumentType, { + queue = null, + channelID = randomInt(281474976710655).toString(), + supervisor = null, + temporary = false, +}: { + queue?: DocumentType | null, + channelID?: string, + supervisor?: string | null, + temporary?: boolean, +} = {}): Promise> { const waitingRoomChannel = new VoiceChannelModel({ - _id: channel, + _id: channelID, channel_type: ChannelType.GuildVoice, locked: false, managed: true, permitted: [], queue: queue, supervisors: [supervisor], + temporary: temporary, }); + guild.voice_channels.push(waitingRoomChannel); await guild.save(); return waitingRoomChannel; +} + +export async function createRoom(guild: DocumentType, { + roomId = randomInt(281474976710655).toString(), + active = true, + tampered = false, + endCertain = false, + events = [] +}: { + roomId?: string, + active?: boolean, + tampered?: boolean, + endCertain?: boolean, + events?: { + emitted_by: string, + type: string, + timestamp: string + }[] +} = {}): Promise> { + const room = await RoomModel.create({ + _id: roomId, + active: active, + tampered: tampered, + end_certain: endCertain, + guild: guild.id, + events: events, + }); + return room; } \ No newline at end of file From 4af1041326205b5768e9a03ac04407b90bf73835 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Wed, 20 Mar 2024 21:47:37 +0100 Subject: [PATCH 113/130] Update to use new utils methods --- .../queue/AddQueueInfoChannelCommand.test.ts | 10 ++++---- .../config/queue/CreateQueueCommand.test.ts | 4 +-- .../RemoveQueueInfoChannelCommand.test.ts | 8 +++--- .../queue/SetWaitingRoomCommand.test.ts | 25 ++++++++++--------- src/commands/queue/QueueJoinCommand.test.ts | 20 +++++++-------- src/commands/queue/QueueLeaveCommand.test.ts | 4 +-- .../queue/QueueSummaryCommand.test.ts | 2 +- .../tutor/TutorSummaryCommand.test.ts | 2 +- .../tutor/queue/TutorQueueListCommand.test.ts | 2 +- .../queue/TutorQueueSummaryCommand.test.ts | 2 +- .../session/TutorSessionEndCommand.test.ts | 6 ++--- .../session/TutorSessionStartCommand.test.ts | 12 ++++----- .../TutorSessionSummaryCommand.test.ts | 2 +- src/managers/DmManager.test.ts | 2 +- tests/testutils.ts | 2 +- 15 files changed, 52 insertions(+), 51 deletions(-) diff --git a/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts index 5ce7070..f983f7f 100644 --- a/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts +++ b/src/commands/config/queue/AddQueueInfoChannelCommand.test.ts @@ -83,7 +83,7 @@ describe("AddQueueInfoChannelCommand", () => { it("should set the queue info channel and reply with a success message", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description"); + const queue = await createQueue(dbGuild, { name: interaction.options.get("queue")?.value as string }) jest.clearAllMocks(); const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); @@ -116,7 +116,7 @@ describe("AddQueueInfoChannelCommand", () => { it("should fail if the channel is not found", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description"); + await createQueue(dbGuild, { name: interaction.options.get("queue")?.value as string }) interaction.options.get = jest.fn().mockImplementation((option: string) => { switch (option) { @@ -178,7 +178,7 @@ describe("AddQueueInfoChannelCommand", () => { }) const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description"); + await createQueue(dbGuild, { name: interaction.options.get("queue")?.value as string }) const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute(); @@ -211,7 +211,7 @@ describe("AddQueueInfoChannelCommand", () => { }) const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description"); + await createQueue(dbGuild, { name: interaction.options.get("queue")?.value as string }) const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute(); @@ -231,7 +231,7 @@ describe("AddQueueInfoChannelCommand", () => { it("should fail if the channel is already a queue info channel", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description", [], false, [{ channel_id: "test channel", events: Object.values(QueueEventType) }]); + const queue = await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string, info_channels: [{ channel_id: "test channel", events: Object.values(QueueEventType) }] }); const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute(); diff --git a/src/commands/config/queue/CreateQueueCommand.test.ts b/src/commands/config/queue/CreateQueueCommand.test.ts index e1e9a75..ed65708 100644 --- a/src/commands/config/queue/CreateQueueCommand.test.ts +++ b/src/commands/config/queue/CreateQueueCommand.test.ts @@ -87,7 +87,7 @@ describe("CreateQueueCommand", () => { it("should fail if the queue name is already taken on the same guild", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - const queue = await createQueue(dbGuild, "test name", "test description") + const queue = await createQueue(dbGuild, { name: interaction.options.get("name")?.value as string }) const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute() @@ -107,7 +107,7 @@ describe("CreateQueueCommand", () => { it("should create a queue if the queue name is already taken on another guild", async () => { const otherGuild = discord.mockGuild() let dbGuild = await discord.getApplication().configManager.getGuildConfig(otherGuild) - await createQueue(dbGuild, "test name", "test description") + await createQueue(dbGuild, { name: interaction.options.get("name")?.value as string }) const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute() diff --git a/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts b/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts index 2e2168c..6581045 100644 --- a/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts +++ b/src/commands/config/queue/RemoveQueueInfoChannelCommand.test.ts @@ -72,7 +72,7 @@ describe("RemoveQueueInfoChannelCommand", () => { const channelName = interaction.options.get("channel")!.value as string; const queueName = interaction.options.get("queue")!.value as string; const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - await createQueue(dbGuild, queueName, "test description", [], false, [{ channel_id: channelName, events: Object.values(QueueEventType) }]); + await createQueue(dbGuild, { name: queueName, info_channels: [{ channel_id: channelName, events: Object.values(QueueEventType) }] }); jest.clearAllMocks(); const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); @@ -101,7 +101,7 @@ describe("RemoveQueueInfoChannelCommand", () => { const channelName = "this channel does not exist"; const queueName = interaction.options.get("queue")!.value as string; const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - await createQueue(dbGuild, queueName, "test description"); + await createQueue(dbGuild, { name: queueName }); interaction.options.get = jest.fn().mockImplementation((option: string) => { switch (option) { @@ -135,7 +135,7 @@ describe("RemoveQueueInfoChannelCommand", () => { const channelName = "another channel"; const queueName = interaction.options.get("queue")!.value as string; const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - await createQueue(dbGuild, queueName, "test description"); + await createQueue(dbGuild, { name: queueName }); interaction.options.get = jest.fn().mockImplementation((option: string) => { switch (option) { @@ -190,7 +190,7 @@ describe("RemoveQueueInfoChannelCommand", () => { const channelName = interaction.options.get("channel")!.value as string; const queueName = interaction.options.get("queue")!.value as string; const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - await createQueue(dbGuild, queueName, "test description"); + await createQueue(dbGuild, { name: queueName }); const replySpy = jest.spyOn(interaction, 'editReply'); await commandInstance.execute(); diff --git a/src/commands/config/queue/SetWaitingRoomCommand.test.ts b/src/commands/config/queue/SetWaitingRoomCommand.test.ts index eaa47ff..b24f50f 100644 --- a/src/commands/config/queue/SetWaitingRoomCommand.test.ts +++ b/src/commands/config/queue/SetWaitingRoomCommand.test.ts @@ -2,7 +2,7 @@ import { MockDiscord } from "@tests/mockDiscord" import SetWaitingRoomCommand from "./SetWaitingRoomCommand" import { container } from "tsyringe" import { BaseMessageOptions, ChannelType, ChatInputCommandInteraction, Colors, EmbedBuilder } from "discord.js" -import { createQueue, createRole, createWaitingRoom } from "@tests/testutils" +import { createQueue, createRole, createVoiceChannel } from "@tests/testutils" import { GuildModel } from "@models/Models" describe("SetWaitingRoomCommand", () => { @@ -91,7 +91,7 @@ describe("SetWaitingRoomCommand", () => { it("should edit the reply with the created waiting room", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string }) await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) jest.clearAllMocks(); @@ -120,7 +120,7 @@ describe("SetWaitingRoomCommand", () => { it("should set the waiting room on the database", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string }) await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) await commandInstance.execute() dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) @@ -133,9 +133,9 @@ describe("SetWaitingRoomCommand", () => { it("should overwrite the waiting room on the database if it already exists for the queue", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - const queue = await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + const queue = await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string }) await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) - await createWaitingRoom(dbGuild, "another channel", queue, `another supervisor ${interaction.guild}`) + await createVoiceChannel(dbGuild, { queue: queue, supervisor: `another supervisor ${interaction.guild}` }) await commandInstance.execute() dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) @@ -148,11 +148,12 @@ describe("SetWaitingRoomCommand", () => { it("should add another waiting room on the database if it is for another queue", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) // create other waiting room - const queue = await createQueue(dbGuild, "another channel", "another description") + const queue = await createQueue(dbGuild) await createRole(dbGuild, "another supervisor") - await createWaitingRoom(dbGuild, "another channel", queue, `another supervisor ${interaction.guild}`) + await createVoiceChannel(dbGuild, { queue: queue, supervisor: `another supervisor ${interaction.guild}` }) + // preparations for the command - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string }) await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) await commandInstance.execute() dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) @@ -165,7 +166,7 @@ describe("SetWaitingRoomCommand", () => { it("should fail if the channel does not exist", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string }) await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) interaction.guild!.channels.cache.get = jest.fn().mockReturnValue(null) @@ -186,7 +187,7 @@ describe("SetWaitingRoomCommand", () => { it("should fail if the channel is not a voice channel", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string }) await createRole(dbGuild, interaction.options.get("supervisor")!.value as string) interaction.guild!.channels.cache.get = jest.fn().mockImplementation(() => { @@ -231,7 +232,7 @@ describe("SetWaitingRoomCommand", () => { it("should fail if the role does not exist", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string }) interaction.guild!.roles.cache.get = jest.fn().mockReturnValue(null) const replySpy = jest.spyOn(interaction, 'editReply') @@ -251,7 +252,7 @@ describe("SetWaitingRoomCommand", () => { it("should fail if the role is not in the database", async () => { let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!) - await createQueue(dbGuild, interaction.options.get("queue")!.value as string, "test description") + await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string }) const replySpy = jest.spyOn(interaction, 'editReply') await commandInstance.execute() diff --git a/src/commands/queue/QueueJoinCommand.test.ts b/src/commands/queue/QueueJoinCommand.test.ts index 6a83077..a902d9d 100644 --- a/src/commands/queue/QueueJoinCommand.test.ts +++ b/src/commands/queue/QueueJoinCommand.test.ts @@ -55,7 +55,7 @@ describe("QueueJoinCommand", () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); const actualQueueName = interaction.options.get("queue")!.value as string const queueName = isLowercase ? actualQueueName.toLowerCase() : actualQueueName.toUpperCase(); - const queue = await createQueue(dbGuild, queueName, "test description"); + const queue = await createQueue(dbGuild, { name: queueName }); jest.clearAllMocks(); const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); @@ -96,7 +96,7 @@ describe("QueueJoinCommand", () => { it("should fail if the user is already in the same queue", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description", [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }]); + const queue = await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string, entries: [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }] }); jest.clearAllMocks(); const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); @@ -118,8 +118,8 @@ describe("QueueJoinCommand", () => { it("should fail if the user is already in another queue", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); - const otherQueue = await createQueue(dbGuild, "another test", "another test description", [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }]); + await createQueue(dbGuild); + const otherQueue = await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string, entries: [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }] }); jest.clearAllMocks(); const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); @@ -141,7 +141,7 @@ describe("QueueJoinCommand", () => { it("should fail if the user has an active session", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string }); await SessionModel.create({ active: true, user: interaction.user.id, guild: interaction.guild?.id, role: SessionRole.coach, started_at: Date.now(), end_certain: false, rooms: [] }); @@ -152,8 +152,8 @@ describe("QueueJoinCommand", () => { expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ - embeds: [{ + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ data: { title: "Error", description: `You have an active session and cannot perform this action.`, @@ -165,8 +165,8 @@ describe("QueueJoinCommand", () => { it("should fail if the queue is locked", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description", [], true); - + const queue = await createQueue(dbGuild, { name: interaction.options.get("queue")!.value as string, locked: true }); + jest.clearAllMocks(); const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); const replySpy = jest.spyOn(interaction, 'reply'); @@ -175,7 +175,7 @@ describe("QueueJoinCommand", () => { expect(saveSpy).toHaveBeenCalledTimes(0); expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ - embeds: [{ + embeds: [{ data: { title: "Error", description: `The queue "${queue.name}" is locked and cannot be joined.`, diff --git a/src/commands/queue/QueueLeaveCommand.test.ts b/src/commands/queue/QueueLeaveCommand.test.ts index 9a70ef7..3c2e53e 100644 --- a/src/commands/queue/QueueLeaveCommand.test.ts +++ b/src/commands/queue/QueueLeaveCommand.test.ts @@ -30,7 +30,7 @@ describe("QueueLeaveCommand", () => { it("should leave the queue and reply with a success message", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description", [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }]); + const queue = await createQueue(dbGuild, { entries: [{ discord_id: interaction.user.id, joinedAt: (Date.now()).toString() }] }); const replySpy = jest.spyOn(interaction, 'reply'); const saveSpy = jest.spyOn(GuildModel.prototype as any, 'save'); @@ -53,7 +53,7 @@ describe("QueueLeaveCommand", () => { it("should fail if the user is not in a queue", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - await createQueue(dbGuild, "test", "test description"); + await createQueue(dbGuild); jest.clearAllMocks(); const replySpy = jest.spyOn(interaction, 'reply'); diff --git a/src/commands/queue/QueueSummaryCommand.test.ts b/src/commands/queue/QueueSummaryCommand.test.ts index b18c9cd..422cbdf 100644 --- a/src/commands/queue/QueueSummaryCommand.test.ts +++ b/src/commands/queue/QueueSummaryCommand.test.ts @@ -51,7 +51,7 @@ describe("InfoCommand", () => { discord_id: interaction.user.id, joinedAt: Date.now().toString(), }; - const queue = await createQueue(dbGuild, "test", "test description", [queueEntry]); + const queue = await createQueue(dbGuild, { entries: [queueEntry] }); const replySpy = jest.spyOn(interaction, 'reply'); await commandInstance.execute(); diff --git a/src/commands/tutor/TutorSummaryCommand.test.ts b/src/commands/tutor/TutorSummaryCommand.test.ts index a8e5758..3ba54b5 100644 --- a/src/commands/tutor/TutorSummaryCommand.test.ts +++ b/src/commands/tutor/TutorSummaryCommand.test.ts @@ -44,7 +44,7 @@ describe("TutorSummaryCommand", () => { it.each([1, 2, 3])(`should reply with a summary of all your Sessions when you had %s sessions`, async (numberOfSessions) => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); const sessions = []; for (let i = 0; i < numberOfSessions; i++) { const session = await createSession(queue, interaction.user.id, interaction.guild!.id); diff --git a/src/commands/tutor/queue/TutorQueueListCommand.test.ts b/src/commands/tutor/queue/TutorQueueListCommand.test.ts index 85824c6..a87ab45 100644 --- a/src/commands/tutor/queue/TutorQueueListCommand.test.ts +++ b/src/commands/tutor/queue/TutorQueueListCommand.test.ts @@ -44,7 +44,7 @@ describe("TutorQueueListCommand", () => { discord_id: member.id, joinedAt: Date.now().toString(), })); - const queue = await createQueue(dbGuild, "test", "test description", queueEntries); + const queue = await createQueue(dbGuild, { entries: queueEntries }); await createSession(queue, interaction.user.id, interaction.guild!.id); if (amount) { diff --git a/src/commands/tutor/queue/TutorQueueSummaryCommand.test.ts b/src/commands/tutor/queue/TutorQueueSummaryCommand.test.ts index b4ecc7f..7d108ff 100644 --- a/src/commands/tutor/queue/TutorQueueSummaryCommand.test.ts +++ b/src/commands/tutor/queue/TutorQueueSummaryCommand.test.ts @@ -28,7 +28,7 @@ describe("TutorQueueSummaryCommand", () => { it("should reply with a summary of the current queue", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); await createSession(queue, interaction.user.id, interaction.guild!.id); const replySpy = jest.spyOn(interaction, 'reply'); diff --git a/src/commands/tutor/session/TutorSessionEndCommand.test.ts b/src/commands/tutor/session/TutorSessionEndCommand.test.ts index a83cf06..ac3f2a6 100644 --- a/src/commands/tutor/session/TutorSessionEndCommand.test.ts +++ b/src/commands/tutor/session/TutorSessionEndCommand.test.ts @@ -45,7 +45,7 @@ describe("TutorSessionEndCommand", () => { it("should end the tutor session and reply with it", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); await createSession(queue, interaction.user.id, interaction.guild!.id); const replySpy = jest.spyOn(interaction, 'editReply'); @@ -82,7 +82,7 @@ describe("TutorSessionEndCommand", () => { it("should end the tutor session on the database", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); await createSession(queue, interaction.user.id, interaction.guild!.id); const saveSpy = jest.spyOn(SessionModel.prototype as any, 'save'); @@ -98,7 +98,7 @@ describe("TutorSessionEndCommand", () => { it("should remove the active session role from the user", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); await createSession(queue, interaction.user.id, interaction.guild!.id); const removeSpy = jest.spyOn(GuildMemberRoleManager.prototype, 'remove'); diff --git a/src/commands/tutor/session/TutorSessionStartCommand.test.ts b/src/commands/tutor/session/TutorSessionStartCommand.test.ts index 067e34d..4d3e159 100644 --- a/src/commands/tutor/session/TutorSessionStartCommand.test.ts +++ b/src/commands/tutor/session/TutorSessionStartCommand.test.ts @@ -49,7 +49,7 @@ describe("TutorSessionStartCommand", () => { it.each([true, false])("should start a tutor session and reply with it (queue parameter is provided: %p)", async (parameterSet) => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); if (parameterSet) { interaction.options.get = jest.fn().mockReturnValue({ value: queue.name }); @@ -63,7 +63,7 @@ describe("TutorSessionStartCommand", () => { embeds: [{ data: { title: "Tutor Session Started", - description: `You have started a tutor session for queue "test".`, + description: `You have started a tutor session for queue "${queue.name}".`, color: Colors.Green, } }] @@ -72,7 +72,7 @@ describe("TutorSessionStartCommand", () => { it.each([true, false])("should start a tutor session save it in the database (queue parameter is provided: %p)", async (parameterSet) => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); if (parameterSet) { interaction.options.get = jest.fn().mockReturnValue({ value: queue.name }); @@ -96,7 +96,7 @@ describe("TutorSessionStartCommand", () => { it.each([true, false])("should add the active session role to the user (queue parameter is provided: %p)", async (parameterSet) => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); if (parameterSet) { interaction.options.get = jest.fn().mockReturnValue({ value: queue.name }); @@ -126,7 +126,7 @@ describe("TutorSessionStartCommand", () => { it("should fail if the queue does not exist", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - await createQueue(dbGuild, "test", "test description"); + await createQueue(dbGuild); interaction.options.get = jest.fn().mockReturnValue({ value: "nonexistent" }); @@ -147,7 +147,7 @@ describe("TutorSessionStartCommand", () => { it("should fail if the user has an active session", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); await createSession(queue, interaction.user.id, interaction.guild!.id); const replySpy = jest.spyOn(interaction, 'editReply'); diff --git a/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts b/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts index 19a7559..a661f9f 100644 --- a/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts +++ b/src/commands/tutor/session/TutorSessionSummaryCommand.test.ts @@ -28,7 +28,7 @@ describe("TutorSessionSummaryCommand", () => { it("should reply with a summary of the current session", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); - const queue = await createQueue(dbGuild, "test", "test description"); + const queue = await createQueue(dbGuild); await createSession(queue, interaction.user.id, interaction.guild!.id); const replySpy = jest.spyOn(interaction, 'reply'); diff --git a/src/managers/DmManager.test.ts b/src/managers/DmManager.test.ts index 57a247c..5d3cecc 100644 --- a/src/managers/DmManager.test.ts +++ b/src/managers/DmManager.test.ts @@ -18,7 +18,7 @@ describe("DmManager", () => { user = discord.mockUser() const guild = discord.mockGuild() const dbGuild = await discord.getApplication().configManager.getGuildConfig(guild) - queue = await createQueue(dbGuild, "test", "test description") + queue = await createQueue(dbGuild) }) describe("sendQueueJoinMessage", () => { diff --git a/tests/testutils.ts b/tests/testutils.ts index c0ea250..9d3b603 100644 --- a/tests/testutils.ts +++ b/tests/testutils.ts @@ -20,7 +20,7 @@ export const config = { } export async function createQueue(guild: DocumentType, { - name = "test queue", + name = `test-queue-${randomInt(1000)}`, description = "test description", entries = [], locked = false, From c3e8d44fa70de91fcd0221f998a3a0dd82e32d63 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Fri, 26 Apr 2024 18:40:23 +0200 Subject: [PATCH 114/130] Add tutor queue next command --- src/Application.ts | 11 +- .../tutor/queue/TutorQueueNextCommand.ts | 118 ++++++++ src/managers/DmManager.test.ts | 27 +- src/managers/DmManager.ts | 13 +- src/managers/QueueManager.ts | 77 +++++- src/managers/RoomManager.ts | 260 ++++++++++++++++++ src/managers/index.ts | 2 + src/models/Queue.ts | 7 + .../errors/ChannelCouldNotBeCreatedError.ts | 25 ++ src/types/errors/QueueIsEmptyError.ts | 10 + src/types/index.ts | 4 + src/utils/handleError.ts | 2 +- 12 files changed, 546 insertions(+), 10 deletions(-) create mode 100644 src/commands/tutor/queue/TutorQueueNextCommand.ts create mode 100644 src/managers/RoomManager.ts create mode 100644 src/types/errors/ChannelCouldNotBeCreatedError.ts create mode 100644 src/types/errors/QueueIsEmptyError.ts diff --git a/src/Application.ts b/src/Application.ts index d3f72dd..ab651ee 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -3,7 +3,7 @@ import 'dotenv/config' import { Client, Partials, ClientOptions, Interaction } from 'discord.js' import { CronJob } from 'cron' import { ConsolaInstance, createConsola } from 'consola' -import { CommandsManager, ConfigManager, QueueManager, UserManager } from "./managers" +import { CommandsManager, ConfigManager, QueueManager, RoomManager, UserManager } from "./managers" import { container, delay, inject, injectable, singleton } from "tsyringe" import Environment from "./Environment" import mongoose from "mongoose" @@ -13,6 +13,7 @@ import { BaseCommandOrSubcommandsHandler } from "@baseCommand" import CommandsLoader from "@loaders/CommandsLoader" import EventsLoader from "@loaders/EventsLoader" import DmManager from "./managers/DmManager" +import { Room } from "@models/Room" /** * The main `Application` class. @@ -36,6 +37,11 @@ export class Application { */ public queueManager: QueueManager + /** + * The room manager responsible for managing the rooms. + */ + public roomManager: RoomManager + /** * The user manager responsible for managing the users in the database. */ @@ -88,7 +94,7 @@ export class Application { * @param client The Discord client. * @param token The bot token. */ - constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager, @inject(delay(() => QueueManager)) queueManager: QueueManager, @inject(delay(() => UserManager)) userManager: UserManager, @inject(delay(() => DmManager)) dmManager: DmManager){ + constructor(@inject("options") options: ClientOptions, @inject("token") token: string, @inject(delay(() => CommandsManager)) commandsManager: CommandsManager, @inject(delay(() => ConfigManager)) configManager: ConfigManager, @inject(delay(() => QueueManager)) queueManager: QueueManager, @inject(delay(() => UserManager)) userManager: UserManager, @inject(delay(() => DmManager)) dmManager: DmManager, @inject(delay(() => RoomManager)) roomManager: RoomManager) { this.client = new Client(options) this.token = token this.logger = createConsola({ level: Environment.logLevel }) @@ -97,6 +103,7 @@ export class Application { this.queueManager = queueManager this.userManager = userManager this.dmManager = dmManager + this.roomManager = roomManager } private loadEvents(): void { diff --git a/src/commands/tutor/queue/TutorQueueNextCommand.ts b/src/commands/tutor/queue/TutorQueueNextCommand.ts new file mode 100644 index 0000000..b035819 --- /dev/null +++ b/src/commands/tutor/queue/TutorQueueNextCommand.ts @@ -0,0 +1,118 @@ +import { BaseCommand } from "@baseCommand"; +import { Guild } from "@models/Guild"; +import { QueueEntry } from "@models/QueueEntry"; +import { Session } from "@models/Session"; +import { ChannelCouldNotBeCreatedError, InteractionNotInGuildError, QueueIsEmptyError, SessionHasNoQueueError, UserHasNoActiveSessionError } from "@types"; +import { ApplicationCommandOptionType, EmbedBuilder, GuildMember, VoiceChannel } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { Queue } from "@models/Queue"; + +export default class TutorQueueNextCommand extends BaseCommand { + public static name = "next"; + public static description = "Accepts the next student(s) in the queue."; + public static options = [{ + name: "amount", + description: "The amount of students to accept.", + type: ApplicationCommandOptionType.Integer, + required: false, + default: 1, + }]; + + /** + * The guild saved in the database. + */ + private dbGuild!: DocumentType; + + /** + * The queue saved in the database. + */ + private dbQueue!: DocumentType; + + /** + * The session saved in the database. + */ + private dbSession!: DocumentType; + + public async execute(): Promise { + try { + const amount = parseInt(this.getOptionValue(TutorQueueNextCommand.options[0])); + await this.loadGuildAndQueue(); + const tutor = this.interaction.member as GuildMember; + const students = await this.selectNextStudents(amount); + const nextRoomNumber = this.getNextRoomNumber(); + const tutoringVoiceChannel = await this.app.roomManager.createTutoringVoiceChannel(this.dbGuild, this.dbQueue, tutor, students, nextRoomNumber) + // Reload the queue to avoid version errors + await this.loadGuildAndQueue(); + await this.app.queueManager.notifyPickedStudents(this.dbQueue, students, tutor, tutoringVoiceChannel) + await this.app.roomManager.moveMembersToRoom(students.concat(tutor), tutoringVoiceChannel, tutor, this.dbQueue) + const embed = this.mountTutorQueueNextEmbed(students, tutoringVoiceChannel); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountTutorQueueNextEmbed(students: GuildMember[], tutoringVoiceChannel: VoiceChannel): EmbedBuilder { + const studentsString = students.join(", "); + return new EmbedBuilder() + .setTitle("Next Students") + .setDescription(`Please join ${tutoringVoiceChannel} if you are not automatically moved.\nYour Participant${students.length > 1 ? "s" : ""}: ${studentsString}`); + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof UserHasNoActiveSessionError || error instanceof SessionHasNoQueueError || error instanceof QueueIsEmptyError || error instanceof ChannelCouldNotBeCreatedError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message); + } + throw error; + } + + private async loadGuildAndQueue(): Promise { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild); + const dbUser = await this.app.userManager.getUser(this.interaction.user); + + // Check if the user has an active session + const dbSession = (await dbUser.getActiveSessions()).find(session => session.guild === this.dbGuild.id); + if (!dbSession) { + throw new UserHasNoActiveSessionError(); + } + if (!dbSession.queue) { + throw new SessionHasNoQueueError(this.dbSession); + } + + // Get the queue + const queueId = dbSession.queue; + this.dbQueue = this.app.queueManager.getQueueById(this.dbGuild, queueId); + this.dbSession = dbSession; + } + + private async selectNextStudents(amount: number): Promise { + // Kick all students no longer on the server + await this.app.queueManager.kickNonServerMembers(await this.interaction.guild!.members.fetch(), this.dbQueue); + + // Check if the queue is empty + if (this.dbQueue.isEmpty()) { + throw new QueueIsEmptyError(this.dbQueue); + } + + // Select the next students + const queueMembers = this.dbQueue.getSortedEntries(amount); + + // Get discord members + return queueMembers.map(queueMember => this.interaction.guild!.members.resolve(queueMember.discord_id)!); + } + + private getNextRoomNumber(): number { + return this.dbSession.getNumberOfRooms() + 1; + } +} \ No newline at end of file diff --git a/src/managers/DmManager.test.ts b/src/managers/DmManager.test.ts index 5d3cecc..b242cbe 100644 --- a/src/managers/DmManager.test.ts +++ b/src/managers/DmManager.test.ts @@ -2,7 +2,7 @@ import { MockDiscord } from "@tests/mockDiscord" import { container } from "tsyringe" import DmManager from "./DmManager" import { createQueue } from "@tests/testutils" -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, DMChannel, MessageCreateOptions, MessagePayload, User } from "discord.js" +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, DMChannel, MessageCreateOptions, MessagePayload, User, VoiceChannel } from "discord.js" import { Queue } from "@models/Queue" import { DocumentType } from "@typegoose/typegoose" @@ -157,6 +157,31 @@ describe("DmManager", () => { }) }) + describe("sendQueuePickedMessage", () => { + it("should send a message to the user", async () => { + const dmSpy = jest.spyOn(user, "createDM").mockResolvedValue(dmChannel); + const sendSpy = jest.spyOn(dmChannel, "send").mockImplementation(() => Promise.resolve({} as any)) + const room: VoiceChannel = { + id: "123", + type: ChannelType.GuildVoice, + name: "Room", + } as any + + await dmManager.sendQueuePickedMessage(user, queue, room) + + expect(dmSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "You found a Coach!", + description: `You found a Coach.\nPlease join ${room} if you are not automatically moved.` + } + }], + })); + }) + }) + describe("sendErrorMessage", () => { it("should send a description of the error to the user", async () => { const error = new Error("My Error") diff --git a/src/managers/DmManager.ts b/src/managers/DmManager.ts index 263cc57..853344d 100644 --- a/src/managers/DmManager.ts +++ b/src/managers/DmManager.ts @@ -1,6 +1,6 @@ import { Application } from "@application"; import { Queue } from "@models/Queue"; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, User } from "discord.js"; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, User, VoiceChannel } from "discord.js"; import { delay, inject, injectable, singleton } from "tsyringe"; import { DocumentType } from "@typegoose/typegoose"; @@ -102,6 +102,17 @@ export default class DmManager { await dmChannel.send({ embeds: [embed] }); } + public async sendQueuePickedMessage(user: User, queue: DocumentType, room: VoiceChannel): Promise { + this.app.logger.debug(`Sending picked message to user "${user.tag}" (id: ${user.id}) for queue "${queue.name}" (id: ${queue._id}) and room "${room.name}" (id: ${room.id})`); + const dmChannel = await user.createDM(); + + const embed = new EmbedBuilder() + .setTitle("You found a Coach!") + .setDescription(`You found a Coach.\nPlease join ${room} if you are not automatically moved.`) + + await dmChannel.send({ embeds: [embed] }); + } + public async sendErrorMessage(user: User, error: Error): Promise { this.app.logger.debug(`Sending error message to user "${user.tag}" (id: ${user.id})`); const dmChannel = await user.createDM(); diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index eee2c53..ec82621 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -4,12 +4,13 @@ import { Queue } from "@models/Queue"; import { DocumentType, mongoose } from "@typegoose/typegoose"; import { AlreadyInQueueError, ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, CouldNotFindQueueError, InvalidEventError, NotInQueueError, QueueAlreadyExistsError, QueueLockedError, UserHasActiveSessionError } from "@types"; import { Guild as DatabaseGuild } from "@models/Guild"; -import { EmbedBuilder, TextChannel, User, Guild as DiscordGuild, Collection } from "discord.js"; +import { EmbedBuilder, TextChannel, User, Guild as DiscordGuild, Collection, GuildMember, VoiceChannel } from "discord.js"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; import { QueueEventType } from "@models/Event"; import { InternalRoles } from "@models/BotRoles"; import { SessionRole, Session } from "@models/Session"; import { QueueEntryModel, SessionModel } from "@models/Models"; +import { QueueEntry } from "@models/QueueEntry"; @injectable() @singleton() @@ -189,16 +190,42 @@ export default class QueueManager { const leaveMessage = queue.getLeaveMessage(user.id); - // remove the user from the queue - const userIndex = queue.entries.findIndex(entry => entry.discord_id === user.id) - queue.entries.splice(userIndex, 1) - await queue.$parent()?.save() + await this.removeUserFromQueue(queue, user.id); this.app.logger.info(`User "${user.username}" (id: ${user.id}) left queue "${queue.name}"`); await this.logQueueActivity(queue, QueueEventType.LEAVE, user); return leaveMessage; } + /** + * Removes a user from the queue. + * @param queue - The queue document. + * @param userId - The ID of the user to remove. + * @returns A promise that resolves when the user is removed from the queue. + */ + private async removeUserFromQueue(queue: DocumentType, userId: string): Promise { + const userIndex = queue.entries.findIndex(entry => entry.discord_id === userId); + if (userIndex === -1) { + this.app.logger.debug(`User with id "${userId}" not found in queue "${queue.name}"`); + return; + } + if (!queue.$parent()) { + this.app.logger.debug(`Could not find parent guild for queue "${queue.name}"`); + return; + } + queue.entries.splice(userIndex, 1); + await queue.$parent()?.save(); + this.app.logger.info(`User with id "${userId}" removed from queue "${queue.name}"`); + } + + /** + * Removes the user from the pending queue stays for the specified queue. + * If the user is found in the pending queue stays, they are removed and a log message is generated. + * + * @param queue - The queue from which the user should be removed. + * @param user - The user to be removed from the pending queue stays. + * @returns A Promise that resolves to void. + */ public async stayInQueue(queue: DocumentType, user: User): Promise { const pendingQueueStays = this.pendingQueueStays.get(queue.id); if (pendingQueueStays && pendingQueueStays.includes(user.id)) { @@ -207,6 +234,46 @@ export default class QueueManager { } } + /** + * Kicks members from the queue who are not on the server. + * @param guildMembers - A collection of guild members on the server. + * @param queue - The queue document. + * @returns A promise that resolves when all members have been kicked from the queue. + */ + public async kickNonServerMembers(guildMembers: Collection, queue: DocumentType): Promise { + const queueMembersNotOnServer = queue.entries.filter(entry => !guildMembers.has(entry.discord_id)); + if (queueMembersNotOnServer.length == 0) { + this.app.logger.debug(`No members to kick from queue "${queue.name}". Everyone is on the server.`); + return; + } + this.app.logger.info(`Kicking ${queueMembersNotOnServer.length} members (${queueMembersNotOnServer.map(memberNotOnServer => memberNotOnServer.discord_id).join(", ")}) from queue "${queue.name}" who are not on the server`); + for (const memberNotOnServer of queueMembersNotOnServer) { + await this.removeUserFromQueue(queue, memberNotOnServer.discord_id); + } + this.app.logger.debug(`Finished: Kicked ${queueMembersNotOnServer.length} members from queue "${queue.name}" who were not on the server`); + } + + /** + * Notifies the picked students that they were picked by a tutor from the queue. + * + * @param queue - The queue from which the students were picked. + * @param students - An array of students who were picked. + * @param tutor - The tutor who picked the students. + * @param room - The voice channel where the tutoring session will take place. + * @returns A Promise that resolves when all notifications have been sent. + */ + public async notifyPickedStudents(queue: DocumentType, students: GuildMember[], tutor: GuildMember, room: VoiceChannel): Promise { + for (const student of students) { + this.app.logger.info(`Notifying student "${student.user.username}" (id: ${student.id}) that they were picked by tutor "${tutor.user.username}" (id: ${tutor.id}) from queue "${queue.name}" to room "${room.name}" (id: ${room.id})`); + const user = await this.app.client.users.fetch(student.id); + + await this.removeUserFromQueue(queue, student.id); + await this.app.dmManager.sendQueuePickedMessage(user, queue, room); + + await this.logQueueActivity(queue, QueueEventType.NEXT, tutor.user, [user]); + } + } + /** * Starts a tutor session for a user in the specified queue. * diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts new file mode 100644 index 0000000..058f775 --- /dev/null +++ b/src/managers/RoomManager.ts @@ -0,0 +1,260 @@ +import { Application } from "@application" +import { Guild } from "@models/Guild"; +import { Queue } from "@models/Queue"; +import { QueueEntry } from "@models/QueueEntry"; +import { VoiceChannel as DatabaseVoiceChannel } from "@models/VoiceChannel"; +import { VoiceChannelSpawner } from "@models/VoiceChannelSpawner"; +import { ChannelType, Guild as DiscordGuild, GuildMember, GuildPremiumTier, OverwriteData, VoiceChannel } from "discord.js"; +import { DocumentType, mongoose } from "@typegoose/typegoose"; +import { delay, inject, injectable, singleton } from "tsyringe"; +import { PermissionOverwriteData } from "@models/PermissionOverwriteData"; +import { interpolateString } from "@utils/interpolateString"; +import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; +import { ChannelCouldNotBeCreatedError } from "@types"; +import { RoomModel } from "@models/Models"; +import { VoiceChannelEvent } from "@models/Event"; +import { Room } from "@models/Room"; + +@injectable() +@singleton() +export default class RoomManager { + protected app: Application; + + constructor(@inject(delay(() => Application)) app: Application) { + this.app = app; + } + + /** + * Moves the specified members to the given room and updates the room data accordingly. + * If the room does not have a database entry, it creates one. + * + * @param members - An array of GuildMembers to be moved. + * @param room - The VoiceChannel to which the members will be moved. + * @param emmitedBy - The GuildMember who emitted the move event. + * @param queue - The Queue associated with the move event. + * @returns A Promise that resolves once the members are moved and the room data is saved. + */ + public async moveMembersToRoom(members: GuildMember[], room: VoiceChannel, emmitedBy: GuildMember, queue: Queue): Promise { + let roomData = await RoomModel.findById(room.id); + if (!roomData) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one...`); + roomData = await this.createRoomOnDatabase(room); + } + for (const member of members) { + try { + await member.voice.setChannel(room); + roomData.events.push({ + emitted_by: emmitedBy.id, + reason: `Automated member move by queue "${queue.name}"`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + this.app.logger.info(`Moved member "${member.displayName}" (id: ${member.id}) to room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`) + } catch (error) { + this.app.logger.error(`Could not move member "${member.displayName}" (id: ${member.id}) to room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); + continue; + } + } + await roomData.save(); + } + + /** + * Creates a room entry in the database. + * + * @param room - The voice channel representing the room. + * @returns A promise that resolves to the created room data. + */ + private async createRoomOnDatabase(room: VoiceChannel): Promise> { + const roomData = await RoomModel.create({ + _id: room.id, + active: true, + tampered: false, + end_certain: false, + guild: room.guild.id, + }); + this.app.logger.info(`Created database entry for room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); + return roomData; + } + + /** + * Creates a tutoring voice channel for a given guild member and queue. + * + * @param dbGuild - The guild document from the database. + * @param dbQueue - The queue document from the database. + * @param member - The guild member for whom the voice channel is being created. + * @param students - The list of students in the queue. + * @param roomNumber - The room number for the voice channel. + * @returns A Promise that resolves to the created VoiceChannel. + * @throws {ChannelCouldNotBeCreatedError} If the voice channel could not be created. + */ + public async createTutoringVoiceChannel(dbGuild: Guild, dbQueue: DocumentType, member: GuildMember, students: GuildMember[], roomNumber: number): Promise { + const guild = member.guild; + const spawner = await this.getRoomSpawner(dbGuild, dbQueue, member, students, roomNumber); + + let room: VoiceChannel; + try { + room = await this.createTemporaryVoiceChannel(member, spawner) + } catch (error) { + const customError = new ChannelCouldNotBeCreatedError(dbQueue.name, guild.name); + this.app.logger.error(customError.message + "\n" + error); + throw customError; + } + + const roomData = await this.createRoomOnDatabase(room); + roomData.events.push({ + emitted_by: member.id, + reason: `Automated room creation for queue "${dbQueue.name}"`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + await roomData.save(); + + return room; + } + + /** + * Retrieves the room spawner for creating a voice channel room. If a spawner exists in the database, it is used. Otherwise, a new ad hoc spawner is created. + * + * @param dbGuild - The guild document from the database. + * @param dbQueue - The queue document from the database. + * @param member - The guild member requesting the room spawner. + * @param students - An array of queue entries representing the students in the queue. + * @param roomNumber - The room number for the voice channel room. + * @returns A Promise that resolves to a VoiceChannelSpawner object. + */ + private async getRoomSpawner(dbGuild: Guild, dbQueue: DocumentType, member: GuildMember, students: GuildMember[], roomNumber: number): Promise { + const guild = member.guild; + let spawner: VoiceChannelSpawner | undefined = dbQueue.room_spawner?.toObject(); + const queueChannelData = dbGuild.voice_channels.find(channel => channel.queue && channel.queue._id.equals(dbQueue._id)); + const queueChannel = guild.channels.cache.get(queueChannelData?._id ?? ""); + const roomName = `${member.displayName}${member.displayName.endsWith("s") ? "'" : "s'"} ${dbQueue.name} Room ${roomNumber}`; + const permissionOverwrites: PermissionOverwriteData[] = students.map(student => { + return { + id: student.id, + allow: ["ViewChannel", "Connect", "Speak", "Stream"], + } as PermissionOverwriteData; + }); + + if (!spawner) { + spawner = { + owner: member.id, + supervisor_roles: queueChannelData?.supervisors ?? [], + permission_overwrites: [ ...permissionOverwrites ], + max_users: 5, + parent: queueChannel?.parentId ?? undefined, + lock_initially: true, + hide_initially: true, + name: roomName, + } as VoiceChannelSpawner; + this.app.logger.info(`Created ad hoc room spawner for queue "${dbQueue.name}" in guild "${guild.name}" (id: ${guild.id})`); + } else { + spawner.name = spawner.name ?? roomName; + this.app.logger.info(`Used existing room spawner for queue "${dbQueue.name}" in guild "${guild.name}" (id: ${guild.id})`); + } + spawner.permission_overwrites = new mongoose.Types.DocumentArray(permissionOverwrites); + return spawner; + } + + /** + * Creates a temporary voice channel for a member using the provided spawner. + * + * @param member - The guild member for whom the voice channel is being created. + * @param spawner - The voice channel spawner object containing the configuration for the channel. + * @returns A Promise that resolves to the created voice channel. + */ + private async createTemporaryVoiceChannel(member: GuildMember, spawner: VoiceChannelSpawner): Promise { + let name = `${member.displayName}${member.displayName.endsWith("s") ? "'" : "s'"} Room`; + if (spawner.name) { + name = spawner.name; + + name = interpolateString(name, { + "owner_name": member.displayName, + "owner": member.id, + "max_users": spawner.max_users, + }); + } + + spawner.permission_overwrites.push({ + id: member.id, + allow: ["ViewChannel", "Connect", "Speak", "Stream", "ManageChannels", "KickMembers"], + }); + + spawner.name = name; + spawner.owner = member.id; + return await this.createManagedVoiceChannel(member.guild, spawner); + } + + /** + * Creates a managed voice channel in the specified guild with the given options. + * + * @param guild - The Discord guild where the voice channel will be created. + * @param options - The options for creating the voice channel. + * @returns A Promise that resolves to the created VoiceChannel. + */ + private async createManagedVoiceChannel(guild: DiscordGuild, options: FilterOutFunctionKeys): Promise { + const permissionOverwrites: OverwriteData[] = options.permission_overwrites; + + permissionOverwrites.push({ + id: guild.members.me!.id, + allow: ["ViewChannel", "Connect", "Speak", "Stream", "MoveMembers", "ManageChannels", "DeafenMembers", "MuteMembers"], + }); + + // allow for Supervisors to see, join and edit the channel + for (const role of options.supervisor_roles) { + permissionOverwrites.push({ + id: role, + allow: ["ViewChannel", "Connect", "Speak", "Stream", "MoveMembers", "ManageChannels", "DeafenMembers", "MuteMembers"], + }); + } + + // Lock the channel if requested + if (options.lock_initially) { + permissionOverwrites.push({ + id: guild.roles.everyone.id, + deny: ["Connect", "Speak"], + }); + } + + // Hide the channel if requested + if (options.hide_initially) { + permissionOverwrites.push({ + id: guild.roles.everyone.id, + deny: ["ViewChannel"], + }); + } + + const bitrates: { [name in GuildPremiumTier]: number } = { + "0": 96000, // Unboosted + "1": 128000, // Boost Level 1 + "2": 256000, // Boost Level 2 + "3": 384000, // Boost Level 3 + }; + + // Create a new Voice Channel + const createdVoiceChannel = await guild.channels.create({ + name: options.name, + type: ChannelType.GuildVoice, + permissionOverwrites: permissionOverwrites, + parent: options.parent, + userLimit: options.max_users, + bitrate: bitrates[guild.premiumTier], + }) + this.app.logger.info(`Created managed voice channel "${createdVoiceChannel.name}" in guild "${guild.name}" (id: ${guild.id})`); + + // Create Database Entry + const dbGuild = await this.app.configManager.getGuildConfig(guild); + dbGuild.voice_channels.push({ + _id: createdVoiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: options.owner, + locked: options.lock_initially, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + category: options.parent, + temporary: true, + } as FilterOutFunctionKeys); + await dbGuild.save(); + this.app.logger.info(`Created database entry for managed voice channel "${createdVoiceChannel.name}" in guild "${guild.name}" (id: ${guild.id})`); + + return createdVoiceChannel; + } +} \ No newline at end of file diff --git a/src/managers/index.ts b/src/managers/index.ts index a66ab92..67ab15c 100644 --- a/src/managers/index.ts +++ b/src/managers/index.ts @@ -1,6 +1,7 @@ import CommandsManager from "./CommandsManager"; import ConfigManager from "./ConfigManager"; import QueueManager from "./QueueManager"; +import RoomManager from "./RoomManager"; import UserManager from "./UserManager"; export { @@ -8,4 +9,5 @@ export { ConfigManager, UserManager, QueueManager, + RoomManager, } \ No newline at end of file diff --git a/src/models/Queue.ts b/src/models/Queue.ts index da8cabc..3fe2891 100644 --- a/src/models/Queue.ts +++ b/src/models/Queue.ts @@ -133,6 +133,13 @@ export class Queue { return this.entries.some(x => x.discord_id === discord_id); } + /** + * Returns `true` if the Queue is Empty + */ + public isEmpty(this: DocumentType): boolean { + return this.entries.length < 1; + } + /** * Gets The Entry that has the given Discord ID * @param discord_id The Discord ID of the Entry diff --git a/src/types/errors/ChannelCouldNotBeCreatedError.ts b/src/types/errors/ChannelCouldNotBeCreatedError.ts new file mode 100644 index 0000000..d79e791 --- /dev/null +++ b/src/types/errors/ChannelCouldNotBeCreatedError.ts @@ -0,0 +1,25 @@ +/** + * An error that is thrown when a voice channel could not be created for a queue. + */ +export default class ChannelCouldNotBeCreatedError extends Error { + /** + * The name of the queue for which the channel could not be created. + */ + public queueName: string + + /** + * The name of the guild in which the channel could not be created. + */ + public guildName: string + + /** + * Creates a new ChannelCouldNotBeCreatedError instance. + * @param queueName The name of the queue for which the channel could not be created. + * @param guildName The name of the guild in which the channel could not be created. + */ + constructor(queueName: string, guildName: string) { + super(`Failed to create temporary voice channel for queue "${queueName}" in guild "${guildName}".`) + this.queueName = queueName + this.guildName = guildName + } +} \ No newline at end of file diff --git a/src/types/errors/QueueIsEmptyError.ts b/src/types/errors/QueueIsEmptyError.ts new file mode 100644 index 0000000..7e11509 --- /dev/null +++ b/src/types/errors/QueueIsEmptyError.ts @@ -0,0 +1,10 @@ +import { Queue } from "@models/Queue"; + +export default class QueueIsEmptyError extends Error { + public queue: Queue; + + constructor(queue: Queue) { + super(`The queue "${queue.name}" is empty.`); + this.queue = queue; + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index d63d603..4ef4ab7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,7 @@ import { QueueListItem } from "./QueueListItem"; import { StringReplacements } from "./StringReplacements"; import AlreadyInQueueError from "./errors/AlreadyInQueueError"; import ChannelAlreadyInfoChannelError from "./errors/ChannelAlreadyInfoChannelError"; +import ChannelCouldNotBeCreatedError from "./errors/ChannelCouldNotBeCreatedError"; import ChannelNotInfoChannelError from "./errors/ChannelNotInfoChannelError"; import CouldNotAssignRoleError from "./errors/CouldNotAssignRoleError"; import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; @@ -18,6 +19,7 @@ import InvalidEventError from "./errors/InvalidEventError"; import MissingOptionError from "./errors/MissingOptionError"; import NotInQueueError from "./errors/NotInQueueError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; +import QueueIsEmptyError from "./errors/QueueIsEmptyError"; import QueueLockedError from "./errors/QueueLockedError"; import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; import SessionHasNoQueueError from "./errors/SessionHasNoQueueError"; @@ -28,6 +30,7 @@ export { OptionRequirement, StringReplacements, QueueAlreadyExistsError, + QueueIsEmptyError, MissingOptionError, CouldNotFindChannelError, CouldNotFindQueueError, @@ -47,6 +50,7 @@ export { InvalidEventError, ChannelAlreadyInfoChannelError, ChannelNotInfoChannelError, + ChannelCouldNotBeCreatedError, GuildHasNoQueueError, EventDate, QueueListItem, diff --git a/src/utils/handleError.ts b/src/utils/handleError.ts index 9d6a7cc..254df1e 100644 --- a/src/utils/handleError.ts +++ b/src/utils/handleError.ts @@ -13,7 +13,7 @@ export function handleInteractionError(error: Error, interaction: Interaction, l const authorName = interaction.user.username const authorTag = interaction.user.tag const errorText = error.toString() || '' - logger.error(`${error.name}: ${interaction.isCommand() && interaction.commandName} on guild "${guildName}", channel "${channelName}" by ${authorName}(${authorTag})`) + logger.error(`${error.name}: ${interaction.isCommand() && interaction.commandName} on guild "${guildName}", channel "${channelName}" by ${authorName}(${authorTag})\n ${error} \n ${error.stack}`) if (errorText.includes('TypeError') || errorText.includes('RangeError')) { logger.error(error) } From 842801d53b5545e5eb8bdeb1d22223a49b068827 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 27 Apr 2024 18:52:27 +0200 Subject: [PATCH 115/130] Add tutor queue next tests --- .../tutor/queue/TutorQueueNextCommand.test.ts | 211 ++++++++++++++++++ .../tutor/queue/TutorQueueNextCommand.ts | 15 +- src/managers/DmManager.test.ts | 6 +- src/managers/RoomManager.ts | 1 + tests/mockDiscord.ts | 11 +- 5 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 src/commands/tutor/queue/TutorQueueNextCommand.test.ts diff --git a/src/commands/tutor/queue/TutorQueueNextCommand.test.ts b/src/commands/tutor/queue/TutorQueueNextCommand.test.ts new file mode 100644 index 0000000..f8968f0 --- /dev/null +++ b/src/commands/tutor/queue/TutorQueueNextCommand.test.ts @@ -0,0 +1,211 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, ApplicationCommandOptionType, Colors, ChannelType, GuildMember, VoiceState } from "discord.js"; +import TutorQueueNextCommand from "./TutorQueueNextCommand"; +import { createQueue, createSession } from "@tests/testutils"; +import { GuildModel } from "@models/Models"; + +describe("TutorQueueNextCommand", () => { + const command = TutorQueueNextCommand; + const discord = new MockDiscord(); + let commandInstance: TutorQueueNextCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + jest.spyOn(discord.getApplication().dmManager, "sendQueuePickedMessage").mockResolvedValue(); + jest.spyOn(interaction.guild!.channels, "create").mockImplementation(() => Promise.resolve(discord.mockVoiceChannel(interaction.guild!)) as any); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("next"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Accepts the next student(s) in the queue."); + }) + + it("should have one option", () => { + expect(command.options).toHaveLength(1); + expect(command.options[0]).toEqual({ + name: "amount", + description: "The amount of students to accept.", + type: ApplicationCommandOptionType.Integer, + required: false, + default: 1, + }); + }) + + it("should defer the interaction", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply') + await commandInstance.execute() + + expect(deferSpy).toHaveBeenCalled() + }) + + it("should create a tutoring voice channel", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const member = discord.mockGuildMember(undefined, interaction.guild!) + const queueEntries = [{ discord_id: member.id, joinedAt: Date.now().toString() }]; + const queue = await createQueue(dbGuild, { entries: queueEntries }); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const createVoiceChannelSpy = jest.spyOn(interaction.guild!.channels, "create").mockImplementation(() => Promise.resolve(discord.mockVoiceChannel(interaction.guild!)) as any); + const saveSpy = jest.spyOn(GuildModel.prototype as any, "save") + + await commandInstance.execute(); + + expect(createVoiceChannelSpy).toHaveBeenCalledTimes(1); + expect(saveSpy).toHaveBeenCalledTimes(2); + const saveSpyRes = await saveSpy.mock.results[0].value; + expect(saveSpyRes.voice_channels).toHaveLength(1); + expect(saveSpyRes.voice_channels[0].channel_type).toBe(ChannelType.GuildVoice); + expect(saveSpyRes.voice_channels[0].owner).toBe(interaction.user.id); + }) + + it.each([null, 1, 2, 3])("should move the next %s students and tutor to the tutoring voice channel", async (amount) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + var members: GuildMember[] = []; + for (let i = 0; i < (amount ?? 1); i++) { + const member = discord.mockGuildMember(undefined, interaction.guild!); + members.push(member); + } + const queueEntries = members.map(member => ({ + discord_id: member.id, + joinedAt: Date.now().toString(), + })); + const queue = await createQueue(dbGuild, { entries: queueEntries }); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const tutoringVoiceChannel = discord.mockVoiceChannel(interaction.guild!); + jest.spyOn(interaction.guild!.channels, "create").mockImplementation(() => Promise.resolve(tutoringVoiceChannel) as any); + const moveMembersToRoomSpy = jest.spyOn(discord.getApplication().roomManager, "moveMembersToRoom"); + const setVoiceChannelSpy = jest.spyOn(VoiceState.prototype, "setChannel").mockResolvedValue({} as any); + + if (amount) { + interaction.options.get = jest.fn().mockReturnValue({ value: amount }); + } + + await commandInstance.execute(); + + expect(moveMembersToRoomSpy).toHaveBeenCalledTimes(1); + expect(setVoiceChannelSpy).toHaveBeenCalledTimes(amount ? amount + 1 : 2); + expect(setVoiceChannelSpy).toHaveBeenCalledWith(tutoringVoiceChannel); + }) + + it.each([null, 1, 3])("should send an embed with the next %s students", async (amount) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + var members: GuildMember[] = []; + for (let i = 0; i < (amount ?? 1); i++) { + const member = discord.mockGuildMember(undefined, interaction.guild!); + members.push(member); + } + const queueEntries = members.map(member => ({ + discord_id: member.id, + joinedAt: Date.now().toString(), + })); + const queue = await createQueue(dbGuild, { entries: queueEntries }); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + if (amount) { + interaction.options.get = jest.fn().mockReturnValue({ value: amount }); + } + + const tutoringVoiceChannel = discord.mockVoiceChannel(interaction.guild!); + jest.spyOn(interaction.guild!.channels, "create").mockImplementation(() => Promise.resolve(tutoringVoiceChannel) as any); + const replySpy = jest.spyOn(interaction, 'editReply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: `Next ${amount && amount > 1 ? "Students" : "Student"}`, + description: `Please join ${tutoringVoiceChannel} if you are not automatically moved.\nYour Participant${amount && amount > 1 ? "s" : ""}: ${members.join(", ")}`, + color: Colors.Green, + } + }] + })); + }) + + it.each([null, 1, 3])("should notify the %s picked students", async (amount) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + var members: GuildMember[] = []; + for (let i = 0; i < (amount ?? 1); i++) { + const member = discord.mockGuildMember(undefined, interaction.guild!); + members.push(member); + } + const queueEntries = members.map(member => ({ + discord_id: member.id, + joinedAt: Date.now().toString(), + })); + const queue = await createQueue(dbGuild, { entries: queueEntries }); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const sendQueuePickedMessageSpy = jest.spyOn(discord.getApplication().dmManager, "sendQueuePickedMessage").mockResolvedValue(); + + if (amount) { + interaction.options.get = jest.fn().mockReturnValue({ value: amount }); + } + + await commandInstance.execute(); + + expect(sendQueuePickedMessageSpy).toHaveBeenCalledTimes(amount ?? 1); + }) + + it("should reply with an error about an empty queue if the queue is empty", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: `The queue "${queue.name}" is empty.`, + color: Colors.Red, + } + }] + })); + }) + + it("should fail if the user has no active session", async () => { + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You do not have an active session.", + color: Colors.Red, + } + }] + })); + }) + + it("should fail if the session has no queue", async () => { + await createSession(null, interaction.user.id, interaction.guild!.id); + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "Your session has no queue.", + color: Colors.Red, + } + }] + })); + }) +}); \ No newline at end of file diff --git a/src/commands/tutor/queue/TutorQueueNextCommand.ts b/src/commands/tutor/queue/TutorQueueNextCommand.ts index b035819..611a8a5 100644 --- a/src/commands/tutor/queue/TutorQueueNextCommand.ts +++ b/src/commands/tutor/queue/TutorQueueNextCommand.ts @@ -3,7 +3,7 @@ import { Guild } from "@models/Guild"; import { QueueEntry } from "@models/QueueEntry"; import { Session } from "@models/Session"; import { ChannelCouldNotBeCreatedError, InteractionNotInGuildError, QueueIsEmptyError, SessionHasNoQueueError, UserHasNoActiveSessionError } from "@types"; -import { ApplicationCommandOptionType, EmbedBuilder, GuildMember, VoiceChannel } from "discord.js"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder, GuildMember, VoiceChannel } from "discord.js"; import { DocumentType } from "@typegoose/typegoose"; import { Queue } from "@models/Queue"; @@ -34,6 +34,7 @@ export default class TutorQueueNextCommand extends BaseCommand { private dbSession!: DocumentType; public async execute(): Promise { + await this.defer(); try { const amount = parseInt(this.getOptionValue(TutorQueueNextCommand.options[0])); await this.loadGuildAndQueue(); @@ -54,21 +55,23 @@ export default class TutorQueueNextCommand extends BaseCommand { } else { throw error; } - } + } } private mountTutorQueueNextEmbed(students: GuildMember[], tutoringVoiceChannel: VoiceChannel): EmbedBuilder { const studentsString = students.join(", "); return new EmbedBuilder() - .setTitle("Next Students") - .setDescription(`Please join ${tutoringVoiceChannel} if you are not automatically moved.\nYour Participant${students.length > 1 ? "s" : ""}: ${studentsString}`); + .setTitle(`Next ${students.length > 1 ? "Students" : "Student"}`) + .setDescription(`Please join ${tutoringVoiceChannel} if you are not automatically moved.\nYour Participant${students.length > 1 ? "s" : ""}: ${studentsString}`) + .setColor(Colors.Green); } private mountErrorEmbed(error: Error): EmbedBuilder { if (error instanceof UserHasNoActiveSessionError || error instanceof SessionHasNoQueueError || error instanceof QueueIsEmptyError || error instanceof ChannelCouldNotBeCreatedError) { return new EmbedBuilder() .setTitle("Error") - .setDescription(error.message); + .setDescription(error.message) + .setColor(Colors.Red); } throw error; } @@ -109,7 +112,7 @@ export default class TutorQueueNextCommand extends BaseCommand { const queueMembers = this.dbQueue.getSortedEntries(amount); // Get discord members - return queueMembers.map(queueMember => this.interaction.guild!.members.resolve(queueMember.discord_id)!); + return await Promise.all(queueMembers.map(async queueMember => this.interaction.guild!.members.fetch(queueMember.discord_id)!)); } private getNextRoomNumber(): number { diff --git a/src/managers/DmManager.test.ts b/src/managers/DmManager.test.ts index b242cbe..a6968f5 100644 --- a/src/managers/DmManager.test.ts +++ b/src/managers/DmManager.test.ts @@ -161,11 +161,7 @@ describe("DmManager", () => { it("should send a message to the user", async () => { const dmSpy = jest.spyOn(user, "createDM").mockResolvedValue(dmChannel); const sendSpy = jest.spyOn(dmChannel, "send").mockImplementation(() => Promise.resolve({} as any)) - const room: VoiceChannel = { - id: "123", - type: ChannelType.GuildVoice, - name: "Room", - } as any + const room = discord.mockVoiceChannel(discord.mockGuild()) await dmManager.sendQueuePickedMessage(user, queue, room) diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index 058f775..93f0ee3 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -251,6 +251,7 @@ export default class RoomManager { afkhell: false, category: options.parent, temporary: true, + supervisors: options.supervisor_roles, } as FilterOutFunctionKeys); await dbGuild.save(); this.app.logger.info(`Created database entry for managed voice channel "${createdVoiceChannel.name}" in guild "${guild.name}" (id: ${guild.id})`); diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index d7e53e3..479e75e 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -9,7 +9,7 @@ import { mockChatInputCommandInteraction } from '@shoginn/discordjs-mock'; import "reflect-metadata" -import { APIGuildMember, APIRole, APIUser, ChannelType, ChatInputCommandInteraction, DMChannel, Guild, GuildMember, Role, TextChannel, User, VoiceState } from 'discord.js'; +import { APIGuildMember, APIRole, APIUser, ChannelType, ChatInputCommandInteraction, DMChannel, Guild, GuildMember, Role, TextChannel, User, VoiceChannel, VoiceState } from 'discord.js'; import { container, singleton } from 'tsyringe'; import { randomInt } from 'crypto'; import assert from 'assert'; @@ -47,6 +47,15 @@ export class MockDiscord { return mockTextChannel(this.app.client, guild); } + public mockVoiceChannel(guild: Guild): VoiceChannel { + return { + id: randomInt(281474976710655).toString(), + type: ChannelType.GuildVoice, + name: "test voice channel", + guild: guild, + } as any; + } + public mockDMChannel(): DMChannel { return Reflect.construct(DMChannel, [this.app.client, {}]) as DMChannel; } From 1f5c2679fb3a5d37097294c56e62734dc9ee5d04 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sat, 27 Apr 2024 20:18:12 +0200 Subject: [PATCH 116/130] Add tutor pick command --- .../tutor/queue/TutorQueuePickCommand.test.ts | 236 ++++++++++++++++++ .../tutor/queue/TutorQueuePickCommand.ts | 124 +++++++++ src/managers/RoomManager.ts | 2 +- src/types/errors/NotInQueueError.ts | 10 +- 4 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 src/commands/tutor/queue/TutorQueuePickCommand.test.ts create mode 100644 src/commands/tutor/queue/TutorQueuePickCommand.ts diff --git a/src/commands/tutor/queue/TutorQueuePickCommand.test.ts b/src/commands/tutor/queue/TutorQueuePickCommand.test.ts new file mode 100644 index 0000000..b9f685a --- /dev/null +++ b/src/commands/tutor/queue/TutorQueuePickCommand.test.ts @@ -0,0 +1,236 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, ApplicationCommandOptionType, ChannelType, GuildMember, VoiceState, Colors } from "discord.js"; +import TutorQueuePickCommand from "./TutorQueuePickCommand"; +import { GuildModel } from "@models/Models"; +import { createQueue, createSession } from "@tests/testutils"; + +describe("TutorQueuePickCommand", () => { + const command = TutorQueuePickCommand; + const discord = new MockDiscord(); + let commandInstance: TutorQueuePickCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + jest.spyOn(discord.getApplication().dmManager, "sendQueuePickedMessage").mockResolvedValue(); + jest.spyOn(interaction.guild!.channels, "create").mockImplementation(() => Promise.resolve(discord.mockVoiceChannel(interaction.guild!)) as any); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("pick"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Picks a student from the queue."); + }) + + it("should have one option", () => { + expect(command.options).toHaveLength(1); + expect(command.options[0]).toEqual({ + name: "member", + description: "The member of the queue to pick.", + type: ApplicationCommandOptionType.User, + required: true, + }); + }) + + it("should defer the interaction", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply') + await commandInstance.execute() + + expect(deferSpy).toHaveBeenCalled() + }) + + it("should create a tutoring voice channel", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const member = discord.mockGuildMember(undefined, interaction.guild!) + const queueEntries = [{ discord_id: member.id, joinedAt: Date.now().toString() }]; + const queue = await createQueue(dbGuild, { entries: queueEntries }); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + interaction.options.get = jest.fn().mockReturnValue({ value: member.id }); + + const createVoiceChannelSpy = jest.spyOn(interaction.guild!.channels, "create").mockImplementation(() => Promise.resolve(discord.mockVoiceChannel(interaction.guild!)) as any); + const saveSpy = jest.spyOn(GuildModel.prototype as any, "save") + + await commandInstance.execute(); + + expect(createVoiceChannelSpy).toHaveBeenCalledTimes(1); + expect(saveSpy).toHaveBeenCalledTimes(2); + const saveSpyRes = await saveSpy.mock.results[0].value; + expect(saveSpyRes.voice_channels).toHaveLength(1); + expect(saveSpyRes.voice_channels[0].channel_type).toBe(ChannelType.GuildVoice); + expect(saveSpyRes.voice_channels[0].owner).toBe(interaction.user.id); + }) + + it("should move the picked user and the tutor to the tutoring voice channel", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const member = discord.mockGuildMember(undefined, interaction.guild!) + const queueEntries = [{ discord_id: member.id, joinedAt: Date.now().toString() }]; + const queue = await createQueue(dbGuild, { entries: queueEntries }); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + interaction.options.get = jest.fn().mockReturnValue({ value: member.id }); + + const tutoringVoiceChannel = discord.mockVoiceChannel(interaction.guild!); + jest.spyOn(interaction.guild!.channels, "create").mockImplementation(() => Promise.resolve(tutoringVoiceChannel) as any); + const moveMembersToRoomSpy = jest.spyOn(discord.getApplication().roomManager, "moveMembersToRoom"); + const setVoiceChannelSpy = jest.spyOn(VoiceState.prototype, "setChannel").mockResolvedValue({} as any); + + await commandInstance.execute(); + + expect(moveMembersToRoomSpy).toHaveBeenCalledTimes(1); + expect(setVoiceChannelSpy).toHaveBeenCalledTimes(2); + expect(setVoiceChannelSpy).toHaveBeenCalledWith(tutoringVoiceChannel); + }) + + it("should send an embed with the picked user", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + var members: GuildMember[] = []; + for (let i = 0; i < 5; i++) { + const member = discord.mockGuildMember(undefined, interaction.guild!); + members.push(member); + } + const queueEntries = members.map(member => ({ + discord_id: member.id, + joinedAt: Date.now().toString(), + })); + const queue = await createQueue(dbGuild, { entries: queueEntries }); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const pickedMember = members[Math.floor(Math.random() * members.length)]; + interaction.options.get = jest.fn().mockReturnValue({ value: pickedMember.id }); + + const tutoringVoiceChannel = discord.mockVoiceChannel(interaction.guild!); + jest.spyOn(interaction.guild!.channels, "create").mockImplementation(() => Promise.resolve(tutoringVoiceChannel) as any); + const replySpy = jest.spyOn(interaction, 'editReply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Student Picked", + description: `You picked ${pickedMember} from the queue.\nPlease join ${tutoringVoiceChannel} if you are not automatically moved.`, + color: Colors.Green, + } + }] + })); + }) + + it("should notify the picked user", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + var members: GuildMember[] = []; + for (let i = 0; i < 5; i++) { + const member = discord.mockGuildMember(undefined, interaction.guild!); + members.push(member); + } + const queueEntries = members.map(member => ({ + discord_id: member.id, + joinedAt: Date.now().toString(), + })); + const queue = await createQueue(dbGuild, { entries: queueEntries }); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const pickedMember = members[Math.floor(Math.random() * members.length)]; + interaction.options.get = jest.fn().mockReturnValue({ value: pickedMember.id }); + + const sendQueuePickedMessageSpy = jest.spyOn(discord.getApplication().dmManager, "sendQueuePickedMessage").mockResolvedValue(); + + await commandInstance.execute(); + + expect(sendQueuePickedMessageSpy).toHaveBeenCalledTimes(1); + }) + + it("should reply with an error if the picked user is not in the queue", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + var members: GuildMember[] = []; + for (let i = 0; i < 5; i++) { + const member = discord.mockGuildMember(undefined, interaction.guild!); + members.push(member); + } + const queueEntries = members.map(member => ({ + discord_id: member.id, + joinedAt: Date.now().toString(), + })); + const queue = await createQueue(dbGuild, { entries: queueEntries }); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const pickedMember = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: pickedMember.id }); + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: `${pickedMember} is currently not in the queue "${queue.name}".`, + color: Colors.Red, + } + }] + })); + }) + + it("should reply with an error about an empty queue if the queue is empty", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const queue = await createQueue(dbGuild); + await createSession(queue, interaction.user.id, interaction.guild!.id); + + const member = discord.mockGuildMember(undefined, interaction.guild!) + interaction.options.get = jest.fn().mockReturnValue({ value: member.id }); + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: `The queue "${queue.name}" is empty.`, + color: Colors.Red, + } + }] + })); + }) + + it("should fail if the user has no active session", async () => { + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You do not have an active session.", + color: Colors.Red, + } + }] + })); + }) + + it("should fail if the session has no queue", async () => { + await createSession(null, interaction.user.id, interaction.guild!.id); + + const replySpy = jest.spyOn(interaction, 'editReply') + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "Your session has no queue.", + color: Colors.Red, + } + }] + })); + }) +}); \ No newline at end of file diff --git a/src/commands/tutor/queue/TutorQueuePickCommand.ts b/src/commands/tutor/queue/TutorQueuePickCommand.ts new file mode 100644 index 0000000..6e3c176 --- /dev/null +++ b/src/commands/tutor/queue/TutorQueuePickCommand.ts @@ -0,0 +1,124 @@ +import { BaseCommand } from "@baseCommand"; +import { Queue } from "@models/Queue"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder, GuildMember, VoiceChannel } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { Session } from "@models/Session"; +import { Guild } from "@models/Guild"; +import { InteractionNotInGuildError, UserHasNoActiveSessionError, SessionHasNoQueueError, QueueIsEmptyError, NotInQueueError, ChannelCouldNotBeCreatedError } from "@types"; + +export default class TutorQueuePickCommand extends BaseCommand { + public static name = "pick"; + public static description = "Picks a student from the queue."; + public static options = [{ + name: "member", + description: "The member of the queue to pick.", + type: ApplicationCommandOptionType.User, + required: true, + }]; + + /** + * The guild saved in the database. + */ + private dbGuild!: DocumentType; + + /** + * The queue saved in the database. + */ + private dbQueue!: DocumentType; + + /** + * The session saved in the database. + */ + private dbSession!: DocumentType; + + public async execute(): Promise { + await this.defer(); + try { + await this.loadGuildAndQueue(); + const tutor = this.interaction.member as GuildMember; + const studentId = this.getOptionValue(TutorQueuePickCommand.options[0]); + const student = await this.getStudent(studentId); + const nextRoomNumber = this.getNextRoomNumber(); + const tutoringVoiceChannel = await this.app.roomManager.createTutoringVoiceChannel(this.dbGuild, this.dbQueue, tutor, [student], nextRoomNumber); + // Reload the queue to avoid version errors + await this.loadGuildAndQueue(); + await this.app.queueManager.notifyPickedStudents(this.dbQueue, [student], tutor, tutoringVoiceChannel); + await this.app.roomManager.moveMembersToRoom([student, tutor], tutoringVoiceChannel, tutor, this.dbQueue); + const embed = this.mountTutorQueuePickEmbed(student, tutoringVoiceChannel); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountTutorQueuePickEmbed(student: GuildMember, tutoringVoiceChannel: VoiceChannel): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Student Picked") + .setDescription(`You picked ${student} from the queue.\nPlease join ${tutoringVoiceChannel} if you are not automatically moved.`) + .setColor(Colors.Green); + return embed; + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof UserHasNoActiveSessionError || error instanceof SessionHasNoQueueError || error instanceof QueueIsEmptyError || error instanceof NotInQueueError || error instanceof ChannelCouldNotBeCreatedError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private async loadGuildAndQueue(): Promise { + if (!this.interaction.guild) { + throw new InteractionNotInGuildError(this.interaction); + } + + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild); + const dbUser = await this.app.userManager.getUser(this.interaction.user); + + // Check if the user has an active session + const dbSession = (await dbUser.getActiveSessions()).find(session => session.guild === this.dbGuild.id); + if (!dbSession) { + throw new UserHasNoActiveSessionError(); + } + if (!dbSession.queue) { + throw new SessionHasNoQueueError(this.dbSession); + } + + // Get the queue + const queueId = dbSession.queue; + this.dbQueue = this.app.queueManager.getQueueById(this.dbGuild, queueId); + this.dbSession = dbSession; + } + + private async getStudent(studentId: string): Promise { + // Kick all students no longer on the server + await this.app.queueManager.kickNonServerMembers(await this.interaction.guild!.members.fetch(), this.dbQueue); + + // Check if the queue is empty + if (this.dbQueue.isEmpty()) { + throw new QueueIsEmptyError(this.dbQueue); + } + + // Get the student + if (!this.dbQueue.contains(studentId)) { + throw new NotInQueueError(this.dbQueue.name, studentId); + } + + // Select the student from the queue + const queueMember = this.dbQueue.getEntry(studentId)!; + + // Get discord member + return await this.interaction.guild!.members.fetch(queueMember.discord_id); + } + + private getNextRoomNumber(): number { + return this.dbSession.getNumberOfRooms() + 1; + } +} \ No newline at end of file diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index 93f0ee3..1460176 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -50,7 +50,7 @@ export default class RoomManager { } as VoiceChannelEvent); this.app.logger.info(`Moved member "${member.displayName}" (id: ${member.id}) to room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`) } catch (error) { - this.app.logger.error(`Could not move member "${member.displayName}" (id: ${member.id}) to room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); + this.app.logger.info(`Could not move member "${member.displayName}" (id: ${member.id}) to room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); continue; } } diff --git a/src/types/errors/NotInQueueError.ts b/src/types/errors/NotInQueueError.ts index f8bb88a..6ff23b4 100644 --- a/src/types/errors/NotInQueueError.ts +++ b/src/types/errors/NotInQueueError.ts @@ -7,12 +7,18 @@ export default class NotInQueueError extends Error { */ public queueName: string | undefined; + /** + * The user that is not in the queue. + */ + public user: string | undefined; + /** * Creates a new instance of the NotInQueueError class. * @param queueName The name of the queue in which the user is not in. If the user is not in any queue, this parameter should be undefined. */ - constructor(queueName?: string) { - super(`You are currently not in ${queueName ? `the queue "${queueName}"` : `a queue`}.`); + constructor(queueName?: string, user?: string) { + super(`${user ? `<@${user}> is` : "You are"} currently not in ${queueName ? `the queue "${queueName}"` : `a queue`}.`); this.queueName = queueName; + this.user = user; } } \ No newline at end of file From c46332874fe6c73b3881fff4c28df97bd1f8e577 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 28 Apr 2024 12:34:03 +0200 Subject: [PATCH 117/130] Fix mongo version error --- src/managers/ConfigManager.ts | 5 ++++- src/managers/QueueManager.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index ed5bd22..87f9ebf 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -14,7 +14,10 @@ export default class ConfigManager { this.app = app; } - public async getGuildConfig(guild: DiscordGuild): Promise> { + public async getGuildConfig(guild: DiscordGuild | string): Promise> { + if (typeof guild === "string") { + guild = await this.app.client.guilds.fetch(guild); + } var guildModel = await GuildModel.findById(guild.id); if (!guildModel) { this.app.logger.debug(`Config for guild "${guild.name}" (id: ${guild.id}) does not exist. Creating...`) diff --git a/src/managers/QueueManager.ts b/src/managers/QueueManager.ts index ec82621..e9b36fd 100644 --- a/src/managers/QueueManager.ts +++ b/src/managers/QueueManager.ts @@ -163,7 +163,9 @@ export default class QueueManager { setTimeout(async () => { const pendingQueueStays = this.pendingQueueStays.get(queue.id); if (pendingQueueStays && pendingQueueStays.includes(user.id)) { - await this.leaveQueue(guild, user); + // Reload the guild to avoid version errors + const reloadedGuild = await this.app.configManager.getGuildConfig(guild._id); + await this.leaveQueue(reloadedGuild, user); // Remove the user from the pending queue stays this.pendingQueueStays.set(queue.id, this.pendingQueueStays.get(queue.id)!.filter(id => id !== user.id)); this.app.logger.info(`User "${user.username}" (id: ${user.id}) left queue "${queue.name}" after disconnect timeout`); From d18cd71394f227bba29d6d999d0b317f6cb4b0a6 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 28 Apr 2024 13:06:26 +0200 Subject: [PATCH 118/130] Add voice close command --- src/commands/voice/VoiceCloseCommand.test.ts | 216 ++++++++++++++++++ src/commands/voice/VoiceCloseCommand.ts | 71 ++++++ src/managers/RoomManager.ts | 44 +++- src/types/errors/ChannelNotTemporaryError.ts | 5 + src/types/errors/CouldNotKickAllUsersError.ts | 5 + src/types/errors/NotInQueueError.ts | 1 + src/types/errors/NotInVoiceChannelError.ts | 15 ++ src/types/errors/UnauthorizedError.ts | 9 + src/types/index.ts | 9 + tests/mockDiscord.ts | 12 +- 10 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 src/commands/voice/VoiceCloseCommand.test.ts create mode 100644 src/commands/voice/VoiceCloseCommand.ts create mode 100644 src/types/errors/ChannelNotTemporaryError.ts create mode 100644 src/types/errors/CouldNotKickAllUsersError.ts create mode 100644 src/types/errors/NotInVoiceChannelError.ts create mode 100644 src/types/errors/UnauthorizedError.ts diff --git a/src/commands/voice/VoiceCloseCommand.test.ts b/src/commands/voice/VoiceCloseCommand.test.ts new file mode 100644 index 0000000..8207138 --- /dev/null +++ b/src/commands/voice/VoiceCloseCommand.test.ts @@ -0,0 +1,216 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChannelType, ChatInputCommandInteraction, Colors, Guild, GuildMember, VoiceChannel, VoiceState } from "discord.js"; +import VoiceCloseCommand from "./VoiceCloseCommand"; +import { mongoose } from "@typegoose/typegoose"; + +describe("VoiceCloseCommand", () => { + const command = VoiceCloseCommand; + const discord = new MockDiscord(); + let commandInstance: VoiceCloseCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("close"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Closes the temporary voice channel and kicks all members from it."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + it("should defer the interaction", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply') + await commandInstance.execute() + + expect(deferSpy).toHaveBeenCalled() + }) + + it.each(["owner", "supervisor"])("should kick all users from the voice channel when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const roomMembers = Array.from({ length: 5 }, () => discord.mockGuildMember()).concat(interaction.member as GuildMember); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: roomMembers }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + + const setVoiceChannelSpy = jest.spyOn(VoiceState.prototype, "setChannel").mockResolvedValue({} as any); + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + + await commandInstance.execute(); + + expect(setVoiceChannelSpy).toHaveBeenCalledTimes(6); + }) + + it.each(["owner", "supervisor"])("should send an embed with the voice channel name when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const roomMembers = Array.from({ length: 5 }, () => discord.mockGuildMember()).concat(interaction.member as GuildMember); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: roomMembers }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "setChannel").mockResolvedValue({} as any); + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'editReply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Voice Channel Closed", + description: `All Users were kicked from the voice channel "${voiceChannel.name}". The channel should close automatically.`, + color: Colors.Green + } + }] + })) + }) + + it("should notify if not all users could be kicked", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const roomMembers = Array.from({ length: 5 }, () => discord.mockGuildMember()).concat(interaction.member as GuildMember); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: roomMembers }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'editReply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: `Could not kick all users from the voice channel.`, + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not in a voice channel", async () => { + const replySpy = jest.spyOn(interaction, 'editReply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are currently not in a voice channel.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the voice channel is not temporary", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [interaction.member as GuildMember] }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: false, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'editReply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The voice channel is not temporary.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not authorized to close the channel", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [interaction.member as GuildMember] }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'editReply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are not authorized to close the channel.", + color: Colors.Red + } + }] + })) + }) +}) \ No newline at end of file diff --git a/src/commands/voice/VoiceCloseCommand.ts b/src/commands/voice/VoiceCloseCommand.ts new file mode 100644 index 0000000..380e444 --- /dev/null +++ b/src/commands/voice/VoiceCloseCommand.ts @@ -0,0 +1,71 @@ +import { BaseCommand } from "@baseCommand"; +import { ChannelNotTemporaryError, CouldNotKickAllUsersError, NotInVoiceChannelError, UnauthorizedError, UnauthorizedErrorReason } from "@types"; +import { Colors, EmbedBuilder, GuildMember, VoiceBasedChannel } from "discord.js"; + +export default class VoiceCloseCommand extends BaseCommand { + public static name = "close"; + public static description = "Closes the temporary voice channel and kicks all members from it."; + public static options = []; + + public async execute() { + await this.defer(); + try { + const voiceChannel = await this.getChannel() + await this.app.roomManager.kickMembersFromRoom(voiceChannel, this.interaction.member as GuildMember); + const embed = this.mountVoiceCloseEmbed(voiceChannel); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountVoiceCloseEmbed(voiceChannel: VoiceBasedChannel): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Voice Channel Closed") + .setDescription(`All Users were kicked from the voice channel "${voiceChannel.name}". The channel should close automatically.`) + .setColor(Colors.Green); + return embed; + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof UnauthorizedError || error instanceof CouldNotKickAllUsersError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private async getChannel(): Promise { + // Check if user is in Voice Channel + const member = this.interaction.member as GuildMember | null; + const channel = member?.voice.channel; + if (!member || !channel) { + this.app.logger.info("User is not in a voice channel."); + throw new NotInVoiceChannelError(); + } + + // Get channel from DB + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const dbChannel = dbGuild.voice_channels.find(vc => vc._id === channel.id); + + if (!dbChannel?.temporary) { + this.app.logger.info("Channel is not temporary."); + throw new ChannelNotTemporaryError(); + } + + // Check if user has permission to close the channel + if (!(dbChannel.owner === member.id || (dbChannel.supervisors && dbChannel.supervisors.includes(member.id)))) { + this.app.logger.info("User is not authorized to close the channel."); + throw new UnauthorizedError(UnauthorizedErrorReason.CloseChannel); + } + + return channel; + } +} \ No newline at end of file diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index 1460176..f492844 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -4,13 +4,13 @@ import { Queue } from "@models/Queue"; import { QueueEntry } from "@models/QueueEntry"; import { VoiceChannel as DatabaseVoiceChannel } from "@models/VoiceChannel"; import { VoiceChannelSpawner } from "@models/VoiceChannelSpawner"; -import { ChannelType, Guild as DiscordGuild, GuildMember, GuildPremiumTier, OverwriteData, VoiceChannel } from "discord.js"; +import { ChannelType, Guild as DiscordGuild, GuildMember, GuildPremiumTier, OverwriteData, VoiceBasedChannel, VoiceChannel } from "discord.js"; import { DocumentType, mongoose } from "@typegoose/typegoose"; import { delay, inject, injectable, singleton } from "tsyringe"; import { PermissionOverwriteData } from "@models/PermissionOverwriteData"; import { interpolateString } from "@utils/interpolateString"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; -import { ChannelCouldNotBeCreatedError } from "@types"; +import { ChannelCouldNotBeCreatedError, CouldNotKickAllUsersError } from "@types"; import { RoomModel } from "@models/Models"; import { VoiceChannelEvent } from "@models/Event"; import { Room } from "@models/Room"; @@ -37,7 +37,7 @@ export default class RoomManager { public async moveMembersToRoom(members: GuildMember[], room: VoiceChannel, emmitedBy: GuildMember, queue: Queue): Promise { let roomData = await RoomModel.findById(room.id); if (!roomData) { - this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one...`); + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one.`); roomData = await this.createRoomOnDatabase(room); } for (const member of members) { @@ -57,13 +57,49 @@ export default class RoomManager { await roomData.save(); } + /** + * Kicks all members from a voice-based channel. + * + * @param room - The voice-based channel to kick members from. + * @param emmitedBy - The guild member who initiated the kick action. + * @throws CouldNotKickAllUsersError - If not all members could be kicked from the room. + */ + public async kickMembersFromRoom(room: VoiceBasedChannel, emmitedBy: GuildMember): Promise { + let roomData = await RoomModel.findById(room.id); + if (!roomData) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one.`); + roomData = await this.createRoomOnDatabase(room); + } + + let kickedAll = true; + for (const member of room.members.values()) { + try { + const room = member.voice.channel; + await member.voice.setChannel(null); + roomData.events.push({ + emitted_by: emmitedBy.id, + reason: `Member kicked by user "${emmitedBy.displayName}" (id: ${emmitedBy.id})`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + this.app.logger.info(`Kicked member "${member.displayName}" (id: ${member.id}) from room "${room?.name}"in guild "${member.guild.name}" (id: ${member.guild.id}), initiated by "${emmitedBy.displayName}" (id: ${emmitedBy.id})`); + } catch (error) { + this.app.logger.info(`Could not kick member "${member.displayName}" (id: ${member.id}) from room "${room.name}" in guild "${member.guild.name}" (id: ${member.guild.id})`); + kickedAll = false; + continue; + } + } + if (!kickedAll) { + throw new CouldNotKickAllUsersError(); + } + } + /** * Creates a room entry in the database. * * @param room - The voice channel representing the room. * @returns A promise that resolves to the created room data. */ - private async createRoomOnDatabase(room: VoiceChannel): Promise> { + private async createRoomOnDatabase(room: VoiceBasedChannel): Promise> { const roomData = await RoomModel.create({ _id: room.id, active: true, diff --git a/src/types/errors/ChannelNotTemporaryError.ts b/src/types/errors/ChannelNotTemporaryError.ts new file mode 100644 index 0000000..534c913 --- /dev/null +++ b/src/types/errors/ChannelNotTemporaryError.ts @@ -0,0 +1,5 @@ +export default class ChannelNotTemporaryError extends Error { + constructor() { + super(`The voice channel is not temporary.`); + } +} \ No newline at end of file diff --git a/src/types/errors/CouldNotKickAllUsersError.ts b/src/types/errors/CouldNotKickAllUsersError.ts new file mode 100644 index 0000000..f7b2444 --- /dev/null +++ b/src/types/errors/CouldNotKickAllUsersError.ts @@ -0,0 +1,5 @@ +export default class CouldNotKickAllUsersError extends Error { + constructor() { + super(`Could not kick all users from the voice channel.`); + } +} \ No newline at end of file diff --git a/src/types/errors/NotInQueueError.ts b/src/types/errors/NotInQueueError.ts index 6ff23b4..8c4f0d2 100644 --- a/src/types/errors/NotInQueueError.ts +++ b/src/types/errors/NotInQueueError.ts @@ -15,6 +15,7 @@ export default class NotInQueueError extends Error { /** * Creates a new instance of the NotInQueueError class. * @param queueName The name of the queue in which the user is not in. If the user is not in any queue, this parameter should be undefined. + * @param user The user that is not in the queue. If the user themselves is not in any queue, this parameter should be undefined. */ constructor(queueName?: string, user?: string) { super(`${user ? `<@${user}> is` : "You are"} currently not in ${queueName ? `the queue "${queueName}"` : `a queue`}.`); diff --git a/src/types/errors/NotInVoiceChannelError.ts b/src/types/errors/NotInVoiceChannelError.ts new file mode 100644 index 0000000..60a3723 --- /dev/null +++ b/src/types/errors/NotInVoiceChannelError.ts @@ -0,0 +1,15 @@ +export default class NotInVoiceChannelError extends Error { + /** + * The user that is not in the voice channel. + */ + public user: string | undefined; + + /** + * Creates a new instance of the NotInVoiceChannelError class. + * @param user The user that is not in the voice channel. If the user themselves is not in any voice channel, this parameter should be undefined. + */ + constructor(user?: string) { + super(`${user ? `<@${user}> is` : "You are"} currently not in a voice channel.`); + this.user = user; + } +} \ No newline at end of file diff --git a/src/types/errors/UnauthorizedError.ts b/src/types/errors/UnauthorizedError.ts new file mode 100644 index 0000000..32ea143 --- /dev/null +++ b/src/types/errors/UnauthorizedError.ts @@ -0,0 +1,9 @@ +export enum UnauthorizedErrorReason { + CloseChannel = "close the channel", +} + +export default class UnauthorizedError extends Error { + constructor(reason: UnauthorizedErrorReason) { + super(`You are not authorized to ${reason}.`); + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 4ef4ab7..ec6cfa1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,23 +6,27 @@ import AlreadyInQueueError from "./errors/AlreadyInQueueError"; import ChannelAlreadyInfoChannelError from "./errors/ChannelAlreadyInfoChannelError"; import ChannelCouldNotBeCreatedError from "./errors/ChannelCouldNotBeCreatedError"; import ChannelNotInfoChannelError from "./errors/ChannelNotInfoChannelError"; +import ChannelNotTemporaryError from "./errors/ChannelNotTemporaryError"; import CouldNotAssignRoleError from "./errors/CouldNotAssignRoleError"; import CouldNotFindChannelError from "./errors/CouldNotFindChannelError"; import CouldNotFindQueueError from "./errors/CouldNotFindQueueError"; import CouldNotFindQueueForSessionError from "./errors/CouldNotFindQueueForSessionError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; +import CouldNotKickAllUsersError from "./errors/CouldNotKickAllUsersError"; import CouldNotRemoveRoleError from "./errors/CouldNotRemoveRoleError"; import GuildHasNoQueueError from "./errors/GuildHasNoQueueError"; import InteractionNotInGuildError from "./errors/InteractionNotInGuildError"; import InvalidEventError from "./errors/InvalidEventError"; import MissingOptionError from "./errors/MissingOptionError"; import NotInQueueError from "./errors/NotInQueueError"; +import NotInVoiceChannelError from "./errors/NotInVoiceChannelError"; import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; import QueueIsEmptyError from "./errors/QueueIsEmptyError"; import QueueLockedError from "./errors/QueueLockedError"; import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; import SessionHasNoQueueError from "./errors/SessionHasNoQueueError"; +import UnauthorizedError, { UnauthorizedErrorReason } from "./errors/UnauthorizedError"; import UserHasActiveSessionError from "./errors/UserHasActiveSessionError"; import UserHasNoActiveSessionError from "./errors/UserHasNoActiveSessionError"; @@ -38,9 +42,12 @@ export { CouldNotFindRoleError, CouldNotAssignRoleError, CouldNotRemoveRoleError, + CouldNotKickAllUsersError, + ChannelNotTemporaryError, RoleNotInDatabaseError, CouldNotFindTypeInFileError, NotInQueueError, + NotInVoiceChannelError, InteractionNotInGuildError, AlreadyInQueueError, UserHasActiveSessionError, @@ -54,4 +61,6 @@ export { GuildHasNoQueueError, EventDate, QueueListItem, + UnauthorizedError, + UnauthorizedErrorReason, } \ No newline at end of file diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 479e75e..367c02e 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -9,7 +9,7 @@ import { mockChatInputCommandInteraction } from '@shoginn/discordjs-mock'; import "reflect-metadata" -import { APIGuildMember, APIRole, APIUser, ChannelType, ChatInputCommandInteraction, DMChannel, Guild, GuildMember, Role, TextChannel, User, VoiceChannel, VoiceState } from 'discord.js'; +import { APIGuildMember, APIRole, APIUser, ChannelType, ChatInputCommandInteraction, Collection, DMChannel, Guild, GuildMember, Role, TextChannel, User, VoiceChannel, VoiceState } from 'discord.js'; import { container, singleton } from 'tsyringe'; import { randomInt } from 'crypto'; import assert from 'assert'; @@ -47,12 +47,18 @@ export class MockDiscord { return mockTextChannel(this.app.client, guild); } - public mockVoiceChannel(guild: Guild): VoiceChannel { - return { + public mockVoiceChannel(guild: Guild, { + members = [], + }: { + members?: GuildMember[], + } = {}): VoiceChannel { + return { id: randomInt(281474976710655).toString(), type: ChannelType.GuildVoice, name: "test voice channel", guild: guild, + members: new Collection(members.map(member => [member.id, member])), + } as any; } From b4940f8338c3d225de6fff5932f3495425b65560 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 28 Apr 2024 16:25:17 +0200 Subject: [PATCH 119/130] Add voice kick command --- src/commands/voice/VoiceCloseCommand.test.ts | 2 +- src/commands/voice/VoiceCloseCommand.ts | 4 +- src/commands/voice/VoiceKickCommand.test.ts | 302 ++++++++++++++++++ src/commands/voice/VoiceKickCommand.ts | 111 +++++++ src/managers/RoomManager.ts | 49 ++- src/types/errors/CouldNotKickAllUsersError.ts | 5 - src/types/errors/CouldNotKickUserError.ts | 15 + src/types/errors/NotInVoiceChannelError.ts | 11 +- src/types/errors/UnauthorizedError.ts | 2 + src/types/index.ts | 4 +- tests/mockDiscord.ts | 11 +- 11 files changed, 495 insertions(+), 21 deletions(-) create mode 100644 src/commands/voice/VoiceKickCommand.test.ts create mode 100644 src/commands/voice/VoiceKickCommand.ts delete mode 100644 src/types/errors/CouldNotKickAllUsersError.ts create mode 100644 src/types/errors/CouldNotKickUserError.ts diff --git a/src/commands/voice/VoiceCloseCommand.test.ts b/src/commands/voice/VoiceCloseCommand.test.ts index 8207138..fa8dfa2 100644 --- a/src/commands/voice/VoiceCloseCommand.test.ts +++ b/src/commands/voice/VoiceCloseCommand.test.ts @@ -124,7 +124,7 @@ describe("VoiceCloseCommand", () => { embeds: [{ data: { title: "Error", - description: `Could not kick all users from the voice channel.`, + description: `Could not kick user ${roomMembers[0]} from the voice channel.`, color: Colors.Red } }] diff --git a/src/commands/voice/VoiceCloseCommand.ts b/src/commands/voice/VoiceCloseCommand.ts index 380e444..4ea815b 100644 --- a/src/commands/voice/VoiceCloseCommand.ts +++ b/src/commands/voice/VoiceCloseCommand.ts @@ -1,5 +1,5 @@ import { BaseCommand } from "@baseCommand"; -import { ChannelNotTemporaryError, CouldNotKickAllUsersError, NotInVoiceChannelError, UnauthorizedError, UnauthorizedErrorReason } from "@types"; +import { ChannelNotTemporaryError, CouldNotKickUserError, NotInVoiceChannelError, UnauthorizedError, UnauthorizedErrorReason } from "@types"; import { Colors, EmbedBuilder, GuildMember, VoiceBasedChannel } from "discord.js"; export default class VoiceCloseCommand extends BaseCommand { @@ -33,7 +33,7 @@ export default class VoiceCloseCommand extends BaseCommand { } private mountErrorEmbed(error: Error): EmbedBuilder { - if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof UnauthorizedError || error instanceof CouldNotKickAllUsersError) { + if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof UnauthorizedError || error instanceof CouldNotKickUserError) { return new EmbedBuilder() .setTitle("Error") .setDescription(error.message) diff --git a/src/commands/voice/VoiceKickCommand.test.ts b/src/commands/voice/VoiceKickCommand.test.ts new file mode 100644 index 0000000..a504f9b --- /dev/null +++ b/src/commands/voice/VoiceKickCommand.test.ts @@ -0,0 +1,302 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, ApplicationCommandOptionType, VoiceState, ChannelType, Colors, PermissionOverwriteManager } from "discord.js"; +import VoiceKickCommand from "./VoiceKickCommand"; +import { mongoose } from "@typegoose/typegoose"; + +describe("VoiceKickCommand", () => { + const command = VoiceKickCommand; + const discord = new MockDiscord(); + let commandInstance: VoiceKickCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("kick"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Kicks a user from the voice channel."); + }) + + it("should have one option", () => { + expect(command.options).toHaveLength(1); + expect(command.options[0]).toEqual({ + name: "member", + description: "The member to kick from the voice channel.", + type: ApplicationCommandOptionType.User, + required: true + }); + }) + + it.each(["owner", "supervisor"])("should kick the user from the voice channel when the initiating user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); + console.log(memberToKick.id); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [memberToKick] }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + const setVoiceChannelSpy = jest.spyOn(VoiceState.prototype, "setChannel").mockResolvedValue({} as any); + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToKick.id }); + + await commandInstance.execute(); + + expect(setVoiceChannelSpy).toHaveBeenCalledTimes(1); + expect(setVoiceChannelSpy).toHaveBeenCalledWith(null); + }); + + it.each(["owner", "supervisor"])("should remove the kicked user from the permission overwrites when the initiating user is %s of the channel", async (userRole) => { + let dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [memberToKick] }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array([memberToKick.id]), + afkhell: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "setChannel").mockResolvedValue({} as any); + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const deletePermissionOverwritesSpy = jest.spyOn(voiceChannel.permissionOverwrites, "delete").mockResolvedValue({} as any); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToKick.id }); + + await commandInstance.execute(); + + dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + expect(deletePermissionOverwritesSpy).toHaveBeenCalledTimes(1); + expect(deletePermissionOverwritesSpy).toHaveBeenCalledWith(memberToKick.id); + expect(dbGuild.voice_channels[0].permitted).not.toContain(memberToKick.id); + }) + + it.each(["owner", "supervisor"])("should send an embed with the voice channel name when the initiating user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [memberToKick] }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "setChannel").mockResolvedValue({} as any); + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply'); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToKick.id }); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "User Kicked", + description: `User ${memberToKick} was kicked from the voice channel.`, + color: Colors.Green + } + }] + })); + }) + + it("should notify if the user could not be kicked", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [memberToKick] }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + supervisors: [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply'); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToKick.id }); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: `Could not kick user ${memberToKick} from the voice channel.`, + color: Colors.Red + } + }] + })); + }) + + it("should fail if the user to kick is the owner of the channel", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [memberToKick] }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: memberToKick.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + supervisors: [interaction.user.id], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply'); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToKick.id }); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are not authorized to kick the owner of the channel.", + color: Colors.Red + } + }] + })); + }) + + + it("should fail if the user is not in a voice channel", async () => { + const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: memberToKick.id }); + + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are currently not in a voice channel.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the voice channel is not temporary", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: memberToKick.id }); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [memberToKick] }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: false, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The voice channel is not temporary.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not authorized to close the channel", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: memberToKick.id }); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [memberToKick] }); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are not authorized to kick a member from the channel.", + color: Colors.Red + } + }] + })) + }) +}); \ No newline at end of file diff --git a/src/commands/voice/VoiceKickCommand.ts b/src/commands/voice/VoiceKickCommand.ts new file mode 100644 index 0000000..268e3e3 --- /dev/null +++ b/src/commands/voice/VoiceKickCommand.ts @@ -0,0 +1,111 @@ +import { BaseCommand } from "@baseCommand"; +import { Guild } from "@models/Guild"; +import { VoiceChannel } from "@models/VoiceChannel"; +import { NotInVoiceChannelError, ChannelNotTemporaryError, UnauthorizedError, UnauthorizedErrorReason, CouldNotKickUserError } from "@types"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder, GuildMember, VoiceBasedChannel } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; + +export default class VoiceKickCommand extends BaseCommand { + public static name = "kick"; + public static description = "Kicks a user from the voice channel."; + public static options = [{ + name: "member", + description: "The member to kick from the voice channel.", + type: ApplicationCommandOptionType.User, + required: true + }]; + + /** + * The Guild from the Database + */ + private dbGuild!: DocumentType; + + public async execute(): Promise { + try { + const memberToKick = await this.getMemberToKick(); + console.log(memberToKick.id); + const { voiceChannel, databaseVoiceChannel } = await this.getChannel(memberToKick); + this.checkIfUserToKickIsOwner(memberToKick, databaseVoiceChannel); + await this.app.roomManager.kickMemberFromRoom(memberToKick, voiceChannel, this.interaction.member as GuildMember); + await this.removeKickedMemberPrivileges(memberToKick, voiceChannel, databaseVoiceChannel); + const embed = this.mountVoiceKickEmbed(memberToKick); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountVoiceKickEmbed(member: GuildMember): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("User Kicked") + .setDescription(`User ${member} was kicked from the voice channel.`) + .setColor(Colors.Green); + return embed; + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof UnauthorizedError || error instanceof CouldNotKickUserError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private async removeKickedMemberPrivileges(member: GuildMember, voiceChannel: VoiceBasedChannel, databaseVoiceChannel: VoiceChannel): Promise { + // Change permitted users in database + if (databaseVoiceChannel.permitted.includes(member.id)) { + databaseVoiceChannel.permitted.splice(databaseVoiceChannel.permitted.indexOf(member.id), 1); + } + await this.dbGuild.save(); + + // Remove user from permission overwrites + await voiceChannel.permissionOverwrites.delete(member.id); + } + + private async getMemberToKick(): Promise { + const memberId = this.getOptionValue(VoiceKickCommand.options[0]); + console.log(memberId); + return await this.interaction.guild?.members.fetch(memberId)!; + } + + private async getChannel(memberToKick: GuildMember): Promise<{ voiceChannel: VoiceBasedChannel, databaseVoiceChannel: VoiceChannel }> { + // Check if user is in Voice Channel + const member = this.interaction.member as GuildMember | null; + const channel = member?.voice.channel; + if (!member || !channel) { + this.app.logger.info("User is not in a voice channel."); + throw new NotInVoiceChannelError(); + } + + // Get channel from DB + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const dbChannel = this.dbGuild.voice_channels.find(vc => vc._id === channel.id); + + if (!dbChannel?.temporary) { + this.app.logger.info("Channel is not temporary."); + throw new ChannelNotTemporaryError(); + } + + // Check if user has permission to kick the member + if (!(dbChannel.owner === member.id || (dbChannel.supervisors && dbChannel.supervisors.includes(member.id)))) { + this.app.logger.info("User is not authorized to kick a member from the channel."); + throw new UnauthorizedError(UnauthorizedErrorReason.KickMember); + } + + return { voiceChannel: channel, databaseVoiceChannel: dbChannel}; + } + + private checkIfUserToKickIsOwner(memberToKick: GuildMember, databaseVoiceChannel: VoiceChannel): void { + if (memberToKick.id === databaseVoiceChannel.owner) { + this.app.logger.info("User is trying to kick the owner of the channel."); + throw new UnauthorizedError(UnauthorizedErrorReason.KickOwner); + } + } +} \ No newline at end of file diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index f492844..65f48e6 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -10,10 +10,11 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { PermissionOverwriteData } from "@models/PermissionOverwriteData"; import { interpolateString } from "@utils/interpolateString"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; -import { ChannelCouldNotBeCreatedError, CouldNotKickAllUsersError } from "@types"; +import { ChannelCouldNotBeCreatedError, CouldNotKickUserError, NotInVoiceChannelError } from "@types"; import { RoomModel } from "@models/Models"; import { VoiceChannelEvent } from "@models/Event"; import { Room } from "@models/Room"; +import { emit } from "process"; @injectable() @singleton() @@ -57,12 +58,48 @@ export default class RoomManager { await roomData.save(); } + /** + * Kicks a member from a room. + * + * @param member - The member to be kicked from the room. + * @param room - The room from which the member will be kicked. + * @param emmitedBy - The member who initiated the kick action. + * @throws {NotInVoiceChannelError} If the member is not in the specified voice channel. + * @throws {CouldNotKickUserError} If the member could not be kicked from the room. + */ + public async kickMemberFromRoom(member: GuildMember, room: VoiceBasedChannel, emmitedBy: GuildMember): Promise { + let roomData = await RoomModel.findById(room.id); + if (!roomData) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one.`); + roomData = await this.createRoomOnDatabase(room); + } + + if (!room.members.has(member.id)) { + this.app.logger.info(`Member "${member.displayName}" (id: ${member.id}) is not in room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); + throw new NotInVoiceChannelError(member.id, room.id); + } + + try { + await member.voice.setChannel(null); + roomData.events.push({ + emitted_by: emmitedBy.id, + reason: `Member kicked by user "${emmitedBy.displayName}" (id: ${emmitedBy.id})`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + this.app.logger.info(`Kicked member "${member.displayName}" (id: ${member.id}) from room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emmitedBy.displayName}" (id: ${emmitedBy.id})`); + } catch (error) { + console.log(error); + this.app.logger.info(`Could not kick member "${member.displayName}" (id: ${member.id}) from room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); + throw new CouldNotKickUserError(member.id); + } + } + /** * Kicks all members from a voice-based channel. * * @param room - The voice-based channel to kick members from. * @param emmitedBy - The guild member who initiated the kick action. - * @throws CouldNotKickAllUsersError - If not all members could be kicked from the room. + * @throws CouldNotKickUserError - If not all members could be kicked from the room. */ public async kickMembersFromRoom(room: VoiceBasedChannel, emmitedBy: GuildMember): Promise { let roomData = await RoomModel.findById(room.id); @@ -71,7 +108,7 @@ export default class RoomManager { roomData = await this.createRoomOnDatabase(room); } - let kickedAll = true; + let notKicked: GuildMember[] = []; for (const member of room.members.values()) { try { const room = member.voice.channel; @@ -84,12 +121,12 @@ export default class RoomManager { this.app.logger.info(`Kicked member "${member.displayName}" (id: ${member.id}) from room "${room?.name}"in guild "${member.guild.name}" (id: ${member.guild.id}), initiated by "${emmitedBy.displayName}" (id: ${emmitedBy.id})`); } catch (error) { this.app.logger.info(`Could not kick member "${member.displayName}" (id: ${member.id}) from room "${room.name}" in guild "${member.guild.name}" (id: ${member.guild.id})`); - kickedAll = false; + notKicked.push(member); continue; } } - if (!kickedAll) { - throw new CouldNotKickAllUsersError(); + if (notKicked.length > 0) { + throw new CouldNotKickUserError(notKicked[0].id) } } diff --git a/src/types/errors/CouldNotKickAllUsersError.ts b/src/types/errors/CouldNotKickAllUsersError.ts deleted file mode 100644 index f7b2444..0000000 --- a/src/types/errors/CouldNotKickAllUsersError.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default class CouldNotKickAllUsersError extends Error { - constructor() { - super(`Could not kick all users from the voice channel.`); - } -} \ No newline at end of file diff --git a/src/types/errors/CouldNotKickUserError.ts b/src/types/errors/CouldNotKickUserError.ts new file mode 100644 index 0000000..515a861 --- /dev/null +++ b/src/types/errors/CouldNotKickUserError.ts @@ -0,0 +1,15 @@ +export default class CouldNotKickUserError extends Error { + /** + * The user that could not be kicked from the voice channel. + */ + public user: string; + + /** + * Creates a new instance of the CouldNotKickUserError class. + * @param user The user that could not be kicked from the voice channel + */ + constructor(user: string) { + super(`Could not kick user <@${user}> from the voice channel.`); + this.user = user; + } +} \ No newline at end of file diff --git a/src/types/errors/NotInVoiceChannelError.ts b/src/types/errors/NotInVoiceChannelError.ts index 60a3723..5c87bf5 100644 --- a/src/types/errors/NotInVoiceChannelError.ts +++ b/src/types/errors/NotInVoiceChannelError.ts @@ -4,12 +4,19 @@ export default class NotInVoiceChannelError extends Error { */ public user: string | undefined; + /** + * The voice channel that the user is not in. + */ + public voiceChannel: string | undefined; + /** * Creates a new instance of the NotInVoiceChannelError class. * @param user The user that is not in the voice channel. If the user themselves is not in any voice channel, this parameter should be undefined. + * @param voiceChannel The voice channel that the user is not in. If the user themselves is not in any voice channel, this parameter should be undefined. */ - constructor(user?: string) { - super(`${user ? `<@${user}> is` : "You are"} currently not in a voice channel.`); + constructor(user?: string, voiceChannel?: string) { + super(`${user ? `<@${user}> is` : "You are"} currently not in ${voiceChannel ? `the voice channel <#${voiceChannel}>` : "a voice channel"}.`); this.user = user; + this.voiceChannel = voiceChannel; } } \ No newline at end of file diff --git a/src/types/errors/UnauthorizedError.ts b/src/types/errors/UnauthorizedError.ts index 32ea143..5a18a8d 100644 --- a/src/types/errors/UnauthorizedError.ts +++ b/src/types/errors/UnauthorizedError.ts @@ -1,5 +1,7 @@ export enum UnauthorizedErrorReason { CloseChannel = "close the channel", + KickMember = "kick a member from the channel", + KickOwner = "kick the owner of the channel", } export default class UnauthorizedError extends Error { diff --git a/src/types/index.ts b/src/types/index.ts index ec6cfa1..5a9839b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,7 +13,7 @@ import CouldNotFindQueueError from "./errors/CouldNotFindQueueError"; import CouldNotFindQueueForSessionError from "./errors/CouldNotFindQueueForSessionError"; import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; -import CouldNotKickAllUsersError from "./errors/CouldNotKickAllUsersError"; +import CouldNotKickUserError from "./errors/CouldNotKickUserError"; import CouldNotRemoveRoleError from "./errors/CouldNotRemoveRoleError"; import GuildHasNoQueueError from "./errors/GuildHasNoQueueError"; import InteractionNotInGuildError from "./errors/InteractionNotInGuildError"; @@ -42,7 +42,7 @@ export { CouldNotFindRoleError, CouldNotAssignRoleError, CouldNotRemoveRoleError, - CouldNotKickAllUsersError, + CouldNotKickUserError, ChannelNotTemporaryError, RoleNotInDatabaseError, CouldNotFindTypeInFileError, diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 367c02e..ca29445 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -9,7 +9,7 @@ import { mockChatInputCommandInteraction } from '@shoginn/discordjs-mock'; import "reflect-metadata" -import { APIGuildMember, APIRole, APIUser, ChannelType, ChatInputCommandInteraction, Collection, DMChannel, Guild, GuildMember, Role, TextChannel, User, VoiceChannel, VoiceState } from 'discord.js'; +import { APIGuildMember, APIRole, APIUser, ChannelType, ChatInputCommandInteraction, Collection, DMChannel, Guild, GuildMember, PermissionOverwriteManager, Role, TextChannel, User, VoiceChannel, VoiceState } from 'discord.js'; import { container, singleton } from 'tsyringe'; import { randomInt } from 'crypto'; import assert from 'assert'; @@ -52,14 +52,19 @@ export class MockDiscord { }: { members?: GuildMember[], } = {}): VoiceChannel { - return { + const voiceChannel = { id: randomInt(281474976710655).toString(), type: ChannelType.GuildVoice, name: "test voice channel", guild: guild, members: new Collection(members.map(member => [member.id, member])), - } as any; + Object.defineProperty(voiceChannel, "permissionOverwrites", { + value: { + delete: jest.fn(() => Promise.resolve()) + } + }); + return voiceChannel; } public mockDMChannel(): DMChannel { From 94348c6efc65768304e47883a0cfd9fdf51fea0d Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 28 Apr 2024 18:44:05 +0200 Subject: [PATCH 120/130] Fix test --- src/events/VoiceChannelUpdateEvent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/VoiceChannelUpdateEvent.test.ts b/src/events/VoiceChannelUpdateEvent.test.ts index 8a582c9..65da7fb 100644 --- a/src/events/VoiceChannelUpdateEvent.test.ts +++ b/src/events/VoiceChannelUpdateEvent.test.ts @@ -294,7 +294,7 @@ describe("VoiceChannelUpdateEvent", () => { const joinSpy = jest.spyOn(eventInstance as any, "handleVoiceJoin") const leaveSpy = jest.spyOn(eventInstance as any, "handleVoiceLeave") const sendQueueLeaveMessage = jest.spyOn(discord.getApplication().dmManager, "sendActuallyLeaveQueueMessage").mockResolvedValue() - const leaveQueueSpy = jest.spyOn(discord.getApplication().queueManager, "leaveQueue").mockResolvedValue("leave message") + const leaveQueueSpy = jest.spyOn(discord.getApplication().queueManager, "leaveQueueWithTimeout").mockResolvedValue("leave message") await eventInstance.execute(oldState, newState) // wait so leave queue can be called From 711097c3f8ea4386ea8104d6fb310905ea387291 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 28 Apr 2024 18:44:24 +0200 Subject: [PATCH 121/130] Improve room handling --- src/commands/voice/VoiceCloseCommand.ts | 34 ++++++------------- src/commands/voice/VoiceKickCommand.test.ts | 2 +- src/commands/voice/VoiceKickCommand.ts | 33 ++++--------------- src/managers/RoomManager.ts | 36 ++++++++++++++++++--- 4 files changed, 48 insertions(+), 57 deletions(-) diff --git a/src/commands/voice/VoiceCloseCommand.ts b/src/commands/voice/VoiceCloseCommand.ts index 4ea815b..171fe3f 100644 --- a/src/commands/voice/VoiceCloseCommand.ts +++ b/src/commands/voice/VoiceCloseCommand.ts @@ -1,4 +1,5 @@ import { BaseCommand } from "@baseCommand"; +import { VoiceChannel } from "@models/VoiceChannel"; import { ChannelNotTemporaryError, CouldNotKickUserError, NotInVoiceChannelError, UnauthorizedError, UnauthorizedErrorReason } from "@types"; import { Colors, EmbedBuilder, GuildMember, VoiceBasedChannel } from "discord.js"; @@ -10,8 +11,11 @@ export default class VoiceCloseCommand extends BaseCommand { public async execute() { await this.defer(); try { - const voiceChannel = await this.getChannel() - await this.app.roomManager.kickMembersFromRoom(voiceChannel, this.interaction.member as GuildMember); + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const member = this.interaction.member as GuildMember; + const { voiceChannel, databaseVoiceChannel } = await this.app.roomManager.getTemporaryVoiceChannel(dbGuild, member); + this.checkClosePermissions(member, databaseVoiceChannel); + await this.app.roomManager.kickMembersFromRoom(voiceChannel, member); const embed = this.mountVoiceCloseEmbed(voiceChannel); await this.send({ embeds: [embed] }); } catch (error) { @@ -42,30 +46,10 @@ export default class VoiceCloseCommand extends BaseCommand { throw error; } - private async getChannel(): Promise { - // Check if user is in Voice Channel - const member = this.interaction.member as GuildMember | null; - const channel = member?.voice.channel; - if (!member || !channel) { - this.app.logger.info("User is not in a voice channel."); - throw new NotInVoiceChannelError(); - } - - // Get channel from DB - const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); - const dbChannel = dbGuild.voice_channels.find(vc => vc._id === channel.id); - - if (!dbChannel?.temporary) { - this.app.logger.info("Channel is not temporary."); - throw new ChannelNotTemporaryError(); - } - + private checkClosePermissions(member: GuildMember, databaseVoiceChannel: VoiceChannel) { // Check if user has permission to close the channel - if (!(dbChannel.owner === member.id || (dbChannel.supervisors && dbChannel.supervisors.includes(member.id)))) { - this.app.logger.info("User is not authorized to close the channel."); + if (!(databaseVoiceChannel.owner === member.id || (databaseVoiceChannel.supervisors && databaseVoiceChannel.supervisors.includes(member.id)))) { throw new UnauthorizedError(UnauthorizedErrorReason.CloseChannel); } - - return channel; - } + } } \ No newline at end of file diff --git a/src/commands/voice/VoiceKickCommand.test.ts b/src/commands/voice/VoiceKickCommand.test.ts index a504f9b..08c2f69 100644 --- a/src/commands/voice/VoiceKickCommand.test.ts +++ b/src/commands/voice/VoiceKickCommand.test.ts @@ -1,5 +1,5 @@ import { MockDiscord } from "@tests/mockDiscord"; -import { ChatInputCommandInteraction, ApplicationCommandOptionType, VoiceState, ChannelType, Colors, PermissionOverwriteManager } from "discord.js"; +import { ChatInputCommandInteraction, ApplicationCommandOptionType, VoiceState, ChannelType, Colors } from "discord.js"; import VoiceKickCommand from "./VoiceKickCommand"; import { mongoose } from "@typegoose/typegoose"; diff --git a/src/commands/voice/VoiceKickCommand.ts b/src/commands/voice/VoiceKickCommand.ts index 268e3e3..e32ca27 100644 --- a/src/commands/voice/VoiceKickCommand.ts +++ b/src/commands/voice/VoiceKickCommand.ts @@ -23,9 +23,10 @@ export default class VoiceKickCommand extends BaseCommand { public async execute(): Promise { try { const memberToKick = await this.getMemberToKick(); - console.log(memberToKick.id); - const { voiceChannel, databaseVoiceChannel } = await this.getChannel(memberToKick); - this.checkIfUserToKickIsOwner(memberToKick, databaseVoiceChannel); + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const member = this.interaction.member as GuildMember; + const { voiceChannel, databaseVoiceChannel } = await this.app.roomManager.getTemporaryVoiceChannel(this.dbGuild, member); + this.checkKickPermissions(member, memberToKick, databaseVoiceChannel); await this.app.roomManager.kickMemberFromRoom(memberToKick, voiceChannel, this.interaction.member as GuildMember); await this.removeKickedMemberPrivileges(memberToKick, voiceChannel, databaseVoiceChannel); const embed = this.mountVoiceKickEmbed(memberToKick); @@ -75,34 +76,14 @@ export default class VoiceKickCommand extends BaseCommand { return await this.interaction.guild?.members.fetch(memberId)!; } - private async getChannel(memberToKick: GuildMember): Promise<{ voiceChannel: VoiceBasedChannel, databaseVoiceChannel: VoiceChannel }> { - // Check if user is in Voice Channel - const member = this.interaction.member as GuildMember | null; - const channel = member?.voice.channel; - if (!member || !channel) { - this.app.logger.info("User is not in a voice channel."); - throw new NotInVoiceChannelError(); - } - - // Get channel from DB - this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); - const dbChannel = this.dbGuild.voice_channels.find(vc => vc._id === channel.id); - - if (!dbChannel?.temporary) { - this.app.logger.info("Channel is not temporary."); - throw new ChannelNotTemporaryError(); - } - + private checkKickPermissions(member: GuildMember, memberToKick: GuildMember, databaseVoiceChannel: VoiceChannel): void { // Check if user has permission to kick the member - if (!(dbChannel.owner === member.id || (dbChannel.supervisors && dbChannel.supervisors.includes(member.id)))) { + if (!(databaseVoiceChannel.owner === member.id || (databaseVoiceChannel.supervisors && databaseVoiceChannel.supervisors.includes(member.id)))) { this.app.logger.info("User is not authorized to kick a member from the channel."); throw new UnauthorizedError(UnauthorizedErrorReason.KickMember); } - return { voiceChannel: channel, databaseVoiceChannel: dbChannel}; - } - - private checkIfUserToKickIsOwner(memberToKick: GuildMember, databaseVoiceChannel: VoiceChannel): void { + // Check if user is trying to kick the owner of the channel if (memberToKick.id === databaseVoiceChannel.owner) { this.app.logger.info("User is trying to kick the owner of the channel."); throw new UnauthorizedError(UnauthorizedErrorReason.KickOwner); diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index 65f48e6..9343d1b 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -1,7 +1,6 @@ import { Application } from "@application" import { Guild } from "@models/Guild"; import { Queue } from "@models/Queue"; -import { QueueEntry } from "@models/QueueEntry"; import { VoiceChannel as DatabaseVoiceChannel } from "@models/VoiceChannel"; import { VoiceChannelSpawner } from "@models/VoiceChannelSpawner"; import { ChannelType, Guild as DiscordGuild, GuildMember, GuildPremiumTier, OverwriteData, VoiceBasedChannel, VoiceChannel } from "discord.js"; @@ -10,11 +9,10 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { PermissionOverwriteData } from "@models/PermissionOverwriteData"; import { interpolateString } from "@utils/interpolateString"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; -import { ChannelCouldNotBeCreatedError, CouldNotKickUserError, NotInVoiceChannelError } from "@types"; +import { ChannelCouldNotBeCreatedError, ChannelNotTemporaryError, CouldNotKickUserError, NotInVoiceChannelError } from "@types"; import { RoomModel } from "@models/Models"; import { VoiceChannelEvent } from "@models/Event"; import { Room } from "@models/Room"; -import { emit } from "process"; @injectable() @singleton() @@ -25,6 +23,34 @@ export default class RoomManager { this.app = app; } + /** + * Retrieves the temporary voice channel associated with a guild member. + * @param dbGuild - The guild object from the database. + * @param member - The guild member for whom to retrieve the temporary voice channel. + * @returns An object containing the voice channel and its corresponding database voice channel. + * @throws {NotInVoiceChannelError} If the member is not in a voice channel. + * @throws {ChannelNotTemporaryError} If the voice channel is not temporary. + */ + public async getTemporaryVoiceChannel(dbGuild: Guild, member: GuildMember): Promise<{ voiceChannel: VoiceBasedChannel, databaseVoiceChannel: DatabaseVoiceChannel }> { + // Check if user is in Voice Channel + const channel = member?.voice.channel; + if (!member || !channel) { + this.app.logger.info("User is not in a voice channel."); + throw new NotInVoiceChannelError(); + } + + // Get channel from DB + const dbChannel = dbGuild.voice_channels.find(vc => vc._id === channel.id); + + // Check if channel is temporary + if (!dbChannel?.temporary) { + this.app.logger.info("Channel is not temporary."); + throw new ChannelNotTemporaryError(); + } + + return { voiceChannel: channel, databaseVoiceChannel: dbChannel}; + } + /** * Moves the specified members to the given room and updates the room data accordingly. * If the room does not have a database entry, it creates one. @@ -49,7 +75,7 @@ export default class RoomManager { reason: `Automated member move by queue "${queue.name}"`, timestamp: Date.now().toString(), } as VoiceChannelEvent); - this.app.logger.info(`Moved member "${member.displayName}" (id: ${member.id}) to room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`) + this.app.logger.info(`Moved member "${member.displayName}" (id: ${member.id}) to room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`) } catch (error) { this.app.logger.info(`Could not move member "${member.displayName}" (id: ${member.id}) to room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); continue; @@ -210,7 +236,7 @@ export default class RoomManager { spawner = { owner: member.id, supervisor_roles: queueChannelData?.supervisors ?? [], - permission_overwrites: [ ...permissionOverwrites ], + permission_overwrites: [...permissionOverwrites], max_users: 5, parent: queueChannel?.parentId ?? undefined, lock_initially: true, From 3c04f1e675b6de382d17d1ee3c70434ad7e2f27a Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 28 Apr 2024 20:54:52 +0200 Subject: [PATCH 122/130] Add voice lock command --- src/commands/voice/VoiceLockCommand.test.ts | 180 ++++++++++++++++++++ src/commands/voice/VoiceLockCommand.ts | 55 ++++++ src/managers/RoomManager.ts | 39 ++++- src/types/errors/RoomAlreadyLockedError.ts | 11 ++ src/types/errors/UnauthorizedError.ts | 1 + src/types/index.ts | 2 + tests/mockDiscord.ts | 1 + 7 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/commands/voice/VoiceLockCommand.test.ts create mode 100644 src/commands/voice/VoiceLockCommand.ts create mode 100644 src/types/errors/RoomAlreadyLockedError.ts diff --git a/src/commands/voice/VoiceLockCommand.test.ts b/src/commands/voice/VoiceLockCommand.test.ts new file mode 100644 index 0000000..825b256 --- /dev/null +++ b/src/commands/voice/VoiceLockCommand.test.ts @@ -0,0 +1,180 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { mongoose } from "@typegoose/typegoose"; +import { ChatInputCommandInteraction, ChannelType, VoiceState, Colors } from "discord.js"; +import VoiceLockCommand from "./VoiceLockCommand"; +import exp from "constants"; + +describe("VoiceLockCommand", () => { + const command = VoiceLockCommand; + const discord = new MockDiscord(); + let commandInstance: VoiceLockCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("lock"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Locks the current voice channel."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + it.each(["owner", "supervisor"])("should lock the voice channel when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + locked: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const editPermissionOverwritesSpy = jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + + await commandInstance.execute(); + + const updatedDbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const updatedVoiceChannel = updatedDbGuild.voice_channels.find(vc => vc._id === voiceChannel.id); + + expect(updatedVoiceChannel?.locked).toBe(true); + expect(editPermissionOverwritesSpy).toHaveBeenCalledTimes(1); + expect(editPermissionOverwritesSpy).toHaveBeenCalledWith(interaction.guild!.roles.everyone, { "Connect": false, "Speak": false }); + }) + + it.each(["owner", "supervisor"])("should send a success message when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + locked: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, "reply"); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Voice Channel Locked", + description: `The voice channel "${voiceChannel}" was locked.`, + color: Colors.Green + } + }] + })) + }) + + it("should fail if the user is not in a voice channel", async () => { + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are currently not in a voice channel.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the voice channel is not temporary", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: false, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The voice channel is not temporary.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not authorized to lock the room", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are not authorized to lock the channel.", + color: Colors.Red + } + }] + })) + }) + +}); \ No newline at end of file diff --git a/src/commands/voice/VoiceLockCommand.ts b/src/commands/voice/VoiceLockCommand.ts new file mode 100644 index 0000000..3abe6a7 --- /dev/null +++ b/src/commands/voice/VoiceLockCommand.ts @@ -0,0 +1,55 @@ +import { BaseCommand } from "@baseCommand"; +import { VoiceChannel } from "@models/VoiceChannel"; +import { ChannelNotTemporaryError, NotInVoiceChannelError, UnauthorizedError, UnauthorizedErrorReason } from "@types"; +import { Colors, EmbedBuilder, GuildMember, VoiceBasedChannel } from "discord.js"; + +export default class VoiceLockCommand extends BaseCommand { + public static name = "lock"; + public static description = "Locks the current voice channel."; + public static options = []; + + public async execute(): Promise { + try { + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const member = this.interaction.member as GuildMember; + const { voiceChannel, databaseVoiceChannel } = await this.app.roomManager.getTemporaryVoiceChannel(dbGuild, member); + this.checkLockPermissions(member, databaseVoiceChannel); + await this.app.roomManager.lockRoom(dbGuild, voiceChannel, databaseVoiceChannel, member); + const embed = this.mountVoiceLockEmbed(voiceChannel); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountVoiceLockEmbed(voiceChannel: VoiceBasedChannel): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Voice Channel Locked") + .setDescription(`The voice channel "${voiceChannel}" was locked.`) + .setColor(Colors.Green); + return embed; + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof UnauthorizedError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private checkLockPermissions(member: GuildMember, databaseVoiceChannel: VoiceChannel) { + // Check if user has permission to lock the channel + if (!(databaseVoiceChannel.owner === member.id || (databaseVoiceChannel.supervisors && databaseVoiceChannel.supervisors.includes(member.id)))) { + this.app.logger.debug(`User ${member.id} is not authorized to lock the channel ${databaseVoiceChannel._id}`); + throw new UnauthorizedError(UnauthorizedErrorReason.LockChannel); + } + } +} \ No newline at end of file diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index 9343d1b..4694ee9 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -9,7 +9,7 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { PermissionOverwriteData } from "@models/PermissionOverwriteData"; import { interpolateString } from "@utils/interpolateString"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; -import { ChannelCouldNotBeCreatedError, ChannelNotTemporaryError, CouldNotKickUserError, NotInVoiceChannelError } from "@types"; +import { ChannelCouldNotBeCreatedError, ChannelNotTemporaryError, CouldNotKickUserError, NotInVoiceChannelError, RoomAlreadyLockedError } from "@types"; import { RoomModel } from "@models/Models"; import { VoiceChannelEvent } from "@models/Event"; import { Room } from "@models/Room"; @@ -156,6 +156,43 @@ export default class RoomManager { } } + /** + * Locks a room and updates its database entry. + * + * @param dbGuild - The database guild document. + * @param room - The voice-based channel to be locked. + * @param databaseVoiceChannel - The database representation of the voice channel. + * @param emittedBy - The guild member who initiated the lock. + * @returns A promise that resolves when the room is locked. + * @throws {RoomAlreadyLockedError} If the room is already locked. + */ + public async lockRoom(dbGuild: DocumentType, room: VoiceBasedChannel, databaseVoiceChannel: DatabaseVoiceChannel, emittedBy: GuildMember): Promise { + let roomData = await RoomModel.findById(room.id); + if (!roomData) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one.`); + roomData = await this.createRoomOnDatabase(room); + } + + if (databaseVoiceChannel.locked) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) is already locked.`); + throw new RoomAlreadyLockedError(room.id); + } + + databaseVoiceChannel.locked = true; + await dbGuild.save(); + + roomData.events.push({ + emitted_by: emittedBy.id, + reason: `Room locked by user "${emittedBy.displayName}" (id: ${emittedBy.id})`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + await roomData.save(); + + await room.permissionOverwrites.edit(room.guild.roles.everyone, { "Connect": false, "Speak": false }); + + this.app.logger.info(`Locked room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emittedBy.displayName}" (id: ${emittedBy.id})`); + } + /** * Creates a room entry in the database. * diff --git a/src/types/errors/RoomAlreadyLockedError.ts b/src/types/errors/RoomAlreadyLockedError.ts new file mode 100644 index 0000000..61b441b --- /dev/null +++ b/src/types/errors/RoomAlreadyLockedError.ts @@ -0,0 +1,11 @@ +export default class RoomAlreadyLockedError extends Error { + /** + * The room that is already locked + */ + public roomId: string; + + constructor(roomId: string) { + super(`The room <#${roomId}> is already locked.`); + this.roomId = roomId; + } +} \ No newline at end of file diff --git a/src/types/errors/UnauthorizedError.ts b/src/types/errors/UnauthorizedError.ts index 5a18a8d..fe90e3f 100644 --- a/src/types/errors/UnauthorizedError.ts +++ b/src/types/errors/UnauthorizedError.ts @@ -2,6 +2,7 @@ export enum UnauthorizedErrorReason { CloseChannel = "close the channel", KickMember = "kick a member from the channel", KickOwner = "kick the owner of the channel", + LockChannel = "lock the channel" } export default class UnauthorizedError extends Error { diff --git a/src/types/index.ts b/src/types/index.ts index 5a9839b..eec1c92 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,6 +25,7 @@ import QueueAlreadyExistsError from "./errors/QueueAlreadyExistsError"; import QueueIsEmptyError from "./errors/QueueIsEmptyError"; import QueueLockedError from "./errors/QueueLockedError"; import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; +import RoomAlreadyLockedError from "./errors/RoomAlreadyLockedError"; import SessionHasNoQueueError from "./errors/SessionHasNoQueueError"; import UnauthorizedError, { UnauthorizedErrorReason } from "./errors/UnauthorizedError"; import UserHasActiveSessionError from "./errors/UserHasActiveSessionError"; @@ -61,6 +62,7 @@ export { GuildHasNoQueueError, EventDate, QueueListItem, + RoomAlreadyLockedError, UnauthorizedError, UnauthorizedErrorReason, } \ No newline at end of file diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index ca29445..3eb1525 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -61,6 +61,7 @@ export class MockDiscord { } as any; Object.defineProperty(voiceChannel, "permissionOverwrites", { value: { + edit: jest.fn(() => Promise.resolve()), delete: jest.fn(() => Promise.resolve()) } }); From 9430e5b15317ce00fa7b9e1f1fdea15190fb690c Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Sun, 28 Apr 2024 20:55:00 +0200 Subject: [PATCH 123/130] Improve voice kick test --- src/commands/voice/VoiceKickCommand.test.ts | 3 +-- src/commands/voice/VoiceKickCommand.ts | 3 +-- src/managers/RoomManager.ts | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/commands/voice/VoiceKickCommand.test.ts b/src/commands/voice/VoiceKickCommand.test.ts index 08c2f69..268beec 100644 --- a/src/commands/voice/VoiceKickCommand.test.ts +++ b/src/commands/voice/VoiceKickCommand.test.ts @@ -37,7 +37,6 @@ describe("VoiceKickCommand", () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); - console.log(memberToKick.id); const voiceChannel = discord.mockVoiceChannel(interaction.guild!, { members: [memberToKick] }); dbGuild.voice_channels.push({ @@ -264,7 +263,7 @@ describe("VoiceKickCommand", () => { })) }) - it("should fail if the user is not authorized to close the channel", async () => { + it("should fail if the user is not authorized to kick a user", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); const memberToKick = discord.mockGuildMember(undefined, interaction.guild!); diff --git a/src/commands/voice/VoiceKickCommand.ts b/src/commands/voice/VoiceKickCommand.ts index e32ca27..e424267 100644 --- a/src/commands/voice/VoiceKickCommand.ts +++ b/src/commands/voice/VoiceKickCommand.ts @@ -63,8 +63,8 @@ export default class VoiceKickCommand extends BaseCommand { // Change permitted users in database if (databaseVoiceChannel.permitted.includes(member.id)) { databaseVoiceChannel.permitted.splice(databaseVoiceChannel.permitted.indexOf(member.id), 1); + await this.dbGuild.save(); } - await this.dbGuild.save(); // Remove user from permission overwrites await voiceChannel.permissionOverwrites.delete(member.id); @@ -72,7 +72,6 @@ export default class VoiceKickCommand extends BaseCommand { private async getMemberToKick(): Promise { const memberId = this.getOptionValue(VoiceKickCommand.options[0]); - console.log(memberId); return await this.interaction.guild?.members.fetch(memberId)!; } diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index 4694ee9..cc8d240 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -114,7 +114,6 @@ export default class RoomManager { } as VoiceChannelEvent); this.app.logger.info(`Kicked member "${member.displayName}" (id: ${member.id}) from room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emmitedBy.displayName}" (id: ${emmitedBy.id})`); } catch (error) { - console.log(error); this.app.logger.info(`Could not kick member "${member.displayName}" (id: ${member.id}) from room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); throw new CouldNotKickUserError(member.id); } From 52b808dc495e912eb5fd5595203d46540d9c4c3b Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Mon, 29 Apr 2024 20:15:13 +0200 Subject: [PATCH 124/130] Add voice permit command --- src/commands/voice/VoicePermitCommand.test.ts | 234 ++++++++++++++++++ src/commands/voice/VoicePermitCommand.ts | 84 +++++++ src/managers/RoomManager.ts | 34 ++- src/types/errors/CouldNotPermitUserError.ts | 16 ++ src/types/errors/UnauthorizedError.ts | 1 + src/types/errors/UserNotInGuildError.ts | 5 + src/types/index.ts | 4 + 7 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 src/commands/voice/VoicePermitCommand.test.ts create mode 100644 src/commands/voice/VoicePermitCommand.ts create mode 100644 src/types/errors/CouldNotPermitUserError.ts create mode 100644 src/types/errors/UserNotInGuildError.ts diff --git a/src/commands/voice/VoicePermitCommand.test.ts b/src/commands/voice/VoicePermitCommand.test.ts new file mode 100644 index 0000000..a74a221 --- /dev/null +++ b/src/commands/voice/VoicePermitCommand.test.ts @@ -0,0 +1,234 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ApplicationCommandOptionType, ChannelType, ChatInputCommandInteraction, Colors, VoiceState } from "discord.js"; +import VoicePermitCommand from "./VoicePermitCommand"; +import { mongoose } from "@typegoose/typegoose"; + +describe("VoicePermitCommand", () => { + const command = VoicePermitCommand; + const discord = new MockDiscord(); + let commandInstance: VoicePermitCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("permit"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Permits a user to join the voice channel."); + }) + + it("should have one option", () => { + expect(command.options).toHaveLength(1); + expect(command.options[0]).toEqual({ + name: "member", + description: "The member to permit to join the voice channel.", + type: ApplicationCommandOptionType.User, + required: true + }); + }) + + it.each(["owner", "supervisor"])("should permit a user to join the voice channel as a %s", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToPermit = discord.mockGuildMember(undefined, interaction.guild!); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const editPermissionOverwritesSpy = jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToPermit.id }); + + await commandInstance.execute(); + + const updatedDbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const updatedVoiceChannel = updatedDbGuild.voice_channels.find(vc => vc._id === voiceChannel.id); + + expect(updatedVoiceChannel?.permitted).toContain(memberToPermit.id); + expect(editPermissionOverwritesSpy).toHaveBeenCalledTimes(1); + expect(editPermissionOverwritesSpy).toHaveBeenCalledWith(memberToPermit, { "ViewChannel": true, "Connect": true, "Speak": true }); + }); + + it.each(["owner", "supervisor"])("should send a success message when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToPermit = discord.mockGuildMember(undefined, interaction.guild!); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + const replySpy = jest.spyOn(interaction, "reply"); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToPermit.id }); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "User Permitted", + description: `User ${memberToPermit} was permitted to join the voice channel "${voiceChannel}".`, + color: Colors.Green + } + }] + })); + }); + + it("should fail if the user to permit is not in the guild", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToPermit = discord.mockGuildMember(); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToPermit.id }); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The user is not in the guild.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not in a voice channel", async () => { + const memberToPermit = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: memberToPermit.id }); + + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are currently not in a voice channel.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the voice channel is not temporary", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToPermit = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: memberToPermit.id }); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: false, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The voice channel is not temporary.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not authorized to lock the room", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToPermit = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: memberToPermit.id }); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are not authorized to permit a member to join the voice channel.", + color: Colors.Red + } + }] + })) + }) +}); \ No newline at end of file diff --git a/src/commands/voice/VoicePermitCommand.ts b/src/commands/voice/VoicePermitCommand.ts new file mode 100644 index 0000000..6742098 --- /dev/null +++ b/src/commands/voice/VoicePermitCommand.ts @@ -0,0 +1,84 @@ +import { BaseCommand } from "@baseCommand"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder, GuildMember, VoiceBasedChannel } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { Guild } from "@models/Guild"; +import { VoiceChannel } from "@models/VoiceChannel"; +import { ChannelNotTemporaryError, CouldNotPermitUserError, NotInVoiceChannelError, UnauthorizedError, UnauthorizedErrorReason, UserNotInGuildError } from "@types"; + +export default class VoicePermitCommand extends BaseCommand { + public static name = "permit"; + public static description = "Permits a user to join the voice channel."; + public static options = [{ + name: "member", + description: "The member to permit to join the voice channel.", + type: ApplicationCommandOptionType.User, + required: true + }]; + + /** + * The Guild from the Database + */ + private dbGuild!: DocumentType; + + public async execute(): Promise { + try { + const memberToPermit = await this.getMemberToPermit(); + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const member = this.interaction.member as GuildMember; + const { voiceChannel, databaseVoiceChannel } = await this.app.roomManager.getTemporaryVoiceChannel(this.dbGuild, member); + this.checkPermitPermissions(member, databaseVoiceChannel); + await this.app.roomManager.permitMemberToJoinRoom(memberToPermit, voiceChannel, member); + await this.addPermittedMemberPrivileges(memberToPermit, databaseVoiceChannel); + const embed = this.mountVoicePermitEmbed(memberToPermit, voiceChannel); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountVoicePermitEmbed(member: GuildMember, voiceChannel: VoiceBasedChannel): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("User Permitted") + .setDescription(`User ${member} was permitted to join the voice channel "${voiceChannel}".`) + .setColor(Colors.Green); + return embed; + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof UnauthorizedError || error instanceof CouldNotPermitUserError || error instanceof UserNotInGuildError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private async addPermittedMemberPrivileges(member: GuildMember, databaseVoiceChannel: VoiceChannel): Promise { + if (!databaseVoiceChannel.permitted.includes(member.id)) { + databaseVoiceChannel.permitted.push(member.id); + await this.dbGuild.save(); + } + } + + private async getMemberToPermit(): Promise { + const memberId = this.getOptionValue(VoicePermitCommand.options[0]); + if (!this.interaction.guild!.members.cache.has(memberId)) { + throw new UserNotInGuildError(); + } + return await this.interaction.guild!.members.fetch(memberId); + } + + private checkPermitPermissions(member: GuildMember, databaseVoiceChannel: VoiceChannel): void { + // Check if the member has permissions to permit a user to join the voice channel + if (!(databaseVoiceChannel.owner === member.id || (databaseVoiceChannel.supervisors && databaseVoiceChannel.supervisors.includes(member.id)))) { + this.app.logger.info(`User ${member.user.tag} tried to permit a user to join the voice channel without permission.`); + throw new UnauthorizedError(UnauthorizedErrorReason.PermitMember); + } + } +} \ No newline at end of file diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index cc8d240..7bf8251 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -9,7 +9,7 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { PermissionOverwriteData } from "@models/PermissionOverwriteData"; import { interpolateString } from "@utils/interpolateString"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; -import { ChannelCouldNotBeCreatedError, ChannelNotTemporaryError, CouldNotKickUserError, NotInVoiceChannelError, RoomAlreadyLockedError } from "@types"; +import { ChannelCouldNotBeCreatedError, ChannelNotTemporaryError, CouldNotKickUserError, CouldNotPermitUserError, NotInVoiceChannelError, RoomAlreadyLockedError } from "@types"; import { RoomModel } from "@models/Models"; import { VoiceChannelEvent } from "@models/Event"; import { Room } from "@models/Room"; @@ -84,6 +84,36 @@ export default class RoomManager { await roomData.save(); } + /** + * Permits a member to join a room by granting necessary permissions and updating the room data. + * If the room does not have a database entry, it creates one. + * + * @param member - The member to permit. + * @param room - The room to join. + * @param emmitedBy - The member who initiated the permission. + * @throws {CouldNotPermitUserError} if the member could not be permitted to join the room. + */ + public async permitMemberToJoinRoom(member: GuildMember, room: VoiceBasedChannel, emmitedBy: GuildMember): Promise { + let roomData = await RoomModel.findById(room.id); + if (!roomData) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one.`); + roomData = await this.createRoomOnDatabase(room); + } + + try { + room.permissionOverwrites.edit(member, { "ViewChannel": true, "Connect": true, "Speak": true }); + roomData.events.push({ + emitted_by: emmitedBy.id, + reason: `Permitted member "${member.displayName}" (id: ${member.id}) to join room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emmitedBy.displayName}" (id: ${emmitedBy.id})`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + this.app.logger.info(`Permitted member "${member.displayName}" (id: ${member.id}) to join room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); + } catch (error) { + this.app.logger.info(`Could not permit member "${member.displayName}" (id: ${member.id}) to join room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id})`); + throw new CouldNotPermitUserError(member.id); + } + } + /** * Kicks a member from a room. * @@ -109,7 +139,7 @@ export default class RoomManager { await member.voice.setChannel(null); roomData.events.push({ emitted_by: emmitedBy.id, - reason: `Member kicked by user "${emmitedBy.displayName}" (id: ${emmitedBy.id})`, + reason: `Kicked member "${member.displayName}" (id: ${member.id}) from room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emmitedBy.displayName}" (id: ${emmitedBy.id})`, timestamp: Date.now().toString(), } as VoiceChannelEvent); this.app.logger.info(`Kicked member "${member.displayName}" (id: ${member.id}) from room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emmitedBy.displayName}" (id: ${emmitedBy.id})`); diff --git a/src/types/errors/CouldNotPermitUserError.ts b/src/types/errors/CouldNotPermitUserError.ts new file mode 100644 index 0000000..e28fb23 --- /dev/null +++ b/src/types/errors/CouldNotPermitUserError.ts @@ -0,0 +1,16 @@ +export default class CouldNotPermitUserError extends Error { + + /** + * The user that could not be permitted to join the voice channel + */ + public user: string; + + /** + * Creates a new instance of the CouldNotPermitUserError class + * @param user The user that could not be permitted to join the voice channel + */ + constructor(user: string) { + super(`Could not permit user <@${user}> to join the voice channel.`); + this.user = user; + } +} \ No newline at end of file diff --git a/src/types/errors/UnauthorizedError.ts b/src/types/errors/UnauthorizedError.ts index fe90e3f..1866cbe 100644 --- a/src/types/errors/UnauthorizedError.ts +++ b/src/types/errors/UnauthorizedError.ts @@ -2,6 +2,7 @@ export enum UnauthorizedErrorReason { CloseChannel = "close the channel", KickMember = "kick a member from the channel", KickOwner = "kick the owner of the channel", + PermitMember = "permit a member to join the voice channel", LockChannel = "lock the channel" } diff --git a/src/types/errors/UserNotInGuildError.ts b/src/types/errors/UserNotInGuildError.ts new file mode 100644 index 0000000..829385d --- /dev/null +++ b/src/types/errors/UserNotInGuildError.ts @@ -0,0 +1,5 @@ +export default class UserNotInGuildError extends Error { + constructor() { + super("The user is not in the guild."); + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index eec1c92..b0bb365 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,7 @@ import CouldNotFindQueueForSessionError from "./errors/CouldNotFindQueueForSessi import CouldNotFindRoleError from "./errors/CouldNotFindRoleError"; import CouldNotFindTypeInFileError from "./errors/CouldNotFindTypeError"; import CouldNotKickUserError from "./errors/CouldNotKickUserError"; +import CouldNotPermitUserError from "./errors/CouldNotPermitUserError"; import CouldNotRemoveRoleError from "./errors/CouldNotRemoveRoleError"; import GuildHasNoQueueError from "./errors/GuildHasNoQueueError"; import InteractionNotInGuildError from "./errors/InteractionNotInGuildError"; @@ -30,6 +31,7 @@ import SessionHasNoQueueError from "./errors/SessionHasNoQueueError"; import UnauthorizedError, { UnauthorizedErrorReason } from "./errors/UnauthorizedError"; import UserHasActiveSessionError from "./errors/UserHasActiveSessionError"; import UserHasNoActiveSessionError from "./errors/UserHasNoActiveSessionError"; +import UserNotInGuildError from "./errors/UserNotInGuildError"; export { OptionRequirement, @@ -42,6 +44,7 @@ export { CouldNotFindQueueForSessionError, CouldNotFindRoleError, CouldNotAssignRoleError, + CouldNotPermitUserError, CouldNotRemoveRoleError, CouldNotKickUserError, ChannelNotTemporaryError, @@ -53,6 +56,7 @@ export { AlreadyInQueueError, UserHasActiveSessionError, UserHasNoActiveSessionError, + UserNotInGuildError, SessionHasNoQueueError, QueueLockedError, InvalidEventError, From 122f1fda09944b46a0afb07a75f8324934673578 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Mon, 29 Apr 2024 20:31:24 +0200 Subject: [PATCH 125/130] Add voice unlock command --- src/commands/voice/VoiceUnlockCommand.test.ts | 178 ++++++++++++++++++ src/commands/voice/VoiceUnlockCommand.ts | 55 ++++++ src/managers/RoomManager.ts | 29 ++- src/types/errors/RoomAlreadyUnlockedError.ts | 11 ++ src/types/errors/UnauthorizedError.ts | 3 +- src/types/index.ts | 2 + 6 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 src/commands/voice/VoiceUnlockCommand.test.ts create mode 100644 src/commands/voice/VoiceUnlockCommand.ts create mode 100644 src/types/errors/RoomAlreadyUnlockedError.ts diff --git a/src/commands/voice/VoiceUnlockCommand.test.ts b/src/commands/voice/VoiceUnlockCommand.test.ts new file mode 100644 index 0000000..b5493ea --- /dev/null +++ b/src/commands/voice/VoiceUnlockCommand.test.ts @@ -0,0 +1,178 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { mongoose } from "@typegoose/typegoose"; +import { ChatInputCommandInteraction, ChannelType, VoiceState, Colors } from "discord.js"; +import VoiceUnlockCommand from "./VoiceUnlockCommand"; + +describe("VoiceUnlockCommand", () => { + const command = VoiceUnlockCommand; + const discord = new MockDiscord(); + let commandInstance: VoiceUnlockCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("unlock"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Unlocks the current voice channel."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + it.each(["owner", "supervisor"])("should unlock the voice channel when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + locked: true, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const editPermissionOverwritesSpy = jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + + await commandInstance.execute(); + + const updatedDbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const updatedVoiceChannel = updatedDbGuild.voice_channels.find(vc => vc._id === voiceChannel.id); + + expect(updatedVoiceChannel?.locked).toBe(false); + expect(editPermissionOverwritesSpy).toHaveBeenCalledTimes(1); + expect(editPermissionOverwritesSpy).toHaveBeenCalledWith(interaction.guild!.roles.everyone, { "ViewChannel": true, "Connect": true, "Speak": true }); + }) + + it.each(["owner", "supervisor"])("should send a success message when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + locked: true, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, "reply"); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Voice Channel Unlocked", + description: `The voice channel "${voiceChannel}" was unlocked.`, + color: Colors.Green + } + }] + })) + }); + + it("should fail if the user is not in a voice channel", async () => { + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are currently not in a voice channel.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the voice channel is not temporary", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: false, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The voice channel is not temporary.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not authorized to lock the room", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are not authorized to unlock the channel.", + color: Colors.Red + } + }] + })) + }) +}); \ No newline at end of file diff --git a/src/commands/voice/VoiceUnlockCommand.ts b/src/commands/voice/VoiceUnlockCommand.ts new file mode 100644 index 0000000..7d9caac --- /dev/null +++ b/src/commands/voice/VoiceUnlockCommand.ts @@ -0,0 +1,55 @@ +import { BaseCommand } from "@baseCommand"; +import { VoiceChannel } from "@models/VoiceChannel"; +import { NotInVoiceChannelError, ChannelNotTemporaryError, UnauthorizedError, UnauthorizedErrorReason } from "@types"; +import { Colors, EmbedBuilder, GuildMember, VoiceBasedChannel } from "discord.js"; + +export default class VoiceUnlockCommand extends BaseCommand { + public static name = "unlock"; + public static description = "Unlocks the current voice channel."; + public static options = []; + + public async execute(): Promise { + try { + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const member = this.interaction.member as GuildMember; + const { voiceChannel, databaseVoiceChannel } = await this.app.roomManager.getTemporaryVoiceChannel(dbGuild, member); + this.checkUnlockPermissions(member, databaseVoiceChannel); + await this.app.roomManager.unlockRoom(dbGuild, voiceChannel, databaseVoiceChannel, member); + const embed = this.mountVoiceUnlockEmbed(voiceChannel); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountVoiceUnlockEmbed(voiceChannel: VoiceBasedChannel): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Voice Channel Unlocked") + .setDescription(`The voice channel "${voiceChannel}" was unlocked.`) + .setColor(Colors.Green); + return embed; + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof UnauthorizedError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private checkUnlockPermissions(member: GuildMember, databaseVoiceChannel: VoiceChannel) { + // Check if user has permission to unlock the channel + if (!(databaseVoiceChannel.owner === member.id || (databaseVoiceChannel.supervisors && databaseVoiceChannel.supervisors.includes(member.id)))) { + this.app.logger.debug(`User ${member.id} is not authorized to unlock the channel ${databaseVoiceChannel._id}`); + throw new UnauthorizedError(UnauthorizedErrorReason.UnlockChannel); + } + } +} \ No newline at end of file diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index 7bf8251..a3390df 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -9,7 +9,7 @@ import { delay, inject, injectable, singleton } from "tsyringe"; import { PermissionOverwriteData } from "@models/PermissionOverwriteData"; import { interpolateString } from "@utils/interpolateString"; import { FilterOutFunctionKeys } from "@typegoose/typegoose/lib/types"; -import { ChannelCouldNotBeCreatedError, ChannelNotTemporaryError, CouldNotKickUserError, CouldNotPermitUserError, NotInVoiceChannelError, RoomAlreadyLockedError } from "@types"; +import { ChannelCouldNotBeCreatedError, ChannelNotTemporaryError, CouldNotKickUserError, CouldNotPermitUserError, NotInVoiceChannelError, RoomAlreadyLockedError, RoomAlreadyUnlockedError } from "@types"; import { RoomModel } from "@models/Models"; import { VoiceChannelEvent } from "@models/Event"; import { Room } from "@models/Room"; @@ -222,6 +222,33 @@ export default class RoomManager { this.app.logger.info(`Locked room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emittedBy.displayName}" (id: ${emittedBy.id})`); } + public async unlockRoom(dbGuild: DocumentType, room: VoiceBasedChannel, databaseVoiceChannel: DatabaseVoiceChannel, emittedBy: GuildMember): Promise { + let roomData = await RoomModel.findById(room.id); + if (!roomData) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one.`); + roomData = await this.createRoomOnDatabase(room); + } + + if (!databaseVoiceChannel.locked) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) is not locked.`); + throw new RoomAlreadyUnlockedError(room.id); + } + + databaseVoiceChannel.locked = false; + await dbGuild.save(); + + roomData.events.push({ + emitted_by: emittedBy.id, + reason: `Room unlocked by user "${emittedBy.displayName}" (id: ${emittedBy.id})`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + await roomData.save(); + + await room.permissionOverwrites.edit(room.guild.roles.everyone, { "ViewChannel": true, "Connect": true, "Speak": true }); + + this.app.logger.info(`Unlocked room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emittedBy.displayName}" (id: ${emittedBy.id})`); + } + /** * Creates a room entry in the database. * diff --git a/src/types/errors/RoomAlreadyUnlockedError.ts b/src/types/errors/RoomAlreadyUnlockedError.ts new file mode 100644 index 0000000..db7a388 --- /dev/null +++ b/src/types/errors/RoomAlreadyUnlockedError.ts @@ -0,0 +1,11 @@ +export default class RoomAlreadyUnlockedError extends Error { + /** + * The room that is already unlocked + */ + public roomId: string; + + constructor(roomId: string) { + super(`The room <#${roomId}> is already unlocked.`); + this.roomId = roomId; + } +} \ No newline at end of file diff --git a/src/types/errors/UnauthorizedError.ts b/src/types/errors/UnauthorizedError.ts index 1866cbe..9e5e83b 100644 --- a/src/types/errors/UnauthorizedError.ts +++ b/src/types/errors/UnauthorizedError.ts @@ -3,7 +3,8 @@ export enum UnauthorizedErrorReason { KickMember = "kick a member from the channel", KickOwner = "kick the owner of the channel", PermitMember = "permit a member to join the voice channel", - LockChannel = "lock the channel" + LockChannel = "lock the channel", + UnlockChannel = "unlock the channel", } export default class UnauthorizedError extends Error { diff --git a/src/types/index.ts b/src/types/index.ts index b0bb365..f813ccd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,6 +27,7 @@ import QueueIsEmptyError from "./errors/QueueIsEmptyError"; import QueueLockedError from "./errors/QueueLockedError"; import RoleNotInDatabaseError from "./errors/RoleNotInDatabaseError"; import RoomAlreadyLockedError from "./errors/RoomAlreadyLockedError"; +import RoomAlreadyUnlockedError from "./errors/RoomAlreadyUnlockedError"; import SessionHasNoQueueError from "./errors/SessionHasNoQueueError"; import UnauthorizedError, { UnauthorizedErrorReason } from "./errors/UnauthorizedError"; import UserHasActiveSessionError from "./errors/UserHasActiveSessionError"; @@ -67,6 +68,7 @@ export { EventDate, QueueListItem, RoomAlreadyLockedError, + RoomAlreadyUnlockedError, UnauthorizedError, UnauthorizedErrorReason, } \ No newline at end of file From 8296844e49951ef61f9c875b923485188dddb1a1 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Mon, 29 Apr 2024 20:49:01 +0200 Subject: [PATCH 126/130] Add voice toggle lock command --- .../voice/VoiceToggleLockCommand.test.ts | 182 ++++++++++++++++++ src/commands/voice/VoiceToggleLockCommand.ts | 60 ++++++ 2 files changed, 242 insertions(+) create mode 100644 src/commands/voice/VoiceToggleLockCommand.test.ts create mode 100644 src/commands/voice/VoiceToggleLockCommand.ts diff --git a/src/commands/voice/VoiceToggleLockCommand.test.ts b/src/commands/voice/VoiceToggleLockCommand.test.ts new file mode 100644 index 0000000..fbf8227 --- /dev/null +++ b/src/commands/voice/VoiceToggleLockCommand.test.ts @@ -0,0 +1,182 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { mongoose } from "@typegoose/typegoose"; +import { ChatInputCommandInteraction, ChannelType, VoiceState, Colors } from "discord.js"; +import VoiceToggleLockCommand from "./VoiceToggleLockCommand"; + +describe("VoiceToggleLockCommand", () => { + const command = VoiceToggleLockCommand; + const discord = new MockDiscord(); + let commandInstance: VoiceToggleLockCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("toggle_lock"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Locks or unlocks the current voice channel."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + describe.each(["lock", "unlock"])("%s the voice channel", (action) => { + it.each(["owner", "supervisor"])("when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + locked: action === "lock" ? false : true, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const editPermissionOverwritesSpy = jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + + await commandInstance.execute(); + + const updatedDbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const updatedVoiceChannel = updatedDbGuild.voice_channels.find(vc => vc._id === voiceChannel.id); + + expect(updatedVoiceChannel?.locked).toBe(action === "lock"); + expect(editPermissionOverwritesSpy).toHaveBeenCalledTimes(1); + expect(editPermissionOverwritesSpy).toHaveBeenCalledWith(interaction.guild!.roles.everyone, { "ViewChannel": action === "lock" ? undefined : true, "Connect": action !== "lock", "Speak": action !== "lock" }); + }) + + it.each(["owner", "supervisor"])("send a success message when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + locked: action === "lock" ? false : true, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, "reply"); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: `Voice Channel ${action === "lock" ? "Locked" : "Unlocked"}`, + description: `The voice channel "${voiceChannel}" was ${action === "lock" ? "locked" : "unlocked"}.`, + color: Colors.Green + } + }] + })); + }) + + it("should fail if the voice channel is not temporary", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + locked: action === "lock" ? false : true, + afkhell: false, + temporary: false, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The voice channel is not temporary.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not authorized to lock or unlock the room", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: "123", + managed: true, + permitted: new mongoose.Types.Array(), + locked: action === "lock" ? false : true, + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: `You are not authorized to ${action} the channel.`, + color: Colors.Red + } + }] + })) + }) + }) + + it("should fail if the user is not in a voice channel", async () => { + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are currently not in a voice channel.", + color: Colors.Red + } + }] + })) + }) +}); \ No newline at end of file diff --git a/src/commands/voice/VoiceToggleLockCommand.ts b/src/commands/voice/VoiceToggleLockCommand.ts new file mode 100644 index 0000000..1109ee9 --- /dev/null +++ b/src/commands/voice/VoiceToggleLockCommand.ts @@ -0,0 +1,60 @@ +import { BaseCommand } from "@baseCommand"; +import { VoiceChannel } from "@models/VoiceChannel"; +import { ChannelNotTemporaryError, NotInVoiceChannelError, UnauthorizedError, UnauthorizedErrorReason } from "@types"; +import { Colors, EmbedBuilder, GuildMember, VoiceBasedChannel } from "discord.js"; + +export default class VoiceToggleLockCommand extends BaseCommand { + public static name = "toggle_lock"; + public static description = "Locks or unlocks the current voice channel."; + public static options = []; + + public async execute(): Promise { + try { + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const member = this.interaction.member as GuildMember; + const { voiceChannel, databaseVoiceChannel } = await this.app.roomManager.getTemporaryVoiceChannel(dbGuild, member); + const shouldLockChannel = !databaseVoiceChannel.locked; + this.checkToggleLockPermissions(member, databaseVoiceChannel, shouldLockChannel); + if (shouldLockChannel) { + await this.app.roomManager.lockRoom(dbGuild, voiceChannel, databaseVoiceChannel, member); + } else { + await this.app.roomManager.unlockRoom(dbGuild, voiceChannel, databaseVoiceChannel, member); + } + const embed = this.mountVoiceToggleLockEmbed(voiceChannel, shouldLockChannel); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountVoiceToggleLockEmbed(voiceChannel: VoiceBasedChannel, locked: boolean): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(`Voice Channel ${locked ? "Locked" : "Unlocked"}`) + .setDescription(`The voice channel "${voiceChannel}" was ${locked ? "locked" : "unlocked"}.`) + .setColor(Colors.Green); + return embed; + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof UnauthorizedError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private checkToggleLockPermissions(member: GuildMember, databaseVoiceChannel: VoiceChannel, lock: boolean) { + // Check if user has permission to lock or unlock the channel + if (!(databaseVoiceChannel.owner === member.id || (databaseVoiceChannel.supervisors && databaseVoiceChannel.supervisors.includes(member.id)))) { + this.app.logger.debug(`User ${member.id} is not authorized to toggle the lock of the channel ${databaseVoiceChannel._id}`); + throw new UnauthorizedError(lock ? UnauthorizedErrorReason.LockChannel : UnauthorizedErrorReason.UnlockChannel); + } + } +} \ No newline at end of file From 4b3dc9d01dcb5d4bbed084bb5e7270d02decb6f4 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Mon, 29 Apr 2024 22:31:10 +0200 Subject: [PATCH 127/130] Add voice toggle visibility command --- .../VoiceToggleVisibilityCommand.test.ts | 179 ++++++++++++++++++ .../voice/VoiceToggleVisibilityCommand.ts | 68 +++++++ src/managers/RoomManager.ts | 68 +++++++ src/types/errors/UnauthorizedError.ts | 2 + tests/mockDiscord.ts | 2 +- 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 src/commands/voice/VoiceToggleVisibilityCommand.test.ts create mode 100644 src/commands/voice/VoiceToggleVisibilityCommand.ts diff --git a/src/commands/voice/VoiceToggleVisibilityCommand.test.ts b/src/commands/voice/VoiceToggleVisibilityCommand.test.ts new file mode 100644 index 0000000..6c657c1 --- /dev/null +++ b/src/commands/voice/VoiceToggleVisibilityCommand.test.ts @@ -0,0 +1,179 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { mongoose } from "@typegoose/typegoose"; +import { ChatInputCommandInteraction, ChannelType, VoiceState, PermissionOverwriteManager, Colors } from "discord.js"; +import VoiceToggleVisibilityCommand from "./VoiceToggleVisibilityCommand"; + +describe("VoiceToggleVisibilityCommand", () => { + const command = VoiceToggleVisibilityCommand; + const discord = new MockDiscord(); + let commandInstance: VoiceToggleVisibilityCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("toggle_visibility"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Hides or shows the current voice channel."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + describe.each(["hide", "show"])("%s the voice channel", (action) => { + it.each(["owner", "supervisor"])("when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + locked: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + jest.spyOn(commandInstance as any, "shouldHideChannel").mockReturnValue(action === "hide"); + const editPermissionOverwritesSpy = jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + + await commandInstance.execute(); + + expect(editPermissionOverwritesSpy).toHaveBeenCalledTimes(1); + expect(editPermissionOverwritesSpy).toHaveBeenCalledWith(interaction.guild!.roles.everyone, { "ViewChannel": action === "hide" ? false : true }); + }) + + it.each(["owner", "supervisor"])("and send a success message when the user is %s of the channel", async (userRole) => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: userRole === "owner" ? interaction.user.id : "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + locked: false, + temporary: true, + supervisors: userRole === "supervisor" ? [interaction.user.id] : [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + jest.spyOn(commandInstance as any, "shouldHideChannel").mockReturnValue(action === "hide"); + const replySpy = jest.spyOn(interaction, "reply"); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: `Voice Channel ${action === "hide" ? "Hidden" : "Visible"}`, + description: `The voice channel "${voiceChannel}" is now ${action === "hide" ? "hidden" : "visible"}.`, + color: Colors.Green, + } + }] + })); + }); + + it("should fail if the voice channel is not temporary", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: false, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The voice channel is not temporary.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not authorized to show or hide the room", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + jest.spyOn(commandInstance as any, "shouldHideChannel").mockReturnValue(action === "hide"); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: `You are not authorized to ${action} the channel.`, + color: Colors.Red + } + }] + })) + }) + }) + + it("should fail if the user is not in a voice channel", async () => { + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are currently not in a voice channel.", + color: Colors.Red + } + }] + })) + }) +}); \ No newline at end of file diff --git a/src/commands/voice/VoiceToggleVisibilityCommand.ts b/src/commands/voice/VoiceToggleVisibilityCommand.ts new file mode 100644 index 0000000..cf227d8 --- /dev/null +++ b/src/commands/voice/VoiceToggleVisibilityCommand.ts @@ -0,0 +1,68 @@ +import { BaseCommand } from "@baseCommand"; +import { VoiceChannel } from "@models/VoiceChannel"; +import { NotInVoiceChannelError, ChannelNotTemporaryError, UnauthorizedError, UnauthorizedErrorReason } from "@types"; +import { GuildMember, VoiceBasedChannel, EmbedBuilder, Colors } from "discord.js"; + +export default class VoiceToggleVisibilityCommand extends BaseCommand { + public static name = "toggle_visibility"; + public static description = "Hides or shows the current voice channel."; + public static options = []; + + public async execute(): Promise { + try { + const dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const member = this.interaction.member as GuildMember; + const { voiceChannel, databaseVoiceChannel } = await this.app.roomManager.getTemporaryVoiceChannel(dbGuild, member); + let shouldHideChannel = this.shouldHideChannel(voiceChannel); + this.checkToggleVisibilityPermissions(member, databaseVoiceChannel, shouldHideChannel); + if (shouldHideChannel) { + await this.app.roomManager.hideRoom(dbGuild, voiceChannel, member); + } else { + await this.app.roomManager.showRoom(dbGuild, voiceChannel, member); + } + const embed = this.mountVoiceToggleVisibilityEmbed(voiceChannel, shouldHideChannel); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountVoiceToggleVisibilityEmbed(voiceChannel: VoiceBasedChannel, hidden: boolean): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(`Voice Channel ${hidden ? "Hidden" : "Visible"}`) + .setDescription(`The voice channel "${voiceChannel}" is now ${hidden ? "hidden" : "visible"}.`) + .setColor(Colors.Green); + return embed; + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof UnauthorizedError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private shouldHideChannel(voiceChannel: VoiceBasedChannel): boolean { + const voiceChannelOverwrites = voiceChannel.permissionOverwrites.cache.get(this.interaction.guild!.roles.everyone.id); + if (voiceChannelOverwrites && voiceChannelOverwrites.deny.has("ViewChannel")) { + return true; + } + return false; + } + + private checkToggleVisibilityPermissions(member: GuildMember, databaseVoiceChannel: VoiceChannel, hidden: boolean) { + // Check if user has permission to hide or show the channel + if (!(databaseVoiceChannel.owner === member.id || (databaseVoiceChannel.supervisors && databaseVoiceChannel.supervisors.includes(member.id)))) { + this.app.logger.debug(`User ${member.id} is not authorized to toggle the visibility of the channel ${databaseVoiceChannel._id}`); + throw new UnauthorizedError(hidden ? UnauthorizedErrorReason.HideChannel : UnauthorizedErrorReason.ShowChannel); + } + } +} \ No newline at end of file diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index a3390df..ac83bf2 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -222,6 +222,18 @@ export default class RoomManager { this.app.logger.info(`Locked room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emittedBy.displayName}" (id: ${emittedBy.id})`); } + /** + * Unlocks a room by updating its database entry and permissions. + * If the room does not have a database entry, it creates one. + * Throws an error if the room is already unlocked. + * + * @param dbGuild - The database guild document. + * @param room - The voice-based channel to unlock. + * @param databaseVoiceChannel - The corresponding database voice channel. + * @param emittedBy - The guild member who initiated the unlock. + * @returns A Promise that resolves when the room is successfully unlocked. + * @throws {RoomAlreadyUnlockedError} If the room is already unlocked. + */ public async unlockRoom(dbGuild: DocumentType, room: VoiceBasedChannel, databaseVoiceChannel: DatabaseVoiceChannel, emittedBy: GuildMember): Promise { let roomData = await RoomModel.findById(room.id); if (!roomData) { @@ -249,6 +261,62 @@ export default class RoomManager { this.app.logger.info(`Unlocked room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emittedBy.displayName}" (id: ${emittedBy.id})`); } + /** + * Hides a room by modifying its permission overwrites. + * If the room does not have a database entry, it creates one before hiding the room. + * + * @param dbGuild - The database representation of the guild. + * @param room - The voice-based channel to hide. + * @param emittedBy - The guild member who initiated the hiding of the room. + * @returns A promise that resolves when the room is successfully hidden. + */ + public async hideRoom(dbGuild: DocumentType, room: VoiceBasedChannel, emittedBy: GuildMember): Promise { + let roomData = await RoomModel.findById(room.id); + if (!roomData) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one.`); + roomData = await this.createRoomOnDatabase(room); + } + + roomData.events.push({ + emitted_by: emittedBy.id, + reason: `Room hidden by user "${emittedBy.displayName}" (id: ${emittedBy.id})`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + await roomData.save(); + + await room.permissionOverwrites.edit(room.guild.roles.everyone, { "ViewChannel": false }); + + this.app.logger.info(`Hid room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emittedBy.displayName}" (id: ${emittedBy.id})`); + } + + /** + * Shows a room by modifying its permissions. + * If the room does not have a database entry, it creates one. + * + * @param dbGuild - The database representation of the guild. + * @param room - The voice-based channel to show. + * @param emittedBy - The guild member who initiated the action. + * @returns A Promise that resolves when the room is shown. + */ + public async showRoom(dbGuild: DocumentType, room: VoiceBasedChannel, emittedBy: GuildMember): Promise { + let roomData = await RoomModel.findById(room.id); + if (!roomData) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one.`); + roomData = await this.createRoomOnDatabase(room); + } + + roomData.events.push({ + emitted_by: emittedBy.id, + reason: `Room shown by user "${emittedBy.displayName}" (id: ${emittedBy.id})`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + await roomData.save(); + + await room.permissionOverwrites.edit(room.guild.roles.everyone, { "ViewChannel": true }); + + this.app.logger.info(`Showed room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emittedBy.displayName}" (id: ${emittedBy.id})`); + } + /** * Creates a room entry in the database. * diff --git a/src/types/errors/UnauthorizedError.ts b/src/types/errors/UnauthorizedError.ts index 9e5e83b..192174b 100644 --- a/src/types/errors/UnauthorizedError.ts +++ b/src/types/errors/UnauthorizedError.ts @@ -5,6 +5,8 @@ export enum UnauthorizedErrorReason { PermitMember = "permit a member to join the voice channel", LockChannel = "lock the channel", UnlockChannel = "unlock the channel", + HideChannel = "hide the channel", + ShowChannel = "show the channel", } export default class UnauthorizedError extends Error { diff --git a/tests/mockDiscord.ts b/tests/mockDiscord.ts index 3eb1525..852b560 100644 --- a/tests/mockDiscord.ts +++ b/tests/mockDiscord.ts @@ -62,7 +62,7 @@ export class MockDiscord { Object.defineProperty(voiceChannel, "permissionOverwrites", { value: { edit: jest.fn(() => Promise.resolve()), - delete: jest.fn(() => Promise.resolve()) + delete: jest.fn(() => Promise.resolve()), } }); return voiceChannel; From 9dc3a638428f2af4ffdb4a679c24ff5eede6f9ad Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 30 Apr 2024 17:30:51 +0200 Subject: [PATCH 128/130] Improve test naming --- src/commands/voice/VoicePermitCommand.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/voice/VoicePermitCommand.test.ts b/src/commands/voice/VoicePermitCommand.test.ts index a74a221..738432f 100644 --- a/src/commands/voice/VoicePermitCommand.test.ts +++ b/src/commands/voice/VoicePermitCommand.test.ts @@ -196,7 +196,7 @@ describe("VoicePermitCommand", () => { })) }) - it("should fail if the user is not authorized to lock the room", async () => { + it("should fail if the user is not authorized to permit a user to join the room", async () => { const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); const memberToPermit = discord.mockGuildMember(undefined, interaction.guild!); From f2de80704c1fbf12b5d5b00f63a085dc25254156 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 30 Apr 2024 17:31:00 +0200 Subject: [PATCH 129/130] Add voice transfer command --- .../voice/VoiceTranserCommand.test.ts | 281 ++++++++++++++++++ src/commands/voice/VoiceTransferCommand.ts | 82 +++++ src/managers/RoomManager.ts | 40 +++ .../errors/CanNotTransferToYourselfError.ts | 5 + src/types/errors/UnauthorizedError.ts | 1 + src/types/index.ts | 2 + 6 files changed, 411 insertions(+) create mode 100644 src/commands/voice/VoiceTranserCommand.test.ts create mode 100644 src/commands/voice/VoiceTransferCommand.ts create mode 100644 src/types/errors/CanNotTransferToYourselfError.ts diff --git a/src/commands/voice/VoiceTranserCommand.test.ts b/src/commands/voice/VoiceTranserCommand.test.ts new file mode 100644 index 0000000..089d404 --- /dev/null +++ b/src/commands/voice/VoiceTranserCommand.test.ts @@ -0,0 +1,281 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ApplicationCommand, ApplicationCommandOptionType, ChannelType, ChatInputCommandInteraction, Colors, VoiceState } from "discord.js"; +import VoiceTransferCommand from "./VoiceTransferCommand"; +import { mongoose } from "@typegoose/typegoose"; + +describe("VoiceTransferCommand", () => { + const command = VoiceTransferCommand; + const discord = new MockDiscord(); + let commandInstance: VoiceTransferCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("transfer"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Transfers the ownership of the voice channel to another user."); + }) + + it("should have the correct options", () => { + expect(command.options).toEqual([{ + name: "member", + description: "The member to transfer the ownership to.", + type: ApplicationCommandOptionType.User, + required: true + }]); + }) + + it("should transfer the ownership of the voice channel", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToTransfer = discord.mockGuildMember(undefined, interaction.guild!); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new Array(memberToTransfer.id), + afkhell: false, + locked: false, + temporary: true, + supervisors: [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const editPermissionOverwritesSpy = jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToTransfer.id }); + + await commandInstance.execute(); + + const updatedDbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + const updatedVoiceChannel = updatedDbGuild.voice_channels.find(vc => vc._id === voiceChannel.id); + + expect(updatedVoiceChannel?.owner).toBe(memberToTransfer.id); + expect(updatedVoiceChannel?.permitted).toContain(interaction.user.id); + expect(updatedVoiceChannel?.permitted).not.toContain(memberToTransfer.id); + expect(editPermissionOverwritesSpy).toHaveBeenCalledTimes(2); + expect(editPermissionOverwritesSpy).toHaveBeenCalledWith(memberToTransfer, { "ViewChannel": true, "Connect": true, "Speak": true }); + expect(editPermissionOverwritesSpy).toHaveBeenCalledWith(interaction.member!, { "ViewChannel": true, "Connect": true, "Speak": true }); + }) + + it("should send a success message", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToTransfer = discord.mockGuildMember(undefined, interaction.guild!); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new Array(memberToTransfer.id), + afkhell: false, + locked: false, + temporary: true, + supervisors: [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + const replySpy = jest.spyOn(interaction, "reply"); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToTransfer.id }); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Ownership Transferred", + description: `The ownership of the voice channel was transferred to ${memberToTransfer}.`, + color: Colors.Green + } + }] + })); + + }) + + it("should fail if the user is only supervisor, not owner", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToTransfer = discord.mockGuildMember(undefined, interaction.guild!); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: "123", + managed: true, + permitted: new Array(memberToTransfer.id), + afkhell: false, + locked: false, + temporary: true, + supervisors: [interaction.user.id], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + const replySpy = jest.spyOn(interaction, "reply"); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToTransfer.id }); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are not authorized to transfer the ownership of the voice channel, since you are not the owner.", + color: Colors.Red + } + }] + })); + }) + + it("should fail if the user to transfer to is not in the guild", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToTransfer = discord.mockGuildMember(); + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new Array(), + afkhell: false, + locked: false, + temporary: true, + supervisors: [], + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + jest.spyOn(voiceChannel.permissionOverwrites, "edit").mockResolvedValue({} as any); + const replySpy = jest.spyOn(interaction, "reply"); + + interaction.options.get = jest.fn().mockReturnValue({ value: memberToTransfer.id }); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The user is not in the guild.", + color: Colors.Red + } + }] + })); + }) + + it("should fail if the user is not in a voice channel", async () => { + const memberToPermit = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: memberToPermit.id }); + + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are currently not in a voice channel.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the voice channel is not temporary", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToPermit = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: memberToPermit.id }); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: interaction.user.id, + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: false, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "The voice channel is not temporary.", + color: Colors.Red + } + }] + })) + }) + + it("should fail if the user is not authorized to transfer the ownership of the voice channel", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const memberToPermit = discord.mockGuildMember(undefined, interaction.guild!); + interaction.options.get = jest.fn().mockReturnValue({ value: memberToPermit.id }); + + const voiceChannel = discord.mockVoiceChannel(interaction.guild!); + + dbGuild.voice_channels.push({ + _id: voiceChannel.id, + channel_type: ChannelType.GuildVoice, + owner: "123", + managed: true, + permitted: new mongoose.Types.Array(), + afkhell: false, + temporary: true, + }); + await dbGuild.save(); + + jest.spyOn(VoiceState.prototype, "channel", "get").mockReturnValue(voiceChannel); + const replySpy = jest.spyOn(interaction, 'reply') + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Error", + description: "You are not authorized to transfer the ownership of the voice channel, since you are not the owner.", + color: Colors.Red + } + }] + })) + }) +}); \ No newline at end of file diff --git a/src/commands/voice/VoiceTransferCommand.ts b/src/commands/voice/VoiceTransferCommand.ts new file mode 100644 index 0000000..3a0691d --- /dev/null +++ b/src/commands/voice/VoiceTransferCommand.ts @@ -0,0 +1,82 @@ +import { BaseCommand } from "@baseCommand"; +import { Guild } from "@models/Guild"; +import { ApplicationCommandOptionType, Colors, EmbedBuilder, GuildMember } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { CanNotTransferToYourselfError, ChannelNotTemporaryError, NotInVoiceChannelError, UnauthorizedError, UnauthorizedErrorReason, UserNotInGuildError } from "@types"; +import { VoiceChannel } from "@models/VoiceChannel"; + +export default class VoiceTransferCommand extends BaseCommand { + public static name = "transfer"; + public static description = "Transfers the ownership of the voice channel to another user."; + public static options = [{ + name: "member", + description: "The member to transfer the ownership to.", + type: ApplicationCommandOptionType.User, + required: true + }]; + + + /** + * The Guild from the Database + */ + private dbGuild!: DocumentType; + + public async execute(): Promise { + try { + const memberToTransfer = await this.getMemberToTransfer(); + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const member = this.interaction.member as GuildMember; + const { voiceChannel, databaseVoiceChannel } = await this.app.roomManager.getTemporaryVoiceChannel(this.dbGuild, member); + this.checkTransferPermissions(this.interaction.member as GuildMember, memberToTransfer, databaseVoiceChannel); + await this.app.roomManager.transferRoomOwnership(this.dbGuild, memberToTransfer, voiceChannel, databaseVoiceChannel, member); + const embed = this.mountVoiceTransferEmbed(memberToTransfer); + await this.send({ embeds: [embed] }); + } catch (error) { + if (error instanceof Error) { + const embed = this.mountErrorEmbed(error); + await this.send({ embeds: [embed] }); + } else { + throw error; + } + } + } + + private mountVoiceTransferEmbed(member: GuildMember): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("Ownership Transferred") + .setDescription(`The ownership of the voice channel was transferred to ${member}.`) + .setColor(Colors.Green); + return embed; + } + + private mountErrorEmbed(error: Error): EmbedBuilder { + if (error instanceof NotInVoiceChannelError || error instanceof ChannelNotTemporaryError || error instanceof CanNotTransferToYourselfError || error instanceof UnauthorizedError || error instanceof UserNotInGuildError) { + return new EmbedBuilder() + .setTitle("Error") + .setDescription(error.message) + .setColor(Colors.Red); + } + throw error; + } + + private async getMemberToTransfer(): Promise { + const memberId = this.getOptionValue(VoiceTransferCommand.options[0]); + if (!this.interaction.guild!.members.cache.has(memberId)) { + throw new UserNotInGuildError(); + } + return await this.interaction.guild!.members.fetch(memberId); + } + + private checkTransferPermissions(member: GuildMember, memberToTransfer: GuildMember, databaseVoiceChannel: VoiceChannel): void { + // Check if user has permission to transfer the room + if (!(databaseVoiceChannel.owner === member.id)) { + this.app.logger.info(`User ${member.id} tried to transfer the room ${databaseVoiceChannel._id} but is not the owner.`); + throw new UnauthorizedError(UnauthorizedErrorReason.TransferChannel); + } + + // Check if the member to transfer is the same as the member + if (member.id === memberToTransfer.id) { + throw new CanNotTransferToYourselfError(); + } + } +} \ No newline at end of file diff --git a/src/managers/RoomManager.ts b/src/managers/RoomManager.ts index ac83bf2..9b4aa30 100644 --- a/src/managers/RoomManager.ts +++ b/src/managers/RoomManager.ts @@ -317,6 +317,46 @@ export default class RoomManager { this.app.logger.info(`Showed room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}), initiated by "${emittedBy.displayName}" (id: ${emittedBy.id})`); } + /** + * Transfers the ownership of a room to a new member. + * + * @param dbGuild - The document representing the guild in the database. + * @param memberToTransfer - The member to whom the room ownership will be transferred. + * @param room - The voice-based channel to transfer ownership of. + * @param databaseVoiceChannel - The database representation of the voice channel. + * @param member - The member initiating the transfer. + * @returns A Promise that resolves when the room ownership transfer is complete. + */ + public async transferRoomOwnership(dbGuild: DocumentType, memberToTransfer: GuildMember, room: VoiceBasedChannel, databaseVoiceChannel: DatabaseVoiceChannel, member: GuildMember): Promise { + let roomData = await RoomModel.findById(room.id); + if (!roomData) { + this.app.logger.info(`Room "${room.name}" in guild "${room.guild.name}" (id: ${room.guild.id}) does not have a database entry. Creating one.`); + roomData = await this.createRoomOnDatabase(room); + } + + roomData.events.push({ + emitted_by: member.id, + reason: `Room ownership transferred to user "${memberToTransfer.displayName}" (id: ${memberToTransfer.id}) by "${member.displayName}" (id: ${member.id})`, + timestamp: Date.now().toString(), + } as VoiceChannelEvent); + await roomData.save(); + + // Change permitted users in database + if (databaseVoiceChannel.permitted.includes(memberToTransfer.id)) { + databaseVoiceChannel.permitted.splice(databaseVoiceChannel.permitted.indexOf(memberToTransfer.id), 1); + } + if (!databaseVoiceChannel.permitted.includes(member.id)) { + databaseVoiceChannel.permitted.push(member.id); + } + + databaseVoiceChannel.owner = memberToTransfer.id; + await dbGuild.save(); + + // Ensure both new and old owners have the necessary permissions + await room.permissionOverwrites.edit(memberToTransfer, { "ViewChannel": true, "Connect": true, "Speak": true }); + await room.permissionOverwrites.edit(member, { "ViewChannel": true, "Connect": true, "Speak": true }); + } + /** * Creates a room entry in the database. * diff --git a/src/types/errors/CanNotTransferToYourselfError.ts b/src/types/errors/CanNotTransferToYourselfError.ts new file mode 100644 index 0000000..122214b --- /dev/null +++ b/src/types/errors/CanNotTransferToYourselfError.ts @@ -0,0 +1,5 @@ +export default class CanNotTransferToYourselfError extends Error { + constructor() { + super("You can not transfer the ownership of the voice channel to yourself."); + } +} \ No newline at end of file diff --git a/src/types/errors/UnauthorizedError.ts b/src/types/errors/UnauthorizedError.ts index 192174b..e25b9d6 100644 --- a/src/types/errors/UnauthorizedError.ts +++ b/src/types/errors/UnauthorizedError.ts @@ -7,6 +7,7 @@ export enum UnauthorizedErrorReason { UnlockChannel = "unlock the channel", HideChannel = "hide the channel", ShowChannel = "show the channel", + TransferChannel = "transfer the ownership of the voice channel, since you are not the owner", } export default class UnauthorizedError extends Error { diff --git a/src/types/index.ts b/src/types/index.ts index f813ccd..636a5cb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ import OptionRequirement from "./OptionRequirement"; import { QueueListItem } from "./QueueListItem"; import { StringReplacements } from "./StringReplacements"; import AlreadyInQueueError from "./errors/AlreadyInQueueError"; +import CanNotTransferToYourselfError from "./errors/CanNotTransferToYourselfError"; import ChannelAlreadyInfoChannelError from "./errors/ChannelAlreadyInfoChannelError"; import ChannelCouldNotBeCreatedError from "./errors/ChannelCouldNotBeCreatedError"; import ChannelNotInfoChannelError from "./errors/ChannelNotInfoChannelError"; @@ -40,6 +41,7 @@ export { QueueAlreadyExistsError, QueueIsEmptyError, MissingOptionError, + CanNotTransferToYourselfError, CouldNotFindChannelError, CouldNotFindQueueError, CouldNotFindQueueForSessionError, From 3c93c84dd69bf8851d3fdb3ef564a43546869fd0 Mon Sep 17 00:00:00 2001 From: niklhut <49069026+niklhut@users.noreply.github.com> Date: Tue, 30 Apr 2024 18:18:48 +0200 Subject: [PATCH 130/130] Add admin session list command --- .../session/AdminSessionListCommand.test.ts | 81 +++++++++++++++++++ .../admin/session/AdminSessionListCommand.ts | 54 +++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/commands/admin/session/AdminSessionListCommand.test.ts create mode 100644 src/commands/admin/session/AdminSessionListCommand.ts diff --git a/src/commands/admin/session/AdminSessionListCommand.test.ts b/src/commands/admin/session/AdminSessionListCommand.test.ts new file mode 100644 index 0000000..dcba6c3 --- /dev/null +++ b/src/commands/admin/session/AdminSessionListCommand.test.ts @@ -0,0 +1,81 @@ +import { MockDiscord } from "@tests/mockDiscord"; +import { ChatInputCommandInteraction, Colors } from "discord.js"; +import AdminSessionListCommand from "./AdminSessionListCommand"; +import { createQueue, createSession } from "@tests/testutils"; + +describe("AdminSessionListCommand", () => { + const command = AdminSessionListCommand; + const discord = new MockDiscord(); + let commandInstance: AdminSessionListCommand; + let interaction: ChatInputCommandInteraction; + + beforeEach(() => { + interaction = discord.mockInteraction(); + commandInstance = new command(interaction, discord.getApplication()); + jest.restoreAllMocks(); + }); + + it("should have the correct name", () => { + expect(command.name).toBe("list"); + }) + + it("should have the correct description", () => { + expect(command.description).toBe("Lists all active sessions."); + }) + + it("should have no options", () => { + expect(command.options).toHaveLength(0); + }) + + it("should defer the reply", async () => { + const deferSpy = jest.spyOn(interaction, 'deferReply') + await commandInstance.execute() + + expect(deferSpy).toHaveBeenCalled() + }) + + it("should list all active sessions", async () => { + const dbGuild = await discord.getApplication().configManager.getGuildConfig(interaction.guild!); + + const queue1 = await createQueue(dbGuild); + const queue2 = await createQueue(dbGuild); + const member1 = discord.mockGuildMember(undefined, interaction.guild!); + const member2 = discord.mockGuildMember(undefined, interaction.guild!); + const member3 = discord.mockGuildMember(undefined, interaction.guild!); + const queue1session1 = await createSession(queue1, member1.id, dbGuild._id, true); + const queue1session2 = await createSession(queue1, member2.id, dbGuild._id, true); + const queue2session1 = await createSession(queue2, member3.id, dbGuild._id, true); + + const replySpy = jest.spyOn(interaction, "editReply"); + + await commandInstance.execute(); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledWith(expect.objectContaining({ + embeds: [{ + data: { + title: "Active Sessions", + description: "There are currently 3 active sessions.", + color: Colors.Green, + fields: expect.arrayContaining([ + expect.objectContaining({ + name: member1.displayName, + value: `- started at: \n- rooms: 0\n- participants: 0\n- queue: ${queue1.name}`, + inline: false + }), + expect.objectContaining({ + name: member2.displayName, + value: `- started at: \n- rooms: 0\n- participants: 0\n- queue: ${queue1.name}`, + inline: false + }), + expect.objectContaining({ + name: member3.displayName, + value: `- started at: \n- rooms: 0\n- participants: 0\n- queue: ${queue2.name}`, + inline: false + }), + ]) + }, + }] + })) + }) +}); \ No newline at end of file diff --git a/src/commands/admin/session/AdminSessionListCommand.ts b/src/commands/admin/session/AdminSessionListCommand.ts new file mode 100644 index 0000000..77dc537 --- /dev/null +++ b/src/commands/admin/session/AdminSessionListCommand.ts @@ -0,0 +1,54 @@ +import { BaseCommand } from "@baseCommand"; +import { SessionModel } from "@models/Models"; +import { Session } from "@models/Session"; +import { EmbedBuilder, Colors } from "discord.js"; +import { DocumentType } from "@typegoose/typegoose"; +import { Guild } from "@models/Guild"; + +export default class AdminSessionListCommand extends BaseCommand { + public static name = "list"; + public static description = "Lists all active sessions."; + public static options = []; + + /** + * The Guild from the Database + */ + private dbGuild!: DocumentType; + + public async execute(): Promise { + await this.defer(); + this.dbGuild = await this.app.configManager.getGuildConfig(this.interaction.guild!); + const sessions = await this.getSortedSessions(); + const embed = await this.mountSessionListEmbed(sessions); + await this.send({ embeds: [embed] }); + } + + private async mountSessionListEmbed(sessions: DocumentType[]): Promise { + const fields = await Promise.all(sessions.map(async session => { + const member = await this.interaction.guild?.members.fetch(session.user)!; + const participants = await session.getNumberOfParticipants(); + const rooms = session.getNumberOfRooms(); + const queue = this.dbGuild.queues.id(session.queue); + return { + name: member.displayName, + value: `- started at: \n` + + `- rooms: ${rooms}\n` + + `- participants: ${participants}\n` + + `- queue: ${queue?.name ?? "Unknown"}`, + inline: false + } + })) + const embed = new EmbedBuilder() + .setTitle("Active Sessions") + .setDescription(`There ${sessions.length === 1 ? "is" : "are"} currently ${sessions.length} active session${sessions.length === 1 ? "" : "s"}.`) + .addFields(fields) + .setColor(Colors.Green); + return embed; + } + + private async getSortedSessions(): Promise[]> { + const sessions = await SessionModel.find({ guild: this.dbGuild.id, active: true }); + const sortedSessions = sessions.sort((a, b) => (+a.started_at!) - (+b.started_at!)); + return sortedSessions; + } +} \ No newline at end of file