[
  {
    "path": ".clabot",
    "content": "{\n  \"contributors\": [\n    \"almeidx\",\n    \"axisiscool\",\n    \"BanTheNons\",\n    \"Benricheson101\",\n    \"brawaru\",\n    \"CleverSource\",\n    \"Dalkskkskk\",\n    \"DarkView\",\n    \"DenverCoder1\",\n    \"dexbiobot\",\n    \"greenbigfrog\",\n    \"hawkeye7662\",\n    \"iamshoXy\",\n    \"Jernik\",\n    \"k200-1\",\n    \"LilyBergonzat\",\n    \"martinbndr\",\n    \"metal0\",\n    \"Obliie\",\n    \"paolojpa\",\n    \"roflmaoqwerty\",\n    \"Rstar284\",\n    \"rubyowo\",\n    \"rukogit\",\n    \"Scraayp\",\n    \"seeyebe\",\n    \"TheKodeToad\",\n    \"thewilloftheshadow\",\n    \"usoka\",\n    \"vcokltfre\",\n    \"WeebHiroyuki\",\n    \"zayKenyon\",\n    \n    \"Dragory\",\n    \"app/dependabot\",\n    \"dependabot[bot]\"\n  ],\n  \"message\": \"Thank you for contributing to Zeppelin! We require contributors to sign our Contributor License Agreement (CLA). To let us review and merge your code, please visit https://github.com/ZeppelinBot/CLA to sign the CLA!\"\n}\n"
  },
  {
    "path": ".cursorignore",
    "content": "# Created by .ignore support plugin (hsz.mobi)\n### Node template\n# Logs\n/logs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.clinic\n.clinic-bot\n.clinic-api\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Typescript v1 declaration files\ntypings/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n*.env\n.env\n\n# windows folder options\ndesktop.ini\n\n# PHPStorm\n.idea/\n\n# Misc\n/convert.js\n/startscript.js\n.cache\nnpm-ls.txt\nnpm-audit.txt\n.vscode/launch.json\n\n# Debug files\n*.debug.ts\n*.debug.js\n\n.vscode/\n\nconfig-errors.txt\n/config-schema.json\n\n*.tsbuildinfo\n\n# Legacy data folders\n/docker/development/data\n/docker/production/data\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "{\n  \"name\": \"Zeppelin Development\",\n\n  \"dockerComposeFile\": \"../docker-compose.development.yml\",\n\n  \"service\": \"devenv\",\n  \"remoteUser\": \"ubuntu\",\n  \"workspaceFolder\": \"/workspace/zeppelin\",\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\n        \"Vue.volar\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": ".dockerignore",
    "content": "**/.git\n**/.github\n**/.idea\n**/.devcontainer\n\n/docker/development/data\n/docker/production/data\n\n**/node_modules\n**/dist\n**/.pnpm-store\n**/.docker\n\n**/*.log\n**/npm-debug.log*\n**/yarn-debug.log*\n**/yarn-error.log*\n**/.clinic\n**/.clinic-bot\n**/.clinic-api\n\n# dotenv environment variables file\n**/*.env\n**/.env\n\n# windows folder options\n**/desktop.ini\n\n# PHPStorm\n**/.idea\n\n# Misc\n**/npm-ls.txt\n**/npm-audit.txt\n**/.cache\n\n# Debug files\n**/*.debug.ts\n**/*.debug.js\n/debug\n\n**/.vscode\n\nconfig-errors.txt\n/config-schema.json\n\n**/*.tsbuildinfo\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".eslintrc.js",
    "content": "module.exports = {\n  root: true,\n  env: {\n    node: true,\n    browser: true,\n    es6: true,\n  },\n  extends: [\"eslint:recommended\", \"plugin:@typescript-eslint/recommended\", \"prettier\"],\n  parser: \"@typescript-eslint/parser\",\n  plugins: [\"@typescript-eslint\"],\n  rules: {\n    \"@typescript-eslint/no-explicit-any\": 0,\n    \"@typescript-eslint/ban-ts-comment\": 0,\n    \"@typescript-eslint/no-non-null-assertion\": 0,\n    \"no-async-promise-executor\": 0,\n    \"@typescript-eslint/no-empty-interface\": 0,\n    \"no-constant-condition\": [\"error\", {\n      checkLoops: false,\n    }],\n    \"prefer-const\": [\"error\", {\n      destructuring: \"all\",\n      ignoreReadBeforeAssign: true,\n    }],\n    \"@typescript-eslint/no-namespace\": [\"error\", {\n      allowDeclarations: true,\n    }],\n  },\n};\n"
  },
  {
    "path": ".gitattributes",
    "content": "package-lock.json binary\npnpm-lock.yaml binary\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\n\nupdates:\n  - package-ecosystem: npm\n    directory: /\n    schedule:\n      interval: daily\n    groups:\n      non-major:\n        update-types:\n          - minor\n          - patch\n\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: weekly\n    groups:\n      github-actions:\n        patterns:\n          - \"*\"\n\n  - package-ecosystem: docker\n    directory: /\n    schedule:\n      interval: weekly\n"
  },
  {
    "path": ".github/workflows/codequality.yml",
    "content": "name: Code quality checks\n\non: [push, pull_request]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8\n\n    - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903\n      with:\n        node-version: 24\n\n    - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061\n      with:\n        version: 10.19.0\n        run_install: true\n\n    - run: |\n        pnpm run lint\n        pnpm run codestyle-check\n"
  },
  {
    "path": ".gitignore",
    "content": "# Created by .ignore support plugin (hsz.mobi)\n### Node template\n# Logs\n/logs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.clinic\n.clinic-bot\n.clinic-api\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n.pnpm-store\n\n# Typescript v1 declaration files\ntypings/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n*.env\n.env\n\n# windows folder options\ndesktop.ini\n\n# PHPStorm\n.idea/\n\n# Misc\n/convert.js\n/startscript.js\n.cache\nnpm-ls.txt\nnpm-audit.txt\n.vscode/launch.json\n\n# Debug files\n*.debug.ts\n*.debug.js\n\n.vscode/\n\nconfig-errors.txt\n/config-schema.json\n\n*.tsbuildinfo\n\n# Legacy data folders\n/docker/development/data\n/docker/production/data\n"
  },
  {
    "path": ".nvmrc",
    "content": "24\n"
  },
  {
    "path": ".prettierignore",
    "content": ".github\n.idea\nnode_modules\n/assets\n/debug\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"printWidth\": 120,\n  \"trailingComma\": \"all\"\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "The project is called Zeppelin. It's a Discord bot that uses Discord.js. The bot is built on the Vety framework (formerly called Knub).\n\nThis repository is a monorepository that contains these projects:\n1. **Backend**: The shared codebase of the bot and API. Located in `backend`.\n2. **Dashboard**: The web dashboard that contains the bot's management interface and documentation. Located in `dashboard`.\n3. **Config checker**: A tool to check the configuration of the bot. Located in `config-checker`.\n\nThere is also a `shared` folder that contains shared code used by all projects, such as types and utilities.\n\n# Backend\nThe backend codebase is located in the `backend` directory. It contains the main bot code, API code, and shared code used by both the bot and API.\nZeppelin's functionality is split into plugins, which are located in the `src/plugins` directory.\nEach plugin has its own directory, with a `types.ts` for config types, `docs.ts` for a `ZeppelinPluginDocs` structure, and the plugin's main file.\nEach plugin has an internal name, such as \"common\". In this example, the folder would be `src/plugins/Common` (note the capitalization). The plugin's main file would be `src/plugins/CommonPlugin.ts`.\nThere are two types of plugins: \"guild plugins\" and \"global plugins\". Guild plugins are loaded on a per-guild basis, while global plugins are loaded once for the entire bot.\nPlugins can specify dependencies on other plugins and call their public methods. Likewise, plugins can specify public methods in the main file.\nAvailable plugins are specified in `src/plugins/availablePlugins.ts`.\n\nZeppelin's data layer uses TypeORM. Entities are located in `src/data/entities`, while repositories are in `src/data`. If the repository name is prefixed with \"Guild\", it's a guild-specific repository. If it's prefixed with \"User\", it's a user-specific repository. If it has no prefix, it's a global repository.\n\nEnvironment variables are parsed in `src/env.ts`.\n"
  },
  {
    "path": "DEVELOPMENT.md",
    "content": "Moved to [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:24 AS build\n\nARG COMMIT_HASH\nARG BUILD_TIME\n\nRUN mkdir /zeppelin\nRUN chown node:node /zeppelin\n\n# Install pnpm\nRUN npm install -g pnpm@10.19.0\n\nUSER node\n\n# Install dependencies before copying over any other files\nCOPY --chown=node:node package.json pnpm-workspace.yaml pnpm-lock.yaml /zeppelin\nRUN mkdir /zeppelin/backend\nCOPY --chown=node:node backend/package.json /zeppelin/backend\nRUN mkdir /zeppelin/shared\nCOPY --chown=node:node shared/package.json /zeppelin/shared\nRUN mkdir /zeppelin/dashboard\nCOPY --chown=node:node dashboard/package.json /zeppelin/dashboard\n\nWORKDIR /zeppelin\nRUN CI=true pnpm install\n\nCOPY --chown=node:node . /zeppelin\n\n# Build backend\nWORKDIR /zeppelin/backend\nRUN pnpm run build\n\n# Build dashboard\nWORKDIR /zeppelin/dashboard\nRUN pnpm run build\n\n# Only keep prod dependencies\nWORKDIR /zeppelin\nRUN CI=true pnpm install --prod\n\n# Add version info\nRUN echo \"${COMMIT_HASH}\" > /zeppelin/.commit-hash\nRUN echo \"${BUILD_TIME}\" > /zeppelin/.build-time\n\n# --- Main image ---\n\nFROM node:24-alpine AS main\n\nRUN npm install -g pnpm@10.19.0\n\nUSER node\nCOPY --from=build --chown=node:node /zeppelin /zeppelin\n\nWORKDIR /zeppelin\n"
  },
  {
    "path": "LICENSE.md",
    "content": "# Elastic License 2.0 (ELv2)\n\n## Elastic License\n\n### Acceptance\n\nBy using the software, you agree to all of the terms and conditions below.\n\n### Copyright License\n\nThe licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations and conditions below.\n\n### Limitations\n\nYou may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software.\n\nYou may not move, change, disable, or circumvent the license key functionality in the software, and you may not remove or obscure any functionality in the software that is protected by the license key.\n\nYou may not alter, remove, or obscure any licensing, copyright, or other notices of the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law.\n\n### Patents\n\nThe licensor grants you a license, under any patent claims the licensor can license, or becomes able to license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case subject to the limitations and conditions in this license. This license does not cover any patent claims that you cause to be infringed by modifications or additions to the software. If you or your company make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.\n\n### Notices\n\nYou must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms.\n\nIf you modify the software, you must include in any modified copies of the software prominent notices stating that you have modified the software.\n\n### No Other Rights\n\nThese terms do not imply any licenses other than those expressly granted in these terms.\n\n### Termination\n\nIf you use the software in violation of these terms, such use is not licensed, and your licenses will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your licenses will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your licenses to terminate automatically and permanently.\n\n### No Liability\n\n***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***\n\n### Definitions\n\nThe **licensor** is the entity offering these terms, and the **software** is the software the licensor makes available under these terms, including any portion of it.\n\n**you** refers to the individual or entity agreeing to these terms.\n\n**your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.\n\n**your licenses** are all the licenses granted to you for the software under these terms.\n\n**use** means anything you do with the software requiring one of your licenses.\n\n**trademark** means trademarks, service marks, and similar rights.\n"
  },
  {
    "path": "MANAGEMENT.md",
    "content": "Moved to [docs/MANAGEMENT.md](docs/MANAGEMENT.md)\n"
  },
  {
    "path": "PRODUCTION.md",
    "content": "Moved to [docs/PRODUCTION.md](docs/PRODUCTION.md)\n"
  },
  {
    "path": "README.md",
    "content": "![Zeppelin Banner](assets/zepbanner.png)\n# Zeppelin\nZeppelin is a moderation bot for Discord, designed with large servers and reliability in mind.\n\n**Main features include:**\n- Extensive automoderator features (automod)\n  - Word filters, spam detection, etc.\n- Detailed moderator action tracking and notes (cases)\n- Customizable server logs\n- Tags/custom commands\n- Reaction roles\n- Tons of utility commands, including a granular member search\n- Full configuration via a web dashboard\n  - Override specific settings and permissions on e.g. a per-user, per-channel, or per-permission-level basis\n- Bot-managed slowmodes\n  - Automatically switches between native slowmodes (for 6h or less) and bot-enforced (for longer slowmodes)\n- Starboard\n- And more!\n\nSee https://zeppelin.gg/ for more details.\n\n## Usage documentation\nFor information on how to use the bot, see https://zeppelin.gg/docs\n\n## Development\nSee [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for instructions on running the development environment.\n\nOnce you have the environment up and running, see [docs/MANAGEMENT.md](docs/MANAGEMENT.md) for how to manage your bot.\n\n## Production\nSee [docs/PRODUCTION.md](docs/PRODUCTION.md) for instructions on how to run the bot in production.\n\nOnce you have the environment up and running, see [docs/MANAGEMENT.md](docs/MANAGEMENT.md) for how to manage your bot.\n"
  },
  {
    "path": "assets/icons/LICENSE",
    "content": "# TWEMOJI\nCopyright 2020 Twitter, Inc and other contributors\nCode licensed under the MIT License: http://opensource.org/licenses/MIT\nGraphics licensed under CC-BY 4.0: https://creativecommons.org/licenses/by/4.0/\n"
  },
  {
    "path": "backend/.gitignore",
    "content": "/.cache\n/dist\n/node_modules\n"
  },
  {
    "path": "backend/.prettierignore",
    "content": "/dist\n"
  },
  {
    "path": "backend/package.json",
    "content": "{\n  \"name\": \"@zeppelinbot/backend\",\n  \"version\": \"0.0.1\",\n  \"description\": \"\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \"./*\": \"./dist/*\"\n  },\n  \"scripts\": {\n    \"watch\": \"tsc-watch --build --onSuccess \\\"node start-dev.js\\\"\",\n    \"watch-yaml-parse-test\": \"tsc-watch --build --onSuccess \\\"node dist/yamlParseTest.js\\\"\",\n    \"build\": \"tsc --build\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"start-bot-dev\": \"node --enable-source-maps --stack-trace-limit=30 --trace-warnings --inspect=0.0.0.0:9229 dist/index.js\",\n    \"start-bot-prod\": \"node --enable-source-maps --stack-trace-limit=30 --trace-warnings dist/index.js\",\n    \"watch-bot\": \"tsc-watch --build --onSuccess \\\"pnpm run start-bot-dev\\\"\",\n    \"start-api-dev\": \"node --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9239 dist/api/index.js\",\n    \"start-api-prod\": \"node --enable-source-maps --stack-trace-limit=30 dist/api/index.js\",\n    \"watch-api\": \"tsc-watch --build --onSuccess \\\"pnpm run start-api-dev\\\"\",\n    \"migrate\": \"pnpm exec typeorm migration:run -d dist/data/dataSource.js\",\n    \"migrate-prod\": \"pnpm run migrate\",\n    \"migrate-dev\": \"pnpm run build && pnpm run migrate\",\n    \"migrate-rollback\": \"pnpm exec typeorm migration:revert -d dist/data/dataSource.js\",\n    \"migrate-rollback-prod\": \"pnpm run migrate-rollback\",\n    \"migrate-rollback-dev\": \"pnpm run build && pnpm run migrate-rollback\",\n    \"validate-active-configs\": \"node --enable-source-maps dist/validateActiveConfigs.js > ../config-errors.txt\",\n    \"export-config-json-schema\": \"node --enable-source-maps dist/exportSchemas.js ../config-checker/public/config-schema.json\",\n    \"test\": \"pnpm run build && pnpm run run-tests\",\n    \"run-tests\": \"ava\",\n    \"test-watch\": \"tsc-watch --build --onSuccess \\\"pnpm exec ava\\\"\"\n  },\n  \"dependencies\": {\n    \"@silvia-odwyer/photon-node\": \"^0.3.1\",\n    \"@zeppelinbot/shared\": \"workspace:*\",\n    \"bufferutil\": \"^4.0.3\",\n    \"cors\": \"^2.8.5\",\n    \"cross-env\": \"^7.0.3\",\n    \"deep-diff\": \"^1.0.2\",\n    \"discord.js\": \"*\",\n    \"emoji-regex\": \"^8.0.0\",\n    \"escape-string-regexp\": \"^1.0.5\",\n    \"express\": \"^4.20.0\",\n    \"fp-ts\": \"^2.0.1\",\n    \"humanize-duration\": \"^3.15.0\",\n    \"js-yaml\": \"^4.1.0\",\n    \"knub-command-manager\": \"^9.1.0\",\n    \"lodash-es\": \"^4.17.21\",\n    \"moment-timezone\": \"^0.5.21\",\n    \"multer\": \"^2.0.2\",\n    \"mysql2\": \"^3.9.8\",\n    \"parse-color\": \"^1.0.0\",\n    \"passport\": \"^0.6.0\",\n    \"passport-custom\": \"^1.0.5\",\n    \"passport-oauth2\": \"^1.6.1\",\n    \"pkg-up\": \"^3.1.0\",\n    \"redis\": \"^5.9.0\",\n    \"reflect-metadata\": \"^0.1.12\",\n    \"regexp-worker\": \"^1.1.0\",\n    \"safe-regex\": \"^2.0.2\",\n    \"seedrandom\": \"^3.0.1\",\n    \"strip-combining-marks\": \"^1.0.0\",\n    \"threads\": \"^1.7.0\",\n    \"tlds\": \"^1.221.1\",\n    \"tmp\": \"0.2.5\",\n    \"tsconfig-paths\": \"^3.9.0\",\n    \"twemoji\": \"^12.1.4\",\n    \"typeorm\": \"^0.3.27\",\n    \"utf-8-validate\": \"^5.0.5\",\n    \"uuid\": \"^9.0.0\",\n    \"vety\": \"1.0.0-rc2\",\n    \"zod\": \"^4.1.12\"\n  },\n  \"devDependencies\": {\n    \"@types/cors\": \"^2.8.5\",\n    \"@types/express\": \"^4.16.1\",\n    \"@types/js-yaml\": \"^3.12.1\",\n    \"@types/lodash-es\": \"^4.17.12\",\n    \"@types/multer\": \"^1.4.7\",\n    \"@types/passport\": \"^1.0.0\",\n    \"@types/passport-oauth2\": \"^1.4.8\",\n    \"@types/passport-strategy\": \"^0.2.35\",\n    \"@types/safe-regex\": \"^1.1.2\",\n    \"@types/tmp\": \"0.0.33\",\n    \"@types/twemoji\": \"^12.1.0\",\n    \"@types/uuid\": \"^9.0.2\",\n    \"ava\": \"^5.3.1\",\n    \"source-map-support\": \"^0.5.16\"\n  },\n  \"ava\": {\n    \"files\": [\n      \"dist/**/*.test.js\"\n    ],\n    \"require\": [\n      \"./register-tsconfig-paths.js\"\n    ]\n  }\n}\n"
  },
  {
    "path": "backend/register-tsconfig-paths.js",
    "content": "/**\n * See:\n * https://github.com/dividab/tsconfig-paths\n * https://github.com/TypeStrong/ts-node/issues/138\n * https://github.com/TypeStrong/ts-node/issues/138#issuecomment-519602402\n * https://github.com/TypeStrong/ts-node/pull/254\n */\n\nconst path = require(\"path\");\nconst tsconfig = require(\"./tsconfig.json\");\nconst tsconfigPaths = require(\"tsconfig-paths\");\n\n// E.g. ./dist/backend\nconst baseUrl = path.resolve(tsconfig.compilerOptions.outDir, path.basename(__dirname));\ntsconfigPaths.register({\n  baseUrl,\n  paths: tsconfig.compilerOptions.paths || [],\n});\n"
  },
  {
    "path": "backend/src/Blocker.ts",
    "content": "export type Block = {\n  count: number;\n  unblock: () => void;\n  getPromise: () => Promise<void>;\n};\n\nexport class Blocker {\n  #blocks: Map<string, Block> = new Map();\n\n  block(key: string): void {\n    if (!this.#blocks.has(key)) {\n      const promise = new Promise<void>((resolve) => {\n        this.#blocks.set(key, {\n          count: 0, // Incremented to 1 further below\n          unblock() {\n            this.count--;\n            if (this.count === 0) {\n              resolve();\n            }\n          },\n          getPromise: () => promise, // :d\n        });\n      });\n    }\n    this.#blocks.get(key)!.count++;\n  }\n\n  unblock(key: string): void {\n    if (this.#blocks.has(key)) {\n      this.#blocks.get(key)!.unblock();\n    }\n  }\n\n  async waitToBeUnblocked(key: string): Promise<void> {\n    if (!this.#blocks.has(key)) {\n      return;\n    }\n    await this.#blocks.get(key)!.getPromise();\n  }\n}\n"
  },
  {
    "path": "backend/src/DiscordJSError.ts",
    "content": "import util from \"util\";\n\nexport class DiscordJSError extends Error {\n  code: number | string | undefined;\n  shardId: number;\n\n  constructor(message: string, code: number | string | undefined, shardId: number) {\n    super(message);\n    this.code = code;\n    this.shardId = shardId;\n  }\n\n  [util.inspect.custom]() {\n    return `[DISCORDJS] [ERROR CODE ${this.code ?? \"?\"}] [SHARD ${this.shardId}] ${this.message}`;\n  }\n}\n"
  },
  {
    "path": "backend/src/Queue.ts",
    "content": "import { SECONDS } from \"./utils.js\";\n\ntype InternalQueueFn = () => Promise<void>;\ntype AnyFn = (...args: any[]) => any;\n\nconst DEFAULT_TIMEOUT = 10 * SECONDS;\n\nexport class Queue<TQueueFunction extends AnyFn = AnyFn> {\n  protected running = false;\n  protected queue: InternalQueueFn[] = [];\n  protected _timeout: number;\n\n  constructor(timeout = DEFAULT_TIMEOUT) {\n    this._timeout = timeout;\n  }\n\n  get timeout(): number {\n    return this._timeout;\n  }\n\n  /**\n   * The number of operations that are currently queued up or running.\n   * I.e. backlog (queue) + current running process, if any.\n   *\n   * If this is 0, queueing a function will run it as soon as possible.\n   */\n  get length(): number {\n    return this.queue.length + (this.running ? 1 : 0);\n  }\n\n  public add(fn: TQueueFunction): Promise<any> {\n    const promise = new Promise<any>((resolve, reject) => {\n      this.queue.push(async () => {\n        try {\n          const result = await fn();\n          resolve(result);\n        } catch (err) {\n          reject(err);\n        }\n      });\n\n      if (!this.running) this.next();\n    });\n\n    return promise;\n  }\n\n  public next(): void {\n    this.running = true;\n\n    if (this.queue.length === 0) {\n      this.running = false;\n      return;\n    }\n\n    const fn = this.queue.shift()!;\n    new Promise((resolve) => {\n      // Either fn() completes or the timeout is reached\n      void fn().then(resolve);\n      setTimeout(resolve, this._timeout);\n    }).then(() => this.next());\n  }\n\n  public clear() {\n    this.queue.splice(0, this.queue.length);\n  }\n}\n"
  },
  {
    "path": "backend/src/QueuedEventEmitter.ts",
    "content": "import { Queue } from \"./Queue.js\";\n\ntype Listener = (...args: any[]) => void;\n\nexport class QueuedEventEmitter {\n  protected listeners: Map<string, Listener[]>;\n  protected queue: Queue;\n\n  constructor() {\n    this.listeners = new Map();\n    this.queue = new Queue();\n  }\n\n  on(eventName: string, listener: Listener): Listener {\n    if (!this.listeners.has(eventName)) {\n      this.listeners.set(eventName, []);\n    }\n\n    this.listeners.get(eventName)!.push(listener);\n    return listener;\n  }\n\n  off(eventName: string, listener: Listener) {\n    if (!this.listeners.has(eventName)) {\n      return;\n    }\n\n    const listeners = this.listeners.get(eventName)!;\n    listeners.splice(listeners.indexOf(listener), 1);\n  }\n\n  once(eventName: string, listener: Listener): Listener {\n    const handler = this.on(eventName, (...args) => {\n      const result = listener(...args);\n      this.off(eventName, handler);\n      return result;\n    });\n    return handler;\n  }\n\n  emit(eventName: string, args: any[] = []): Promise<void> {\n    const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get(\"*\") || [])];\n\n    let promise: Promise<any> = Promise.resolve();\n    listeners.forEach((listener) => {\n      promise = this.queue.add(listener.bind(null, ...args));\n    });\n\n    return promise;\n  }\n}\n"
  },
  {
    "path": "backend/src/RecoverablePluginError.ts",
    "content": "import { Guild } from \"discord.js\";\n\nexport enum ERRORS {\n  NO_MUTE_ROLE_IN_CONFIG = 1,\n  UNKNOWN_NOTE_CASE,\n  INVALID_EMOJI,\n  NO_USER_NOTIFICATION_CHANNEL,\n  INVALID_USER_NOTIFICATION_CHANNEL,\n  INVALID_USER,\n  INVALID_MUTE_ROLE_ID,\n  MUTE_ROLE_ABOVE_ZEP,\n  USER_ABOVE_ZEP,\n  USER_NOT_MODERATABLE,\n  TEMPLATE_PARSE_ERROR,\n}\n\nexport const RECOVERABLE_PLUGIN_ERROR_MESSAGES = {\n  [ERRORS.NO_MUTE_ROLE_IN_CONFIG]: \"No mute role specified in config\",\n  [ERRORS.UNKNOWN_NOTE_CASE]: \"Tried to add a note to an unknown case\",\n  [ERRORS.INVALID_EMOJI]: \"Invalid emoji\",\n  [ERRORS.NO_USER_NOTIFICATION_CHANNEL]: \"No user notify channel specified\",\n  [ERRORS.INVALID_USER_NOTIFICATION_CHANNEL]: \"Invalid user notify channel specified\",\n  [ERRORS.INVALID_USER]: \"Invalid user\",\n  [ERRORS.INVALID_MUTE_ROLE_ID]: \"Specified mute role is not valid\",\n  [ERRORS.MUTE_ROLE_ABOVE_ZEP]: \"Specified mute role is above Zeppelin in the role hierarchy\",\n  [ERRORS.USER_ABOVE_ZEP]: \"Cannot mute user, specified user is above Zeppelin in the role hierarchy\",\n  [ERRORS.USER_NOT_MODERATABLE]: \"Cannot mute user, specified user is not moderatable\",\n  [ERRORS.TEMPLATE_PARSE_ERROR]: \"Template parse error\",\n};\n\nexport class RecoverablePluginError extends Error {\n  public readonly code: ERRORS;\n  public readonly guild?: Guild;\n\n  constructor(code: ERRORS, guild?: Guild) {\n    super(RECOVERABLE_PLUGIN_ERROR_MESSAGES[code]);\n    this.guild = guild;\n    this.code = code;\n  }\n}\n"
  },
  {
    "path": "backend/src/RegExpRunner.ts",
    "content": "import { CooldownManager } from \"vety\";\nimport { EventEmitter } from \"node:events\";\nimport { RegExpWorker, TimeoutError } from \"regexp-worker\";\nimport { MINUTES, SECONDS } from \"./utils.js\";\nimport Timeout = NodeJS.Timeout;\n\nconst isTimeoutError = (a): a is TimeoutError => {\n  return a.message != null && a.elapsedTimeMs != null;\n};\n\nexport class RegExpTimeoutError extends Error {\n  constructor(\n    message: string,\n    public elapsedTimeMs: number,\n  ) {\n    super(message);\n  }\n}\n\nexport function allowTimeout(err: RegExpTimeoutError | Error) {\n  if (err instanceof RegExpTimeoutError) {\n    return null;\n  }\n\n  throw err;\n}\n\n// Regex timeout starts at a higher value while the bot loads initially, and gets lowered afterwards\nconst INITIAL_REGEX_TIMEOUT = 5 * SECONDS;\nconst INITIAL_REGEX_TIMEOUT_DURATION = 30 * SECONDS;\nconst FINAL_REGEX_TIMEOUT = 5 * SECONDS;\n\nconst regexTimeoutUpgradePromise = new Promise((resolve) => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION));\n\nlet newWorkerTimeout = INITIAL_REGEX_TIMEOUT;\nregexTimeoutUpgradePromise.then(() => (newWorkerTimeout = FINAL_REGEX_TIMEOUT));\n\nconst REGEX_FAIL_TO_COOLDOWN_COUNT = 5; // If a regex times out this many times...\nconst REGEX_FAIL_DECAY_TIME = 2 * MINUTES; // ...in this interval...\nconst REGEX_FAIL_COOLDOWN = 2 * MINUTES + 30 * SECONDS; // ...it goes on cooldown for this long\n\nexport interface RegExpRunner {\n  on(event: \"timeout\", listener: (regexSource: string, timeoutMs: number) => void);\n  on(event: \"repeatedTimeout\", listener: (regexSource: string, timeoutMs: number, failTimes: number) => void);\n}\n\n/**\n * Leverages RegExpWorker to run regular expressions in worker threads with a timeout.\n * Repeatedly failing regexes are put on a cooldown where requests to execute them are ignored.\n */\nexport class RegExpRunner extends EventEmitter {\n  private _worker: RegExpWorker | null;\n  private readonly _failedTimesInterval: Timeout;\n\n  private cooldown: CooldownManager;\n  private failedTimes: Map<string, number>;\n\n  constructor() {\n    super();\n    this.cooldown = new CooldownManager();\n    this.failedTimes = new Map();\n    this._failedTimesInterval = setInterval(() => {\n      for (const [pattern, times] of this.failedTimes.entries()) {\n        this.failedTimes.set(pattern, times - 1);\n      }\n    }, REGEX_FAIL_DECAY_TIME);\n  }\n\n  private get worker(): RegExpWorker {\n    if (!this._worker) {\n      this._worker = new RegExpWorker(newWorkerTimeout);\n      if (newWorkerTimeout !== FINAL_REGEX_TIMEOUT) {\n        regexTimeoutUpgradePromise.then(() => {\n          if (!this._worker) return;\n          this._worker.timeout = FINAL_REGEX_TIMEOUT;\n        });\n      }\n    }\n\n    return this._worker;\n  }\n\n  public async exec(regex: RegExp, str: string): Promise<null | RegExpExecArray[]> {\n    if (this.cooldown.isOnCooldown(regex.source)) {\n      return null;\n    }\n\n    try {\n      const result = await this.worker.execRegExp(regex, str);\n      return result.matches.length || regex.global ? result.matches : null;\n    } catch (e) {\n      if (isTimeoutError(e)) {\n        if (this.failedTimes.has(regex.source)) {\n          // Regex has failed before, increment fail counter\n          this.failedTimes.set(regex.source, this.failedTimes.get(regex.source)! + 1);\n        } else {\n          // This is the first time this regex failed, init fail counter\n          this.failedTimes.set(regex.source, 1);\n        }\n\n        if (this.failedTimes.has(regex.source) && this.failedTimes.get(regex.source)! >= REGEX_FAIL_TO_COOLDOWN_COUNT) {\n          // Regex has failed too many times, set it on cooldown\n          this.cooldown.setCooldown(regex.source, REGEX_FAIL_COOLDOWN);\n          this.failedTimes.delete(regex.source);\n          this.emit(\"repeatedTimeout\", regex.source, this.worker.timeout, REGEX_FAIL_TO_COOLDOWN_COUNT);\n        }\n\n        this.emit(\"timeout\", regex.source, this.worker.timeout);\n\n        throw new RegExpTimeoutError(e.message, e.elapsedTimeMs);\n      }\n\n      throw e;\n    }\n  }\n\n  public async dispose() {\n    await this.worker.dispose();\n    this._worker = null;\n    clearInterval(this._failedTimesInterval);\n  }\n}\n"
  },
  {
    "path": "backend/src/SimpleCache.ts",
    "content": "import Timeout = NodeJS.Timeout;\n\nconst CLEAN_INTERVAL = 1000;\n\nexport class SimpleCache<T = any> {\n  protected readonly retentionTime: number;\n  protected readonly maxItems: number;\n\n  protected cleanTimeout: Timeout;\n  protected unloaded: boolean;\n\n  protected store: Map<string, { remove_at: number; value: T }>;\n\n  constructor(retentionTime: number, maxItems?: number) {\n    this.retentionTime = retentionTime;\n\n    if (maxItems) {\n      this.maxItems = maxItems;\n    }\n\n    this.store = new Map();\n  }\n\n  unload() {\n    this.unloaded = true;\n    clearTimeout(this.cleanTimeout);\n  }\n\n  cleanLoop() {\n    const now = Date.now();\n    for (const [key, info] of this.store.entries()) {\n      if (now >= info.remove_at) {\n        this.store.delete(key);\n      }\n    }\n\n    if (!this.unloaded) {\n      this.cleanTimeout = setTimeout(() => this.cleanLoop(), CLEAN_INTERVAL);\n    }\n  }\n\n  set(key: string, value: T) {\n    this.store.set(key, {\n      remove_at: Date.now() + this.retentionTime,\n      value,\n    });\n\n    if (this.maxItems && this.store.size > this.maxItems) {\n      const keyToDelete = this.store.keys().next().value!;\n      this.store.delete(keyToDelete);\n    }\n  }\n\n  get(key: string): T | null {\n    const info = this.store.get(key);\n    if (!info) return null;\n\n    return info.value;\n  }\n\n  has(key: string) {\n    return this.store.has(key);\n  }\n\n  delete(key: string) {\n    this.store.delete(key);\n  }\n\n  clear() {\n    this.store.clear();\n  }\n}\n"
  },
  {
    "path": "backend/src/SimpleError.ts",
    "content": "import util from \"util\";\n\nexport class SimpleError extends Error {\n  public message: string;\n\n  constructor(message: string) {\n    super(message);\n  }\n\n  [util.inspect.custom]() {\n    return `Error: ${this.message}`;\n  }\n}\n"
  },
  {
    "path": "backend/src/api/archives.ts",
    "content": "import express, { Request, Response } from \"express\";\nimport moment from \"moment-timezone\";\nimport { GuildArchives } from \"../data/GuildArchives.js\";\nimport { notFound } from \"./responses.js\";\n\nexport function initArchives(router: express.Router) {\n  const archives = new GuildArchives(null);\n\n  // Legacy redirect\n  router.get(\"/spam-logs/:id\", (req: Request, res: Response) => {\n    res.redirect(\"/archives/\" + req.params.id);\n  });\n\n  router.get(\"/archives/:id\", async (req: Request, res: Response) => {\n    const archive = await archives.find(req.params.id);\n    if (!archive) return notFound(res);\n\n    let body = archive.body;\n\n    // Add some metadata at the end of the log file (but only if it doesn't already have it directly in the body)\n    // TODO: Use server timezone / date formats\n    if (archive.body.indexOf(\"Log file generated on\") === -1) {\n      const createdAt = moment.utc(archive.created_at).format(\"YYYY-MM-DD [at] HH:mm:ss [(+00:00)]\");\n      body += `\\n\\nLog file generated on ${createdAt}`;\n\n      if (archive.expires_at !== null) {\n        const expiresAt = moment.utc(archive.expires_at).format(\"YYYY-MM-DD [at] HH:mm:ss [(+00:00)]\");\n        body += `\\nExpires at ${expiresAt}`;\n      }\n    }\n\n    res.setHeader(\"Content-Type\", \"text/plain; charset=UTF-8\");\n    res.setHeader(\"X-Content-Type-Options\", \"nosniff\");\n    res.end(body);\n  });\n}\n"
  },
  {
    "path": "backend/src/api/auth.ts",
    "content": "import express, { Request, Response } from \"express\";\nimport https from \"https\";\nimport { pick } from \"lodash-es\";\nimport passport from \"passport\";\nimport { Strategy as CustomStrategy } from \"passport-custom\";\nimport OAuth2Strategy from \"passport-oauth2\";\nimport { ApiLogins } from \"../data/ApiLogins.js\";\nimport { ApiPermissionAssignments } from \"../data/ApiPermissionAssignments.js\";\nimport { ApiUserInfo } from \"../data/ApiUserInfo.js\";\nimport { ApiUserInfoData } from \"../data/entities/ApiUserInfo.js\";\nimport { env } from \"../env.js\";\nimport { ok } from \"./responses.js\";\n\ninterface IPassportApiUser {\n  apiKey: string;\n  userId: string;\n}\n\ndeclare global {\n  namespace Express {\n    interface User extends IPassportApiUser {}\n  }\n}\n\nconst DISCORD_API_URL = \"https://discord.com/api\";\n\nfunction simpleDiscordAPIRequest(bearerToken, path): Promise<any> {\n  return new Promise((resolve, reject) => {\n    const request = https.get(\n      `${DISCORD_API_URL}/${path}`,\n      {\n        headers: {\n          Authorization: `Bearer ${bearerToken}`,\n        },\n      },\n      (res) => {\n        if (res.statusCode !== 200) {\n          reject(new Error(`Discord API error ${res.statusCode}`));\n          return;\n        }\n\n        let rawData = \"\";\n        res.on(\"data\", (data) => (rawData += data));\n        res.on(\"end\", () => {\n          resolve(JSON.parse(rawData));\n        });\n      },\n    );\n\n    request.on(\"error\", (err) => reject(err));\n  });\n}\n\nexport function initAuth(router: express.Router) {\n  router.use(passport.initialize());\n\n  passport.serializeUser((user, done) => done(null, user));\n  passport.deserializeUser((user, done) => done(null, user as IPassportApiUser));\n\n  const apiLogins = new ApiLogins();\n  const apiUserInfo = new ApiUserInfo();\n  const apiPermissionAssignments = new ApiPermissionAssignments();\n\n  // Initialize API tokens\n  passport.use(\n    \"api-token\",\n    new CustomStrategy(async (req, cb) => {\n      const apiKey = req.header(\"X-Api-Key\") || req.body?.[\"X-Api-Key\"];\n      if (!apiKey) return cb(\"API key missing\");\n\n      const userId = await apiLogins.getUserIdByApiKey(apiKey);\n      if (userId) {\n        void apiLogins.refreshApiKeyExpiryTime(apiKey); // Refresh expiry time in the background\n        return cb(null, { apiKey, userId });\n      }\n\n      cb(\"API key not found\");\n    }),\n  );\n\n  // Initialize OAuth2 for Discord login\n  // When the user logs in through OAuth2, we create them a \"login\" (= api token) and update their user info in the DB\n  passport.use(\n    new OAuth2Strategy(\n      {\n        authorizationURL: \"https://discord.com/api/oauth2/authorize\",\n        tokenURL: \"https://discord.com/api/oauth2/token\",\n        clientID: env.CLIENT_ID,\n        clientSecret: env.CLIENT_SECRET,\n        callbackURL: `${env.API_URL}/auth/oauth-callback`,\n        scope: [\"identify\"],\n      },\n      async (accessToken, refreshToken, profile, cb) => {\n        const user = await simpleDiscordAPIRequest(accessToken, \"users/@me\");\n\n        // Make sure the user is able to access at least 1 guild\n        const permissions = await apiPermissionAssignments.getByUserId(user.id);\n        if (permissions.length === 0) {\n          cb(null, {});\n          return;\n        }\n\n        // Generate API key\n        const apiKey = await apiLogins.addLogin(user.id);\n        const userData = pick(user, [\"username\", \"discriminator\", \"avatar\"]) as ApiUserInfoData;\n        await apiUserInfo.update(user.id, userData);\n        // TODO: Revoke access token, we don't need it anymore\n        cb(null, { apiKey });\n      },\n    ),\n  );\n\n  router.get(\"/auth/login\", passport.authenticate(\"oauth2\"));\n  router.get(\n    \"/auth/oauth-callback\",\n    passport.authenticate(\"oauth2\", { failureRedirect: \"/\", session: false }),\n    (req: Request, res: Response) => {\n      if (req.user && req.user.apiKey) {\n        res.redirect(`${env.DASHBOARD_URL}/login-callback/?apiKey=${req.user.apiKey}`);\n      } else {\n        res.redirect(`${env.DASHBOARD_URL}/login-callback/?error=noAccess`);\n      }\n    },\n  );\n  router.post(\"/auth/validate-key\", async (req: Request, res: Response) => {\n    const key = req.body.key;\n    if (!key) {\n      return res.status(400).json({ error: \"No key supplied\" });\n    }\n\n    const userId = await apiLogins.getUserIdByApiKey(key);\n    if (!userId) {\n      return res.json({ valid: false });\n    }\n\n    res.json({ valid: true, userId });\n  });\n  router.post(\"/auth/logout\", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => {\n    await apiLogins.expireApiKey(req.user!.apiKey);\n    return ok(res);\n  });\n\n  // API route to refresh the given API token's expiry time\n  // The actual refreshing happens in the api-token passport strategy above, so we just return 200 OK here\n  router.post(\"/auth/refresh\", ...apiTokenAuthHandlers(), (req, res) => {\n    return ok(res);\n  });\n}\n\nexport function apiTokenAuthHandlers() {\n  return [\n    passport.authenticate(\"api-token\", { failWithError: true, session: false }),\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    (err, req: Request, res: Response, next) => {\n      return res.status(401).json({ error: err.message });\n    },\n  ];\n}\n"
  },
  {
    "path": "backend/src/api/docs.ts",
    "content": "import express from \"express\";\nimport { z } from \"zod\";\nimport { $ZodPipeDef } from \"zod/v4/core\";\nimport { availableGuildPlugins } from \"../plugins/availablePlugins.js\";\nimport { ZeppelinGuildPluginInfo } from \"../types.js\";\nimport { indentLines } from \"../utils.js\";\nimport { notFound } from \"./responses.js\";\n\nfunction isZodObject(schema: z.ZodType): schema is z.ZodObject<any> {\n  return schema.def.type === \"object\";\n}\n\nfunction isZodRecord(schema: z.ZodType): schema is z.ZodRecord<any> {\n  return schema.def.type === \"record\";\n}\n\nfunction isZodOptional(schema: z.ZodType): schema is z.ZodOptional<any> {\n  return schema.def.type === \"optional\";\n}\n\nfunction isZodArray(schema: z.ZodType): schema is z.ZodArray<any> {\n  return schema.def.type === \"array\";\n}\n\nfunction isZodUnion(schema: z.ZodType): schema is z.ZodUnion<any> {\n  return schema.def.type === \"union\";\n}\n\nfunction isZodNullable(schema: z.ZodType): schema is z.ZodNullable<any> {\n  return schema.def.type === \"nullable\";\n}\n\nfunction isZodDefault(schema: z.ZodType): schema is z.ZodDefault<any> {\n  return schema.def.type === \"default\";\n}\n\nfunction isZodLiteral(schema: z.ZodType): schema is z.ZodLiteral<any> {\n  return schema.def.type === \"literal\";\n}\n\nfunction isZodIntersection(schema: z.ZodType): schema is z.ZodIntersection<any, any> {\n  return schema.def.type === \"intersection\";\n}\n\nfunction formatZodConfigSchema(schema: z.ZodType) {\n  if (isZodObject(schema)) {\n    return (\n      `{\\n` +\n      Object.entries(schema.def.shape)\n        .map(([k, value]) => indentLines(`${k}: ${formatZodConfigSchema(value as z.ZodType)}`, 2))\n        .join(\"\\n\") +\n      \"\\n}\"\n    );\n  }\n  if (isZodRecord(schema)) {\n    return \"{\\n\" + indentLines(`[string]: ${formatZodConfigSchema(schema.valueType as z.ZodType)}`, 2) + \"\\n}\";\n  }\n  if (isZodOptional(schema)) {\n    return `Optional<${formatZodConfigSchema(schema.def.innerType)}>`;\n  }\n  if (isZodArray(schema)) {\n    return `Array<${formatZodConfigSchema(schema.def.element)}>`;\n  }\n  if (isZodUnion(schema)) {\n    return schema.def.options.map((t) => formatZodConfigSchema(t)).join(\" | \");\n  }\n  if (isZodNullable(schema)) {\n    return `Nullable<${formatZodConfigSchema(schema.def.innerType)}>`;\n  }\n  if (isZodDefault(schema)) {\n    return formatZodConfigSchema(schema.def.innerType);\n  }\n  if (isZodLiteral(schema)) {\n    return schema.def.values;\n  }\n  if (isZodIntersection(schema)) {\n    return [\n      formatZodConfigSchema(schema.def.left as z.ZodType),\n      formatZodConfigSchema(schema.def.right as z.ZodType),\n    ].join(\" & \");\n  }\n  if (schema.def.type === \"string\") {\n    return \"string\";\n  }\n  if (schema.def.type === \"number\") {\n    return \"number\";\n  }\n  if (schema.def.type === \"boolean\") {\n    return \"boolean\";\n  }\n  if (schema.def.type === \"never\") {\n    return \"never\";\n  }\n  if (schema.def.type === \"pipe\") {\n    return formatZodConfigSchema((schema.def as $ZodPipeDef).in as z.ZodType);\n  }\n  return \"unknown\";\n}\n\nconst availableGuildPluginsByName = availableGuildPlugins.reduce<Record<string, ZeppelinGuildPluginInfo>>(\n  (map, obj) => {\n    map[obj.plugin.name] = obj;\n    return map;\n  },\n  {},\n);\n\nexport function initDocs(router: express.Router) {\n  const docsPlugins = availableGuildPlugins.filter((obj) => obj.docs.type !== \"internal\");\n\n  router.get(\"/docs/plugins\", (req: express.Request, res: express.Response) => {\n    res.json(\n      docsPlugins.map((obj) => ({\n        name: obj.plugin.name,\n        info: {\n          prettyName: obj.docs.prettyName,\n          type: obj.docs.type,\n        },\n      })),\n    );\n  });\n\n  router.get(\"/docs/plugins/:pluginName\", (req: express.Request, res: express.Response) => {\n    const pluginInfo = availableGuildPluginsByName[req.params.pluginName];\n    if (!pluginInfo) {\n      return notFound(res);\n    }\n\n    const { configSchema, ...info } = pluginInfo.docs;\n    const formattedConfigSchema = formatZodConfigSchema(configSchema);\n\n    const messageCommands = (pluginInfo.plugin.messageCommands || []).map((cmd) => ({\n      trigger: cmd.trigger,\n      permission: cmd.permission,\n      signature: cmd.signature,\n      description: cmd.description,\n      usage: cmd.usage,\n      config: cmd.config,\n    }));\n\n    const defaultOptions = pluginInfo.docs.configSchema.safeParse({}).data ?? {};\n\n    res.json({\n      name: pluginInfo.plugin.name,\n      info,\n      configSchema: formattedConfigSchema,\n      defaultOptions,\n      messageCommands,\n    });\n  });\n}\n"
  },
  {
    "path": "backend/src/api/guilds/importExport.ts",
    "content": "import { ApiPermissions } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport express, { Request, Response } from \"express\";\nimport moment from \"moment-timezone\";\nimport { z } from \"zod\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { Case } from \"../../data/entities/Case.js\";\nimport { MINUTES } from \"../../utils.js\";\nimport { requireGuildPermission } from \"../permissions.js\";\nimport { rateLimit } from \"../rateLimits.js\";\nimport { clientError, ok } from \"../responses.js\";\n\nconst caseHandlingModeSchema = z.union([\n  z.literal(\"replace\"),\n  z.literal(\"bumpExistingCases\"),\n  z.literal(\"bumpImportedCases\"),\n]);\n\ntype CaseHandlingMode = z.infer<typeof caseHandlingModeSchema>;\n\nconst caseNoteData = z.object({\n  mod_id: z.string(),\n  mod_name: z.string(),\n  body: z.string(),\n  created_at: z.string(),\n});\n\nconst caseData = z.object({\n  case_number: z.number(),\n  user_id: z.string(),\n  user_name: z.string(),\n  mod_id: z.nullable(z.string()),\n  mod_name: z.nullable(z.string()),\n  type: z.number(),\n  created_at: z.string(),\n  is_hidden: z.boolean(),\n  pp_id: z.nullable(z.string()),\n  pp_name: z.nullable(z.string()),\n  log_message_id: z.string().optional(),\n  notes: z.array(caseNoteData),\n});\n\nconst importExportData = z.object({\n  cases: z.array(caseData),\n});\ntype TImportExportData = z.infer<typeof importExportData>;\n\nexport function initGuildsImportExportAPI(guildRouter: express.Router) {\n  const importExportRouter = express.Router();\n\n  importExportRouter.get(\n    \"/:guildId/pre-import\",\n    requireGuildPermission(ApiPermissions.ManageAccess),\n    async (req: Request) => {\n      const guildCases = GuildCases.getGuildInstance(req.params.guildId);\n      const minNum = await guildCases.getMinCaseNumber();\n      const maxNum = await guildCases.getMaxCaseNumber();\n\n      return {\n        minCaseNumber: minNum,\n        maxCaseNumber: maxNum,\n      };\n    },\n  );\n\n  importExportRouter.post(\n    \"/:guildId/import\",\n    requireGuildPermission(ApiPermissions.ManageAccess),\n    rateLimit(\n      (req) => `import-${req.params.guildId}`,\n      5 * MINUTES,\n      \"A single server can only import data once every 5 minutes\",\n    ),\n    async (req: Request, res: Response) => {\n      let data: TImportExportData;\n      try {\n        data = importExportData.parse(req.body.data);\n      } catch (err) {\n        const prettyMessage = `${err.issues[0].code}: expected ${err.issues[0].expected}, received ${\n          err.issues[0].received\n        } at /${err.issues[0].path.join(\"/\")}`;\n        return clientError(res, `Invalid import data format: ${prettyMessage}`);\n        return;\n      }\n\n      let caseHandlingMode: CaseHandlingMode;\n      try {\n        caseHandlingMode = caseHandlingModeSchema.parse(req.body.caseHandlingMode);\n      } catch (err) {\n        return clientError(res, \"Invalid case handling mode\");\n        return;\n      }\n\n      const seenCaseNumbers = new Set();\n      for (const theCase of data.cases) {\n        if (seenCaseNumbers.has(theCase.case_number)) {\n          return clientError(res, `Duplicate case number: ${theCase.case_number}`);\n        }\n        seenCaseNumbers.add(theCase.case_number);\n      }\n\n      const guildCases = GuildCases.getGuildInstance(req.params.guildId);\n\n      // Prepare cases\n      if (caseHandlingMode === \"replace\") {\n        // Replace existing cases\n        await guildCases.deleteAllCases();\n      } else if (caseHandlingMode === \"bumpExistingCases\") {\n        // Bump existing numbers\n        const maxNumberInData = data.cases.reduce((max, theCase) => Math.max(max, theCase.case_number), 0);\n        await guildCases.bumpCaseNumbers(maxNumberInData);\n      } else if (caseHandlingMode === \"bumpImportedCases\") {\n        const maxExistingNumber = await guildCases.getMaxCaseNumber();\n        for (const theCase of data.cases) {\n          theCase.case_number += maxExistingNumber;\n        }\n      }\n\n      // Import cases\n      for (const theCase of data.cases) {\n        const insertData: any = {\n          ...theCase,\n          is_hidden: theCase.is_hidden ? 1 : 0,\n          guild_id: req.params.guildId,\n          notes: undefined,\n        };\n\n        const caseInsertData = await guildCases.createInternal(insertData);\n        for (const note of theCase.notes) {\n          await guildCases.createNote(caseInsertData.identifiers[0].id, note);\n        }\n      }\n\n      ok(res);\n    },\n  );\n\n  const exportBatchSize = 500;\n  importExportRouter.post(\n    \"/:guildId/export\",\n    requireGuildPermission(ApiPermissions.ManageAccess),\n    rateLimit(\n      (req) => `export-${req.params.guildId}`,\n      5 * MINUTES,\n      \"A single server can only export data once every 5 minutes\",\n    ),\n    async (req: Request, res: Response) => {\n      const guildCases = GuildCases.getGuildInstance(req.params.guildId);\n\n      const data: TImportExportData = {\n        cases: [],\n      };\n\n      let n = 0;\n      let cases: Case[];\n      do {\n        cases = await guildCases.getExportCases(n, exportBatchSize);\n        n += cases.length;\n\n        for (const theCase of cases) {\n          data.cases.push({\n            case_number: theCase.case_number,\n            user_id: theCase.user_id,\n            user_name: theCase.user_name,\n            mod_id: theCase.mod_id,\n            mod_name: theCase.mod_name,\n            type: theCase.type,\n            created_at: theCase.created_at,\n            is_hidden: theCase.is_hidden,\n            pp_id: theCase.pp_id,\n            pp_name: theCase.pp_name,\n            log_message_id: theCase.log_message_id ?? undefined,\n            notes: theCase.notes.map((note) => ({\n              mod_id: note.mod_id,\n              mod_name: note.mod_name,\n              body: note.body,\n              created_at: note.created_at,\n            })),\n          });\n        }\n      } while (cases.length === exportBatchSize);\n\n      const filename = `export_${req.params.guildId}_${moment().format(\"YYYY-MM-DD_HH-mm-ss\")}.json`;\n      const serialized = JSON.stringify(data, null, 2);\n\n      res.setHeader(\"Content-Disposition\", `attachment; filename=${filename}`);\n      res.setHeader(\"Content-Type\", \"application/octet-stream\");\n      res.setHeader(\"Content-Length\", serialized.length);\n      res.send(serialized);\n    },\n  );\n\n  guildRouter.use(\"/\", importExportRouter);\n}\n"
  },
  {
    "path": "backend/src/api/guilds/index.ts",
    "content": "import express from \"express\";\nimport { apiTokenAuthHandlers } from \"../auth.js\";\nimport { initGuildsImportExportAPI } from \"./importExport.js\";\nimport { initGuildsMiscAPI } from \"./misc.js\";\n\nexport function initGuildsAPI(router: express.Router) {\n  const guildRouter = express.Router();\n  guildRouter.use(...apiTokenAuthHandlers());\n\n  initGuildsMiscAPI(guildRouter);\n  initGuildsImportExportAPI(guildRouter);\n\n  router.use(\"/guilds\", guildRouter);\n}\n"
  },
  {
    "path": "backend/src/api/guilds/misc.ts",
    "content": "import { ApiPermissions } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport express, { Request, Response } from \"express\";\nimport { YAMLException } from \"js-yaml\";\nimport moment from \"moment-timezone\";\nimport { Queue } from \"../../Queue.js\";\nimport { validateGuildConfig } from \"../../configValidator.js\";\nimport { AllowedGuilds } from \"../../data/AllowedGuilds.js\";\nimport { ApiAuditLog } from \"../../data/ApiAuditLog.js\";\nimport { ApiPermissionAssignments, ApiPermissionTypes } from \"../../data/ApiPermissionAssignments.js\";\nimport { Configs } from \"../../data/Configs.js\";\nimport { AuditLogEventTypes } from \"../../data/apiAuditLogTypes.js\";\nimport { isSnowflake } from \"../../utils.js\";\nimport { loadYamlSafely } from \"../../utils/loadYamlSafely.js\";\nimport { ObjectAliasError } from \"../../utils/validateNoObjectAliases.js\";\nimport { hasGuildPermission, requireGuildPermission } from \"../permissions.js\";\nimport { clientError, ok, serverError, unauthorized } from \"../responses.js\";\n\nconst apiPermissionAssignments = new ApiPermissionAssignments();\nconst auditLog = new ApiAuditLog();\n\nexport function initGuildsMiscAPI(router: express.Router) {\n  const allowedGuilds = new AllowedGuilds();\n  const configs = new Configs();\n\n  const miscRouter = express.Router();\n\n  miscRouter.get(\"/available\", async (req: Request, res: Response) => {\n    const guilds = await allowedGuilds.getForApiUser(req.user!.userId);\n    res.json(guilds);\n  });\n\n  miscRouter.get(\n    \"/my-permissions\", // a\n    async (req: Request, res: Response) => {\n      const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId);\n      res.json(permissions);\n    },\n  );\n\n  miscRouter.get(\"/:guildId\", async (req: Request, res: Response) => {\n    if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) {\n      return unauthorized(res);\n    }\n\n    const guild = await allowedGuilds.find(req.params.guildId);\n    res.json(guild);\n  });\n\n  miscRouter.post(\"/:guildId/check-permission\", async (req: Request, res: Response) => {\n    const permission = req.body.permission;\n    const hasPermission = await hasGuildPermission(req.user!.userId, req.params.guildId, permission);\n    res.json({ result: hasPermission });\n  });\n\n  miscRouter.get(\n    \"/:guildId/config\",\n    requireGuildPermission(ApiPermissions.ReadConfig),\n    async (req: Request, res: Response) => {\n      const config = await configs.getActiveByKey(`guild-${req.params.guildId}`);\n      res.json({ config: config ? config.config : \"\" });\n    },\n  );\n\n  miscRouter.post(\"/:guildId/config\", requireGuildPermission(ApiPermissions.EditConfig), async (req, res) => {\n    let config = req.body.config;\n    if (config == null) return clientError(res, \"No config supplied\");\n\n    config = config.trim() + \"\\n\"; // Normalize start/end whitespace in the config\n\n    const currentConfig = await configs.getActiveByKey(`guild-${req.params.guildId}`);\n    if (currentConfig && config === currentConfig.config) {\n      return ok(res);\n    }\n\n    // Validate config\n    let parsedConfig;\n    try {\n      parsedConfig = loadYamlSafely(config);\n    } catch (e) {\n      if (e instanceof YAMLException) {\n        return res.status(400).json({ errors: [e.message] });\n      }\n\n      if (e instanceof ObjectAliasError) {\n        return res.status(400).json({ errors: [e.message] });\n      }\n\n      // tslint:disable-next-line:no-console\n      console.error(\"Error when loading YAML: \" + e.message);\n      return serverError(res, \"Server error\");\n    }\n\n    if (parsedConfig == null) {\n      parsedConfig = {};\n    }\n\n    const error = await validateGuildConfig(parsedConfig);\n    if (error) {\n      return res.status(422).json({ errors: [error] });\n    }\n\n    await configs.saveNewRevision(`guild-${req.params.guildId}`, config, req.user!.userId);\n\n    ok(res);\n  });\n\n  miscRouter.get(\n    \"/:guildId/permissions\",\n    requireGuildPermission(ApiPermissions.ManageAccess),\n    async (req: Request, res: Response) => {\n      const permissions = await apiPermissionAssignments.getByGuildId(req.params.guildId);\n      res.json(permissions);\n    },\n  );\n\n  const permissionManagementQueue = new Queue();\n  miscRouter.post(\n    \"/:guildId/set-target-permissions\",\n    requireGuildPermission(ApiPermissions.ManageAccess),\n    async (req: Request, res: Response) => {\n      await permissionManagementQueue.add(async () => {\n        const { type, targetId, permissions, expiresAt } = req.body;\n\n        if (type !== ApiPermissionTypes.User) {\n          return clientError(res, \"Invalid type\");\n        }\n        if (!isSnowflake(targetId)) {\n          return clientError(res, \"Invalid targetId\");\n        }\n        const validPermissions = new Set(Object.values(ApiPermissions));\n        validPermissions.delete(ApiPermissions.Owner);\n        if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) {\n          return clientError(res, \"Invalid permissions\");\n        }\n        if (expiresAt != null && !moment.utc(expiresAt).isValid()) {\n          return clientError(res, \"Invalid expiresAt\");\n        }\n\n        const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);\n        if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) {\n          return clientError(res, \"Can't change owner permissions\");\n        }\n\n        if (permissions.length === 0) {\n          await apiPermissionAssignments.removeUser(req.params.guildId, targetId);\n          await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, {\n            type: ApiPermissionTypes.User,\n            target_id: targetId,\n          });\n        } else {\n          const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);\n          if (existing) {\n            await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions);\n            await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, {\n              type: ApiPermissionTypes.User,\n              target_id: targetId,\n              permissions,\n              expires_at: existing.expires_at,\n            });\n          } else {\n            await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt);\n            await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, {\n              type: ApiPermissionTypes.User,\n              target_id: targetId,\n              permissions,\n              expires_at: expiresAt,\n            });\n          }\n        }\n\n        ok(res);\n      });\n    },\n  );\n\n  router.use(\"/\", miscRouter);\n}\n"
  },
  {
    "path": "backend/src/api/guilds.ts",
    "content": "import { ApiPermissions } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport express, { Request, Response } from \"express\";\nimport jsYaml from \"js-yaml\";\nimport moment from \"moment-timezone\";\nimport { Queue } from \"../Queue.js\";\nimport { validateGuildConfig } from \"../configValidator.js\";\nimport { AllowedGuilds } from \"../data/AllowedGuilds.js\";\nimport { ApiAuditLog } from \"../data/ApiAuditLog.js\";\nimport { ApiPermissionAssignments, ApiPermissionTypes } from \"../data/ApiPermissionAssignments.js\";\nimport { Configs } from \"../data/Configs.js\";\nimport { AuditLogEventTypes } from \"../data/apiAuditLogTypes.js\";\nimport { isSnowflake } from \"../utils.js\";\nimport { loadYamlSafely } from \"../utils/loadYamlSafely.js\";\nimport { ObjectAliasError } from \"../utils/validateNoObjectAliases.js\";\nimport { apiTokenAuthHandlers } from \"./auth.js\";\nimport { hasGuildPermission, requireGuildPermission } from \"./permissions.js\";\nimport { clientError, ok, serverError, unauthorized } from \"./responses.js\";\n\nconst YAMLException = jsYaml.YAMLException;\n\nconst apiPermissionAssignments = new ApiPermissionAssignments();\nconst auditLog = new ApiAuditLog();\n\nexport function initGuildsAPI(app: express.Express) {\n  const allowedGuilds = new AllowedGuilds();\n  const configs = new Configs();\n\n  const guildRouter = express.Router();\n  guildRouter.use(...apiTokenAuthHandlers());\n\n  guildRouter.get(\"/available\", async (req: Request, res: Response) => {\n    const guilds = await allowedGuilds.getForApiUser(req.user!.userId);\n    res.json(guilds);\n  });\n\n  guildRouter.get(\n    \"/my-permissions\", // a\n    async (req: Request, res: Response) => {\n      const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId);\n      res.json(permissions);\n    },\n  );\n\n  guildRouter.get(\"/:guildId\", async (req: Request, res: Response) => {\n    if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) {\n      return unauthorized(res);\n    }\n\n    const guild = await allowedGuilds.find(req.params.guildId);\n    res.json(guild);\n  });\n\n  guildRouter.post(\"/:guildId/check-permission\", async (req: Request, res: Response) => {\n    const permission = req.body.permission;\n    const hasPermission = await hasGuildPermission(req.user!.userId, req.params.guildId, permission);\n    res.json({ result: hasPermission });\n  });\n\n  guildRouter.get(\n    \"/:guildId/config\",\n    requireGuildPermission(ApiPermissions.ReadConfig),\n    async (req: Request, res: Response) => {\n      const config = await configs.getActiveByKey(`guild-${req.params.guildId}`);\n      res.json({ config: config ? config.config : \"\" });\n    },\n  );\n\n  guildRouter.post(\"/:guildId/config\", requireGuildPermission(ApiPermissions.EditConfig), async (req, res) => {\n    let config = req.body.config;\n    if (config == null) return clientError(res, \"No config supplied\");\n\n    config = config.trim() + \"\\n\"; // Normalize start/end whitespace in the config\n\n    const currentConfig = await configs.getActiveByKey(`guild-${req.params.guildId}`);\n    if (currentConfig && config === currentConfig.config) {\n      return ok(res);\n    }\n\n    // Validate config\n    let parsedConfig;\n    try {\n      parsedConfig = loadYamlSafely(config);\n    } catch (e) {\n      if (e instanceof YAMLException) {\n        return res.status(400).json({ errors: [e.message] });\n      }\n\n      if (e instanceof ObjectAliasError) {\n        return res.status(400).json({ errors: [e.message] });\n      }\n\n      // tslint:disable-next-line:no-console\n      console.error(\"Error when loading YAML: \" + e.message);\n      return serverError(res, \"Server error\");\n    }\n\n    if (parsedConfig == null) {\n      parsedConfig = {};\n    }\n\n    const error = await validateGuildConfig(parsedConfig);\n    if (error) {\n      return res.status(422).json({ errors: [error] });\n    }\n\n    await configs.saveNewRevision(`guild-${req.params.guildId}`, config, req.user!.userId);\n\n    ok(res);\n  });\n\n  guildRouter.get(\n    \"/:guildId/permissions\",\n    requireGuildPermission(ApiPermissions.ManageAccess),\n    async (req: Request, res: Response) => {\n      const permissions = await apiPermissionAssignments.getByGuildId(req.params.guildId);\n      res.json(permissions);\n    },\n  );\n\n  const permissionManagementQueue = new Queue();\n  guildRouter.post(\n    \"/:guildId/set-target-permissions\",\n    requireGuildPermission(ApiPermissions.ManageAccess),\n    async (req: Request, res: Response) => {\n      await permissionManagementQueue.add(async () => {\n        const { type, targetId, permissions, expiresAt } = req.body;\n\n        if (type !== ApiPermissionTypes.User) {\n          return clientError(res, \"Invalid type\");\n        }\n        if (!isSnowflake(targetId) || targetId === req.user!.userId) {\n          return clientError(res, \"Invalid targetId\");\n        }\n        const validPermissions = new Set(Object.values(ApiPermissions));\n        validPermissions.delete(ApiPermissions.Owner);\n        if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) {\n          return clientError(res, \"Invalid permissions\");\n        }\n        if (expiresAt != null && !moment.utc(expiresAt).isValid()) {\n          return clientError(res, \"Invalid expiresAt\");\n        }\n\n        const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);\n        if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) {\n          return clientError(res, \"Can't change owner permissions\");\n        }\n\n        if (permissions.length === 0) {\n          await apiPermissionAssignments.removeUser(req.params.guildId, targetId);\n          await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, {\n            type: ApiPermissionTypes.User,\n            target_id: targetId,\n          });\n        } else {\n          const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);\n          if (existing) {\n            await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions);\n            await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, {\n              type: ApiPermissionTypes.User,\n              target_id: targetId,\n              permissions,\n              expires_at: existing.expires_at,\n            });\n          } else {\n            await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt);\n            await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, {\n              type: ApiPermissionTypes.User,\n              target_id: targetId,\n              permissions,\n              expires_at: expiresAt,\n            });\n          }\n        }\n\n        ok(res);\n      });\n    },\n  );\n\n  app.use(\"/guilds\", guildRouter);\n}\n"
  },
  {
    "path": "backend/src/api/index.ts",
    "content": "// KEEP THIS AS FIRST IMPORT\n// See comment in module for details\nimport \"../threadsSignalFix.js\";\n\nimport { connect } from \"../data/db.js\";\nimport { env } from \"../env.js\";\nimport { setIsAPI } from \"../globals.js\";\n\nif (!env.KEY) {\n  // tslint:disable-next-line:no-console\n  console.error(\"Project root .env with KEY is required!\");\n  process.exit(1);\n}\n\nfunction errorHandler(err) {\n  console.error(err.stack || err); // tslint:disable-line:no-console\n  process.exit(1);\n}\n\nprocess.on(\"unhandledRejection\", errorHandler);\n\nsetIsAPI(true);\n\n// Connect to the database before loading the rest of the code (that depend on the database connection)\nconsole.log(\"Connecting to database...\"); // tslint:disable-line\nconnect().then(() => {\n  import(\"./start.js\");\n});\n"
  },
  {
    "path": "backend/src/api/permissions.ts",
    "content": "import { ApiPermissions, hasPermission, permissionArrToSet } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport { Request, Response } from \"express\";\nimport { ApiPermissionAssignments } from \"../data/ApiPermissionAssignments.js\";\nimport { isStaff } from \"../staff.js\";\nimport { unauthorized } from \"./responses.js\";\n\nconst apiPermissionAssignments = new ApiPermissionAssignments();\n\nexport const hasGuildPermission = async (userId: string, guildId: string, permission: ApiPermissions) => {\n  if (isStaff(userId)) {\n    return true;\n  }\n\n  const permAssignment = await apiPermissionAssignments.getByGuildAndUserId(guildId, userId);\n  if (!permAssignment) {\n    return false;\n  }\n\n  return hasPermission(permissionArrToSet(permAssignment.permissions), permission);\n};\n\n/**\n * Requires `guildId` in req.params\n */\nexport function requireGuildPermission(permission: ApiPermissions) {\n  return async (req: Request, res: Response, next) => {\n    if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, permission))) {\n      return unauthorized(res);\n    }\n\n    next();\n  };\n}\n"
  },
  {
    "path": "backend/src/api/rateLimits.ts",
    "content": "import { Request, Response } from \"express\";\nimport { error } from \"./responses.js\";\n\nconst lastRequestsByKey: Map<string, number> = new Map();\n\nexport function rateLimit(getKey: (req: Request) => string, limitMs: number, message = \"Rate limited\") {\n  return async (req: Request, res: Response, next) => {\n    const key = getKey(req);\n    if (lastRequestsByKey.has(key)) {\n      if (lastRequestsByKey.get(key)! > Date.now() - limitMs) {\n        return error(res, message, 429);\n      }\n    }\n\n    lastRequestsByKey.set(key, Date.now());\n    next();\n  };\n}\n"
  },
  {
    "path": "backend/src/api/responses.ts",
    "content": "import { Response } from \"express\";\n\nexport function unauthorized(res: Response) {\n  res.status(403).json({ error: \"Unauthorized\" });\n}\n\nexport function error(res: Response, message: string, statusCode = 500) {\n  res.status(statusCode).json({ error: message });\n}\n\nexport function serverError(res: Response, message = \"Server error\") {\n  error(res, message, 500);\n}\n\nexport function clientError(res: Response, message: string) {\n  error(res, message, 400);\n}\n\nexport function notFound(res: Response) {\n  res.status(404).json({ error: \"Not found\" });\n}\n\nexport function ok(res: Response) {\n  res.json({ result: \"ok\" });\n}\n"
  },
  {
    "path": "backend/src/api/staff.ts",
    "content": "import express, { Request, Response } from \"express\";\nimport { isStaff } from \"../staff.js\";\nimport { apiTokenAuthHandlers } from \"./auth.js\";\n\nexport function initStaff(app: express.Express) {\n  const staffRouter = express.Router();\n  staffRouter.use(...apiTokenAuthHandlers());\n\n  staffRouter.get(\"/status\", (req: Request, res: Response) => {\n    const userIsStaff = isStaff(req.user!.userId);\n    res.json({ isStaff: userIsStaff });\n  });\n\n  app.use(\"/staff\", staffRouter);\n}\n"
  },
  {
    "path": "backend/src/api/start.ts",
    "content": "import cors from \"cors\";\nimport express from \"express\";\nimport multer from \"multer\";\nimport { TokenError } from \"passport-oauth2\";\nimport { env } from \"../env.js\";\nimport { initArchives } from \"./archives.js\";\nimport { initAuth } from \"./auth.js\";\nimport { initDocs } from \"./docs.js\";\nimport { initGuildsAPI } from \"./guilds/index.js\";\nimport { clientError, error, notFound } from \"./responses.js\";\nimport { startBackgroundTasks } from \"./tasks.js\";\n\nconst apiPathPrefix = env.API_PATH_PREFIX || (env.NODE_ENV === \"development\" ? \"/api\" : \"\");\n\nconst app = express();\n\napp.use(\n  cors({\n    origin: env.DASHBOARD_URL,\n  }),\n);\napp.use(\n  express.json({\n    limit: \"50mb\",\n  }),\n);\napp.use(multer().none());\n\nconst rootRouter = express.Router();\n\ninitAuth(rootRouter);\ninitGuildsAPI(rootRouter);\ninitArchives(rootRouter);\ninitDocs(rootRouter);\n\n// Default route\nrootRouter.get(\"/\", (req, res) => {\n  res.json({ status: \"cookies\", with: \"milk\" });\n});\n\napp.use(apiPathPrefix, rootRouter);\n\n// Error response\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\napp.use((err, req, res, next) => {\n  if (err instanceof TokenError) {\n    clientError(res, \"Invalid code\");\n  } else {\n    console.error(err); // tslint:disable-line\n    error(res, \"Server error\", err.status || 500);\n  }\n});\n\n// 404 response\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\napp.use((req, res, next) => {\n  return notFound(res);\n});\n\nconst port = 3001;\napp.listen(port, \"0.0.0.0\", () => console.log(`API server listening on port ${port}`)); // tslint:disable-line\n\nstartBackgroundTasks();\n"
  },
  {
    "path": "backend/src/api/tasks.ts",
    "content": "import { ApiPermissionAssignments } from \"../data/ApiPermissionAssignments.js\";\nimport { MINUTES } from \"../utils.js\";\n\nexport function startBackgroundTasks() {\n  // Clear expired API permissions every minute\n  const apiPermissions = new ApiPermissionAssignments();\n  setInterval(() => {\n    apiPermissions.clearExpiredPermissions();\n  }, 1 * MINUTES);\n}\n"
  },
  {
    "path": "backend/src/commandTypes.ts",
    "content": "import {\n  escapeCodeBlock,\n  escapeInlineCode,\n  GuildChannel,\n  GuildMember,\n  GuildTextBasedChannel,\n  Snowflake,\n  User,\n} from \"discord.js\";\nimport {\n  baseCommandParameterTypeHelpers,\n  CommandContext,\n  messageCommandBaseTypeConverters,\n  TypeConversionError,\n} from \"vety\";\nimport { createTypeHelper } from \"knub-command-manager\";\nimport {\n  channelMentionRegex,\n  convertDelayStringToMS,\n  inputPatternToRegExp,\n  isValidSnowflake,\n  resolveMember,\n  resolveUser,\n  resolveUserId,\n  roleMentionRegex,\n  UnknownUser,\n} from \"./utils.js\";\nimport { isValidTimezone } from \"./utils/isValidTimezone.js\";\nimport { MessageTarget, resolveMessageTarget } from \"./utils/resolveMessageTarget.js\";\n\nexport const commandTypes = {\n  ...messageCommandBaseTypeConverters,\n\n  delay(value) {\n    const result = convertDelayStringToMS(value);\n    if (result == null) {\n      throw new TypeConversionError(`Could not convert ${value} to a delay`);\n    }\n\n    return result;\n  },\n\n  async resolvedUser(value, context: CommandContext<any>) {\n    const result = await resolveUser(context.pluginData.client, value, \"commandTypes:resolvedUser\");\n    if (result == null || result instanceof UnknownUser) {\n      throw new TypeConversionError(`User \\`${escapeCodeBlock(value)}\\` was not found`);\n    }\n    return result;\n  },\n\n  async resolvedUserLoose(value, context: CommandContext<any>) {\n    const result = await resolveUser(context.pluginData.client, value, \"commandTypes:resolvedUserLoose\");\n    if (result == null) {\n      throw new TypeConversionError(`Invalid user: \\`${escapeCodeBlock(value)}\\``);\n    }\n    return result;\n  },\n\n  async resolvedMember(value, context: CommandContext<any>) {\n    if (!(context.message.channel instanceof GuildChannel)) {\n      throw new TypeConversionError(`Cannot resolve member for non-guild channels`);\n    }\n\n    const result = await resolveMember(context.pluginData.client, context.message.channel.guild, value);\n    if (result == null) {\n      throw new TypeConversionError(`Member \\`${escapeCodeBlock(value)}\\` was not found or they have left the server`);\n    }\n    return result;\n  },\n\n  async messageTarget(value: string, context: CommandContext<any>) {\n    value = String(value).trim();\n\n    const result = await resolveMessageTarget(context.pluginData, value);\n    if (!result) {\n      throw new TypeConversionError(`Unknown message \\`${escapeInlineCode(value)}\\``);\n    }\n\n    return result;\n  },\n\n  async anyId(value: string, context: CommandContext<any>) {\n    const userId = resolveUserId(context.pluginData.client, value);\n    if (userId) return userId as Snowflake;\n\n    const channelIdMatch = value.match(channelMentionRegex);\n    if (channelIdMatch) return channelIdMatch[1] as Snowflake;\n\n    const roleIdMatch = value.match(roleMentionRegex);\n    if (roleIdMatch) return roleIdMatch[1] as Snowflake;\n\n    if (isValidSnowflake(value)) {\n      return value as Snowflake;\n    }\n\n    throw new TypeConversionError(`Could not parse ID: \\`${escapeInlineCode(value)}\\``);\n  },\n\n  regex(value: string): RegExp {\n    try {\n      return inputPatternToRegExp(value);\n    } catch (e) {\n      throw new TypeConversionError(`Could not parse RegExp: \\`${escapeInlineCode(e.message)}\\``);\n    }\n  },\n\n  timezone(value: string) {\n    if (!isValidTimezone(value)) {\n      throw new TypeConversionError(`Invalid timezone: ${escapeInlineCode(value)}`);\n    }\n\n    return value;\n  },\n\n  guildTextBasedChannel(value: string, context: CommandContext<any>) {\n    return messageCommandBaseTypeConverters.textChannel(value, context);\n  },\n};\n\nexport const commandTypeHelpers = {\n  ...baseCommandParameterTypeHelpers,\n\n  delay: createTypeHelper<number>(commandTypes.delay),\n  resolvedUser: createTypeHelper<Promise<User>>(commandTypes.resolvedUser),\n  resolvedUserLoose: createTypeHelper<Promise<User | UnknownUser>>(commandTypes.resolvedUserLoose),\n  resolvedMember: createTypeHelper<Promise<GuildMember>>(commandTypes.resolvedMember),\n  messageTarget: createTypeHelper<Promise<MessageTarget>>(commandTypes.messageTarget),\n  anyId: createTypeHelper<Promise<Snowflake>>(commandTypes.anyId),\n  regex: createTypeHelper<RegExp>(commandTypes.regex),\n  timezone: createTypeHelper<string>(commandTypes.timezone),\n  guildTextBasedChannel: createTypeHelper<GuildTextBasedChannel>(commandTypes.guildTextBasedChannel),\n};\n"
  },
  {
    "path": "backend/src/configValidator.ts",
    "content": "import { BaseConfig, ConfigValidationError, GuildPluginBlueprint, PluginConfigManager } from \"vety\";\nimport { z, ZodError } from \"zod\";\nimport { availableGuildPlugins } from \"./plugins/availablePlugins.js\";\nimport { zZeppelinGuildConfig } from \"./types.js\";\nimport { formatZodIssue } from \"./utils/formatZodIssue.js\";\n\nconst pluginNameToPlugin = new Map<string, GuildPluginBlueprint<any, any>>();\nfor (const pluginInfo of availableGuildPlugins) {\n  pluginNameToPlugin.set(pluginInfo.plugin.name, pluginInfo.plugin);\n}\n\nexport async function validateGuildConfig(config: any): Promise<string | null> {\n  const validationResult = zZeppelinGuildConfig.safeParse(config);\n  if (!validationResult.success) {\n    return validationResult.error.issues.map(formatZodIssue).join(\"\\n\");\n  }\n\n  const guildConfig = config as BaseConfig;\n\n  if (guildConfig.plugins) {\n    for (const [pluginName, pluginOptions] of Object.entries(guildConfig.plugins)) {\n      if (!pluginNameToPlugin.has(pluginName)) {\n        return `Unknown plugin: ${pluginName}`;\n      }\n\n      if (typeof pluginOptions !== \"object\" || pluginOptions == null) {\n        return `Invalid options specified for plugin ${pluginName}`;\n      }\n\n      const plugin = pluginNameToPlugin.get(pluginName)!;\n      const configManager = new PluginConfigManager(pluginOptions, {\n        configSchema: plugin.configSchema,\n        defaultOverrides: plugin.defaultOverrides ?? [],\n        levels: {},\n        customOverrideCriteriaFunctions: plugin.customOverrideCriteriaFunctions,\n      });\n\n      try {\n        await configManager.init();\n      } catch (err) {\n        if (err instanceof ZodError) {\n          return `${pluginName}:\\n${z.prettifyError(err)}`;\n        }\n        if (err instanceof ConfigValidationError) {\n          return `${pluginName}: ${err.message}`;\n        }\n\n        throw err;\n      }\n    }\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "backend/src/data/AllowedGuilds.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { DBDateFormat } from \"../utils.js\";\nimport { ApiPermissionTypes } from \"./ApiPermissionAssignments.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { AllowedGuild } from \"./entities/AllowedGuild.js\";\n\nexport class AllowedGuilds extends BaseRepository {\n  private allowedGuilds: Repository<AllowedGuild>;\n\n  constructor() {\n    super();\n    this.allowedGuilds = dataSource.getRepository(AllowedGuild);\n  }\n\n  async isAllowed(guildId: string) {\n    const count = await this.allowedGuilds.count({\n      where: {\n        id: guildId,\n      },\n    });\n    return count !== 0;\n  }\n\n  find(guildId: string) {\n    return this.allowedGuilds.findOne({\n      where: {\n        id: guildId,\n      },\n    });\n  }\n\n  getForApiUser(userId: string) {\n    return this.allowedGuilds\n      .createQueryBuilder(\"allowed_guilds\")\n      .innerJoin(\n        \"api_permissions\",\n        \"api_permissions\",\n        \"api_permissions.guild_id = allowed_guilds.id AND api_permissions.type = :type AND api_permissions.target_id = :userId\",\n        { type: ApiPermissionTypes.User, userId },\n      )\n      .getMany();\n  }\n\n  updateInfo(id, name, icon, ownerId) {\n    return this.allowedGuilds.update(\n      { id },\n      { name, icon, owner_id: ownerId, updated_at: moment.utc().format(DBDateFormat) },\n    );\n  }\n\n  add(id, data: Partial<Omit<AllowedGuild, \"id\">> = {}) {\n    return this.allowedGuilds.insert({\n      name: \"Server\",\n      icon: null,\n      owner_id: \"0\",\n      ...data,\n      id,\n    });\n  }\n\n  remove(id) {\n    return this.allowedGuilds.delete({ id });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/ApiAuditLog.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { AuditLogEventData, AuditLogEventType } from \"./apiAuditLogTypes.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ApiAuditLogEntry } from \"./entities/ApiAuditLogEntry.js\";\n\nexport class ApiAuditLog extends BaseRepository {\n  private auditLog: Repository<ApiAuditLogEntry<any>>;\n\n  constructor() {\n    super();\n    this.auditLog = dataSource.getRepository(ApiAuditLogEntry);\n  }\n\n  addEntry<TEventType extends AuditLogEventType>(\n    guildId: string,\n    authorId: string,\n    eventType: TEventType,\n    eventData: AuditLogEventData[TEventType],\n  ) {\n    this.auditLog.insert({\n      guild_id: guildId,\n      author_id: authorId,\n      event_type: eventType as any,\n      event_data: eventData as any,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/ApiLogins.ts",
    "content": "import crypto from \"crypto\";\nimport moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\n// tslint:disable-next-line:no-submodule-imports\nimport { v4 as uuidv4 } from \"uuid\";\nimport { DAYS, DBDateFormat } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ApiLogin } from \"./entities/ApiLogin.js\";\n\nconst LOGIN_EXPIRY_TIME = 1 * DAYS;\n\nexport class ApiLogins extends BaseRepository {\n  private apiLogins: Repository<ApiLogin>;\n\n  constructor() {\n    super();\n    this.apiLogins = dataSource.getRepository(ApiLogin);\n  }\n\n  async getUserIdByApiKey(apiKey: string): Promise<string | null> {\n    const [loginId, token] = apiKey.split(\".\");\n    if (!loginId || !token) {\n      return null;\n    }\n\n    const login = await this.apiLogins\n      .createQueryBuilder()\n      .where(\"id = :id\", { id: loginId })\n      .andWhere(\"expires_at > NOW()\")\n      .getOne();\n\n    if (!login) {\n      return null;\n    }\n\n    const hash = crypto.createHash(\"sha256\");\n    hash.update(loginId + token); // Remember to use loginId as the salt\n    const hashedToken = hash.digest(\"hex\");\n    if (hashedToken !== login.token) {\n      return null;\n    }\n\n    return login.user_id;\n  }\n\n  async addLogin(userId: string): Promise<string> {\n    // Generate random login id\n    let loginId;\n    while (true) {\n      loginId = uuidv4();\n      const existing = await this.apiLogins.findOne({\n        where: {\n          id: loginId,\n        },\n      });\n      if (!existing) break;\n    }\n\n    // Generate token\n    const token = uuidv4();\n    const hash = crypto.createHash(\"sha256\");\n    hash.update(loginId + token); // Use loginId as a salt\n    const hashedToken = hash.digest(\"hex\");\n\n    // Save this to the DB\n    await this.apiLogins.insert({\n      id: loginId,\n      token: hashedToken,\n      user_id: userId,\n      logged_in_at: moment.utc().format(DBDateFormat),\n      expires_at: moment.utc().add(LOGIN_EXPIRY_TIME, \"ms\").format(DBDateFormat),\n    });\n\n    return `${loginId}.${token}`;\n  }\n\n  expireApiKey(apiKey) {\n    const [loginId, token] = apiKey.split(\".\");\n    if (!loginId || !token) return;\n\n    return this.apiLogins.update(\n      { id: loginId },\n      {\n        expires_at: moment.utc().format(DBDateFormat),\n      },\n    );\n  }\n\n  async refreshApiKeyExpiryTime(apiKey) {\n    const [loginId, token] = apiKey.split(\".\");\n    if (!loginId || !token) return;\n\n    const updatedTime = moment().utc().add(LOGIN_EXPIRY_TIME, \"ms\");\n\n    const login = await this.apiLogins.createQueryBuilder().where(\"id = :id\", { id: loginId }).getOne();\n    if (!login || moment.utc(login.expires_at).isSameOrAfter(updatedTime)) return;\n\n    await this.apiLogins.update(\n      { id: loginId },\n      {\n        expires_at: updatedTime.format(DBDateFormat),\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/data/ApiPermissionAssignments.ts",
    "content": "import { ApiPermissions } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport { Repository } from \"typeorm\";\nimport { ApiAuditLog } from \"./ApiAuditLog.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { AuditLogEventTypes } from \"./apiAuditLogTypes.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ApiPermissionAssignment } from \"./entities/ApiPermissionAssignment.js\";\n\nexport enum ApiPermissionTypes {\n  User = \"USER\",\n  Role = \"ROLE\",\n}\n\nexport class ApiPermissionAssignments extends BaseRepository {\n  private apiPermissions: Repository<ApiPermissionAssignment>;\n  private auditLogs: ApiAuditLog;\n\n  constructor() {\n    super();\n    this.apiPermissions = dataSource.getRepository(ApiPermissionAssignment);\n    this.auditLogs = new ApiAuditLog();\n  }\n\n  getByGuildId(guildId) {\n    return this.apiPermissions.find({\n      where: {\n        guild_id: guildId,\n      },\n    });\n  }\n\n  getByUserId(userId) {\n    return this.apiPermissions.find({\n      where: {\n        type: ApiPermissionTypes.User,\n        target_id: userId,\n      },\n    });\n  }\n\n  getByGuildAndUserId(guildId, userId) {\n    return this.apiPermissions.findOne({\n      where: {\n        guild_id: guildId,\n        type: ApiPermissionTypes.User,\n        target_id: userId,\n      },\n    });\n  }\n\n  addUser(guildId, userId, permissions: ApiPermissions[], expiresAt: string | null = null) {\n    return this.apiPermissions.insert({\n      guild_id: guildId,\n      type: ApiPermissionTypes.User,\n      target_id: userId,\n      permissions,\n      expires_at: expiresAt,\n    });\n  }\n\n  removeUser(guildId, userId) {\n    return this.apiPermissions.delete({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId });\n  }\n\n  async updateUserPermissions(guildId: string, userId: string, permissions: ApiPermissions[]): Promise<void> {\n    await this.apiPermissions.update(\n      {\n        guild_id: guildId,\n        type: ApiPermissionTypes.User,\n        target_id: userId,\n      },\n      {\n        permissions,\n      },\n    );\n  }\n\n  async clearExpiredPermissions() {\n    await this.apiPermissions\n      .createQueryBuilder()\n      .where(\"expires_at IS NOT NULL\")\n      .andWhere(\"expires_at <= NOW()\")\n      .delete()\n      .execute();\n  }\n\n  async applyOwnerChange(guildId: string, newOwnerId: string) {\n    const existingPermissions = await this.getByGuildId(guildId);\n    let updatedOwner = false;\n    for (const perm of existingPermissions) {\n      let hasChanges = false;\n\n      // Remove owner permission from anyone who currently has it\n      if (perm.permissions.includes(ApiPermissions.Owner)) {\n        perm.permissions.splice(perm.permissions.indexOf(ApiPermissions.Owner), 1);\n        hasChanges = true;\n      }\n\n      // Add owner permission if we encounter the new owner\n      if (perm.type === ApiPermissionTypes.User && perm.target_id === newOwnerId) {\n        perm.permissions.push(ApiPermissions.Owner);\n        updatedOwner = true;\n        hasChanges = true;\n      }\n\n      if (hasChanges) {\n        const criteria = {\n          guild_id: perm.guild_id,\n          type: perm.type,\n          target_id: perm.target_id,\n        };\n        if (perm.permissions.length === 0) {\n          // No remaining permissions -> remove entry\n          this.auditLogs.addEntry(guildId, \"0\", AuditLogEventTypes.REMOVE_API_PERMISSION, {\n            type: perm.type,\n            target_id: perm.target_id,\n          });\n          await this.apiPermissions.delete(criteria);\n        } else {\n          this.auditLogs.addEntry(guildId, \"0\", AuditLogEventTypes.EDIT_API_PERMISSION, {\n            type: perm.type,\n            target_id: perm.target_id,\n            permissions: perm.permissions,\n            expires_at: perm.expires_at,\n          });\n          await this.apiPermissions.update(criteria, {\n            permissions: perm.permissions,\n          });\n        }\n      }\n    }\n\n    if (!updatedOwner) {\n      this.auditLogs.addEntry(guildId, \"0\", AuditLogEventTypes.ADD_API_PERMISSION, {\n        type: ApiPermissionTypes.User,\n        target_id: newOwnerId,\n        permissions: [ApiPermissions.Owner],\n        expires_at: null,\n      });\n      await this.apiPermissions.insert({\n        guild_id: guildId,\n        type: ApiPermissionTypes.User,\n        target_id: newOwnerId,\n        permissions: [ApiPermissions.Owner],\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/data/ApiUserInfo.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { DBDateFormat } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ApiUserInfoData, ApiUserInfo as ApiUserInfoEntity } from \"./entities/ApiUserInfo.js\";\n\nexport class ApiUserInfo extends BaseRepository {\n  private apiUserInfo: Repository<ApiUserInfoEntity>;\n\n  constructor() {\n    super();\n    this.apiUserInfo = dataSource.getRepository(ApiUserInfoEntity);\n  }\n\n  get(id) {\n    return this.apiUserInfo.findOne({\n      where: {\n        id,\n      },\n    });\n  }\n\n  update(id, data: ApiUserInfoData) {\n    return dataSource.transaction(async (entityManager) => {\n      const repo = entityManager.getRepository(ApiUserInfoEntity);\n\n      const existingInfo = await repo.findOne({ where: { id } });\n      const updatedAt = moment.utc().format(DBDateFormat);\n\n      if (existingInfo) {\n        await repo.update({ id }, { data, updated_at: updatedAt });\n      } else {\n        await repo.insert({ id, data, updated_at: updatedAt });\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/Archives.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ArchiveEntry } from \"./entities/ArchiveEntry.js\";\n\nexport class Archives extends BaseRepository {\n  protected archives: Repository<ArchiveEntry>;\n\n  constructor() {\n    super();\n    this.archives = dataSource.getRepository(ArchiveEntry);\n  }\n\n  public deleteExpiredArchives() {\n    this.archives\n      .createQueryBuilder()\n      .andWhere(\"expires_at IS NOT NULL\")\n      .andWhere(\"expires_at <= NOW()\")\n      .delete()\n      .execute();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/BaseGuildRepository.ts",
    "content": "import { BaseRepository } from \"./BaseRepository.js\";\n\nexport class BaseGuildRepository<TEntity = unknown> extends BaseRepository<TEntity> {\n  private static guildInstances: Map<string, any>;\n\n  protected guildId: string;\n\n  constructor(guildId: string) {\n    super();\n    this.guildId = guildId;\n  }\n\n  /**\n   * Returns a cached instance of the inheriting class for the specified guildId,\n   * or creates a new instance if one doesn't exist yet\n   */\n  public static getGuildInstance<T extends typeof BaseGuildRepository>(this: T, guildId: string): InstanceType<T> {\n    if (!this.guildInstances) {\n      this.guildInstances = new Map();\n    }\n\n    if (!this.guildInstances.has(guildId)) {\n      this.guildInstances.set(guildId, new this(guildId));\n    }\n\n    return this.guildInstances.get(guildId) as InstanceType<T>;\n  }\n}\n"
  },
  {
    "path": "backend/src/data/BaseRepository.ts",
    "content": "import { asyncMap } from \"../utils/async.js\";\n\nexport class BaseRepository<TEntity = unknown> {\n  private nextRelations: string[];\n\n  constructor() {\n    this.nextRelations = [];\n  }\n\n  /**\n   * Primes the specified relation(s) to be used in the next database operation.\n   * Can be chained.\n   */\n  public with(relations: string | string[]): this {\n    if (Array.isArray(relations)) {\n      this.nextRelations.push(...relations);\n    } else {\n      this.nextRelations.push(relations);\n    }\n\n    return this;\n  }\n\n  /**\n   * Gets and resets the relations primed using with()\n   */\n  protected getRelations(): string[] {\n    const relations = this.nextRelations || [];\n    this.nextRelations = [];\n    return relations;\n  }\n\n  protected async _processEntityFromDB(entity) {\n    // No-op, override in repository\n    return entity;\n  }\n\n  protected async _processEntityToDB(entity) {\n    // No-op, override in repository\n    return entity;\n  }\n\n  protected async processEntityFromDB<T extends TEntity | null>(entity: T): Promise<T> {\n    return this._processEntityFromDB(entity);\n  }\n\n  protected async processMultipleEntitiesFromDB<TArr extends TEntity[]>(entities: TArr): Promise<TArr> {\n    return asyncMap(entities, (entity) => this.processEntityFromDB(entity)) as Promise<TArr>;\n  }\n\n  protected async processEntityToDB<T extends Partial<TEntity>>(entity: T): Promise<T> {\n    return this._processEntityToDB(entity);\n  }\n}\n"
  },
  {
    "path": "backend/src/data/CaseTypes.ts",
    "content": "export enum CaseTypes {\n  Ban = 1,\n  Unban,\n  Note,\n  Warn,\n  Kick,\n  Mute,\n  Unmute,\n  Deleted,\n  Softban,\n}\n\nexport const CaseNameToType = {\n  ban: CaseTypes.Ban,\n  unban: CaseTypes.Unban,\n  note: CaseTypes.Note,\n  warn: CaseTypes.Warn,\n  kick: CaseTypes.Kick,\n  mute: CaseTypes.Mute,\n  unmute: CaseTypes.Unmute,\n  deleted: CaseTypes.Deleted,\n  softban: CaseTypes.Softban,\n};\n\nexport const CaseTypeToName = Object.entries(CaseNameToType).reduce((map, [name, type]) => {\n  map[type] = name;\n  return map;\n}, {}) as Record<CaseTypes, string>;\n"
  },
  {
    "path": "backend/src/data/Configs.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { isAPI } from \"../globals.js\";\nimport { HOURS, SECONDS } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { cleanupConfigs } from \"./cleanup/configs.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Config } from \"./entities/Config.js\";\n\nconst CLEANUP_INTERVAL = 1 * HOURS;\n\nasync function cleanup() {\n  await cleanupConfigs();\n  setTimeout(cleanup, CLEANUP_INTERVAL);\n}\n\nif (isAPI()) {\n  // Start first cleanup 30 seconds after startup\n  // TODO: Move to bot startup code\n  setTimeout(cleanup, 30 * SECONDS);\n}\n\nexport class Configs extends BaseRepository {\n  private configs: Repository<Config>;\n\n  constructor() {\n    super();\n    this.configs = dataSource.getRepository(Config);\n  }\n\n  getActive() {\n    return this.configs.find({\n      where: { is_active: true },\n    });\n  }\n\n  getActiveByKey(key) {\n    return this.configs.findOne({\n      where: {\n        key,\n        is_active: true,\n      },\n    });\n  }\n\n  async getHighestId(): Promise<number> {\n    const rows = await dataSource.query(\"SELECT MAX(id) AS highest_id FROM configs\");\n    return (rows.length && rows[0].highest_id) || 0;\n  }\n\n  getActiveLargerThanId(id) {\n    return this.configs.createQueryBuilder().where(\"id > :id\", { id }).andWhere(\"is_active = 1\").getMany();\n  }\n\n  async hasConfig(key) {\n    return (await this.getActiveByKey(key)) != null;\n  }\n\n  getRevisions(key, num = 10) {\n    return this.configs.find({\n      relations: this.getRelations(),\n      where: { key },\n      select: [\"id\", \"key\", \"is_active\", \"edited_by\", \"edited_at\"],\n      order: {\n        edited_at: \"DESC\",\n      },\n      take: num,\n    });\n  }\n\n  async saveNewRevision(key, config, editedBy) {\n    return dataSource.transaction(async (entityManager) => {\n      const repo = entityManager.getRepository(Config);\n      // Mark all old revisions inactive\n      await repo.update({ key }, { is_active: false });\n      // Add new, active revision\n      await repo.insert({\n        key,\n        config,\n        is_active: true,\n        edited_by: editedBy,\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/DefaultLogMessages.json",
    "content": "{\n  \"MEMBER_NOTE\": \"{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}\",\n  \"MEMBER_WARN\": \"{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}\",\n  \"MEMBER_MUTE\": \"{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}\",\n  \"MEMBER_TIMED_MUTE\": \"{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}\",\n  \"MEMBER_UNMUTE\": \"{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}\",\n  \"MEMBER_TIMED_UNMUTE\": \"{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}\",\n  \"MEMBER_MUTE_EXPIRED\": \"{timestamp} 🔊 {userMention(member)}'s mute expired\",\n  \"MEMBER_KICK\": \"{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}\",\n  \"MEMBER_BAN\": \"{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}\",\n  \"MEMBER_UNBAN\": \"{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}\",\n  \"MEMBER_FORCEBAN\": \"{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}\",\n  \"MEMBER_SOFTBAN\": \"{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}\",\n  \"MEMBER_JOIN\": \"{timestamp} 📥 {new} {userMention(member)} joined (created <t:{account_age_ts}:R>)\",\n  \"MEMBER_LEAVE\": \"{timestamp} 📤 {userMention(member)} left the server\",\n  \"MEMBER_ROLE_ADD\": \"{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**\",\n  \"MEMBER_ROLE_REMOVE\": \"{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**\",\n  \"MEMBER_ROLE_CHANGES\": \"{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**\",\n  \"MEMBER_NICK_CHANGE\": \"{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**\",\n  \"MEMBER_USERNAME_CHANGE\": \"{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**\",\n  \"MEMBER_RESTORE\": \"{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin\",\n  \"MEMBER_TIMED_BAN\": \"{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}\",\n  \"MEMBER_TIMED_UNBAN\": \"{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}\",\n\n  \"CHANNEL_CREATE\": \"{timestamp} 🖊 Channel {channelMention(channel)} was created\",\n  \"CHANNEL_DELETE\": \"{timestamp} 🗑 Channel {channelMention(channel)} was deleted\",\n  \"CHANNEL_UPDATE\": \"{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\\n{differenceString}\",\n\n  \"THREAD_CREATE\": \"{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>\",\n  \"THREAD_DELETE\": \"{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>\",\n  \"THREAD_UPDATE\": \"{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\\n{differenceString}\",\n\n  \"ROLE_CREATE\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created\",\n  \"ROLE_DELETE\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted\",\n  \"ROLE_UPDATE\": \"{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\\n{differenceString}\",\n\n  \"MESSAGE_EDIT\": \"{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}\",\n  \"MESSAGE_DELETE\": \"{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n  \"MESSAGE_DELETE_BULK\": \"{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})\",\n  \"MESSAGE_DELETE_BARE\": \"{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)\",\n  \"MESSAGE_DELETE_AUTO\": \"{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n\n  \"VOICE_CHANNEL_JOIN\": \"{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}\",\n  \"VOICE_CHANNEL_MOVE\": \"{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}\",\n  \"VOICE_CHANNEL_LEAVE\": \"{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}\",\n  \"VOICE_CHANNEL_FORCE_MOVE\": \"{timestamp} \\uD83C\\uDF99 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}\",\n  \"VOICE_CHANNEL_FORCE_DISCONNECT\": \"{timestamp} \\uD83C\\uDF99 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}\",\n\n  \"STAGE_INSTANCE_CREATE\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>\",\n  \"STAGE_INSTANCE_DELETE\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>\",\n  \"STAGE_INSTANCE_UPDATE\": \"{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\\n{differenceString}\",\n\n  \"EMOJI_CREATE\": \"{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created\",\n  \"EMOJI_DELETE\": \"{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted\",\n  \"EMOJI_UPDATE\": \"{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\\n{differenceString}\",\n\n  \"STICKER_CREATE\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}\",\n  \"STICKER_DELETE\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.\",\n  \"STICKER_UPDATE\": \"{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\\n{differenceString}\",\n\n  \"COMMAND\": \"{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\\n`{command}`\",\n\n  \"MESSAGE_SPAM_DETECTED\": \"{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\\n{archiveUrl}\",\n  \"OTHER_SPAM_DETECTED\": \"{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)\",\n  \"CENSOR\": \"{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\\n```{messageText}```\",\n  \"CLEAN\": \"{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\\n{archiveUrl}\",\n\n  \"CASE_CREATE\": \"{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})\",\n  \"CASE_DELETE\": \"{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}\",\n\n  \"MASSUNBAN\": \"{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users\",\n  \"MASSBAN\": \"{timestamp} ⚒ {userMention(mod)} massbanned {count} users\",\n  \"MASSMUTE\": \"{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users\",\n\n  \"MEMBER_JOIN_WITH_PRIOR_RECORDS\": \"{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\\n{recentCaseSummary}\",\n\n  \"CASE_UPDATE\": \"{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\\n```{note}```\",\n\n  \"MEMBER_MUTE_REJOIN\": \"{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin\",\n\n  \"SCHEDULED_MESSAGE\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}\",\n  \"SCHEDULED_REPEATED_MESSAGE\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}\",\n  \"REPEATED_MESSAGE\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}\",\n  \"POSTED_SCHEDULED_MESSAGE\": \"{timestamp} \\uD83D\\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}\",\n\n  \"BOT_ALERT\": \"{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}\",\n  \"DM_FAILED\": \"{timestamp} \\uD83D\\uDEA7 Failed to send DM ({source}) to {userMention(user)}\",\n\n  \"AUTOMOD_ACTION\": \"{timestamp} \\uD83E\\uDD16 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\\n{matchSummary}\\nActions taken: **{actionsTaken}**\",\n  \"SET_ANTIRAID_USER\": \"{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**\",\n  \"SET_ANTIRAID_AUTO\": \"{timestamp} ⚔ Anti-raid automatically set to **{level}**\"\n}\n"
  },
  {
    "path": "backend/src/data/FishFish.ts",
    "content": "import { z } from \"zod\";\nimport { env } from \"../env.js\";\nimport { HOURS, MINUTES, SECONDS } from \"../utils.js\";\n\nconst API_ROOT = \"https://api.fishfish.gg/v1\";\n\nconst zDomainCategory = z.literal([\"safe\", \"malware\", \"phishing\"]);\n\nconst zDomain = z.object({\n  name: z.string(),\n  category: zDomainCategory,\n  description: z.string(),\n  added: z.number(),\n  checked: z.number(),\n});\nexport type FishFishDomain = z.output<typeof zDomain>;\n\nconst FULL_REFRESH_INTERVAL = 6 * HOURS;\nconst domains = new Map<string, FishFishDomain>();\n\nlet sessionTokenPromise: Promise<string> | null = null;\n\nconst WS_RECONNECT_DELAY = 30 * SECONDS;\nlet updatesWs: WebSocket | null = null;\n\nexport class FishFishError extends Error {}\n\nconst zTokenResponse = z.object({\n  expires: z.number(),\n  token: z.string(),\n});\n\nasync function getSessionToken(): Promise<string> {\n  if (sessionTokenPromise) {\n    return sessionTokenPromise;\n  }\n\n  const apiKey = env.FISHFISH_API_KEY;\n  if (!apiKey) {\n    throw new FishFishError(\"FISHFISH_API_KEY is missing\");\n  }\n\n  sessionTokenPromise = (async () => {\n    const response = await fetch(`${API_ROOT}/users/@me/tokens`, {\n      method: \"POST\",\n      headers: {\n        Authorization: apiKey,\n        \"Content-Type\": \"application/json\",\n      },\n    });\n\n    if (!response.ok) {\n      throw new FishFishError(`Failed to get session token: ${response.status} ${response.statusText}`);\n    }\n\n    const parseResult = zTokenResponse.safeParse(await response.json());\n    if (!parseResult.success) {\n      throw new FishFishError(`Parse error when fetching session token: ${parseResult.error.message}`);\n    }\n\n    const timeUntilExpiry = parseResult.data.expires * 1000 - Date.now();\n    setTimeout(\n      () => {\n        sessionTokenPromise = null;\n      },\n      timeUntilExpiry - 1 * MINUTES,\n    ); // Subtract a minute to ensure we refresh before expiry\n\n    return parseResult.data.token;\n  })();\n  sessionTokenPromise.catch((err) => {\n    sessionTokenPromise = null;\n    throw err;\n  });\n\n  return sessionTokenPromise;\n}\n\nasync function fishFishApiCall(method: string, path: string, query: Record<string, string> = {}): Promise<unknown> {\n  const sessionToken = await getSessionToken();\n  const queryParams = new URLSearchParams(query);\n  const response = await fetch(`https://api.fishfish.gg/v1/${path}?${queryParams}`, {\n    method,\n    headers: {\n      Authorization: sessionToken,\n      \"Content-Type\": \"application/json\",\n    },\n  });\n\n  if (!response.ok) {\n    throw new FishFishError(`FishFish API call failed: ${response.status} ${response.statusText}`);\n  }\n\n  return response.json();\n}\n\nasync function refreshFishFishDomains() {\n  const rawData = await fishFishApiCall(\"GET\", \"domains\", { full: \"true\" });\n  const parseResult = z.array(zDomain).safeParse(rawData);\n  if (!parseResult.success) {\n    throw new FishFishError(`Parse error when refreshing domains: ${parseResult.error.message}`);\n  }\n\n  domains.clear();\n  for (const domain of parseResult.data) {\n    domains.set(domain.name, domain);\n  }\n\n  domains.set(\"malware-link.test.zeppelin.gg\", {\n    name: \"malware-link.test.zeppelin.gg\",\n    category: \"malware\",\n    description: \"\",\n    added: Date.now(),\n    checked: Date.now(),\n  });\n  domains.set(\"phishing-link.test.zeppelin.gg\", {\n    name: \"phishing-link.test.zeppelin.gg\",\n    category: \"phishing\",\n    description: \"\",\n    added: Date.now(),\n    checked: Date.now(),\n  });\n  domains.set(\"safe-link.test.zeppelin.gg\", {\n    name: \"safe-link.test.zeppelin.gg\",\n    category: \"safe\",\n    description: \"\",\n    added: Date.now(),\n    checked: Date.now(),\n  });\n\n  console.log(\"[FISHFISH] Refreshed FishFish domains, total count:\", domains.size);\n}\n\nexport async function initFishFish() {\n  if (!env.FISHFISH_API_KEY) {\n    console.warn(\"[FISHFISH] FISHFISH_API_KEY is not set, FishFish functionality will be disabled.\");\n    return;\n  }\n\n  await refreshFishFishDomains();\n  // Real-time updates disabled until we switch to a WebSocket lib that supports authorization headers\n  // void subscribeToFishFishUpdates();\n  setInterval(() => refreshFishFishDomains(), FULL_REFRESH_INTERVAL);\n}\n\nexport function getFishFishDomain(domain: string): FishFishDomain | undefined {\n  return domains.get(domain.toLowerCase());\n}\n"
  },
  {
    "path": "backend/src/data/GuildAntiraidLevels.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { AntiraidLevel } from \"./entities/AntiraidLevel.js\";\n\nexport class GuildAntiraidLevels extends BaseGuildRepository {\n  protected antiraidLevels: Repository<AntiraidLevel>;\n\n  constructor(guildId: string) {\n    super(guildId);\n    this.antiraidLevels = dataSource.getRepository(AntiraidLevel);\n  }\n\n  async get() {\n    const row = await this.antiraidLevels.findOne({\n      where: {\n        guild_id: this.guildId,\n      },\n    });\n\n    return row?.level ?? null;\n  }\n\n  async set(level: string | null) {\n    if (level === null) {\n      await this.antiraidLevels.delete({\n        guild_id: this.guildId,\n      });\n    } else {\n      // Upsert: https://stackoverflow.com/a/47064558/316944\n      // But the MySQL version: https://github.com/typeorm/typeorm/issues/1090#issuecomment-634391487\n      await this.antiraidLevels\n        .createQueryBuilder()\n        .insert()\n        .values({\n          guild_id: this.guildId,\n          level,\n        })\n        .orUpdate({\n          conflict_target: [\"guild_id\"],\n          overwrite: [\"level\"],\n        })\n        .execute();\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildArchives.ts",
    "content": "import { Guild, Snowflake } from \"discord.js\";\nimport moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { TemplateSafeValueContainer, renderTemplate } from \"../templateFormatter.js\";\nimport { renderUsername, trimLines } from \"../utils.js\";\nimport { decrypt, encrypt } from \"../utils/crypt.js\";\nimport { isDefaultSticker } from \"../utils/isDefaultSticker.js\";\nimport { channelToTemplateSafeChannel, guildToTemplateSafeGuild } from \"../utils/templateSafeObjects.js\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ArchiveEntry } from \"./entities/ArchiveEntry.js\";\nimport { SavedMessage } from \"./entities/SavedMessage.js\";\n\nconst DEFAULT_EXPIRY_DAYS = 30;\n\nconst MESSAGE_ARCHIVE_HEADER_FORMAT = trimLines(`\n  Server: {guild.name} ({guild.id})\n`);\nconst MESSAGE_ARCHIVE_MESSAGE_FORMAT =\n  \"[#{channel.name}] [{user.id}] [{timestamp}] {username}: {content}{attachments}{stickers}\";\n\nexport class GuildArchives extends BaseGuildRepository<ArchiveEntry> {\n  protected archives: Repository<ArchiveEntry>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.archives = dataSource.getRepository(ArchiveEntry);\n  }\n\n  protected async _processEntityFromDB(entity: ArchiveEntry | undefined) {\n    if (entity == null) {\n      return entity;\n    }\n\n    entity.body = await decrypt(entity.body);\n    return entity;\n  }\n\n  protected async _processEntityToDB(entity: Partial<ArchiveEntry>) {\n    if (entity.body) {\n      entity.body = await encrypt(entity.body);\n    }\n    return entity;\n  }\n\n  async find(id: string): Promise<ArchiveEntry | null> {\n    const result = await this.archives.findOne({\n      where: { id },\n      relations: this.getRelations(),\n    });\n    return this.processEntityFromDB(result);\n  }\n\n  async makePermanent(id: string): Promise<void> {\n    await this.archives.update(\n      { id },\n      {\n        expires_at: null,\n      },\n    );\n  }\n\n  /**\n   * @return - ID of the created archive\n   */\n  async create(body: string, expiresAt?: moment.Moment): Promise<string> {\n    if (!expiresAt) {\n      expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, \"days\");\n    }\n\n    const data = await this.processEntityToDB({\n      guild_id: this.guildId,\n      body,\n      expires_at: expiresAt.format(\"YYYY-MM-DD HH:mm:ss\"),\n    });\n    const result = await this.archives.insert(data);\n\n    return result.identifiers[0].id;\n  }\n\n  protected async renderLinesFromSavedMessages(savedMessages: SavedMessage[], guild: Guild): Promise<string[]> {\n    const msgLines: string[] = [];\n    for (const msg of savedMessages) {\n      const channel = guild.channels.cache.get(msg.channel_id as Snowflake);\n      const partialUser = new TemplateSafeValueContainer({ ...msg.data.author, id: msg.user_id });\n\n      const line = await renderTemplate(\n        MESSAGE_ARCHIVE_MESSAGE_FORMAT,\n        new TemplateSafeValueContainer({\n          id: msg.id,\n          timestamp: moment.utc(msg.posted_at).format(\"YYYY-MM-DD HH:mm:ss\"),\n          content: msg.data.content,\n          attachments: msg.data.attachments?.map((att) => {\n            return JSON.stringify({ name: att.name, url: att.url, type: att.contentType });\n          }),\n          stickers: msg.data.stickers?.map((sti) => {\n            return JSON.stringify({ name: sti.name, id: sti.id, isDefault: isDefaultSticker(sti.id) });\n          }),\n          user: partialUser,\n          channel: channel ? channelToTemplateSafeChannel(channel) : null,\n          username: renderUsername(msg.data.author.username, msg.data.author.discriminator),\n        }),\n      );\n\n      msgLines.push(line);\n    }\n    return msgLines;\n  }\n\n  /**\n   * @return - ID of the created archive\n   */\n  async createFromSavedMessages(\n    savedMessages: SavedMessage[],\n    guild: Guild,\n    expiresAt?: moment.Moment,\n  ): Promise<string> {\n    if (expiresAt == null) {\n      expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, \"days\");\n    }\n\n    const headerStr = await renderTemplate(\n      MESSAGE_ARCHIVE_HEADER_FORMAT,\n      new TemplateSafeValueContainer({\n        guild: guildToTemplateSafeGuild(guild),\n      }),\n    );\n    const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild);\n    const messagesStr = msgLines.join(\"\\n\");\n\n    return this.create([headerStr, messagesStr].join(\"\\n\\n\"), expiresAt);\n  }\n\n  async addSavedMessagesToArchive(archiveId: string, savedMessages: SavedMessage[], guild: Guild) {\n    const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild);\n    const messagesStr = msgLines.join(\"\\n\");\n\n    let archive = await this.find(archiveId);\n    if (archive == null) {\n      throw new Error(\"Archive not found\");\n    }\n\n    archive.body += \"\\n\" + messagesStr;\n    archive = await this.processEntityToDB(archive);\n\n    await this.archives.update({ id: archiveId }, { body: archive.body });\n  }\n\n  getUrl(baseUrl, archiveId) {\n    return baseUrl ? `${baseUrl}/archives/${archiveId}` : `Archive ID: ${archiveId}`;\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildAutoReactions.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { AutoReaction } from \"./entities/AutoReaction.js\";\n\nexport class GuildAutoReactions extends BaseGuildRepository {\n  private autoReactions: Repository<AutoReaction>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.autoReactions = dataSource.getRepository(AutoReaction);\n  }\n\n  async all(): Promise<AutoReaction[]> {\n    return this.autoReactions.find({\n      where: {\n        guild_id: this.guildId,\n      },\n    });\n  }\n\n  async getForChannel(channelId: string): Promise<AutoReaction | null> {\n    return this.autoReactions.findOne({\n      where: {\n        guild_id: this.guildId,\n        channel_id: channelId,\n      },\n    });\n  }\n\n  async removeFromChannel(channelId: string) {\n    await this.autoReactions.delete({\n      guild_id: this.guildId,\n      channel_id: channelId,\n    });\n  }\n\n  async set(channelId: string, reactions: string[]) {\n    const existingRecord = await this.getForChannel(channelId);\n    if (existingRecord) {\n      this.autoReactions.update(\n        {\n          guild_id: this.guildId,\n          channel_id: channelId,\n        },\n        {\n          reactions,\n        },\n      );\n    } else {\n      await this.autoReactions.insert({\n        guild_id: this.guildId,\n        channel_id: channelId,\n        reactions,\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildButtonRoles.ts",
    "content": "import { getRepository, Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { ButtonRole } from \"./entities/ButtonRole.js\";\n\nexport class GuildButtonRoles extends BaseGuildRepository {\n  private buttonRoles: Repository<ButtonRole>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.buttonRoles = getRepository(ButtonRole);\n  }\n\n  async getForButtonId(buttonId: string) {\n    return this.buttonRoles.findOne({\n      where: {\n        guild_id: this.guildId,\n        button_id: buttonId,\n      },\n    });\n  }\n\n  async getAllForMessageId(messageId: string) {\n    return this.buttonRoles.find({\n      where: {\n        guild_id: this.guildId,\n        message_id: messageId,\n      },\n    });\n  }\n\n  async removeForButtonId(buttonId: string) {\n    return this.buttonRoles.delete({\n      guild_id: this.guildId,\n      button_id: buttonId,\n    });\n  }\n\n  async removeAllForMessageId(messageId: string) {\n    return this.buttonRoles.delete({\n      guild_id: this.guildId,\n      message_id: messageId,\n    });\n  }\n\n  async getForButtonGroup(buttonGroup: string) {\n    return this.buttonRoles.find({\n      where: {\n        guild_id: this.guildId,\n        button_group: buttonGroup,\n      },\n    });\n  }\n\n  async add(channelId: string, messageId: string, buttonId: string, buttonGroup: string, buttonName: string) {\n    await this.buttonRoles.insert({\n      guild_id: this.guildId,\n      channel_id: channelId,\n      message_id: messageId,\n      button_id: buttonId,\n      button_group: buttonGroup,\n      button_name: buttonName,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildCases.ts",
    "content": "import { FindOptionsWhere, In, InsertResult, Repository } from \"typeorm\";\nimport { Queue } from \"../Queue.js\";\nimport { chunkArray } from \"../utils.js\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { CaseTypes } from \"./CaseTypes.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Case } from \"./entities/Case.js\";\nimport { CaseNote } from \"./entities/CaseNote.js\";\n\nexport class GuildCases extends BaseGuildRepository {\n  private cases: Repository<Case>;\n  private caseNotes: Repository<CaseNote>;\n\n  protected createQueue: Queue;\n\n  constructor(guildId) {\n    super(guildId);\n    this.cases = dataSource.getRepository(Case);\n    this.caseNotes = dataSource.getRepository(CaseNote);\n    this.createQueue = new Queue();\n  }\n\n  async get(ids: number[]): Promise<Case[]> {\n    return this.cases.find({\n      relations: this.getRelations(),\n      where: {\n        guild_id: this.guildId,\n        id: In(ids),\n      },\n    });\n  }\n\n  async find(id: number): Promise<Case | null> {\n    return this.cases.findOne({\n      relations: this.getRelations(),\n      where: {\n        guild_id: this.guildId,\n        id,\n      },\n    });\n  }\n\n  async findByCaseNumber(caseNumber: number): Promise<Case | null> {\n    return this.cases.findOne({\n      relations: this.getRelations(),\n      where: {\n        guild_id: this.guildId,\n        case_number: caseNumber,\n      },\n    });\n  }\n\n  async findLatestByModId(modId: string): Promise<Case | null> {\n    return this.cases.findOne({\n      relations: this.getRelations(),\n      where: {\n        guild_id: this.guildId,\n        mod_id: modId,\n      },\n      order: {\n        case_number: \"DESC\",\n      },\n    });\n  }\n\n  async findByAuditLogId(auditLogId: string): Promise<Case | null> {\n    return this.cases.findOne({\n      relations: this.getRelations(),\n      where: {\n        guild_id: this.guildId,\n        audit_log_id: auditLogId,\n      },\n    });\n  }\n\n  async getByUserId(\n    userId: string,\n    filters: Omit<FindOptionsWhere<Case>, \"guild_id\" | \"user_id\"> = {},\n  ): Promise<Case[]> {\n    return this.cases.find({\n      relations: this.getRelations(),\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n        ...filters,\n      },\n    });\n  }\n\n  async getRecentByUserId(userId: string, count: number, skip = 0): Promise<Case[]> {\n    return this.cases.find({\n      relations: this.getRelations(),\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n      skip,\n      take: count,\n      order: {\n        case_number: \"DESC\",\n      },\n    });\n  }\n\n  async getTotalCasesByModId(\n    modId: string,\n    filters: Omit<FindOptionsWhere<Case>, \"guild_id\" | \"mod_id\" | \"is_hidden\"> = {},\n  ): Promise<number> {\n    return this.cases.count({\n      where: {\n        guild_id: this.guildId,\n        mod_id: modId,\n        is_hidden: false,\n        ...filters,\n      },\n    });\n  }\n\n  async getRecentByModId(\n    modId: string,\n    count: number,\n    skip = 0,\n    filters: Omit<FindOptionsWhere<Case>, \"guild_id\" | \"mod_id\"> = {},\n  ): Promise<Case[]> {\n    const where: FindOptionsWhere<Case> = {\n      guild_id: this.guildId,\n      mod_id: modId,\n      is_hidden: false,\n      ...filters,\n    };\n\n    if (where.is_hidden === true) {\n      delete where.is_hidden;\n    }\n\n    return this.cases.find({\n      relations: this.getRelations(),\n      where,\n      skip,\n      take: count,\n      order: {\n        case_number: \"DESC\",\n      },\n    });\n  }\n\n  async getMinCaseNumber(): Promise<number> {\n    const result = await this.cases\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .select([\"MIN(case_number) AS min_case_number\"])\n      .getRawOne<{ min_case_number: number }>();\n\n    return result?.min_case_number || 0;\n  }\n\n  async getMaxCaseNumber(): Promise<number> {\n    const result = await this.cases\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .select([\"MAX(case_number) AS max_case_number\"])\n      .getRawOne<{ max_case_number: number }>();\n\n    return result?.max_case_number || 0;\n  }\n\n  async setHidden(id: number, hidden: boolean): Promise<void> {\n    await this.cases.update(\n      { id },\n      {\n        is_hidden: hidden,\n      },\n    );\n  }\n\n  async createInternal(data): Promise<InsertResult> {\n    return this.createQueue.add(async () => {\n      const lastCaseNumberRow = await this.cases\n        .createQueryBuilder()\n        .select([\"MAX(case_number) AS last_case_number\"])\n        .where(\"guild_id = :guildId\", { guildId: this.guildId })\n        .getRawOne();\n      const lastCaseNumber = lastCaseNumberRow?.last_case_number || 0;\n\n      return this.cases\n        .insert({\n          case_number: lastCaseNumber + 1,\n          ...data,\n          guild_id: this.guildId,\n        })\n        .catch((err) => {\n          if (err?.code === \"ER_DUP_ENTRY\") {\n            if (data.audit_log_id) {\n              // FIXME: Debug\n              // tslint:disable-next-line:no-console\n              console.trace(`Tried to insert case with duplicate audit_log_id`);\n              return this.createInternal({\n                ...data,\n                audit_log_id: undefined,\n              });\n            }\n          }\n\n          throw err;\n        });\n    });\n  }\n\n  async create(data): Promise<Case> {\n    const result = await this.createInternal(data);\n    return (await this.find(result.identifiers[0].id))!;\n  }\n\n  update(id, data) {\n    return this.cases.update(id, data);\n  }\n\n  async softDelete(id: number, deletedById: string, deletedByName: string, deletedByText: string) {\n    return dataSource.transaction(async (entityManager) => {\n      const cases = entityManager.getRepository(Case);\n      const caseNotes = entityManager.getRepository(CaseNote);\n\n      await Promise.all([\n        caseNotes.delete({\n          case_id: id,\n        }),\n        cases.update(id, {\n          user_id: \"0\",\n          user_name: \"Unknown#0000\",\n          mod_id: null,\n          mod_name: \"Unknown#0000\",\n          type: CaseTypes.Deleted,\n          audit_log_id: null,\n          is_hidden: false,\n          pp_id: null,\n          pp_name: null,\n        }),\n      ]);\n\n      await caseNotes.insert({\n        case_id: id,\n        mod_id: deletedById,\n        mod_name: deletedByName,\n        body: deletedByText,\n      });\n    });\n  }\n\n  async createNote(caseId: number, data: any): Promise<void> {\n    await this.caseNotes.insert({\n      ...data,\n      case_id: caseId,\n    });\n  }\n\n  async deleteAllCases(): Promise<void> {\n    const idRows = await this.cases\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .select([\"id\"])\n      .getRawMany<{ id: number }>();\n    const ids = idRows.map((r) => r.id);\n    const batches = chunkArray(ids, 500);\n    for (const batch of batches) {\n      await this.cases.createQueryBuilder().where(\"id IN (:ids)\", { ids: batch }).delete().execute();\n    }\n  }\n\n  async bumpCaseNumbers(amount: number): Promise<void> {\n    await this.cases\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .update()\n      .set({\n        case_number: () => `case_number + ${parseInt(amount as unknown as string, 10)}`,\n      })\n      .execute();\n  }\n\n  getExportCases(skip: number, take: number): Promise<Case[]> {\n    return this.cases.find({\n      where: {\n        guild_id: this.guildId,\n      },\n      relations: [\"notes\"],\n      order: {\n        case_number: \"ASC\",\n      },\n      skip,\n      take,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildContextMenuLinks.ts",
    "content": "import { DeleteResult, InsertResult, Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ContextMenuLink } from \"./entities/ContextMenuLink.js\";\n\nexport class GuildContextMenuLinks extends BaseGuildRepository {\n  private contextLinks: Repository<ContextMenuLink>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.contextLinks = dataSource.getRepository(ContextMenuLink);\n  }\n\n  async get(id: string): Promise<ContextMenuLink | null> {\n    return this.contextLinks.findOne({\n      where: {\n        guild_id: this.guildId,\n        context_id: id,\n      },\n    });\n  }\n\n  async create(contextId: string, contextAction: string): Promise<InsertResult> {\n    return this.contextLinks.insert({\n      guild_id: this.guildId,\n      context_id: contextId,\n      action_name: contextAction,\n    });\n  }\n\n  async deleteAll(): Promise<DeleteResult> {\n    return this.contextLinks.delete({\n      guild_id: this.guildId,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildCounters.ts",
    "content": "import moment from \"moment-timezone\";\nimport { FindOptionsWhere, In, IsNull, Not, Repository } from \"typeorm\";\nimport { Queue } from \"../Queue.js\";\nimport { DAYS, DBDateFormat, HOURS, MINUTES } from \"../utils.js\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Counter } from \"./entities/Counter.js\";\nimport { CounterTrigger, TriggerComparisonOp, isValidCounterComparisonOp } from \"./entities/CounterTrigger.js\";\nimport { CounterTriggerState } from \"./entities/CounterTriggerState.js\";\nimport { CounterValue } from \"./entities/CounterValue.js\";\n\nconst DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS;\nconst DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS;\n\nexport const MIN_COUNTER_VALUE = 0;\nexport const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT\n\nconst decayQueue = new Queue();\n\nasync function deleteCountersMarkedToBeDeleted(): Promise<void> {\n  await dataSource.getRepository(Counter).createQueryBuilder().where(\"delete_at <= NOW()\").delete().execute();\n}\n\nasync function deleteTriggersMarkedToBeDeleted(): Promise<void> {\n  await dataSource.getRepository(CounterTrigger).createQueryBuilder().where(\"delete_at <= NOW()\").delete().execute();\n}\n\nsetInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS);\nsetInterval(deleteTriggersMarkedToBeDeleted, 1 * HOURS);\n\nsetTimeout(deleteCountersMarkedToBeDeleted, 1 * MINUTES);\nsetTimeout(deleteTriggersMarkedToBeDeleted, 1 * MINUTES);\n\nexport class GuildCounters extends BaseGuildRepository {\n  private counters: Repository<Counter>;\n  private counterValues: Repository<CounterValue>;\n  private counterTriggers: Repository<CounterTrigger>;\n  private counterTriggerStates: Repository<CounterTriggerState>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.counters = dataSource.getRepository(Counter);\n    this.counterValues = dataSource.getRepository(CounterValue);\n    this.counterTriggers = dataSource.getRepository(CounterTrigger);\n    this.counterTriggerStates = dataSource.getRepository(CounterTriggerState);\n  }\n\n  async findOrCreateCounter(name: string, perChannel: boolean, perUser: boolean): Promise<Counter> {\n    const existing = await this.counters.findOne({\n      where: {\n        guild_id: this.guildId,\n        name,\n      },\n    });\n\n    if (existing) {\n      // If the existing counter's properties match the ones we're looking for, return it.\n      // Otherwise, delete the existing counter and re-create it with the proper properties.\n      if (existing.per_channel === perChannel && existing.per_user === perUser) {\n        await this.counters.update({ id: existing.id }, { delete_at: null });\n\n        return existing;\n      }\n\n      await this.counters.delete({ id: existing.id });\n    }\n\n    const insertResult = await this.counters.insert({\n      guild_id: this.guildId,\n      name,\n      per_channel: perChannel,\n      per_user: perUser,\n      last_decay_at: moment.utc().format(DBDateFormat),\n    });\n\n    return (await this.counters.findOne({\n      where: {\n        id: insertResult.identifiers[0].id,\n      },\n    }))!;\n  }\n\n  async markUnusedCountersToBeDeleted(idsToKeep: number[]): Promise<void> {\n    const criteria: FindOptionsWhere<Counter> = {\n      guild_id: this.guildId,\n      delete_at: IsNull(),\n    };\n\n    if (idsToKeep.length) {\n      criteria.id = Not(In(idsToKeep));\n    }\n\n    const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTERS_AFTER, \"ms\").format(DBDateFormat);\n\n    await this.counters.update(criteria, {\n      delete_at: deleteAt,\n    });\n  }\n\n  async deleteCountersMarkedToBeDeleted(): Promise<void> {\n    await this.counters.createQueryBuilder().where(\"delete_at <= NOW()\").delete().execute();\n  }\n\n  async changeCounterValue(\n    id: number,\n    channelId: string | null,\n    userId: string | null,\n    change: number,\n    initialValue: number,\n  ): Promise<void> {\n    if (typeof change !== \"number\" || Number.isNaN(change) || !Number.isFinite(change)) {\n      throw new Error(`changeCounterValue() change argument must be a number`);\n    }\n\n    channelId = channelId || \"0\";\n    userId = userId || \"0\";\n\n    const rawUpdate =\n      change >= 0\n        ? `value = LEAST(value + ${change}, ${MAX_COUNTER_VALUE})`\n        : `value = GREATEST(value ${change}, ${MIN_COUNTER_VALUE})`;\n\n    await this.counterValues.query(\n      `\n      INSERT INTO counter_values (counter_id, channel_id, user_id, value)\n      VALUES (?, ?, ?, ?)\n      ON DUPLICATE KEY UPDATE ${rawUpdate}\n    `,\n      [id, channelId, userId, Math.max(initialValue + change, 0)],\n    );\n  }\n\n  async setCounterValue(id: number, channelId: string | null, userId: string | null, value: number): Promise<void> {\n    if (typeof value !== \"number\" || Number.isNaN(value) || !Number.isFinite(value)) {\n      throw new Error(`setCounterValue() value argument must be a number`);\n    }\n\n    channelId = channelId || \"0\";\n    userId = userId || \"0\";\n\n    value = Math.max(value, 0);\n\n    await this.counterValues.query(\n      `\n      INSERT INTO counter_values (counter_id, channel_id, user_id, value)\n      VALUES (?, ?, ?, ?)\n      ON DUPLICATE KEY UPDATE value = ?\n    `,\n      [id, channelId, userId, value, value],\n    );\n  }\n\n  decay(id: number, decayPeriodMs: number, decayAmount: number) {\n    return decayQueue.add(async () => {\n      const counter = (await this.counters.findOne({\n        where: {\n          id,\n        },\n      }))!;\n\n      const diffFromLastDecayMs = moment.utc().diff(moment.utc(counter.last_decay_at!), \"ms\");\n      if (diffFromLastDecayMs < decayPeriodMs) {\n        return;\n      }\n\n      const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount);\n      if (decayAmountToApply === 0 || Number.isNaN(decayAmountToApply)) {\n        return;\n      }\n\n      // Calculate new last_decay_at based on the rounded decay amount we applied. This makes it so that over time, the decayed amount will stay accurate, even if we round some here.\n      const newLastDecayDate = moment\n        .utc(counter.last_decay_at)\n        .add((decayAmountToApply / decayAmount) * decayPeriodMs, \"ms\")\n        .format(DBDateFormat);\n\n      const rawUpdate =\n        decayAmountToApply >= 0\n          ? `GREATEST(value - ${decayAmountToApply}, ${MIN_COUNTER_VALUE})`\n          : `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`;\n\n      // Using an UPDATE with ORDER BY in an attempt to avoid deadlocks from simultaneous decays\n      // Also see https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks-handling.html\n      await this.counterValues\n        .createQueryBuilder(\"CounterValue\")\n        .where(\"counter_id = :id\", { id })\n        .orderBy(\"id\")\n        .update({\n          value: () => rawUpdate,\n        })\n        .execute();\n\n      await this.counters.update(\n        {\n          id,\n        },\n        {\n          last_decay_at: newLastDecayDate,\n        },\n      );\n    });\n  }\n\n  async markUnusedTriggersToBeDeleted(triggerIdsToKeep: number[]) {\n    let triggersToMarkQuery = this.counterTriggers\n      .createQueryBuilder(\"counterTriggers\")\n      .innerJoin(Counter, \"counters\", \"counters.id = counterTriggers.counter_id\")\n      .where(\"counters.guild_id = :guildId\", { guildId: this.guildId });\n\n    // If there are no active triggers, we just mark all triggers from the guild to be deleted.\n    // Otherwise, we mark all but the active triggers in the guild.\n    if (triggerIdsToKeep.length) {\n      triggersToMarkQuery = triggersToMarkQuery.andWhere(\"counterTriggers.id NOT IN (:...triggerIds)\", {\n        triggerIds: triggerIdsToKeep,\n      });\n    }\n\n    const triggersToMark = await triggersToMarkQuery.getMany();\n\n    if (triggersToMark.length) {\n      const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, \"ms\").format(DBDateFormat);\n\n      await this.counterTriggers.update(\n        {\n          id: In(triggersToMark.map((t) => t.id)),\n        },\n        {\n          delete_at: deleteAt,\n        },\n      );\n    }\n  }\n\n  async deleteTriggersMarkedToBeDeleted(): Promise<void> {\n    await this.counterTriggers.createQueryBuilder().where(\"delete_at <= NOW()\").delete().execute();\n  }\n\n  async initCounterTrigger(\n    counterId: number,\n    triggerName: string,\n    comparisonOp: TriggerComparisonOp,\n    comparisonValue: number,\n    reverseComparisonOp: TriggerComparisonOp,\n    reverseComparisonValue: number,\n  ): Promise<CounterTrigger> {\n    if (!isValidCounterComparisonOp(comparisonOp)) {\n      throw new Error(`Invalid comparison op: ${comparisonOp}`);\n    }\n\n    if (!isValidCounterComparisonOp(reverseComparisonOp)) {\n      throw new Error(`Invalid comparison op: ${reverseComparisonOp}`);\n    }\n\n    if (typeof comparisonValue !== \"number\") {\n      throw new Error(`Invalid comparison value: ${comparisonValue}`);\n    }\n\n    if (typeof reverseComparisonValue !== \"number\") {\n      throw new Error(`Invalid comparison value: ${reverseComparisonValue}`);\n    }\n\n    return dataSource.transaction(async (entityManager) => {\n      const existing = await entityManager.findOne(CounterTrigger, {\n        where: {\n          counter_id: counterId,\n          name: triggerName,\n        },\n      });\n\n      if (existing) {\n        // Since all existing triggers are marked as to-be-deleted before they are re-initialized, this needs to be reset\n        await entityManager.update(CounterTrigger, existing.id, {\n          comparison_op: comparisonOp,\n          comparison_value: comparisonValue,\n          reverse_comparison_op: reverseComparisonOp,\n          reverse_comparison_value: reverseComparisonValue,\n          delete_at: null,\n        });\n        return existing;\n      }\n\n      const insertResult = await entityManager.insert(CounterTrigger, {\n        counter_id: counterId,\n        name: triggerName,\n        comparison_op: comparisonOp,\n        comparison_value: comparisonValue,\n        reverse_comparison_op: reverseComparisonOp,\n        reverse_comparison_value: reverseComparisonValue,\n      });\n\n      return (await entityManager.findOne(CounterTrigger, {\n        where: {\n          id: insertResult.identifiers[0].id,\n        },\n      }))!;\n    });\n  }\n\n  /**\n   * Checks if a counter value with the given parameters triggers the specified comparison for the specified counter.\n   * If it does, mark this comparison for these parameters as triggered.\n   * Note that if this comparison for these parameters was already triggered previously, this function will return false.\n   * This means that a specific comparison for the specific parameters specified will only trigger *once* until the reverse trigger is triggered.\n   *\n   * @param counterId\n   * @param comparisonOp\n   * @param comparisonValue\n   * @param userId\n   * @param channelId\n   * @return Whether the given parameters newly triggered the given comparison\n   */\n  async checkForTrigger(\n    counterTrigger: CounterTrigger,\n    channelId: string | null,\n    userId: string | null,\n  ): Promise<boolean> {\n    channelId = channelId || \"0\";\n    userId = userId || \"0\";\n\n    return dataSource.transaction(async (entityManager) => {\n      const previouslyTriggered = await entityManager.findOne(CounterTriggerState, {\n        where: {\n          trigger_id: counterTrigger.id,\n          user_id: userId!,\n          channel_id: channelId!,\n        },\n      });\n\n      if (previouslyTriggered) {\n        return false;\n      }\n\n      const matchingValue = await entityManager\n        .createQueryBuilder(CounterValue, \"cv\")\n        .leftJoin(\n          CounterTriggerState,\n          \"triggerStates\",\n          \"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id\",\n          { triggerId: counterTrigger.id },\n        )\n        .where(`cv.value ${counterTrigger.comparison_op} :value`, { value: counterTrigger.comparison_value })\n        .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id })\n        .andWhere(\"cv.channel_id = :channelId AND cv.user_id = :userId\", { channelId, userId })\n        .andWhere(\"triggerStates.id IS NULL\")\n        .getOne();\n\n      if (matchingValue) {\n        await entityManager.insert(CounterTriggerState, {\n          trigger_id: counterTrigger.id,\n          user_id: userId!,\n          channel_id: channelId!,\n        });\n\n        return true;\n      }\n\n      return false;\n    });\n  }\n\n  /**\n   * Checks if any counter values of the specified counter match the specified comparison.\n   * Like checkForTrigger(), this can only happen *once* per unique counter value parameters until the reverse trigger is triggered for those values.\n   *\n   * @return Counter value parameters that triggered the condition\n   */\n  async checkAllValuesForTrigger(\n    counterTrigger: CounterTrigger,\n  ): Promise<Array<{ channelId: string; userId: string }>> {\n    return dataSource.transaction(async (entityManager) => {\n      const matchingValues = await entityManager\n        .createQueryBuilder(CounterValue, \"cv\")\n        .leftJoin(\n          CounterTriggerState,\n          \"triggerStates\",\n          \"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id\",\n          { triggerId: counterTrigger.id },\n        )\n        .where(`cv.value ${counterTrigger.comparison_op} :value`, { value: counterTrigger.comparison_value })\n        .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id })\n        .andWhere(\"triggerStates.id IS NULL\")\n        .getMany();\n\n      if (matchingValues.length) {\n        await entityManager.insert(\n          CounterTriggerState,\n          matchingValues.map((row) => ({\n            trigger_id: counterTrigger.id,\n            channel_id: row.channel_id,\n            user_id: row.user_id,\n          })),\n        );\n      }\n\n      return matchingValues.map((row) => ({\n        channelId: row.channel_id,\n        userId: row.user_id,\n      }));\n    });\n  }\n\n  /**\n   * Checks if a counter value with the given parameters *no longer* matches the specified comparison, and thus triggers a \"reverse trigger\".\n   * Like checkForTrigger(), this can only happen *once* until the comparison is triggered normally again.\n   *\n   * @param counterId\n   * @param comparisonOp\n   * @param comparisonValue\n   * @param userId\n   * @param channelId\n   * @return Whether the given parameters triggered a reverse trigger for the given comparison\n   */\n  async checkForReverseTrigger(\n    counterTrigger: CounterTrigger,\n    channelId: string | null,\n    userId: string | null,\n  ): Promise<boolean> {\n    channelId = channelId || \"0\";\n    userId = userId || \"0\";\n\n    return dataSource.transaction(async (entityManager) => {\n      const matchingValue = await entityManager\n        .createQueryBuilder(CounterValue, \"cv\")\n        .innerJoin(\n          CounterTriggerState,\n          \"triggerStates\",\n          \"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id\",\n          { triggerId: counterTrigger.id },\n        )\n        .where(`cv.value ${counterTrigger.reverse_comparison_op} :value`, {\n          value: counterTrigger.reverse_comparison_value,\n        })\n        .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id })\n        .andWhere(`cv.channel_id = :channelId AND cv.user_id = :userId`, { channelId, userId })\n        .getOne();\n\n      if (matchingValue) {\n        await entityManager.delete(CounterTriggerState, {\n          trigger_id: counterTrigger.id,\n          user_id: userId!,\n          channel_id: channelId!,\n        });\n\n        return true;\n      }\n\n      return false;\n    });\n  }\n\n  /**\n   * Checks if any counter values of the specified counter *no longer* match the specified comparison, and thus triggers a \"reverse trigger\" for those values.\n   * Like checkForTrigger(), this can only happen *once* per unique counter value parameters until the comparison is triggered normally again.\n   *\n   * @return Counter value parameters that triggered a reverse trigger\n   */\n  async checkAllValuesForReverseTrigger(\n    counterTrigger: CounterTrigger,\n  ): Promise<Array<{ channelId: string; userId: string }>> {\n    return dataSource.transaction(async (entityManager) => {\n      const matchingValues: Array<{\n        id: string;\n        triggerStateId: string;\n        user_id: string;\n        channel_id: string;\n      }> = await entityManager\n        .createQueryBuilder(CounterValue, \"cv\")\n        .innerJoin(\n          CounterTriggerState,\n          \"triggerStates\",\n          \"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id\",\n          { triggerId: counterTrigger.id },\n        )\n        .where(`cv.value ${counterTrigger.reverse_comparison_op} :value`, {\n          value: counterTrigger.reverse_comparison_value,\n        })\n        .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id })\n        .select([\n          \"cv.id AS id\",\n          \"cv.user_id AS user_id\",\n          \"cv.channel_id AS channel_id\",\n          \"triggerStates.id AS triggerStateId\",\n        ])\n        .getRawMany();\n\n      if (matchingValues.length) {\n        await entityManager.delete(CounterTriggerState, {\n          id: In(matchingValues.map((v) => v.triggerStateId)),\n        });\n      }\n\n      return matchingValues.map((row) => ({\n        channelId: row.channel_id,\n        userId: row.user_id,\n      }));\n    });\n  }\n\n  async getCurrentValue(\n    counterId: number,\n    channelId: string | null,\n    userId: string | null,\n  ): Promise<number | undefined> {\n    const value = await this.counterValues.findOne({\n      where: {\n        counter_id: counterId,\n        channel_id: channelId || \"0\",\n        user_id: userId || \"0\",\n      },\n    });\n\n    return value?.value;\n  }\n\n  async resetAllCounterValues(counterId: number): Promise<void> {\n    // Foreign keys will remove any related triggers and counter values\n    await this.counters.delete({\n      id: counterId,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildEvents.ts",
    "content": "import { Mute } from \"./entities/Mute.js\";\nimport { Reminder } from \"./entities/Reminder.js\";\nimport { ScheduledPost } from \"./entities/ScheduledPost.js\";\nimport { Tempban } from \"./entities/Tempban.js\";\nimport { VCAlert } from \"./entities/VCAlert.js\";\n\ninterface GuildEventArgs extends Record<string, unknown[]> {\n  expiredMute: [Mute];\n  timeoutMuteToRenew: [Mute];\n  scheduledPost: [ScheduledPost];\n  reminder: [Reminder];\n  expiredTempban: [Tempban];\n  expiredVCAlert: [VCAlert];\n}\n\ntype GuildEvent = keyof GuildEventArgs;\n\ntype GuildEventListener<K extends GuildEvent> = (...args: GuildEventArgs[K]) => void;\n\ntype ListenerMap = {\n  [K in GuildEvent]?: Array<GuildEventListener<K>>;\n};\n\nconst guildListeners: Map<string, ListenerMap> = new Map();\n\n/**\n * @return - Function to unregister the listener\n */\nexport function onGuildEvent<K extends GuildEvent>(\n  guildId: string,\n  eventName: K,\n  listener: GuildEventListener<K>,\n): () => void {\n  if (!guildListeners.has(guildId)) {\n    guildListeners.set(guildId, {});\n  }\n  const listenerMap = guildListeners.get(guildId)!;\n  if (listenerMap[eventName] == null) {\n    listenerMap[eventName] = [];\n  }\n  listenerMap[eventName]!.push(listener);\n\n  return () => {\n    listenerMap[eventName]!.splice(listenerMap[eventName]!.indexOf(listener), 1);\n  };\n}\n\nexport function emitGuildEvent<K extends GuildEvent>(guildId: string, eventName: K, args: GuildEventArgs[K]): void {\n  if (!guildListeners.has(guildId)) {\n    return;\n  }\n  const listenerMap = guildListeners.get(guildId)!;\n  if (listenerMap[eventName] == null) {\n    return;\n  }\n  for (const listener of listenerMap[eventName]!) {\n    listener(...args);\n  }\n}\n\nexport function hasGuildEventListener<K extends GuildEvent>(guildId: string, eventName: K): boolean {\n  if (!guildListeners.has(guildId)) {\n    return false;\n  }\n  const listenerMap = guildListeners.get(guildId)!;\n  if (listenerMap[eventName] == null || listenerMap[eventName]!.length === 0) {\n    return false;\n  }\n  return true;\n}\n"
  },
  {
    "path": "backend/src/data/GuildLogs.ts",
    "content": "import * as events from \"events\";\nimport { LogType } from \"./LogType.js\";\n\n// Use the same instance for the same guild, even if a new instance is created\nconst guildInstances: Map<string, GuildLogs> = new Map();\n\ninterface IIgnoredLog {\n  type: keyof typeof LogType;\n  ignoreId: any;\n}\n\nexport class GuildLogs extends events.EventEmitter {\n  protected guildId: string;\n  protected ignoredLogs: IIgnoredLog[];\n\n  constructor(guildId) {\n    if (guildInstances.has(guildId)) {\n      // Return existing instance for this guild if one exists\n      return guildInstances.get(guildId)!;\n    }\n\n    super();\n    this.guildId = guildId;\n    this.ignoredLogs = [];\n\n    // Store the instance for this guild so it can be returned later if a new instance for this guild is requested\n    guildInstances.set(guildId, this);\n  }\n\n  log(type: keyof typeof LogType, data: any, ignoreId?: string) {\n    if (ignoreId && this.isLogIgnored(type, ignoreId)) {\n      this.clearIgnoredLog(type, ignoreId);\n      return;\n    }\n\n    this.emit(\"log\", { type, data });\n  }\n\n  ignoreLog(type: keyof typeof LogType, ignoreId: any, timeout?: number) {\n    this.ignoredLogs.push({ type, ignoreId });\n\n    // Clear after expiry (15sec by default)\n    setTimeout(\n      () => {\n        this.clearIgnoredLog(type, ignoreId);\n      },\n      timeout || 1000 * 15,\n    );\n  }\n\n  isLogIgnored(type: keyof typeof LogType, ignoreId: any) {\n    return this.ignoredLogs.some((info) => type === info.type && ignoreId === info.ignoreId);\n  }\n\n  clearIgnoredLog(type: keyof typeof LogType, ignoreId: any) {\n    this.ignoredLogs.splice(\n      this.ignoredLogs.findIndex((info) => type === info.type && ignoreId === info.ignoreId),\n      1,\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildMemberCache.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { Blocker } from \"../Blocker.js\";\nimport { DBDateFormat, MINUTES } from \"../utils.js\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { MemberCacheItem } from \"./entities/MemberCacheItem.js\";\n\nconst SAVE_PENDING_BLOCKER_KEY = \"save-pending\" as const;\n\nconst DELETION_DELAY = 5 * MINUTES;\n\ntype UpdateData = Pick<MemberCacheItem, \"username\" | \"nickname\" | \"roles\">;\n\nexport class GuildMemberCache extends BaseGuildRepository {\n  #memberCache: Repository<MemberCacheItem>;\n\n  #pendingUpdates: Map<string, Partial<MemberCacheItem>>;\n\n  #blocker: Blocker;\n\n  constructor(guildId: string) {\n    super(guildId);\n    this.#memberCache = dataSource.getRepository(MemberCacheItem);\n    this.#pendingUpdates = new Map();\n    this.#blocker = new Blocker();\n  }\n\n  async savePendingUpdates(): Promise<void> {\n    await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY);\n\n    if (this.#pendingUpdates.size === 0) {\n      return;\n    }\n\n    this.#blocker.block(SAVE_PENDING_BLOCKER_KEY);\n\n    const entitiesToSave = Array.from(this.#pendingUpdates.values());\n    this.#pendingUpdates.clear();\n\n    await this.#memberCache.upsert(entitiesToSave, [\"guild_id\", \"user_id\"]).finally(() => {\n      this.#blocker.unblock(SAVE_PENDING_BLOCKER_KEY);\n    });\n  }\n\n  async getCachedMemberData(userId: string): Promise<MemberCacheItem | null> {\n    await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY);\n\n    const dbItem = await this.#memberCache.findOne({\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n    });\n    const pendingItem = this.#pendingUpdates.get(userId);\n    if (!dbItem && !pendingItem) {\n      return null;\n    }\n\n    const item = new MemberCacheItem();\n    Object.assign(item, dbItem ?? {});\n    Object.assign(item, pendingItem ?? {});\n    return item;\n  }\n\n  async setCachedMemberData(userId: string, data: UpdateData): Promise<void> {\n    await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY);\n\n    if (!this.#pendingUpdates.has(userId)) {\n      const newItem = new MemberCacheItem();\n      newItem.guild_id = this.guildId;\n      newItem.user_id = userId;\n      this.#pendingUpdates.set(userId, newItem);\n    }\n    Object.assign(this.#pendingUpdates.get(userId)!, data);\n    this.#pendingUpdates.get(userId)!.last_seen = moment().format(\"YYYY-MM-DD\");\n  }\n\n  async markMemberForDeletion(userId: string): Promise<void> {\n    await this.#memberCache.update(\n      {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n      {\n        delete_at: moment().add(DELETION_DELAY, \"ms\").format(DBDateFormat),\n      },\n    );\n  }\n\n  async unmarkMemberForDeletion(userId: string): Promise<void> {\n    await this.#memberCache.update(\n      {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n      {\n        delete_at: null,\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildMemberTimezones.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { MemberTimezone } from \"./entities/MemberTimezone.js\";\n\nexport class GuildMemberTimezones extends BaseGuildRepository {\n  protected memberTimezones: Repository<MemberTimezone>;\n\n  constructor(guildId: string) {\n    super(guildId);\n    this.memberTimezones = dataSource.getRepository(MemberTimezone);\n  }\n\n  get(memberId: string) {\n    return this.memberTimezones.findOne({\n      where: {\n        guild_id: this.guildId,\n        member_id: memberId,\n      },\n    });\n  }\n\n  async set(memberId, timezone: string) {\n    await dataSource.transaction(async (entityManager) => {\n      const repo = entityManager.getRepository(MemberTimezone);\n      const existingRow = await repo.findOne({\n        where: {\n          guild_id: this.guildId,\n          member_id: memberId,\n        },\n      });\n\n      if (existingRow) {\n        await repo.update(\n          {\n            guild_id: this.guildId,\n            member_id: memberId,\n          },\n          {\n            timezone,\n          },\n        );\n      } else {\n        await repo.insert({\n          guild_id: this.guildId,\n          member_id: memberId,\n          timezone,\n        });\n      }\n    });\n  }\n\n  reset(memberId: string) {\n    return this.memberTimezones.delete({\n      guild_id: this.guildId,\n      member_id: memberId,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildMutes.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Brackets, Repository } from \"typeorm\";\nimport { DBDateFormat } from \"../utils.js\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { MuteTypes } from \"./MuteTypes.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Mute } from \"./entities/Mute.js\";\n\nexport type AddMuteParams = {\n  userId: Mute[\"user_id\"];\n  type: MuteTypes;\n  expiresAt: number | null;\n  rolesToRestore?: Mute[\"roles_to_restore\"];\n  muteRole?: string | null;\n  timeoutExpiresAt?: number;\n};\n\nexport class GuildMutes extends BaseGuildRepository {\n  private mutes: Repository<Mute>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.mutes = dataSource.getRepository(Mute);\n  }\n\n  async getExpiredMutes(): Promise<Mute[]> {\n    return this.mutes\n      .createQueryBuilder(\"mutes\")\n      .where(\"guild_id = :guild_id\", { guild_id: this.guildId })\n      .andWhere(\"expires_at IS NOT NULL\")\n      .andWhere(\"expires_at <= NOW()\")\n      .getMany();\n  }\n\n  async findExistingMuteForUserId(userId: string): Promise<Mute | null> {\n    return this.mutes.findOne({\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n    });\n  }\n\n  async isMuted(userId: string): Promise<boolean> {\n    const mute = await this.findExistingMuteForUserId(userId);\n    return mute != null;\n  }\n\n  async addMute(params: AddMuteParams): Promise<Mute> {\n    const expiresAt = params.expiresAt ? moment.utc(params.expiresAt).format(DBDateFormat) : null;\n    const timeoutExpiresAt = params.timeoutExpiresAt ? moment.utc(params.timeoutExpiresAt).format(DBDateFormat) : null;\n\n    const result = await this.mutes.insert({\n      guild_id: this.guildId,\n      user_id: params.userId,\n      type: params.type,\n      expires_at: expiresAt,\n      roles_to_restore: params.rolesToRestore ?? [],\n      mute_role: params.muteRole,\n      timeout_expires_at: timeoutExpiresAt,\n    });\n\n    return (await this.mutes.findOne({ where: result.identifiers[0] }))!;\n  }\n\n  async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]): Promise<void> {\n    const expiresAt = newExpiryTime ? moment.utc().add(newExpiryTime, \"ms\").format(\"YYYY-MM-DD HH:mm:ss\") : null;\n\n    if (rolesToRestore && rolesToRestore.length) {\n      await this.mutes.update(\n        {\n          guild_id: this.guildId,\n          user_id: userId,\n        },\n        {\n          expires_at: expiresAt,\n          roles_to_restore: rolesToRestore,\n        },\n      );\n    } else {\n      await this.mutes.update(\n        {\n          guild_id: this.guildId,\n          user_id: userId,\n        },\n        {\n          expires_at: expiresAt,\n        },\n      );\n    }\n  }\n\n  async updateExpiresAt(userId: string, timestamp: number | null): Promise<void> {\n    const expiresAt = timestamp ? moment.utc(timestamp).format(\"YYYY-MM-DD HH:mm:ss\") : null;\n    await this.mutes.update(\n      {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n      {\n        expires_at: expiresAt,\n      },\n    );\n  }\n\n  async updateTimeoutExpiresAt(userId: string, timestamp: number): Promise<void> {\n    const timeoutExpiresAt = moment.utc(timestamp).format(DBDateFormat);\n    await this.mutes.update(\n      {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n      {\n        timeout_expires_at: timeoutExpiresAt,\n      },\n    );\n  }\n\n  async getActiveMutes(): Promise<Mute[]> {\n    return this.mutes\n      .createQueryBuilder(\"mutes\")\n      .where(\"guild_id = :guild_id\", { guild_id: this.guildId })\n      .andWhere(\n        new Brackets((qb) => {\n          qb.where(\"expires_at > NOW()\").orWhere(\"expires_at IS NULL\");\n        }),\n      )\n      .getMany();\n  }\n\n  async setCaseId(userId: string, caseId: number) {\n    await this.mutes.update(\n      {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n      {\n        case_id: caseId,\n      },\n    );\n  }\n\n  async clear(userId) {\n    await this.mutes.delete({\n      guild_id: this.guildId,\n      user_id: userId,\n    });\n  }\n\n  async fillMissingMuteRole(muteRole: string): Promise<void> {\n    await this.mutes\n      .createQueryBuilder()\n      .where(\"guild_id = :guild_id\", { guild_id: this.guildId })\n      .andWhere(\"type = :type\", { type: MuteTypes.Role })\n      .andWhere(\"mute_role IS NULL\")\n      .update({\n        mute_role: muteRole,\n      })\n      .execute();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildNicknameHistory.ts",
    "content": "import { In, Repository } from \"typeorm\";\nimport { isAPI } from \"../globals.js\";\nimport { MINUTES, SECONDS } from \"../utils.js\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { cleanupNicknames } from \"./cleanup/nicknames.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { NicknameHistoryEntry } from \"./entities/NicknameHistoryEntry.js\";\n\nconst CLEANUP_INTERVAL = 5 * MINUTES;\n\nasync function cleanup() {\n  await cleanupNicknames();\n  setTimeout(cleanup, CLEANUP_INTERVAL);\n}\n\nif (!isAPI()) {\n  // Start first cleanup 30 seconds after startup\n  // TODO: Move to bot startup code\n  setTimeout(cleanup, 30 * SECONDS);\n}\n\nexport const MAX_NICKNAME_ENTRIES_PER_USER = 10;\n\nexport class GuildNicknameHistory extends BaseGuildRepository {\n  private nicknameHistory: Repository<NicknameHistoryEntry>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.nicknameHistory = dataSource.getRepository(NicknameHistoryEntry);\n  }\n\n  async getByUserId(userId): Promise<NicknameHistoryEntry[]> {\n    return this.nicknameHistory.find({\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n      order: {\n        id: \"DESC\",\n      },\n    });\n  }\n\n  getLastEntry(userId): Promise<NicknameHistoryEntry | null> {\n    return this.nicknameHistory.findOne({\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n      order: {\n        id: \"DESC\",\n      },\n    });\n  }\n\n  async addEntry(userId, nickname) {\n    await this.nicknameHistory.insert({\n      guild_id: this.guildId,\n      user_id: userId,\n      nickname,\n    });\n\n    // Cleanup (leave only the last MAX_USERNAME_ENTRIES_PER_USER entries)\n    const toDelete = await this.nicknameHistory\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .andWhere(\"user_id = :userId\", { userId })\n      .orderBy(\"id\", \"DESC\")\n      .skip(MAX_NICKNAME_ENTRIES_PER_USER)\n      .take(99_999)\n      .getMany();\n\n    if (toDelete.length > 0) {\n      await this.nicknameHistory.delete({\n        id: In(toDelete.map((v) => v.id)),\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildPersistedData.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { PersistedData } from \"./entities/PersistedData.js\";\n\nexport class GuildPersistedData extends BaseGuildRepository {\n  private persistedData: Repository<PersistedData>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.persistedData = dataSource.getRepository(PersistedData);\n  }\n\n  async find(userId: string) {\n    return this.persistedData.findOne({\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n    });\n  }\n\n  async set(userId: string, data: Partial<PersistedData> = {}) {\n    const existing = await this.find(userId);\n    if (existing) {\n      await this.persistedData.update(\n        {\n          guild_id: this.guildId,\n          user_id: userId,\n        },\n        data,\n      );\n    } else {\n      await this.persistedData.insert({\n        ...data,\n        guild_id: this.guildId,\n        user_id: userId,\n      });\n    }\n  }\n\n  async clear(userId: string) {\n    await this.persistedData.delete({\n      guild_id: this.guildId,\n      user_id: userId,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildPingableRoles.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { PingableRole } from \"./entities/PingableRole.js\";\n\nexport class GuildPingableRoles extends BaseGuildRepository {\n  private pingableRoles: Repository<PingableRole>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.pingableRoles = dataSource.getRepository(PingableRole);\n  }\n\n  async all(): Promise<PingableRole[]> {\n    return this.pingableRoles.find({\n      where: {\n        guild_id: this.guildId,\n      },\n    });\n  }\n\n  async getForChannel(channelId: string): Promise<PingableRole[]> {\n    return this.pingableRoles.find({\n      where: {\n        guild_id: this.guildId,\n        channel_id: channelId,\n      },\n    });\n  }\n\n  async getByChannelAndRoleId(channelId: string, roleId: string): Promise<PingableRole | null> {\n    return this.pingableRoles.findOne({\n      where: {\n        guild_id: this.guildId,\n        channel_id: channelId,\n        role_id: roleId,\n      },\n    });\n  }\n\n  async delete(channelId: string, roleId: string) {\n    await this.pingableRoles.delete({\n      guild_id: this.guildId,\n      channel_id: channelId,\n      role_id: roleId,\n    });\n  }\n\n  async add(channelId: string, roleId: string) {\n    await this.pingableRoles.insert({\n      guild_id: this.guildId,\n      channel_id: channelId,\n      role_id: roleId,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildReactionRoles.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ReactionRole } from \"./entities/ReactionRole.js\";\n\nexport class GuildReactionRoles extends BaseGuildRepository {\n  private reactionRoles: Repository<ReactionRole>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.reactionRoles = dataSource.getRepository(ReactionRole);\n  }\n\n  async all(): Promise<ReactionRole[]> {\n    return this.reactionRoles.find({\n      where: {\n        guild_id: this.guildId,\n      },\n    });\n  }\n\n  async getForMessage(messageId: string): Promise<ReactionRole[]> {\n    return this.reactionRoles.find({\n      where: {\n        guild_id: this.guildId,\n        message_id: messageId,\n      },\n      order: {\n        order: \"ASC\",\n      },\n    });\n  }\n\n  async getByMessageAndEmoji(messageId: string, emoji: string): Promise<ReactionRole | null> {\n    return this.reactionRoles.findOne({\n      where: {\n        guild_id: this.guildId,\n        message_id: messageId,\n        emoji,\n      },\n    });\n  }\n\n  async removeFromMessage(messageId: string, emoji?: string) {\n    const criteria: any = {\n      guild_id: this.guildId,\n      message_id: messageId,\n    };\n\n    if (emoji) {\n      criteria.emoji = emoji;\n    }\n\n    await this.reactionRoles.delete(criteria);\n  }\n\n  async add(\n    channelId: string,\n    messageId: string,\n    emoji: string,\n    roleId: string,\n    exclusive?: boolean,\n    position?: number,\n  ) {\n    await this.reactionRoles.insert({\n      guild_id: this.guildId,\n      channel_id: channelId,\n      message_id: messageId,\n      emoji,\n      role_id: roleId,\n      is_exclusive: Boolean(exclusive),\n      order: position,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildReminders.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Reminder } from \"./entities/Reminder.js\";\n\nexport class GuildReminders extends BaseGuildRepository {\n  private reminders: Repository<Reminder>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.reminders = dataSource.getRepository(Reminder);\n  }\n\n  async getDueReminders(): Promise<Reminder[]> {\n    return this.reminders\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .andWhere(\"remind_at <= NOW()\")\n      .getMany();\n  }\n\n  async getRemindersByUserId(userId: string): Promise<Reminder[]> {\n    return this.reminders.find({\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n    });\n  }\n\n  find(id: number) {\n    return this.reminders.findOne({\n      where: { id },\n    });\n  }\n\n  async delete(id) {\n    await this.reminders.delete({\n      guild_id: this.guildId,\n      id,\n    });\n  }\n\n  async add(userId: string, channelId: string, remindAt: string, body: string, created_at: string) {\n    const result = await this.reminders.insert({\n      guild_id: this.guildId,\n      user_id: userId,\n      channel_id: channelId,\n      remind_at: remindAt,\n      body,\n      created_at,\n    });\n\n    return (await this.find(result.identifiers[0].id))!;\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildRoleButtons.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { RoleButtonsItem } from \"./entities/RoleButtonsItem.js\";\n\nexport class GuildRoleButtons extends BaseGuildRepository {\n  private roleButtons: Repository<RoleButtonsItem>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.roleButtons = dataSource.getRepository(RoleButtonsItem);\n  }\n\n  getSavedRoleButtons(): Promise<RoleButtonsItem[]> {\n    return this.roleButtons.find({\n      where: {\n        guild_id: this.guildId,\n      },\n    });\n  }\n\n  async deleteRoleButtonItem(name: string): Promise<void> {\n    await this.roleButtons.delete({\n      guild_id: this.guildId,\n      name,\n    });\n  }\n\n  async saveRoleButtonItem(name: string, channelId: string, messageId: string, hash: string): Promise<void> {\n    await this.roleButtons.insert({\n      guild_id: this.guildId,\n      name,\n      channel_id: channelId,\n      message_id: messageId,\n      hash,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildRoleQueue.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { RoleQueueItem } from \"./entities/RoleQueueItem.js\";\n\nexport class GuildRoleQueue extends BaseGuildRepository {\n  private roleQueue: Repository<RoleQueueItem>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.roleQueue = dataSource.getRepository(RoleQueueItem);\n  }\n\n  consumeNextRoleAssignments(count: number): Promise<RoleQueueItem[]> {\n    return dataSource.transaction(async (entityManager) => {\n      const repository = entityManager.getRepository(RoleQueueItem);\n\n      const nextAssignments = await repository\n        .createQueryBuilder()\n        .where(\"guild_id = :guildId\", { guildId: this.guildId })\n        .addOrderBy(\"priority\", \"DESC\")\n        .addOrderBy(\"id\", \"ASC\")\n        .take(count)\n        .getMany();\n\n      if (nextAssignments.length > 0) {\n        const ids = nextAssignments.map((assignment) => assignment.id);\n        await repository.createQueryBuilder().where(\"id IN (:ids)\", { ids }).delete().execute();\n      }\n\n      return nextAssignments;\n    });\n  }\n\n  async addQueueItem(userId: string, roleId: string, shouldAdd: boolean, priority = 0) {\n    await this.roleQueue.insert({\n      guild_id: this.guildId,\n      user_id: userId,\n      role_id: roleId,\n      should_add: shouldAdd,\n      priority,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildSavedMessages.ts",
    "content": "import { GuildChannel, Message } from \"discord.js\";\nimport moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { QueuedEventEmitter } from \"../QueuedEventEmitter.js\";\nimport { noop } from \"../utils.js\";\nimport { asyncMap } from \"../utils/async.js\";\nimport { decryptJson, encryptJson } from \"../utils/cryptHelpers.js\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { buildEntity } from \"./buildEntity.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ISavedMessageData, SavedMessage } from \"./entities/SavedMessage.js\";\n\nexport class GuildSavedMessages extends BaseGuildRepository<SavedMessage> {\n  private messages: Repository<SavedMessage>;\n  protected toBePermanent: Set<string>;\n\n  public events: QueuedEventEmitter;\n\n  constructor(guildId) {\n    super(guildId);\n    this.messages = dataSource.getRepository(SavedMessage);\n    this.events = new QueuedEventEmitter();\n\n    this.toBePermanent = new Set();\n  }\n\n  protected msgToSavedMessageData(msg: Message): ISavedMessageData {\n    const data: ISavedMessageData = {\n      author: {\n        username: msg.author.username,\n        discriminator: msg.author.discriminator,\n      },\n      content: msg.content,\n      timestamp: msg.createdTimestamp,\n    };\n\n    if (msg.attachments.size) {\n      data.attachments = Array.from(msg.attachments.values()).map((att) => ({\n        id: att.id,\n        contentType: att.contentType,\n        name: att.name,\n        proxyURL: att.proxyURL,\n        size: att.size,\n        spoiler: att.spoiler,\n        url: att.url,\n        width: att.width,\n      }));\n    }\n\n    if (msg.embeds.length) {\n      data.embeds = msg.embeds.map((embed) => ({\n        title: embed.title,\n        description: embed.description,\n        url: embed.url,\n        timestamp: embed.timestamp ? Date.parse(embed.timestamp) : null,\n        color: embed.color,\n\n        fields: embed.fields.map((field) => ({\n          name: field.name,\n          value: field.value,\n          inline: field.inline ?? false,\n        })),\n\n        author: embed.author\n          ? {\n              name: embed.author.name,\n              url: embed.author.url,\n              iconURL: embed.author.iconURL,\n              proxyIconURL: embed.author.proxyIconURL,\n            }\n          : undefined,\n\n        thumbnail: embed.thumbnail\n          ? {\n              url: embed.thumbnail.url,\n              proxyURL: embed.thumbnail.proxyURL,\n              height: embed.thumbnail.height,\n              width: embed.thumbnail.width,\n            }\n          : undefined,\n\n        image: embed.image\n          ? {\n              url: embed.image.url,\n              proxyURL: embed.image.proxyURL,\n              height: embed.image.height,\n              width: embed.image.width,\n            }\n          : undefined,\n\n        video: embed.video\n          ? {\n              url: embed.video.url,\n              proxyURL: embed.video.proxyURL,\n              height: embed.video.height,\n              width: embed.video.width,\n            }\n          : undefined,\n\n        footer: embed.footer\n          ? {\n              text: embed.footer.text,\n              iconURL: embed.footer.iconURL,\n              proxyIconURL: embed.footer.proxyIconURL,\n            }\n          : undefined,\n      }));\n    }\n\n    if (msg.stickers?.size) {\n      data.stickers = Array.from(msg.stickers.values()).map((sticker) => ({\n        format: sticker.format,\n        guildId: sticker.guildId,\n        id: sticker.id,\n        name: sticker.name,\n        description: sticker.description,\n        available: sticker.available,\n        type: sticker.type,\n      }));\n    }\n\n    if (msg.reference && (msg.reference.messageId || msg.reference.channelId || msg.reference.guildId)) {\n      data.reference = {\n        messageId: msg.reference.messageId ?? null,\n        channelId: msg.reference.channelId ?? null,\n        guildId: msg.reference.guildId ?? null,\n      };\n    }\n\n    return data;\n  }\n\n  protected async _processEntityFromDB(entity: SavedMessage | undefined) {\n    if (entity == null) {\n      return entity;\n    }\n\n    entity.data = (await decryptJson(entity.data as unknown as string)) as ISavedMessageData;\n    return entity;\n  }\n\n  protected async _processEntityToDB(entity: Partial<SavedMessage>) {\n    if (entity.data) {\n      entity.data = (await encryptJson(entity.data)) as any;\n    }\n    return entity;\n  }\n\n  async find(id: string, includeDeleted = false): Promise<SavedMessage | null> {\n    let query = this.messages\n      .createQueryBuilder()\n      .where(\"guild_id = :guild_id\", { guild_id: this.guildId })\n      .andWhere(\"id = :id\", { id });\n\n    if (!includeDeleted) {\n      query = query.andWhere(\"deleted_at IS NULL\");\n    }\n\n    const result = await query.getOne();\n\n    return this.processEntityFromDB(result);\n  }\n\n  async getUserMessagesByChannelAfterId(userId, channelId, afterId, limit?: number): Promise<SavedMessage[]> {\n    let query = this.messages\n      .createQueryBuilder()\n      .where(\"guild_id = :guild_id\", { guild_id: this.guildId })\n      .andWhere(\"channel_id = :channel_id\", { channel_id: channelId })\n      .andWhere(\"user_id = :user_id\", { user_id: userId })\n      .andWhere(\"id > :afterId\", { afterId })\n      .andWhere(\"deleted_at IS NULL\");\n\n    if (limit != null) {\n      query = query.limit(limit);\n    }\n\n    const results = await query.getMany();\n\n    return this.processMultipleEntitiesFromDB(results);\n  }\n\n  async getMultiple(messageIds: string[]): Promise<SavedMessage[]> {\n    if (messageIds.length === 0) {\n      return [];\n    }\n\n    const results = await this.messages\n      .createQueryBuilder()\n      .where(\"guild_id = :guild_id\", { guild_id: this.guildId })\n      .andWhere(\"id IN (:messageIds)\", { messageIds })\n      .getMany();\n\n    return this.processMultipleEntitiesFromDB(results);\n  }\n\n  async createFromMsg(msg: Message, overrides = {}): Promise<void> {\n    // FIXME: Hotfix\n    if (!msg.channel) {\n      return;\n    }\n\n    // Don't actually save bot messages. Just pass them through as if they were saved.\n    if (msg.author.bot) {\n      const fakeSavedMessage = buildEntity(SavedMessage, await this.msgToInsertReadyEntity(msg));\n      this.fireCreateEvents(fakeSavedMessage);\n      return;\n    }\n\n    await this.createFromMessages([msg], overrides);\n  }\n\n  async createFromMessages(messages: Message[], overrides = {}): Promise<void> {\n    const items = await asyncMap(messages, async (msg) => ({\n      ...(await this.msgToInsertReadyEntity(msg)),\n      ...overrides,\n    }));\n    await this.insertBulk(items);\n  }\n\n  protected async msgToInsertReadyEntity(msg: Message): Promise<Partial<SavedMessage>> {\n    const savedMessageData = this.msgToSavedMessageData(msg);\n    const postedAt = moment.utc(msg.createdTimestamp, \"x\").format(\"YYYY-MM-DD HH:mm:ss\");\n\n    return {\n      id: msg.id,\n      guild_id: (msg.channel as GuildChannel).guild.id,\n      channel_id: msg.channel.id,\n      user_id: msg.author.id,\n      is_bot: msg.author.bot,\n      data: savedMessageData,\n      posted_at: postedAt,\n    };\n  }\n\n  protected async insertBulk(items: Array<Partial<SavedMessage>>): Promise<void> {\n    for (const item of items) {\n      if (this.toBePermanent.has(item.id!)) {\n        item.is_permanent = true;\n        this.toBePermanent.delete(item.id!);\n      }\n    }\n\n    const itemsToInsert = await asyncMap(items, (item) => this.processEntityToDB({ ...item }));\n    await this.messages.createQueryBuilder().insert().values(itemsToInsert).execute().catch(noop);\n\n    for (const item of items) {\n      // perf: save a db lookup and message content decryption by building the entity manually\n      const inserted = buildEntity(SavedMessage, item);\n      this.fireCreateEvents(inserted);\n    }\n  }\n\n  protected async fireCreateEvents(message: SavedMessage) {\n    this.events.emit(\"create\", [message]);\n    this.events.emit(`create:${message.id}`, [message]);\n  }\n\n  async markAsDeleted(id): Promise<void> {\n    await this.messages\n      .createQueryBuilder(\"messages\")\n      .update()\n      .set({\n        deleted_at: () => \"NOW(3)\",\n      })\n      .where(\"guild_id = :guild_id\", { guild_id: this.guildId })\n      .andWhere(\"id = :id\", { id })\n      .execute();\n\n    const deleted = await this.find(id, true);\n\n    if (deleted) {\n      this.events.emit(\"delete\", [deleted]);\n      this.events.emit(`delete:${id}`, [deleted]);\n    }\n  }\n\n  /**\n   * Marks the specified messages as deleted in the database (if they weren't already marked before).\n   * If any messages were marked as deleted, also emits the deleteBulk event.\n   */\n  async markBulkAsDeleted(ids) {\n    const deletedAt = moment.utc().format(\"YYYY-MM-DD HH:mm:ss\");\n\n    await this.messages\n      .createQueryBuilder()\n      .update()\n      .set({ deleted_at: deletedAt })\n      .where(\"guild_id = :guild_id\", { guild_id: this.guildId })\n      .andWhere(\"id IN (:ids)\", { ids })\n      .andWhere(\"deleted_at IS NULL\")\n      .execute();\n\n    let deleted = await this.messages\n      .createQueryBuilder()\n      .where(\"id IN (:ids)\", { ids })\n      .andWhere(\"deleted_at = :deletedAt\", { deletedAt })\n      .getMany();\n    deleted = await this.processMultipleEntitiesFromDB(deleted);\n\n    if (deleted.length) {\n      this.events.emit(\"deleteBulk\", [deleted]);\n    }\n  }\n\n  async saveEdit(id, newData: ISavedMessageData): Promise<void> {\n    const oldMessage = await this.find(id);\n    if (!oldMessage) return;\n\n    const newMessage = { ...oldMessage, data: newData };\n\n    // @ts-ignore\n    const updateData = await this.processEntityToDB({\n      data: newData,\n    });\n    await this.messages.update({ id }, updateData);\n\n    this.events.emit(\"update\", [newMessage, oldMessage]);\n    this.events.emit(`update:${id}`, [newMessage, oldMessage]);\n  }\n\n  async saveEditFromMsg(msg: Message): Promise<void> {\n    const newData = this.msgToSavedMessageData(msg);\n    await this.saveEdit(msg.id, newData);\n  }\n\n  async setPermanent(id: string): Promise<void> {\n    const savedMsg = await this.find(id);\n    if (savedMsg) {\n      await this.messages.update(\n        { id },\n        {\n          is_permanent: true,\n        },\n      );\n    } else {\n      this.toBePermanent.add(id);\n    }\n  }\n\n  async onceMessageAvailable(\n    id: string,\n    handler: (msg?: SavedMessage) => any,\n    timeout: number = 60 * 1000,\n  ): Promise<void> {\n    let called = false;\n    let onceEventListener;\n    let timeoutFn;\n\n    const callHandler = async (msg?: SavedMessage) => {\n      this.events.off(`create:${id}`, onceEventListener);\n      clearTimeout(timeoutFn);\n\n      if (called) return;\n      called = true;\n\n      await handler(msg);\n    };\n\n    onceEventListener = this.events.once(`create:${id}`, callHandler);\n    timeoutFn = setTimeout(() => {\n      called = true;\n      callHandler(undefined);\n    }, timeout);\n\n    const messageInDB = await this.find(id);\n    if (messageInDB) {\n      callHandler(messageInDB);\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildScheduledPosts.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ScheduledPost } from \"./entities/ScheduledPost.js\";\n\nexport class GuildScheduledPosts extends BaseGuildRepository {\n  private scheduledPosts: Repository<ScheduledPost>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.scheduledPosts = dataSource.getRepository(ScheduledPost);\n  }\n\n  all(): Promise<ScheduledPost[]> {\n    return this.scheduledPosts.createQueryBuilder().where(\"guild_id = :guildId\", { guildId: this.guildId }).getMany();\n  }\n\n  getDueScheduledPosts(): Promise<ScheduledPost[]> {\n    return this.scheduledPosts\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .andWhere(\"post_at <= NOW()\")\n      .getMany();\n  }\n\n  find(id: number) {\n    return this.scheduledPosts.findOne({\n      where: {\n        id,\n      },\n    });\n  }\n\n  async delete(id) {\n    await this.scheduledPosts.delete({\n      guild_id: this.guildId,\n      id,\n    });\n  }\n\n  async create(data: Partial<ScheduledPost>) {\n    const result = await this.scheduledPosts.insert({\n      ...data,\n      guild_id: this.guildId,\n    });\n\n    return (await this.find(result.identifiers[0].id))!;\n  }\n\n  async update(id: number, data: Partial<ScheduledPost>) {\n    await this.scheduledPosts.update(id, data);\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildSlowmodes.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { SlowmodeChannel } from \"./entities/SlowmodeChannel.js\";\nimport { SlowmodeUser } from \"./entities/SlowmodeUser.js\";\n\nexport class GuildSlowmodes extends BaseGuildRepository {\n  private slowmodeChannels: Repository<SlowmodeChannel>;\n  private slowmodeUsers: Repository<SlowmodeUser>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.slowmodeChannels = dataSource.getRepository(SlowmodeChannel);\n    this.slowmodeUsers = dataSource.getRepository(SlowmodeUser);\n  }\n\n  async getChannelSlowmode(channelId): Promise<SlowmodeChannel | null> {\n    return this.slowmodeChannels.findOne({\n      where: {\n        guild_id: this.guildId,\n        channel_id: channelId,\n      },\n    });\n  }\n\n  async setChannelSlowmode(channelId, seconds): Promise<void> {\n    const existingSlowmode = await this.getChannelSlowmode(channelId);\n    if (existingSlowmode) {\n      await this.slowmodeChannels.update(\n        {\n          guild_id: this.guildId,\n          channel_id: channelId,\n        },\n        {\n          slowmode_seconds: seconds,\n        },\n      );\n    } else {\n      await this.slowmodeChannels.insert({\n        guild_id: this.guildId,\n        channel_id: channelId,\n        slowmode_seconds: seconds,\n      });\n    }\n  }\n\n  async deleteChannelSlowmode(channelId): Promise<void> {\n    await this.slowmodeChannels.delete({\n      guild_id: this.guildId,\n      channel_id: channelId,\n    });\n  }\n\n  async getChannelSlowmodeUser(channelId, userId): Promise<SlowmodeUser | null> {\n    return this.slowmodeUsers.findOne({\n      where: {\n        guild_id: this.guildId,\n        channel_id: channelId,\n        user_id: userId,\n      },\n    });\n  }\n\n  async userHasSlowmode(channelId, userId): Promise<boolean> {\n    return (await this.getChannelSlowmodeUser(channelId, userId)) != null;\n  }\n\n  async addSlowmodeUser(channelId, userId): Promise<void> {\n    const slowmode = await this.getChannelSlowmode(channelId);\n    if (!slowmode) return;\n\n    const expiresAt = moment.utc().add(slowmode.slowmode_seconds, \"seconds\").format(\"YYYY-MM-DD HH:mm:ss\");\n\n    if (await this.userHasSlowmode(channelId, userId)) {\n      // Update existing\n      await this.slowmodeUsers.update(\n        {\n          guild_id: this.guildId,\n          channel_id: channelId,\n          user_id: userId,\n        },\n        {\n          expires_at: expiresAt,\n        },\n      );\n    } else {\n      // Add new\n      await this.slowmodeUsers.insert({\n        guild_id: this.guildId,\n        channel_id: channelId,\n        user_id: userId,\n        expires_at: expiresAt,\n      });\n    }\n  }\n\n  async clearSlowmodeUser(channelId, userId): Promise<void> {\n    await this.slowmodeUsers.delete({\n      guild_id: this.guildId,\n      channel_id: channelId,\n      user_id: userId,\n    });\n  }\n\n  async getChannelSlowmodeUsers(channelId): Promise<SlowmodeUser[]> {\n    return this.slowmodeUsers.find({\n      where: {\n        guild_id: this.guildId,\n        channel_id: channelId,\n      },\n    });\n  }\n\n  async getExpiredSlowmodeUsers(): Promise<SlowmodeUser[]> {\n    return this.slowmodeUsers\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .andWhere(\"expires_at <= NOW()\")\n      .getMany();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildStarboardMessages.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { StarboardMessage } from \"./entities/StarboardMessage.js\";\n\nexport class GuildStarboardMessages extends BaseGuildRepository {\n  private allStarboardMessages: Repository<StarboardMessage>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.allStarboardMessages = dataSource.getRepository(StarboardMessage);\n  }\n\n  async getStarboardMessagesForMessageId(messageId: string) {\n    return this.allStarboardMessages\n      .createQueryBuilder()\n      .where(\"guild_id = :gid\", { gid: this.guildId })\n      .andWhere(\"message_id = :msgid\", { msgid: messageId })\n      .getMany();\n  }\n\n  async getStarboardMessagesForStarboardMessageId(starboardMessageId: string) {\n    return this.allStarboardMessages\n      .createQueryBuilder()\n      .where(\"guild_id = :gid\", { gid: this.guildId })\n      .andWhere(\"starboard_message_id = :messageId\", { messageId: starboardMessageId })\n      .getMany();\n  }\n\n  async getMatchingStarboardMessages(starboardChannelId: string, sourceMessageId: string) {\n    return this.allStarboardMessages\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .andWhere(\"message_id = :msgId\", { msgId: sourceMessageId })\n      .andWhere(\"starboard_channel_id = :channelId\", { channelId: starboardChannelId })\n      .getMany();\n  }\n\n  async createStarboardMessage(starboardId: string, messageId: string, starboardMessageId: string) {\n    await this.allStarboardMessages.insert({\n      message_id: messageId,\n      starboard_message_id: starboardMessageId,\n      starboard_channel_id: starboardId,\n      guild_id: this.guildId,\n    });\n  }\n\n  async deleteStarboardMessage(starboardMessageId: string, starboardChannelId: string) {\n    await this.allStarboardMessages.delete({\n      guild_id: this.guildId,\n      starboard_message_id: starboardMessageId,\n      starboard_channel_id: starboardChannelId,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildStarboardReactions.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { StarboardReaction } from \"./entities/StarboardReaction.js\";\n\nexport class GuildStarboardReactions extends BaseGuildRepository {\n  private allStarboardReactions: Repository<StarboardReaction>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.allStarboardReactions = dataSource.getRepository(StarboardReaction);\n  }\n\n  async getAllReactionsForMessageId(messageId: string) {\n    return this.allStarboardReactions\n      .createQueryBuilder()\n      .where(\"guild_id = :gid\", { gid: this.guildId })\n      .andWhere(\"message_id = :msgid\", { msgid: messageId })\n      .getMany();\n  }\n\n  async createStarboardReaction(messageId: string, reactorId: string) {\n    const existingReaction = await this.allStarboardReactions.findOne({\n      where: {\n        guild_id: this.guildId,\n        message_id: messageId,\n        reactor_id: reactorId,\n      },\n    });\n\n    if (existingReaction) {\n      return;\n    }\n\n    await this.allStarboardReactions.insert({\n      guild_id: this.guildId,\n      message_id: messageId,\n      reactor_id: reactorId,\n    });\n  }\n\n  async deleteAllStarboardReactionsForMessageId(messageId: string) {\n    await this.allStarboardReactions.delete({\n      guild_id: this.guildId,\n      message_id: messageId,\n    });\n  }\n\n  async deleteStarboardReaction(messageId: string, reactorId: string) {\n    await this.allStarboardReactions.delete({\n      guild_id: this.guildId,\n      reactor_id: reactorId,\n      message_id: messageId,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildStats.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { StatValue } from \"./entities/StatValue.js\";\n\nexport class GuildStats extends BaseGuildRepository {\n  private stats: Repository<StatValue>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.stats = dataSource.getRepository(StatValue);\n  }\n\n  async saveValue(source: string, key: string, value: number): Promise<void> {\n    await this.stats.insert({\n      guild_id: this.guildId,\n      source,\n      key,\n      value,\n    });\n  }\n\n  async deleteOldValues(source: string, cutoff: string): Promise<void> {\n    await this.stats\n      .createQueryBuilder()\n      .where(\"source = :source\", { source })\n      .andWhere(\"created_at < :cutoff\", { cutoff })\n      .delete();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildTags.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Tag } from \"./entities/Tag.js\";\nimport { TagResponse } from \"./entities/TagResponse.js\";\n\nexport class GuildTags extends BaseGuildRepository {\n  private tags: Repository<Tag>;\n  private tagResponses: Repository<TagResponse>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.tags = dataSource.getRepository(Tag);\n    this.tagResponses = dataSource.getRepository(TagResponse);\n  }\n\n  async all(): Promise<Tag[]> {\n    return this.tags.find({\n      where: {\n        guild_id: this.guildId,\n      },\n    });\n  }\n\n  async find(tag): Promise<Tag | null> {\n    return this.tags.findOne({\n      where: {\n        guild_id: this.guildId,\n        tag,\n      },\n    });\n  }\n\n  async createOrUpdate(tag, body, userId) {\n    const existingTag = await this.find(tag);\n    if (existingTag) {\n      await this.tags\n        .createQueryBuilder()\n        .update()\n        .set({\n          body,\n          user_id: userId,\n          created_at: () => \"NOW()\",\n        })\n        .where(\"guild_id = :guildId\", { guildId: this.guildId })\n        .andWhere(\"tag = :tag\", { tag })\n        .execute();\n    } else {\n      await this.tags.insert({\n        guild_id: this.guildId,\n        user_id: userId,\n        tag,\n        body,\n      });\n    }\n  }\n\n  async delete(tag) {\n    await this.tags.delete({\n      guild_id: this.guildId,\n      tag,\n    });\n  }\n\n  async findResponseByCommandMessageId(messageId: string): Promise<TagResponse | null> {\n    return this.tagResponses.findOne({\n      where: {\n        guild_id: this.guildId,\n        command_message_id: messageId,\n      },\n    });\n  }\n\n  async findResponseByResponseMessageId(messageId: string): Promise<TagResponse | null> {\n    return this.tagResponses.findOne({\n      where: {\n        guild_id: this.guildId,\n        response_message_id: messageId,\n      },\n    });\n  }\n\n  async addResponse(cmdMessageId, responseMessageId) {\n    await this.tagResponses.insert({\n      guild_id: this.guildId,\n      command_message_id: cmdMessageId,\n      response_message_id: responseMessageId,\n    });\n  }\n\n  async deleteResponseByCommandMessageId(messageId: string): Promise<void> {\n    await this.tagResponses.delete({\n      guild_id: this.guildId,\n      command_message_id: messageId,\n    });\n  }\n\n  async deleteResponseByResponseMessageId(messageId: string): Promise<void> {\n    await this.tagResponses.delete({\n      guild_id: this.guildId,\n      response_message_id: messageId,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildTempbans.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Tempban } from \"./entities/Tempban.js\";\n\nexport class GuildTempbans extends BaseGuildRepository {\n  private tempbans: Repository<Tempban>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.tempbans = dataSource.getRepository(Tempban);\n  }\n\n  async getExpiredTempbans(): Promise<Tempban[]> {\n    return this.tempbans\n      .createQueryBuilder(\"mutes\")\n      .where(\"guild_id = :guild_id\", { guild_id: this.guildId })\n      .andWhere(\"expires_at IS NOT NULL\")\n      .andWhere(\"expires_at <= NOW()\")\n      .getMany();\n  }\n\n  async findExistingTempbanForUserId(userId: string): Promise<Tempban | null> {\n    return this.tempbans.findOne({\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n    });\n  }\n\n  async addTempban(userId, expiryTime, modId): Promise<Tempban> {\n    const expiresAt = moment.utc().add(expiryTime, \"ms\").format(\"YYYY-MM-DD HH:mm:ss\");\n\n    const result = await this.tempbans.insert({\n      guild_id: this.guildId,\n      user_id: userId,\n      mod_id: modId,\n      expires_at: expiresAt,\n      created_at: moment.utc().format(\"YYYY-MM-DD HH:mm:ss\"),\n    });\n\n    return (await this.tempbans.findOne({ where: result.identifiers[0] }))!;\n  }\n\n  async updateExpiryTime(userId, newExpiryTime, modId) {\n    const expiresAt = moment.utc().add(newExpiryTime, \"ms\").format(\"YYYY-MM-DD HH:mm:ss\");\n\n    return this.tempbans.update(\n      {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n      {\n        created_at: moment.utc().format(\"YYYY-MM-DD HH:mm:ss\"),\n        expires_at: expiresAt,\n        mod_id: modId,\n      },\n    );\n  }\n\n  async clear(userId) {\n    await this.tempbans.delete({\n      guild_id: this.guildId,\n      user_id: userId,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/GuildVCAlerts.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseGuildRepository } from \"./BaseGuildRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { VCAlert } from \"./entities/VCAlert.js\";\n\nexport class GuildVCAlerts extends BaseGuildRepository {\n  private allAlerts: Repository<VCAlert>;\n\n  constructor(guildId) {\n    super(guildId);\n    this.allAlerts = dataSource.getRepository(VCAlert);\n  }\n\n  async getOutdatedAlerts(): Promise<VCAlert[]> {\n    return this.allAlerts\n      .createQueryBuilder()\n      .where(\"guild_id = :guildId\", { guildId: this.guildId })\n      .andWhere(\"expires_at <= NOW()\")\n      .getMany();\n  }\n\n  async getAllGuildAlerts(): Promise<VCAlert[]> {\n    return this.allAlerts.createQueryBuilder().where(\"guild_id = :guildId\", { guildId: this.guildId }).getMany();\n  }\n\n  async getAlertsByUserId(userId: string): Promise<VCAlert[]> {\n    return this.allAlerts.find({\n      where: {\n        guild_id: this.guildId,\n        user_id: userId,\n      },\n    });\n  }\n\n  async getAlertsByRequestorId(requestorId: string): Promise<VCAlert[]> {\n    return this.allAlerts.find({\n      where: {\n        guild_id: this.guildId,\n        requestor_id: requestorId,\n      },\n    });\n  }\n\n  find(id: number) {\n    return this.allAlerts.findOne({\n      where: { id },\n    });\n  }\n\n  async delete(id) {\n    await this.allAlerts.delete({\n      guild_id: this.guildId,\n      id,\n    });\n  }\n\n  async add(requestorId: string, userId: string, channelId: string, expiresAt: string, body: string, active: boolean) {\n    const result = await this.allAlerts.insert({\n      guild_id: this.guildId,\n      requestor_id: requestorId,\n      user_id: userId,\n      channel_id: channelId,\n      expires_at: expiresAt,\n      body,\n      active,\n    });\n\n    return (await this.find(result.identifiers[0].id))!;\n  }\n}\n"
  },
  {
    "path": "backend/src/data/LogType.ts",
    "content": "export const LogType = {\n  MEMBER_WARN: \"MEMBER_WARN\",\n  MEMBER_MUTE: \"MEMBER_MUTE\",\n  MEMBER_UNMUTE: \"MEMBER_UNMUTE\",\n  MEMBER_MUTE_EXPIRED: \"MEMBER_MUTE_EXPIRED\",\n  MEMBER_KICK: \"MEMBER_KICK\",\n  MEMBER_BAN: \"MEMBER_BAN\",\n  MEMBER_UNBAN: \"MEMBER_UNBAN\",\n  MEMBER_FORCEBAN: \"MEMBER_FORCEBAN\",\n  MEMBER_SOFTBAN: \"MEMBER_SOFTBAN\",\n  MEMBER_JOIN: \"MEMBER_JOIN\",\n  MEMBER_LEAVE: \"MEMBER_LEAVE\",\n  MEMBER_ROLE_ADD: \"MEMBER_ROLE_ADD\",\n  MEMBER_ROLE_REMOVE: \"MEMBER_ROLE_REMOVE\",\n  MEMBER_NICK_CHANGE: \"MEMBER_NICK_CHANGE\",\n  MEMBER_USERNAME_CHANGE: \"MEMBER_USERNAME_CHANGE\",\n  MEMBER_RESTORE: \"MEMBER_RESTORE\",\n  CHANNEL_CREATE: \"CHANNEL_CREATE\",\n  CHANNEL_DELETE: \"CHANNEL_DELETE\",\n  CHANNEL_UPDATE: \"CHANNEL_UPDATE\",\n  THREAD_CREATE: \"THREAD_CREATE\",\n  THREAD_DELETE: \"THREAD_DELETE\",\n  THREAD_UPDATE: \"THREAD_UPDATE\",\n  ROLE_CREATE: \"ROLE_CREATE\",\n  ROLE_DELETE: \"ROLE_DELETE\",\n  ROLE_UPDATE: \"ROLE_UPDATE\",\n  MESSAGE_EDIT: \"MESSAGE_EDIT\",\n  MESSAGE_DELETE: \"MESSAGE_DELETE\",\n  MESSAGE_DELETE_BULK: \"MESSAGE_DELETE_BULK\",\n  MESSAGE_DELETE_BARE: \"MESSAGE_DELETE_BARE\",\n  VOICE_CHANNEL_JOIN: \"VOICE_CHANNEL_JOIN\",\n  VOICE_CHANNEL_LEAVE: \"VOICE_CHANNEL_LEAVE\",\n  VOICE_CHANNEL_MOVE: \"VOICE_CHANNEL_MOVE\",\n  STAGE_INSTANCE_CREATE: \"STAGE_INSTANCE_CREATE\",\n  STAGE_INSTANCE_DELETE: \"STAGE_INSTANCE_DELETE\",\n  STAGE_INSTANCE_UPDATE: \"STAGE_INSTANCE_UPDATE\",\n  EMOJI_CREATE: \"EMOJI_CREATE\",\n  EMOJI_DELETE: \"EMOJI_DELETE\",\n  EMOJI_UPDATE: \"EMOJI_UPDATE\",\n  STICKER_CREATE: \"STICKER_CREATE\",\n  STICKER_DELETE: \"STICKER_DELETE\",\n  STICKER_UPDATE: \"STICKER_UPDATE\",\n  COMMAND: \"COMMAND\",\n  MESSAGE_SPAM_DETECTED: \"MESSAGE_SPAM_DETECTED\",\n  CENSOR: \"CENSOR\",\n  CLEAN: \"CLEAN\",\n  CASE_CREATE: \"CASE_CREATE\",\n  MASSUNBAN: \"MASSUNBAN\",\n  MASSBAN: \"MASSBAN\",\n  MASSMUTE: \"MASSMUTE\",\n  MEMBER_TIMED_MUTE: \"MEMBER_TIMED_MUTE\",\n  MEMBER_TIMED_UNMUTE: \"MEMBER_TIMED_UNMUTE\",\n  MEMBER_TIMED_BAN: \"MEMBER_TIMED_BAN\",\n  MEMBER_TIMED_UNBAN: \"MEMBER_TIMED_UNBAN\",\n  MEMBER_JOIN_WITH_PRIOR_RECORDS: \"MEMBER_JOIN_WITH_PRIOR_RECORDS\",\n  OTHER_SPAM_DETECTED: \"OTHER_SPAM_DETECTED\",\n  MEMBER_ROLE_CHANGES: \"MEMBER_ROLE_CHANGES\",\n  VOICE_CHANNEL_FORCE_MOVE: \"VOICE_CHANNEL_FORCE_MOVE\",\n  VOICE_CHANNEL_FORCE_DISCONNECT: \"VOICE_CHANNEL_FORCE_DISCONNECT\",\n  CASE_UPDATE: \"CASE_UPDATE\",\n  MEMBER_MUTE_REJOIN: \"MEMBER_MUTE_REJOIN\",\n  SCHEDULED_MESSAGE: \"SCHEDULED_MESSAGE\",\n  POSTED_SCHEDULED_MESSAGE: \"POSTED_SCHEDULED_MESSAGE\",\n  BOT_ALERT: \"BOT_ALERT\",\n  AUTOMOD_ACTION: \"AUTOMOD_ACTION\",\n  SCHEDULED_REPEATED_MESSAGE: \"SCHEDULED_REPEATED_MESSAGE\",\n  REPEATED_MESSAGE: \"REPEATED_MESSAGE\",\n  MESSAGE_DELETE_AUTO: \"MESSAGE_DELETE_AUTO\",\n  SET_ANTIRAID_USER: \"SET_ANTIRAID_USER\",\n  SET_ANTIRAID_AUTO: \"SET_ANTIRAID_AUTO\",\n  MEMBER_NOTE: \"MEMBER_NOTE\",\n  CASE_DELETE: \"CASE_DELETE\",\n  DM_FAILED: \"DM_FAILED\",\n} as const;\n"
  },
  {
    "path": "backend/src/data/MemberCache.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { DAYS } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { MemberCacheItem } from \"./entities/MemberCacheItem.js\";\n\nconst STALE_PERIOD = 90 * DAYS;\n\nexport class MemberCache extends BaseRepository {\n  #memberCache: Repository<MemberCacheItem>;\n\n  constructor() {\n    super();\n    this.#memberCache = dataSource.getRepository(MemberCacheItem);\n  }\n\n  async deleteStaleData(): Promise<void> {\n    const cutoff = moment().subtract(STALE_PERIOD, \"ms\").format(\"YYYY-MM-DD\");\n    await this.#memberCache.createQueryBuilder().where(\"last_seen < :cutoff\", { cutoff }).delete().execute();\n  }\n\n  async deleteMarkedToBeDeletedEntries(): Promise<void> {\n    await this.#memberCache\n      .createQueryBuilder()\n      .where(\"delete_at IS NOT NULL AND delete_at <= NOW()\")\n      .delete()\n      .execute();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/MuteTypes.ts",
    "content": "export enum MuteTypes {\n  Role = 1,\n  Timeout = 2,\n}\n"
  },
  {
    "path": "backend/src/data/Mutes.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { DAYS, DBDateFormat } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { MuteTypes } from \"./MuteTypes.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Mute } from \"./entities/Mute.js\";\n\nconst OLD_EXPIRED_MUTE_THRESHOLD = 7 * DAYS;\n\nexport const MAX_TIMEOUT_DURATION = 27 * DAYS;\n// When a timeout is under this duration but the mute expires later, the timeout will be reset to max duration\nexport const TIMEOUT_RENEWAL_THRESHOLD = 21 * DAYS;\n\nexport class Mutes extends BaseRepository {\n  private mutes: Repository<Mute>;\n\n  constructor() {\n    super();\n    this.mutes = dataSource.getRepository(Mute);\n  }\n\n  findMute(guildId: string, userId: string): Promise<Mute | null> {\n    return this.mutes.findOne({\n      where: {\n        guild_id: guildId,\n        user_id: userId,\n      },\n    });\n  }\n\n  getSoonExpiringMutes(threshold: number): Promise<Mute[]> {\n    const thresholdDateStr = moment.utc().add(threshold, \"ms\").format(DBDateFormat);\n    return this.mutes\n      .createQueryBuilder(\"mutes\")\n      .andWhere(\"expires_at IS NOT NULL\")\n      .andWhere(\"expires_at <= :date\", { date: thresholdDateStr })\n      .getMany();\n  }\n\n  getTimeoutMutesToRenew(threshold: number): Promise<Mute[]> {\n    const thresholdDateStr = moment.utc().add(threshold, \"ms\").format(DBDateFormat);\n    return this.mutes\n      .createQueryBuilder(\"mutes\")\n      .andWhere(\"type = :type\", { type: MuteTypes.Timeout })\n      .andWhere(\"(expires_at IS NULL OR timeout_expires_at < expires_at)\")\n      .andWhere(\"timeout_expires_at <= :date\", { date: thresholdDateStr })\n      .getMany();\n  }\n\n  async clearOldExpiredMutes(): Promise<void> {\n    const thresholdDateStr = moment.utc().subtract(OLD_EXPIRED_MUTE_THRESHOLD, \"ms\").format(DBDateFormat);\n    await this.mutes\n      .createQueryBuilder(\"mutes\")\n      .andWhere(\"expires_at IS NOT NULL\")\n      .andWhere(\"expires_at <= :date\", { date: thresholdDateStr })\n      .delete()\n      .execute();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/Reminders.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { DBDateFormat } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Reminder } from \"./entities/Reminder.js\";\n\nexport class Reminders extends BaseRepository {\n  private reminders: Repository<Reminder>;\n\n  constructor() {\n    super();\n    this.reminders = dataSource.getRepository(Reminder);\n  }\n\n  async getRemindersDueSoon(threshold: number): Promise<Reminder[]> {\n    const thresholdDateStr = moment.utc().add(threshold, \"ms\").format(DBDateFormat);\n    return this.reminders.createQueryBuilder().andWhere(\"remind_at <= :date\", { date: thresholdDateStr }).getMany();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/ScheduledPosts.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { DBDateFormat } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { ScheduledPost } from \"./entities/ScheduledPost.js\";\n\nexport class ScheduledPosts extends BaseRepository {\n  private scheduledPosts: Repository<ScheduledPost>;\n\n  constructor() {\n    super();\n    this.scheduledPosts = dataSource.getRepository(ScheduledPost);\n  }\n\n  getScheduledPostsDueSoon(threshold: number): Promise<ScheduledPost[]> {\n    const thresholdDateStr = moment.utc().add(threshold, \"ms\").format(DBDateFormat);\n    return this.scheduledPosts.createQueryBuilder().andWhere(\"post_at <= :date\", { date: thresholdDateStr }).getMany();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/Supporters.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Supporter } from \"./entities/Supporter.js\";\n\nexport class Supporters extends BaseRepository {\n  private supporters: Repository<Supporter>;\n\n  constructor() {\n    super();\n    this.supporters = dataSource.getRepository(Supporter);\n  }\n\n  getAll() {\n    return this.supporters.find();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/Tempbans.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { DBDateFormat } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Tempban } from \"./entities/Tempban.js\";\n\nexport class Tempbans extends BaseRepository {\n  private tempbans: Repository<Tempban>;\n\n  constructor() {\n    super();\n    this.tempbans = dataSource.getRepository(Tempban);\n  }\n\n  getSoonExpiringTempbans(threshold: number): Promise<Tempban[]> {\n    const thresholdDateStr = moment.utc().add(threshold, \"ms\").format(DBDateFormat);\n    return this.tempbans.createQueryBuilder().where(\"expires_at <= :date\", { date: thresholdDateStr }).getMany();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/UsernameHistory.ts",
    "content": "import { In, Repository } from \"typeorm\";\nimport { isAPI } from \"../globals.js\";\nimport { MINUTES, SECONDS } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { cleanupUsernames } from \"./cleanup/usernames.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { UsernameHistoryEntry } from \"./entities/UsernameHistoryEntry.js\";\n\nconst CLEANUP_INTERVAL = 5 * MINUTES;\n\nasync function cleanup() {\n  await cleanupUsernames();\n  setTimeout(cleanup, CLEANUP_INTERVAL);\n}\n\nif (!isAPI()) {\n  // Start first cleanup 30 seconds after startup\n  // TODO: Move to bot startup code\n  setTimeout(cleanup, 30 * SECONDS);\n}\n\nexport const MAX_USERNAME_ENTRIES_PER_USER = 5;\n\nexport class UsernameHistory extends BaseRepository {\n  private usernameHistory: Repository<UsernameHistoryEntry>;\n\n  constructor() {\n    super();\n    this.usernameHistory = dataSource.getRepository(UsernameHistoryEntry);\n  }\n\n  async getByUserId(userId): Promise<UsernameHistoryEntry[]> {\n    return this.usernameHistory.find({\n      where: {\n        user_id: userId,\n      },\n      order: {\n        id: \"DESC\",\n      },\n      take: MAX_USERNAME_ENTRIES_PER_USER,\n    });\n  }\n\n  getLastEntry(userId): Promise<UsernameHistoryEntry | null> {\n    return this.usernameHistory.findOne({\n      where: {\n        user_id: userId,\n      },\n      order: {\n        id: \"DESC\",\n      },\n    });\n  }\n\n  async addEntry(userId, username) {\n    await this.usernameHistory.insert({\n      user_id: userId,\n      username,\n    });\n\n    // Cleanup (leave only the last MAX_USERNAME_ENTRIES_PER_USER entries)\n    const toDelete = await this.usernameHistory\n      .createQueryBuilder()\n      .where(\"user_id = :userId\", { userId })\n      .orderBy(\"id\", \"DESC\")\n      .skip(MAX_USERNAME_ENTRIES_PER_USER)\n      .take(99_999)\n      .getMany();\n\n    if (toDelete.length > 0) {\n      await this.usernameHistory.delete({\n        id: In(toDelete.map((v) => v.id)),\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/data/VCAlerts.ts",
    "content": "import moment from \"moment-timezone\";\nimport { Repository } from \"typeorm\";\nimport { DBDateFormat } from \"../utils.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { VCAlert } from \"./entities/VCAlert.js\";\n\nexport class VCAlerts extends BaseRepository {\n  private allAlerts: Repository<VCAlert>;\n\n  constructor() {\n    super();\n    this.allAlerts = dataSource.getRepository(VCAlert);\n  }\n\n  async getSoonExpiringAlerts(threshold: number): Promise<VCAlert[]> {\n    const thresholdDateStr = moment.utc().add(threshold, \"ms\").format(DBDateFormat);\n    return this.allAlerts.createQueryBuilder().andWhere(\"expires_at <= :date\", { date: thresholdDateStr }).getMany();\n  }\n}\n"
  },
  {
    "path": "backend/src/data/Webhooks.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { decrypt, encrypt } from \"../utils/crypt.js\";\nimport { BaseRepository } from \"./BaseRepository.js\";\nimport { dataSource } from \"./dataSource.js\";\nimport { Webhook } from \"./entities/Webhook.js\";\n\nexport class Webhooks extends BaseRepository {\n  repository: Repository<Webhook> = dataSource.getRepository(Webhook);\n\n  protected async _processEntityFromDB(entity) {\n    entity.token = await decrypt(entity.token);\n    return entity;\n  }\n\n  protected async _processEntityToDB(entity) {\n    entity.token = await encrypt(entity.token);\n    return entity;\n  }\n\n  async find(id: string): Promise<Webhook | null> {\n    const result = await this.repository.findOne({\n      where: {\n        id,\n      },\n    });\n\n    return result ? this._processEntityFromDB(result) : null;\n  }\n\n  async findByChannelId(channelId: string): Promise<Webhook | null> {\n    const result = await this.repository.findOne({\n      where: {\n        channel_id: channelId,\n      },\n    });\n\n    return result ? this.processEntityFromDB(result) : null;\n  }\n\n  async create(data: Partial<Webhook>): Promise<void> {\n    data = await this.processEntityToDB(data);\n    await this.repository.insert(data);\n  }\n\n  async delete(id: string): Promise<void> {\n    await this.repository.delete({ id });\n  }\n}\n"
  },
  {
    "path": "backend/src/data/Zalgo.ts",
    "content": "// From https://github.com/b1naryth1ef/rowboat/blob/master/rowboat/util/zalgo.py\nconst zalgoChars = [\n  \"\\u030d\",\n  \"\\u030e\",\n  \"\\u0304\",\n  \"\\u0305\",\n  \"\\u033f\",\n  \"\\u0311\",\n  \"\\u0306\",\n  \"\\u0310\",\n  \"\\u0352\",\n  \"\\u0357\",\n  \"\\u0351\",\n  \"\\u0307\",\n  \"\\u0308\",\n  \"\\u030a\",\n  \"\\u0342\",\n  \"\\u0343\",\n  \"\\u0344\",\n  \"\\u034a\",\n  \"\\u034b\",\n  \"\\u034c\",\n  \"\\u0303\",\n  \"\\u0302\",\n  \"\\u030c\",\n  \"\\u0350\",\n  \"\\u0300\",\n  \"\\u030b\",\n  \"\\u030f\",\n  \"\\u0312\",\n  \"\\u0313\",\n  \"\\u0314\",\n  \"\\u033d\",\n  \"\\u0309\",\n  \"\\u0363\",\n  \"\\u0364\",\n  \"\\u0365\",\n  \"\\u0366\",\n  \"\\u0367\",\n  \"\\u0368\",\n  \"\\u0369\",\n  \"\\u036a\",\n  \"\\u036b\",\n  \"\\u036c\",\n  \"\\u036d\",\n  \"\\u036e\",\n  \"\\u036f\",\n  \"\\u033e\",\n  \"\\u035b\",\n  \"\\u0346\",\n  \"\\u031a\",\n  \"\\u0315\",\n  \"\\u031b\",\n  \"\\u0340\",\n  \"\\u0341\",\n  \"\\u0358\",\n  \"\\u0321\",\n  \"\\u0322\",\n  \"\\u0327\",\n  \"\\u0328\",\n  \"\\u0334\",\n  \"\\u0335\",\n  \"\\u0336\",\n  \"\\u034f\",\n  \"\\u035c\",\n  \"\\u035d\",\n  \"\\u035e\",\n  \"\\u035f\",\n  \"\\u0360\",\n  \"\\u0362\",\n  \"\\u0338\",\n  \"\\u0337\",\n  \"\\u0361\",\n  \"\\u0489\",\n  \"\\u0316\",\n  \"\\u0317\",\n  \"\\u0318\",\n  \"\\u0319\",\n  \"\\u031c\",\n  \"\\u031d\",\n  \"\\u031e\",\n  \"\\u031f\",\n  \"\\u0320\",\n  \"\\u0324\",\n  \"\\u0325\",\n  \"\\u0326\",\n  \"\\u0329\",\n  \"\\u032a\",\n  \"\\u032b\",\n  \"\\u032c\",\n  \"\\u032d\",\n  \"\\u032e\",\n  \"\\u032f\",\n  \"\\u0330\",\n  \"\\u0331\",\n  \"\\u0332\",\n  \"\\u0333\",\n  \"\\u0339\",\n  \"\\u033a\",\n  \"\\u033b\",\n  \"\\u033c\",\n  \"\\u0345\",\n  \"\\u0347\",\n  \"\\u0348\",\n  \"\\u0349\",\n  \"\\u034d\",\n  \"\\u034e\",\n  \"\\u0353\",\n  \"\\u0354\",\n  \"\\u0355\",\n  \"\\u0356\",\n  \"\\u0359\",\n  \"\\u035a\",\n  \"\\u0323\",\n];\n\nexport const ZalgoRegex = new RegExp(zalgoChars.join(\"|\"));\n"
  },
  {
    "path": "backend/src/data/apiAuditLogTypes.ts",
    "content": "import { ApiPermissionTypes } from \"./ApiPermissionAssignments.js\";\n\nexport const AuditLogEventTypes = {\n  ADD_API_PERMISSION: \"ADD_API_PERMISSION\" as const,\n  EDIT_API_PERMISSION: \"EDIT_API_PERMISSION\" as const,\n  REMOVE_API_PERMISSION: \"REMOVE_API_PERMISSION\" as const,\n  EDIT_CONFIG: \"EDIT_CONFIG\" as const,\n};\n\nexport type AuditLogEventType = keyof typeof AuditLogEventTypes;\n\nexport type AddApiPermissionEventData = {\n  target_id: string;\n  permissions: string[];\n  expires_at: string | null;\n};\n\nexport type RemoveApiPermissionEventData = {\n  target_id: string;\n};\n\nexport interface AuditLogEventData extends Record<AuditLogEventType, unknown> {\n  ADD_API_PERMISSION: {\n    type: ApiPermissionTypes;\n    target_id: string;\n    permissions: string[];\n    expires_at: string | null;\n  };\n\n  EDIT_API_PERMISSION: {\n    type: ApiPermissionTypes;\n    target_id: string;\n    permissions: string[];\n    expires_at: string | null;\n  };\n\n  REMOVE_API_PERMISSION: {\n    type: ApiPermissionTypes;\n    target_id: string;\n  };\n\n  EDIT_CONFIG: Record<string, never>;\n}\n\nexport type AnyAuditLogEventData = AuditLogEventData[AuditLogEventType];\n"
  },
  {
    "path": "backend/src/data/buildEntity.ts",
    "content": "export function buildEntity<T extends object>(Entity: new () => T, data: Partial<T>): T {\n  const instance = new Entity();\n  for (const [key, value] of Object.entries(data)) {\n    instance[key] = value;\n  }\n  return instance;\n}\n"
  },
  {
    "path": "backend/src/data/cleanup/configs.ts",
    "content": "import moment from \"moment-timezone\";\nimport { In } from \"typeorm\";\nimport { DBDateFormat } from \"../../utils.js\";\nimport { dataSource } from \"../dataSource.js\";\nimport { Config } from \"../entities/Config.js\";\n\nconst CLEAN_PER_LOOP = 50;\n\nexport async function cleanupConfigs() {\n  const configRepository = dataSource.getRepository(Config);\n\n  // FIXME: The query below doesn't work on MySQL 8.0. Pending an update.\n  return;\n\n  let cleaned = 0;\n  let rows;\n\n  // >1 month old: 1 config retained per month\n  const oneMonthCutoff = moment.utc().subtract(30, \"days\").format(DBDateFormat);\n  do {\n    rows = await dataSource.query(\n      `\n      WITH _configs\n      AS (\n        SELECT\n          id,\n          \\`key\\`,\n          YEAR(edited_at) AS \\`year\\`,\n          MONTH(edited_at) AS \\`month\\`,\n          ROW_NUMBER() OVER (\n            PARTITION BY \\`key\\`, \\`year\\`, \\`month\\`\n            ORDER BY edited_at\n          ) AS row_num\n        FROM\n          configs\n        WHERE\n          is_active = 0\n          AND edited_at < ?\n      )\n      SELECT *\n      FROM _configs\n      WHERE row_num > 1\n    `,\n      [oneMonthCutoff],\n    );\n\n    if (rows.length > 0) {\n      await configRepository.delete({\n        id: In(rows.map((r) => r.id)),\n      });\n    }\n\n    cleaned += rows.length;\n  } while (rows.length === CLEAN_PER_LOOP);\n\n  // >2 weeks old: 1 config retained per day\n  const twoWeekCutoff = moment.utc().subtract(2, \"weeks\").format(DBDateFormat);\n  do {\n    rows = await dataSource.query(\n      `\n      WITH _configs\n      AS (\n        SELECT\n          id,\n          \\`key\\`,\n          DATE(edited_at) AS \\`date\\`,\n          ROW_NUMBER() OVER (\n            PARTITION BY \\`key\\`, \\`date\\`\n            ORDER BY edited_at\n          ) AS row_num\n        FROM\n          configs\n        WHERE\n          is_active = 0\n          AND edited_at < ?\n          AND edited_at >= ?\n      )\n      SELECT *\n      FROM _configs\n      WHERE row_num > 1\n    `,\n      [twoWeekCutoff, oneMonthCutoff],\n    );\n\n    if (rows.length > 0) {\n      await configRepository.delete({\n        id: In(rows.map((r) => r.id)),\n      });\n    }\n\n    cleaned += rows.length;\n  } while (rows.length === CLEAN_PER_LOOP);\n\n  return cleaned;\n}\n"
  },
  {
    "path": "backend/src/data/cleanup/messages.ts",
    "content": "import moment from \"moment-timezone\";\nimport { In } from \"typeorm\";\nimport { DAYS, DBDateFormat, MINUTES, SECONDS, sleep } from \"../../utils.js\";\nimport { dataSource } from \"../dataSource.js\";\nimport { SavedMessage } from \"../entities/SavedMessage.js\";\n\n/**\n * How long message edits, deletions, etc. will include the original message content.\n * This is very heavy storage-wise, so keeping it as low as possible is ideal.\n */\nconst RETENTION_PERIOD = 1 * DAYS;\nconst BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES;\nconst DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES;\nconst CLEAN_PER_LOOP = 100;\n\nexport async function cleanupMessages(): Promise<number> {\n  let cleaned = 0;\n\n  const messagesRepository = dataSource.getRepository(SavedMessage);\n\n  const deletedAtThreshold = moment.utc().subtract(DELETED_MESSAGE_RETENTION_PERIOD, \"ms\").format(DBDateFormat);\n  const postedAtThreshold = moment.utc().subtract(RETENTION_PERIOD, \"ms\").format(DBDateFormat);\n  const botPostedAtThreshold = moment.utc().subtract(BOT_MESSAGE_RETENTION_PERIOD, \"ms\").format(DBDateFormat);\n\n  // SELECT + DELETE messages in batches\n  // This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below\n  // when a message was being inserted at the same time\n  let ids: string[];\n  do {\n    const deletedMessageRows = await dataSource.query(\n      `\n      SELECT id\n      FROM messages\n      WHERE (\n        deleted_at IS NOT NULL\n        AND deleted_at <= ?\n      )\n      LIMIT ${CLEAN_PER_LOOP}\n    `,\n      [deletedAtThreshold],\n    );\n\n    const oldPostedRows = await dataSource.query(\n      `\n      SELECT id\n      FROM messages\n      WHERE (\n        posted_at <= ?\n        AND is_permanent = 0\n      )\n      LIMIT ${CLEAN_PER_LOOP}\n    `,\n      [postedAtThreshold],\n    );\n\n    const oldBotPostedRows = await dataSource.query(\n      `\n      SELECT id\n      FROM messages\n      WHERE (\n        is_bot = 1\n        AND posted_at <= ?\n        AND is_permanent = 0\n      )\n      LIMIT ${CLEAN_PER_LOOP}\n    `,\n      [botPostedAtThreshold],\n    );\n\n    ids = Array.from(\n      new Set([\n        ...deletedMessageRows.map((r) => r.id),\n        ...oldPostedRows.map((r) => r.id),\n        ...oldBotPostedRows.map((r) => r.id),\n      ]),\n    );\n\n    if (ids.length > 0) {\n      await messagesRepository.delete({\n        id: In(ids),\n      });\n      await sleep(1 * SECONDS);\n    }\n\n    cleaned += ids.length;\n  } while (ids.length > 0);\n\n  return cleaned;\n}\n"
  },
  {
    "path": "backend/src/data/cleanup/nicknames.ts",
    "content": "import moment from \"moment-timezone\";\nimport { In } from \"typeorm\";\nimport { DAYS, DBDateFormat } from \"../../utils.js\";\nimport { dataSource } from \"../dataSource.js\";\nimport { NicknameHistoryEntry } from \"../entities/NicknameHistoryEntry.js\";\n\nexport const NICKNAME_RETENTION_PERIOD = 30 * DAYS;\nconst CLEAN_PER_LOOP = 500;\n\nexport async function cleanupNicknames(): Promise<number> {\n  let cleaned = 0;\n\n  const nicknameHistoryRepository = dataSource.getRepository(NicknameHistoryEntry);\n  const dateThreshold = moment.utc().subtract(NICKNAME_RETENTION_PERIOD, \"ms\").format(DBDateFormat);\n\n  // Clean old nicknames (NICKNAME_RETENTION_PERIOD)\n  let rows;\n  do {\n    rows = await dataSource.query(\n      `\n      SELECT id\n      FROM nickname_history\n      WHERE timestamp < ?\n      LIMIT ${CLEAN_PER_LOOP}\n    `,\n      [dateThreshold],\n    );\n\n    if (rows.length > 0) {\n      await nicknameHistoryRepository.delete({\n        id: In(rows.map((r) => r.id)),\n      });\n    }\n\n    cleaned += rows.length;\n  } while (rows.length === CLEAN_PER_LOOP);\n\n  return cleaned;\n}\n"
  },
  {
    "path": "backend/src/data/cleanup/usernames.ts",
    "content": "import moment from \"moment-timezone\";\nimport { In } from \"typeorm\";\nimport { DAYS, DBDateFormat } from \"../../utils.js\";\nimport { dataSource } from \"../dataSource.js\";\nimport { UsernameHistoryEntry } from \"../entities/UsernameHistoryEntry.js\";\n\nexport const USERNAME_RETENTION_PERIOD = 30 * DAYS;\nconst CLEAN_PER_LOOP = 500;\n\nexport async function cleanupUsernames(): Promise<number> {\n  let cleaned = 0;\n\n  const usernameHistoryRepository = dataSource.getRepository(UsernameHistoryEntry);\n  const dateThreshold = moment.utc().subtract(USERNAME_RETENTION_PERIOD, \"ms\").format(DBDateFormat);\n\n  // Clean old usernames (USERNAME_RETENTION_PERIOD)\n  let rows;\n  do {\n    rows = await dataSource.query(\n      `\n      SELECT id\n      FROM username_history\n      WHERE timestamp < ?\n      LIMIT ${CLEAN_PER_LOOP}\n    `,\n      [dateThreshold],\n    );\n\n    if (rows.length > 0) {\n      await usernameHistoryRepository.delete({\n        id: In(rows.map((r) => r.id)),\n      });\n    }\n\n    cleaned += rows.length;\n  } while (rows.length === CLEAN_PER_LOOP);\n\n  return cleaned;\n}\n"
  },
  {
    "path": "backend/src/data/dataSource.ts",
    "content": "import moment from \"moment-timezone\";\nimport path from \"path\";\nimport { DataSource } from \"typeorm\";\nimport { env } from \"../env.js\";\nimport { backendDir } from \"../paths.js\";\n\nmoment.tz.setDefault(\"UTC\");\n\nconst entities = path.relative(process.cwd(), path.resolve(backendDir, \"dist/data/entities/*.js\"));\nconst migrations = path.relative(process.cwd(), path.resolve(backendDir, \"dist/migrations/*.js\"));\n\nexport const dataSource = new DataSource({\n  type: \"mysql\",\n  host: env.DB_HOST || \"mysql\",\n  port: env.DB_PORT || 3306,\n  username: env.DB_USER || \"zeppelin\",\n  password: env.DB_PASSWORD || env.DEVELOPMENT_MYSQL_PASSWORD,\n  database: env.DB_DATABASE || \"zeppelin\",\n  charset: \"utf8mb4\",\n  supportBigNumbers: true,\n  bigNumberStrings: true,\n  dateStrings: true,\n  synchronize: false,\n  connectTimeout: 2000,\n\n  logging: [\"error\", \"warn\"],\n\n  // Entities\n  entities: [entities],\n\n  // Pool options\n  extra: {\n    typeCast(field, next) {\n      if (field.type === \"DATETIME\") {\n        const val = field.string();\n        return val != null ? moment.utc(val).format(\"YYYY-MM-DD HH:mm:ss\") : null;\n      }\n\n      return next();\n    },\n  },\n\n  // Migrations\n  migrations: [migrations],\n});\n"
  },
  {
    "path": "backend/src/data/db.ts",
    "content": "import { SimpleError } from \"../SimpleError.js\";\nimport { dataSource } from \"./dataSource.js\";\n\nlet connectionPromise: Promise<void>;\n\nexport function connect() {\n  if (!connectionPromise) {\n    connectionPromise = dataSource.initialize().then(async (initializedDataSource) => {\n      const tzResult = await initializedDataSource.query(\"SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP) AS tz\");\n      if (tzResult[0].tz !== \"00:00:00\") {\n        throw new SimpleError(`Database timezone must be UTC (detected ${tzResult[0].tz})`);\n      }\n    });\n  }\n\n  return connectionPromise;\n}\n\nexport function disconnect() {\n  if (connectionPromise) {\n    connectionPromise.then(() => dataSource.destroy());\n  }\n}\n"
  },
  {
    "path": "backend/src/data/entities/AllowedGuild.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"allowed_guilds\")\nexport class AllowedGuild {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column()\n  name: string;\n\n  @Column({ type: String, nullable: true })\n  icon: string | null;\n\n  @Column()\n  owner_id: string;\n\n  @Column()\n  created_at: string;\n\n  @Column()\n  updated_at: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/AntiraidLevel.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"antiraid_levels\")\nexport class AntiraidLevel {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  level: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/ApiAuditLogEntry.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\nimport { AuditLogEventData, AuditLogEventType } from \"../apiAuditLogTypes.js\";\n\n@Entity(\"api_audit_log\")\nexport class ApiAuditLogEntry<TEventType extends AuditLogEventType> {\n  @Column()\n  @PrimaryColumn()\n  id: number;\n\n  @Column()\n  guild_id: string;\n\n  @Column()\n  author_id: string;\n\n  @Column({ type: String })\n  event_type: TEventType;\n\n  @Column(\"simple-json\")\n  event_data: AuditLogEventData[TEventType];\n\n  @Column()\n  created_at: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/ApiLogin.ts",
    "content": "import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, Relation } from \"typeorm\";\nimport { ApiUserInfo } from \"./ApiUserInfo.js\";\n\n@Entity(\"api_logins\")\nexport class ApiLogin {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column()\n  token: string;\n\n  @Column()\n  user_id: string;\n\n  @Column()\n  logged_in_at: string;\n\n  @Column()\n  expires_at: string;\n\n  @ManyToOne(() => ApiUserInfo, (userInfo) => userInfo.logins)\n  @JoinColumn({ name: \"user_id\" })\n  userInfo: Relation<ApiUserInfo>;\n}\n"
  },
  {
    "path": "backend/src/data/entities/ApiPermissionAssignment.ts",
    "content": "import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, Relation } from \"typeorm\";\nimport { ApiPermissionTypes } from \"../ApiPermissionAssignments.js\";\nimport { ApiUserInfo } from \"./ApiUserInfo.js\";\n\n@Entity(\"api_permissions\")\nexport class ApiPermissionAssignment {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column({ type: String })\n  @PrimaryColumn()\n  type: ApiPermissionTypes;\n\n  @Column()\n  @PrimaryColumn()\n  target_id: string;\n\n  @Column(\"simple-array\")\n  permissions: string[];\n\n  @Column({ type: String, nullable: true })\n  expires_at: string | null;\n\n  @ManyToOne(() => ApiUserInfo, (userInfo) => userInfo.permissionAssignments)\n  @JoinColumn({ name: \"target_id\" })\n  userInfo: Relation<ApiUserInfo>;\n}\n"
  },
  {
    "path": "backend/src/data/entities/ApiUserInfo.ts",
    "content": "import { Column, Entity, OneToMany, PrimaryColumn, Relation } from \"typeorm\";\nimport { ApiLogin } from \"./ApiLogin.js\";\nimport { ApiPermissionAssignment } from \"./ApiPermissionAssignment.js\";\n\nexport interface ApiUserInfoData {\n  username: string;\n  discriminator: string;\n  avatar: string;\n}\n\n@Entity(\"api_user_info\")\nexport class ApiUserInfo {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column(\"simple-json\")\n  data: ApiUserInfoData;\n\n  @Column()\n  updated_at: string;\n\n  @OneToMany(() => ApiLogin, (login) => login.userInfo)\n  logins: Relation<ApiLogin[]>;\n\n  @OneToMany(() => ApiPermissionAssignment, (p) => p.userInfo)\n  permissionAssignments: Relation<ApiPermissionAssignment[]>;\n}\n"
  },
  {
    "path": "backend/src/data/entities/ArchiveEntry.ts",
    "content": "import { Column, Entity, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity(\"archives\")\nexport class ArchiveEntry {\n  @Column()\n  @PrimaryGeneratedColumn(\"uuid\")\n  id: string;\n\n  @Column() guild_id: string;\n\n  @Column({\n    type: \"mediumtext\",\n  })\n  body: string;\n\n  @Column() created_at: string;\n\n  @Column({ type: String, nullable: true }) expires_at: string | null;\n}\n"
  },
  {
    "path": "backend/src/data/entities/AutoReaction.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"auto_reactions\")\nexport class AutoReaction {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  channel_id: string;\n\n  @Column(\"simple-array\") reactions: string[];\n}\n"
  },
  {
    "path": "backend/src/data/entities/ButtonRole.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"button_roles\")\nexport class ButtonRole {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  channel_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  message_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  button_id: string;\n\n  @Column() button_group: string;\n\n  @Column() button_name: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/Case.ts",
    "content": "import { Column, Entity, OneToMany, PrimaryGeneratedColumn, Relation } from \"typeorm\";\nimport { CaseNote } from \"./CaseNote.js\";\n\n@Entity(\"cases\")\nexport class Case {\n  @PrimaryGeneratedColumn() id: number;\n\n  @Column() guild_id: string;\n\n  @Column() case_number: number;\n\n  @Column() user_id: string;\n\n  @Column() user_name: string;\n\n  @Column({ type: String, nullable: true }) mod_id: string | null;\n\n  @Column({ type: String, nullable: true }) mod_name: string | null;\n\n  @Column() type: number;\n\n  @Column({ type: String, nullable: true }) audit_log_id: string | null;\n\n  @Column() created_at: string;\n\n  @Column() is_hidden: boolean;\n\n  @Column({ type: String, nullable: true }) pp_id: string | null;\n\n  @Column({ type: String, nullable: true }) pp_name: string | null;\n\n  /**\n   * ID of the channel and message where this case was logged.\n   * Format: \"channelid-messageid\"\n   */\n  @Column({ type: String, nullable: true }) log_message_id: string | null;\n\n  @OneToMany(() => CaseNote, (note) => note.case)\n  notes: Relation<CaseNote[]>;\n}\n"
  },
  {
    "path": "backend/src/data/entities/CaseNote.ts",
    "content": "import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Relation } from \"typeorm\";\nimport { Case } from \"./Case.js\";\n\n@Entity(\"case_notes\")\nexport class CaseNote {\n  @PrimaryGeneratedColumn() id: number;\n\n  @Column() case_id: number;\n\n  @Column() mod_id: string;\n\n  @Column() mod_name: string;\n\n  @Column() body: string;\n\n  @Column() created_at: string;\n\n  @ManyToOne(() => Case, (theCase) => theCase.notes)\n  @JoinColumn({ name: \"case_id\" })\n  case: Relation<Case>;\n}\n"
  },
  {
    "path": "backend/src/data/entities/Config.ts",
    "content": "import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from \"typeorm\";\nimport { ApiUserInfo } from \"./ApiUserInfo.js\";\n\n@Entity(\"configs\")\nexport class Config {\n  @Column()\n  @PrimaryColumn()\n  id: number;\n\n  @Column()\n  key: string;\n\n  @Column()\n  config: string;\n\n  @Column()\n  is_active: boolean;\n\n  @Column()\n  edited_by: string;\n\n  @Column()\n  edited_at: string;\n\n  @ManyToOne(() => ApiUserInfo)\n  @JoinColumn({ name: \"edited_by\" })\n  userInfo: ApiUserInfo;\n}\n"
  },
  {
    "path": "backend/src/data/entities/ContextMenuLink.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"context_menus\")\nexport class ContextMenuLink {\n  @Column() guild_id: string;\n\n  @Column() @PrimaryColumn() context_id: string;\n\n  @Column() action_name: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/Counter.ts",
    "content": "import { Column, Entity, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity(\"counters\")\nexport class Counter {\n  @PrimaryGeneratedColumn()\n  id: number;\n\n  @Column()\n  guild_id: string;\n\n  @Column()\n  name: string;\n\n  @Column()\n  per_channel: boolean;\n\n  @Column()\n  per_user: boolean;\n\n  @Column()\n  last_decay_at: string;\n\n  @Column({ type: \"datetime\", nullable: true })\n  delete_at: string | null;\n}\n"
  },
  {
    "path": "backend/src/data/entities/CounterTrigger.ts",
    "content": "import { Column, Entity, PrimaryGeneratedColumn } from \"typeorm\";\n\nexport const TRIGGER_COMPARISON_OPS = [\"=\", \"!=\", \">\", \"<\", \">=\", \"<=\"] as const;\n\nexport type TriggerComparisonOp = (typeof TRIGGER_COMPARISON_OPS)[number];\n\nconst REVERSE_OPS: Record<TriggerComparisonOp, TriggerComparisonOp> = {\n  \"=\": \"!=\",\n  \"!=\": \"=\",\n  \">\": \"<=\",\n  \"<\": \">=\",\n  \">=\": \"<\",\n  \"<=\": \">\",\n};\n\nexport function getReverseCounterComparisonOp(op: TriggerComparisonOp): TriggerComparisonOp {\n  return REVERSE_OPS[op];\n}\n\nconst comparisonStringRegex = new RegExp(`^(${TRIGGER_COMPARISON_OPS.join(\"|\")})(\\\\d*)$`);\n\n/**\n * @return Parsed comparison op and value, or null if the comparison string was invalid\n */\nexport function parseCounterConditionString(str: string): [TriggerComparisonOp, number] | null {\n  const matches = str.match(comparisonStringRegex);\n  return matches ? [matches[1] as TriggerComparisonOp, parseInt(matches[2], 10)] : null;\n}\n\nexport function buildCounterConditionString(comparisonOp: TriggerComparisonOp, comparisonValue: number): string {\n  return `${comparisonOp}${comparisonValue}`;\n}\n\nexport function isValidCounterComparisonOp(op: string): boolean {\n  return TRIGGER_COMPARISON_OPS.includes(op as any);\n}\n\n@Entity(\"counter_triggers\")\nexport class CounterTrigger {\n  @PrimaryGeneratedColumn()\n  id: number;\n\n  @Column()\n  counter_id: number;\n\n  @Column()\n  name: string;\n\n  @Column({ type: \"varchar\" })\n  comparison_op: TriggerComparisonOp;\n\n  @Column()\n  comparison_value: number;\n\n  @Column({ type: \"varchar\" })\n  reverse_comparison_op: TriggerComparisonOp;\n\n  @Column()\n  reverse_comparison_value: number;\n\n  @Column({ type: \"datetime\", nullable: true })\n  delete_at: string | null;\n}\n"
  },
  {
    "path": "backend/src/data/entities/CounterTriggerState.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"counter_trigger_states\")\nexport class CounterTriggerState {\n  @Column({ type: \"bigint\", generated: \"increment\" })\n  @PrimaryColumn()\n  id: string;\n\n  @Column()\n  trigger_id: number;\n\n  @Column({ type: \"bigint\" })\n  channel_id: string;\n\n  @Column({ type: \"bigint\" })\n  user_id: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/CounterValue.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"counter_values\")\nexport class CounterValue {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column()\n  counter_id: number;\n\n  @Column({ type: \"bigint\" })\n  channel_id: string;\n\n  @Column({ type: \"bigint\" })\n  user_id: string;\n\n  @Column()\n  value: number;\n}\n"
  },
  {
    "path": "backend/src/data/entities/MemberCacheItem.ts",
    "content": "import { Column, Entity, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity(\"member_cache\")\nexport class MemberCacheItem {\n  @PrimaryGeneratedColumn() id: number;\n\n  @Column() guild_id: string;\n\n  @Column() user_id: string;\n\n  @Column() username: string;\n\n  @Column({ type: String, nullable: true }) nickname: string | null;\n\n  @Column(\"simple-json\") roles: string[];\n\n  @Column() last_seen: string;\n\n  @Column({ type: String, nullable: true }) delete_at: string | null;\n}\n"
  },
  {
    "path": "backend/src/data/entities/MemberTimezone.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"member_timezones\")\nexport class MemberTimezone {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  member_id: string;\n\n  @Column() timezone: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/Mute.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"mutes\")\nexport class Mute {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  user_id: string;\n\n  @Column() type: number;\n\n  @Column() created_at: string;\n\n  @Column({ type: String, nullable: true }) expires_at: string | null;\n\n  @Column() case_id: number;\n\n  @Column(\"simple-array\") roles_to_restore: string[];\n\n  @Column({ type: String, nullable: true }) mute_role: string | null;\n\n  @Column({ type: String, nullable: true }) timeout_expires_at: string | null;\n}\n"
  },
  {
    "path": "backend/src/data/entities/NicknameHistoryEntry.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"nickname_history\")\nexport class NicknameHistoryEntry {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column() guild_id: string;\n\n  @Column() user_id: string;\n\n  @Column() nickname: string;\n\n  @Column() timestamp: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/PersistedData.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"persisted_data\")\nexport class PersistedData {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  user_id: string;\n\n  @Column(\"simple-array\") roles: string[];\n\n  @Column() nickname: string;\n\n  @Column({ type: \"boolean\" }) is_voice_muted: boolean;\n}\n"
  },
  {
    "path": "backend/src/data/entities/PingableRole.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"pingable_roles\")\nexport class PingableRole {\n  @Column()\n  @PrimaryColumn()\n  id: number;\n\n  @Column() guild_id: string;\n\n  @Column() channel_id: string;\n\n  @Column() role_id: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/ReactionRole.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"reaction_roles\")\nexport class ReactionRole {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  channel_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  message_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  emoji: string;\n\n  @Column() role_id: string;\n\n  @Column() is_exclusive: boolean;\n\n  @Column() order: number;\n}\n"
  },
  {
    "path": "backend/src/data/entities/Reminder.ts",
    "content": "import { Column, Entity, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity(\"reminders\")\nexport class Reminder {\n  @PrimaryGeneratedColumn()\n  id: number;\n\n  @Column() guild_id: string;\n\n  @Column() user_id: string;\n\n  @Column() channel_id: string;\n\n  @Column() remind_at: string;\n\n  @Column() body: string;\n\n  @Column() created_at: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/RoleButtonsItem.ts",
    "content": "import { Column, Entity, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity(\"role_buttons\")\nexport class RoleButtonsItem {\n  @PrimaryGeneratedColumn() id: number;\n\n  @Column() guild_id: string;\n\n  @Column() name: string;\n\n  @Column() channel_id: string;\n\n  @Column() message_id: string;\n\n  @Column() hash: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/RoleQueueItem.ts",
    "content": "import { Column, Entity, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity(\"role_queue\")\nexport class RoleQueueItem {\n  @PrimaryGeneratedColumn() id: number;\n\n  @Column() guild_id: string;\n\n  @Column() user_id: string;\n\n  @Column() role_id: string;\n\n  @Column() should_add: boolean;\n\n  @Column() priority: number;\n}\n"
  },
  {
    "path": "backend/src/data/entities/SavedMessage.ts",
    "content": "import { EmbedType, Snowflake, StickerFormatType, StickerType } from \"discord.js\";\nimport { Column, Entity, PrimaryColumn } from \"typeorm\";\n\nexport interface ISavedMessageAttachmentData {\n  id: Snowflake;\n  contentType: string | null;\n  name: string | null;\n  proxyURL: string;\n  size: number;\n  spoiler: boolean;\n  url: string;\n  width: number | null;\n}\n\nexport interface ISavedMessageEmbedData {\n  title: string | null;\n  type?: EmbedType;\n  description: string | null;\n  url: string | null;\n  timestamp: number | null;\n  color: number | null;\n  fields: Array<{\n    name: string;\n    value: string;\n    inline: boolean;\n  }>;\n  author?: {\n    name?: string;\n    url?: string;\n    iconURL?: string;\n    proxyIconURL?: string;\n  };\n  thumbnail?: {\n    url: string;\n    proxyURL?: string;\n    height?: number;\n    width?: number;\n  };\n  image?: {\n    url: string;\n    proxyURL?: string;\n    height?: number;\n    width?: number;\n  };\n  video?: {\n    url?: string;\n    proxyURL?: string;\n    height?: number;\n    width?: number;\n  };\n  footer?: {\n    text?: string;\n    iconURL?: string;\n    proxyIconURL?: string;\n  };\n}\n\nexport interface ISavedMessageStickerData {\n  format: StickerFormatType;\n  guildId: Snowflake | null;\n  id: Snowflake;\n  name: string;\n  description: string | null;\n  available: boolean | null;\n  type: StickerType | null;\n}\n\nexport interface ISavedMessageData {\n  attachments?: ISavedMessageAttachmentData[];\n  author: {\n    username: string;\n    discriminator: string;\n  };\n  content: string;\n  embeds?: ISavedMessageEmbedData[];\n  stickers?: ISavedMessageStickerData[];\n  timestamp: number;\n  reference?: {\n    messageId?: Snowflake | null;\n    channelId?: Snowflake | null;\n    guildId?: Snowflake | null;\n  };\n}\n\n@Entity(\"messages\")\nexport class SavedMessage {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column() guild_id: string;\n\n  @Column() channel_id: string;\n\n  @Column() user_id: string;\n\n  @Column() is_bot: boolean;\n\n  @Column({\n    type: \"mediumtext\",\n  })\n  data: ISavedMessageData;\n\n  @Column() posted_at: string;\n\n  @Column() deleted_at: string;\n\n  @Column() is_permanent: boolean;\n}\n"
  },
  {
    "path": "backend/src/data/entities/ScheduledPost.ts",
    "content": "import { Attachment } from \"discord.js\";\nimport { Column, Entity, PrimaryGeneratedColumn } from \"typeorm\";\nimport { StrictMessageContent } from \"../../utils.js\";\n\n@Entity(\"scheduled_posts\")\nexport class ScheduledPost {\n  @PrimaryGeneratedColumn()\n  id: number;\n\n  @Column() guild_id: string;\n\n  @Column() author_id: string;\n\n  @Column() author_name: string;\n\n  @Column() channel_id: string;\n\n  @Column(\"simple-json\") content: StrictMessageContent;\n\n  @Column(\"simple-json\") attachments: Attachment[];\n\n  @Column({ type: String, nullable: true }) post_at: string | null;\n\n  /**\n   * How often to post the message, in milliseconds\n   */\n  @Column({ type: String, nullable: true }) repeat_interval: number | null;\n\n  @Column({ type: String, nullable: true }) repeat_until: string | null;\n\n  @Column({ type: String, nullable: true }) repeat_times: number | null;\n\n  @Column() enable_mentions: boolean;\n}\n"
  },
  {
    "path": "backend/src/data/entities/SlowmodeChannel.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"slowmode_channels\")\nexport class SlowmodeChannel {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  channel_id: string;\n\n  @Column() slowmode_seconds: number;\n}\n"
  },
  {
    "path": "backend/src/data/entities/SlowmodeUser.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"slowmode_users\")\nexport class SlowmodeUser {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  channel_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  user_id: string;\n\n  @Column() expires_at: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/StarboardMessage.ts",
    "content": "import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from \"typeorm\";\nimport { SavedMessage } from \"./SavedMessage.js\";\n\n@Entity(\"starboard_messages\")\nexport class StarboardMessage {\n  @Column()\n  message_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  starboard_message_id: string;\n\n  @Column()\n  starboard_channel_id: string;\n\n  @Column()\n  guild_id: string;\n\n  @OneToOne(() => SavedMessage)\n  @JoinColumn({ name: \"message_id\" })\n  message: SavedMessage;\n}\n"
  },
  {
    "path": "backend/src/data/entities/StarboardReaction.ts",
    "content": "import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from \"typeorm\";\nimport { SavedMessage } from \"./SavedMessage.js\";\n\n@Entity(\"starboard_reactions\")\nexport class StarboardReaction {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column()\n  guild_id: string;\n\n  @Column()\n  message_id: string;\n\n  @Column()\n  reactor_id: string;\n\n  @OneToOne(() => SavedMessage)\n  @JoinColumn({ name: \"message_id\" })\n  message: SavedMessage;\n}\n"
  },
  {
    "path": "backend/src/data/entities/StatValue.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"stats\")\nexport class StatValue {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column()\n  guild_id: string;\n\n  @Column()\n  source: string;\n\n  @Column() key: string;\n\n  @Column() value: number;\n\n  @Column() created_at: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/Supporter.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"supporters\")\nexport class Supporter {\n  @Column()\n  @PrimaryColumn()\n  user_id: string;\n\n  @Column()\n  name: string;\n\n  @Column({ type: String, nullable: true })\n  amount: string | null;\n}\n"
  },
  {
    "path": "backend/src/data/entities/Tag.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"tags\")\nexport class Tag {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  tag: string;\n\n  @Column() user_id: string;\n\n  @Column() body: string;\n\n  @Column() created_at: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/TagResponse.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"tag_responses\")\nexport class TagResponse {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column() guild_id: string;\n\n  @Column() command_message_id: string;\n\n  @Column() response_message_id: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/Tempban.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"tempbans\")\nexport class Tempban {\n  @Column()\n  @PrimaryColumn()\n  guild_id: string;\n\n  @Column()\n  @PrimaryColumn()\n  user_id: string;\n\n  @Column() mod_id: string;\n\n  @Column() created_at: string;\n\n  @Column() expires_at: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/UsernameHistoryEntry.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"username_history\")\nexport class UsernameHistoryEntry {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column() user_id: string;\n\n  @Column() username: string;\n\n  @Column() timestamp: string;\n}\n"
  },
  {
    "path": "backend/src/data/entities/VCAlert.ts",
    "content": "import { Column, Entity, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity(\"vc_alerts\")\nexport class VCAlert {\n  @PrimaryGeneratedColumn()\n  id: number;\n\n  @Column() guild_id: string;\n\n  @Column() requestor_id: string;\n\n  @Column() user_id: string;\n\n  @Column() channel_id: string;\n\n  @Column() expires_at: string;\n\n  @Column() body: string;\n\n  @Column() active: boolean;\n}\n"
  },
  {
    "path": "backend/src/data/entities/Webhook.ts",
    "content": "import { Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity(\"webhooks\")\nexport class Webhook {\n  @Column()\n  @PrimaryColumn()\n  id: string;\n\n  @Column() guild_id: string;\n\n  @Column() channel_id: string;\n\n  @Column() token: string;\n}\n"
  },
  {
    "path": "backend/src/data/getChannelIdFromMessageId.ts",
    "content": "import { Repository } from \"typeorm\";\nimport { dataSource } from \"./dataSource.js\";\nimport { SavedMessage } from \"./entities/SavedMessage.js\";\n\nlet repository: Repository<SavedMessage>;\n\nexport async function getChannelIdFromMessageId(messageId: string): Promise<string | null> {\n  if (!repository) {\n    repository = dataSource.getRepository(SavedMessage);\n  }\n\n  const savedMessage = await repository.findOne({ where: { id: messageId } });\n  if (savedMessage) {\n    return savedMessage.channel_id;\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "backend/src/data/loops/expiredArchiveDeletionLoop.ts",
    "content": "// tslint:disable:no-console\n\nimport { lazyMemoize, MINUTES } from \"../../utils.js\";\nimport { Archives } from \"../Archives.js\";\n\nconst LOOP_INTERVAL = 15 * MINUTES;\nconst getArchivesRepository = lazyMemoize(() => new Archives());\n\nexport async function runExpiredArchiveDeletionLoop() {\n  console.log(\"[EXPIRED ARCHIVE DELETION LOOP] Deleting expired archives\");\n  await getArchivesRepository().deleteExpiredArchives();\n  setTimeout(() => runExpiredArchiveDeletionLoop(), LOOP_INTERVAL);\n}\n"
  },
  {
    "path": "backend/src/data/loops/expiredMemberCacheDeletionLoop.ts",
    "content": "// tslint:disable:no-console\n\nimport { HOURS, lazyMemoize } from \"../../utils.js\";\nimport { MemberCache } from \"../MemberCache.js\";\n\nconst LOOP_INTERVAL = 6 * HOURS;\nconst getMemberCacheRepository = lazyMemoize(() => new MemberCache());\n\nexport async function runExpiredMemberCacheDeletionLoop() {\n  console.log(\"[EXPIRED MEMBER CACHE DELETION LOOP] Deleting stale member cache entries\");\n  await getMemberCacheRepository().deleteStaleData();\n  setTimeout(() => runExpiredMemberCacheDeletionLoop(), LOOP_INTERVAL);\n}\n"
  },
  {
    "path": "backend/src/data/loops/expiringMutesLoop.ts",
    "content": "// tslint:disable:no-console\n\nimport moment from \"moment-timezone\";\nimport { lazyMemoize, MINUTES, SECONDS } from \"../../utils.js\";\nimport { Mute } from \"../entities/Mute.js\";\nimport { emitGuildEvent, hasGuildEventListener } from \"../GuildEvents.js\";\nimport { Mutes, TIMEOUT_RENEWAL_THRESHOLD } from \"../Mutes.js\";\nimport Timeout = NodeJS.Timeout;\n\nconst LOOP_INTERVAL = 15 * MINUTES;\nconst MAX_TRIES_PER_SERVER = 3;\nconst getMutesRepository = lazyMemoize(() => new Mutes());\nconst timeouts = new Map<string, Timeout>();\n\nfunction muteToKey(mute: Mute) {\n  return `${mute.guild_id}/${mute.user_id}`;\n}\n\nasync function broadcastExpiredMute(guildId: string, userId: string, tries = 0): Promise<void> {\n  const mute = await getMutesRepository().findMute(guildId, userId);\n  if (!mute) {\n    // Mute was already cleared\n    return;\n  }\n  if (!mute.expires_at || moment(mute.expires_at).diff(moment()) > 10 * SECONDS) {\n    // Mute duration was changed and it's no longer expiring now\n    return;\n  }\n\n  console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`);\n  if (!hasGuildEventListener(mute.guild_id, \"expiredMute\")) {\n    // If there are no listeners registered for the server yet, try again in a bit\n    if (tries < MAX_TRIES_PER_SERVER) {\n      timeouts.set(\n        muteToKey(mute),\n        setTimeout(() => broadcastExpiredMute(guildId, userId, tries + 1), 1 * MINUTES),\n      );\n    }\n    return;\n  }\n  emitGuildEvent(mute.guild_id, \"expiredMute\", [mute]);\n}\n\nfunction broadcastTimeoutMuteToRenew(mute: Mute, tries = 0) {\n  if (!hasGuildEventListener(mute.guild_id, \"timeoutMuteToRenew\")) {\n    // If there are no listeners registered for the server yet, try again in a bit\n    if (tries < MAX_TRIES_PER_SERVER) {\n      timeouts.set(\n        muteToKey(mute),\n        setTimeout(() => broadcastTimeoutMuteToRenew(mute, tries + 1), 1 * MINUTES),\n      );\n    }\n    return;\n  }\n  console.log(`[EXPIRING MUTES LOOP] Broadcasting timeout mute to renew: ${mute.guild_id}/${mute.user_id}`);\n  emitGuildEvent(mute.guild_id, \"timeoutMuteToRenew\", [mute]);\n}\n\nexport async function runExpiringMutesLoop() {\n  console.log(\"[EXPIRING MUTES LOOP] Clearing old timeouts\");\n  for (const timeout of timeouts.values()) {\n    clearTimeout(timeout);\n  }\n\n  console.log(\"[EXPIRING MUTES LOOP] Clearing old expired mutes\");\n  await getMutesRepository().clearOldExpiredMutes();\n\n  console.log(\"[EXPIRING MUTES LOOP] Setting timeouts for expiring mutes\");\n  const expiringMutes = await getMutesRepository().getSoonExpiringMutes(LOOP_INTERVAL);\n  for (const mute of expiringMutes) {\n    const remaining = Math.max(0, moment.utc(mute.expires_at!).diff(moment.utc()));\n    timeouts.set(\n      muteToKey(mute),\n      setTimeout(() => broadcastExpiredMute(mute.guild_id, mute.user_id), remaining),\n    );\n  }\n\n  console.log(\"[EXPIRING MUTES LOOP] Broadcasting timeout mutes to renew\");\n  const timeoutMutesToRenew = await getMutesRepository().getTimeoutMutesToRenew(TIMEOUT_RENEWAL_THRESHOLD);\n  for (const mute of timeoutMutesToRenew) {\n    broadcastTimeoutMuteToRenew(mute);\n  }\n\n  console.log(\"[EXPIRING MUTES LOOP] Scheduling next loop\");\n  setTimeout(() => runExpiringMutesLoop(), LOOP_INTERVAL);\n}\n\nexport function registerExpiringMute(mute: Mute) {\n  clearExpiringMute(mute);\n\n  if (mute.expires_at === null) {\n    return;\n  }\n\n  console.log(\"[EXPIRING MUTES LOOP] Registering new expiring mute\");\n  const remaining = Math.max(0, moment.utc(mute.expires_at).diff(moment.utc()));\n  if (remaining > LOOP_INTERVAL) {\n    return;\n  }\n\n  timeouts.set(\n    muteToKey(mute),\n    setTimeout(() => broadcastExpiredMute(mute.guild_id, mute.user_id), remaining),\n  );\n}\n\nexport function clearExpiringMute(mute: Mute) {\n  console.log(\"[EXPIRING MUTES LOOP] Clearing expiring mute\");\n  if (timeouts.has(muteToKey(mute))) {\n    clearTimeout(timeouts.get(muteToKey(mute))!);\n  }\n}\n"
  },
  {
    "path": "backend/src/data/loops/expiringTempbansLoop.ts",
    "content": "// tslint:disable:no-console\n\nimport moment from \"moment-timezone\";\nimport { lazyMemoize, MINUTES } from \"../../utils.js\";\nimport { Tempban } from \"../entities/Tempban.js\";\nimport { emitGuildEvent, hasGuildEventListener } from \"../GuildEvents.js\";\nimport { Tempbans } from \"../Tempbans.js\";\nimport Timeout = NodeJS.Timeout;\n\nconst LOOP_INTERVAL = 15 * MINUTES;\nconst MAX_TRIES_PER_SERVER = 3;\nconst getBansRepository = lazyMemoize(() => new Tempbans());\nconst timeouts = new Map<string, Timeout>();\n\nfunction tempbanToKey(tempban: Tempban) {\n  return `${tempban.guild_id}/${tempban.user_id}`;\n}\n\nfunction broadcastExpiredTempban(tempban: Tempban, tries = 0) {\n  if (!hasGuildEventListener(tempban.guild_id, \"expiredTempban\")) {\n    // If there are no listeners registered for the server yet, try again in a bit\n    if (tries < MAX_TRIES_PER_SERVER) {\n      timeouts.set(\n        tempbanToKey(tempban),\n        setTimeout(() => broadcastExpiredTempban(tempban, tries + 1), 1 * MINUTES),\n      );\n    }\n    return;\n  }\n  console.log(`[EXPIRING TEMPBANS LOOP] Broadcasting expired tempban: ${tempban.guild_id}/${tempban.user_id}`);\n  emitGuildEvent(tempban.guild_id, \"expiredTempban\", [tempban]);\n}\n\nexport async function runExpiringTempbansLoop() {\n  console.log(\"[EXPIRING TEMPBANS LOOP] Clearing old timeouts\");\n  for (const timeout of timeouts.values()) {\n    clearTimeout(timeout);\n  }\n\n  console.log(\"[EXPIRING TEMPBANS LOOP] Setting timeouts for expiring tempbans\");\n  const expiringTempbans = await getBansRepository().getSoonExpiringTempbans(LOOP_INTERVAL);\n  for (const tempban of expiringTempbans) {\n    const remaining = Math.max(0, moment.utc(tempban.expires_at!).diff(moment.utc()));\n    timeouts.set(\n      tempbanToKey(tempban),\n      setTimeout(() => broadcastExpiredTempban(tempban), remaining),\n    );\n  }\n\n  console.log(\"[EXPIRING TEMPBANS LOOP] Scheduling next loop\");\n  setTimeout(() => runExpiringTempbansLoop(), LOOP_INTERVAL);\n}\n\nexport function registerExpiringTempban(tempban: Tempban) {\n  clearExpiringTempban(tempban);\n\n  console.log(\"[EXPIRING TEMPBANS LOOP] Registering new expiring tempban\");\n  const remaining = Math.max(0, moment.utc(tempban.expires_at).diff(moment.utc()));\n  if (remaining > LOOP_INTERVAL) {\n    return;\n  }\n\n  timeouts.set(\n    tempbanToKey(tempban),\n    setTimeout(() => broadcastExpiredTempban(tempban), remaining),\n  );\n}\n\nexport function clearExpiringTempban(tempban: Tempban) {\n  console.log(\"[EXPIRING TEMPBANS LOOP] Clearing expiring tempban\");\n  if (timeouts.has(tempbanToKey(tempban))) {\n    clearTimeout(timeouts.get(tempbanToKey(tempban))!);\n  }\n}\n"
  },
  {
    "path": "backend/src/data/loops/expiringVCAlertsLoop.ts",
    "content": "// tslint:disable:no-console\n\nimport moment from \"moment-timezone\";\nimport { lazyMemoize, MINUTES } from \"../../utils.js\";\nimport { VCAlert } from \"../entities/VCAlert.js\";\nimport { emitGuildEvent, hasGuildEventListener } from \"../GuildEvents.js\";\nimport { VCAlerts } from \"../VCAlerts.js\";\nimport Timeout = NodeJS.Timeout;\n\nconst LOOP_INTERVAL = 15 * MINUTES;\nconst MAX_TRIES_PER_SERVER = 3;\nconst getVCAlertsRepository = lazyMemoize(() => new VCAlerts());\nconst timeouts = new Map<number, Timeout>();\n\nfunction broadcastExpiredVCAlert(alert: VCAlert, tries = 0) {\n  console.log(`[EXPIRING VCALERTS LOOP] Broadcasting expired vcalert: ${alert.guild_id}/${alert.user_id}`);\n  if (!hasGuildEventListener(alert.guild_id, \"expiredVCAlert\")) {\n    // If there are no listeners registered for the server yet, try again in a bit\n    if (tries < MAX_TRIES_PER_SERVER) {\n      timeouts.set(\n        alert.id,\n        setTimeout(() => broadcastExpiredVCAlert(alert, tries + 1), 1 * MINUTES),\n      );\n    }\n    return;\n  }\n  emitGuildEvent(alert.guild_id, \"expiredVCAlert\", [alert]);\n}\n\nexport async function runExpiringVCAlertsLoop() {\n  console.log(\"[EXPIRING VCALERTS LOOP] Clearing old timeouts\");\n  for (const timeout of timeouts.values()) {\n    clearTimeout(timeout);\n  }\n\n  console.log(\"[EXPIRING VCALERTS LOOP] Setting timeouts for expiring vcalerts\");\n  const expiringVCAlerts = await getVCAlertsRepository().getSoonExpiringAlerts(LOOP_INTERVAL);\n  for (const alert of expiringVCAlerts) {\n    const remaining = Math.max(0, moment.utc(alert.expires_at!).diff(moment.utc()));\n    timeouts.set(\n      alert.id,\n      setTimeout(() => broadcastExpiredVCAlert(alert), remaining),\n    );\n  }\n\n  console.log(\"[EXPIRING VCALERTS LOOP] Scheduling next loop\");\n  setTimeout(() => runExpiringVCAlertsLoop(), LOOP_INTERVAL);\n}\n\nexport function registerExpiringVCAlert(alert: VCAlert) {\n  clearExpiringVCAlert(alert);\n\n  console.log(\"[EXPIRING VCALERTS LOOP] Registering new expiring vcalert\");\n  const remaining = Math.max(0, moment.utc(alert.expires_at).diff(moment.utc()));\n  if (remaining > LOOP_INTERVAL) {\n    return;\n  }\n\n  timeouts.set(\n    alert.id,\n    setTimeout(() => broadcastExpiredVCAlert(alert), remaining),\n  );\n}\n\nexport function clearExpiringVCAlert(alert: VCAlert) {\n  console.log(\"[EXPIRING VCALERTS LOOP] Clearing expiring vcalert\");\n  if (timeouts.has(alert.id)) {\n    clearTimeout(timeouts.get(alert.id)!);\n  }\n}\n"
  },
  {
    "path": "backend/src/data/loops/memberCacheDeletionLoop.ts",
    "content": "// tslint:disable:no-console\n\nimport { lazyMemoize, MINUTES } from \"../../utils.js\";\nimport { MemberCache } from \"../MemberCache.js\";\n\nconst LOOP_INTERVAL = 5 * MINUTES;\nconst getMemberCacheRepository = lazyMemoize(() => new MemberCache());\n\nexport async function runMemberCacheDeletionLoop() {\n  console.log(\"[MEMBER CACHE DELETION LOOP] Deleting entries marked to be deleted\");\n  await getMemberCacheRepository().deleteMarkedToBeDeletedEntries();\n  setTimeout(() => runMemberCacheDeletionLoop(), LOOP_INTERVAL);\n}\n"
  },
  {
    "path": "backend/src/data/loops/savedMessageCleanupLoop.ts",
    "content": "// tslint:disable:no-console\n\nimport { MINUTES } from \"../../utils.js\";\nimport { cleanupMessages } from \"../cleanup/messages.js\";\n\nconst LOOP_INTERVAL = 5 * MINUTES;\n\nexport async function runSavedMessageCleanupLoop() {\n  try {\n    console.log(\"[SAVED MESSAGE CLEANUP LOOP] Deleting old/deleted messages from the database\");\n    const deleted = await cleanupMessages();\n    console.log(`[SAVED MESSAGE CLEANUP LOOP] Deleted ${deleted} old/deleted messages from the database`);\n  } finally {\n    setTimeout(() => runSavedMessageCleanupLoop(), LOOP_INTERVAL);\n  }\n}\n"
  },
  {
    "path": "backend/src/data/loops/upcomingRemindersLoop.ts",
    "content": "// tslint:disable:no-console\n\nimport moment from \"moment-timezone\";\nimport { lazyMemoize, MINUTES } from \"../../utils.js\";\nimport { Reminder } from \"../entities/Reminder.js\";\nimport { emitGuildEvent, hasGuildEventListener } from \"../GuildEvents.js\";\nimport { Reminders } from \"../Reminders.js\";\nimport Timeout = NodeJS.Timeout;\n\nconst LOOP_INTERVAL = 15 * MINUTES;\nconst MAX_TRIES_PER_SERVER = 3;\nconst getRemindersRepository = lazyMemoize(() => new Reminders());\nconst timeouts = new Map<number, Timeout>();\n\nfunction broadcastReminder(reminder: Reminder, tries = 0) {\n  if (!hasGuildEventListener(reminder.guild_id, \"reminder\")) {\n    // If there are no listeners registered for the server yet, try again in a bit\n    if (tries < MAX_TRIES_PER_SERVER) {\n      timeouts.set(\n        reminder.id,\n        setTimeout(() => broadcastReminder(reminder, tries + 1), 1 * MINUTES),\n      );\n    }\n    return;\n  }\n  emitGuildEvent(reminder.guild_id, \"reminder\", [reminder]);\n}\n\nexport async function runUpcomingRemindersLoop() {\n  console.log(\"[REMINDERS LOOP] Clearing old timeouts\");\n  for (const timeout of timeouts.values()) {\n    clearTimeout(timeout);\n  }\n\n  console.log(\"[REMINDERS LOOP] Setting timeouts for upcoming reminders\");\n  const remindersDueSoon = await getRemindersRepository().getRemindersDueSoon(LOOP_INTERVAL);\n  for (const reminder of remindersDueSoon) {\n    const remaining = Math.max(0, moment.utc(reminder.remind_at!).diff(moment.utc()));\n    timeouts.set(\n      reminder.id,\n      setTimeout(() => broadcastReminder(reminder), remaining),\n    );\n  }\n\n  console.log(\"[REMINDERS LOOP] Scheduling next loop\");\n  setTimeout(() => runUpcomingRemindersLoop(), LOOP_INTERVAL);\n}\n\nexport function registerUpcomingReminder(reminder: Reminder) {\n  clearUpcomingReminder(reminder);\n\n  console.log(\"[REMINDERS LOOP] Registering new upcoming reminder\");\n  const remaining = Math.max(0, moment.utc(reminder.remind_at).diff(moment.utc()));\n  if (remaining > LOOP_INTERVAL) {\n    return;\n  }\n\n  timeouts.set(\n    reminder.id,\n    setTimeout(() => broadcastReminder(reminder), remaining),\n  );\n}\n\nexport function clearUpcomingReminder(reminder: Reminder) {\n  console.log(\"[REMINDERS LOOP] Clearing upcoming reminder\");\n  if (timeouts.has(reminder.id)) {\n    clearTimeout(timeouts.get(reminder.id)!);\n  }\n}\n"
  },
  {
    "path": "backend/src/data/loops/upcomingScheduledPostsLoop.ts",
    "content": "// tslint:disable:no-console\n\nimport moment from \"moment-timezone\";\nimport { lazyMemoize, MINUTES } from \"../../utils.js\";\nimport { ScheduledPost } from \"../entities/ScheduledPost.js\";\nimport { emitGuildEvent, hasGuildEventListener } from \"../GuildEvents.js\";\nimport { ScheduledPosts } from \"../ScheduledPosts.js\";\nimport Timeout = NodeJS.Timeout;\n\nconst LOOP_INTERVAL = 15 * MINUTES;\nconst MAX_TRIES_PER_SERVER = 3;\nconst getScheduledPostsRepository = lazyMemoize(() => new ScheduledPosts());\nconst timeouts = new Map<number, Timeout>();\n\nfunction broadcastScheduledPost(post: ScheduledPost, tries = 0) {\n  if (!hasGuildEventListener(post.guild_id, \"scheduledPost\")) {\n    // If there are no listeners registered for the server yet, try again in a bit\n    if (tries < MAX_TRIES_PER_SERVER) {\n      timeouts.set(\n        post.id,\n        setTimeout(() => broadcastScheduledPost(post, tries + 1), 1 * MINUTES),\n      );\n    }\n    return;\n  }\n  emitGuildEvent(post.guild_id, \"scheduledPost\", [post]);\n}\n\nexport async function runUpcomingScheduledPostsLoop() {\n  console.log(\"[SCHEDULED POSTS LOOP] Clearing old timeouts\");\n  for (const timeout of timeouts.values()) {\n    clearTimeout(timeout);\n  }\n\n  console.log(\"[SCHEDULED POSTS LOOP] Setting timeouts for upcoming scheduled posts\");\n  const postsDueSoon = await getScheduledPostsRepository().getScheduledPostsDueSoon(LOOP_INTERVAL);\n  for (const post of postsDueSoon) {\n    const remaining = Math.max(0, moment.utc(post.post_at!).diff(moment.utc()));\n    timeouts.set(\n      post.id,\n      setTimeout(() => broadcastScheduledPost(post), remaining),\n    );\n  }\n\n  console.log(\"[SCHEDULED POSTS LOOP] Scheduling next loop\");\n  setTimeout(() => runUpcomingScheduledPostsLoop(), LOOP_INTERVAL);\n}\n\nexport function registerUpcomingScheduledPost(post: ScheduledPost) {\n  clearUpcomingScheduledPost(post);\n\n  if (post.post_at === null) {\n    return;\n  }\n\n  const remaining = Math.max(0, moment.utc(post.post_at).diff(moment.utc()));\n  if (remaining > LOOP_INTERVAL) {\n    return;\n  }\n\n  console.log(\"[SCHEDULED POSTS LOOP] Registering new upcoming scheduled post\");\n  timeouts.set(\n    post.id,\n    setTimeout(() => broadcastScheduledPost(post), remaining),\n  );\n}\n\nexport function clearUpcomingScheduledPost(post: ScheduledPost) {\n  if (timeouts.has(post.id)) {\n    console.log(\"[SCHEDULED POSTS LOOP] Clearing upcoming scheduled post\");\n    clearTimeout(timeouts.get(post.id)!);\n  }\n}\n"
  },
  {
    "path": "backend/src/data/queryLogger.ts",
    "content": "import { AdvancedConsoleLogger } from \"typeorm\";\n\nlet groupedQueryStats: Map<string, number> = new Map();\n\nconst selectTableRegex = /FROM `?([^\\s`]+)/i;\nconst updateTableRegex = /UPDATE `?([^\\s`]+)/i;\nconst deleteTableRegex = /FROM `?([^\\s`]+)/;\nconst insertTableRegex = /INTO `?([^\\s`]+)/;\n\nexport class QueryLogger extends AdvancedConsoleLogger {\n  logQuery(query: string): any {\n    let type: string | undefined;\n    let table: string | undefined;\n\n    if (query.startsWith(\"SELECT\")) {\n      type = \"SELECT\";\n      table = query.match(selectTableRegex)?.[1];\n    } else if (query.startsWith(\"UPDATE\")) {\n      type = \"UPDATE\";\n      table = query.match(updateTableRegex)?.[1];\n    } else if (query.startsWith(\"DELETE\")) {\n      type = \"DELETE\";\n      table = query.match(deleteTableRegex)?.[1];\n    } else if (query.startsWith(\"INSERT\")) {\n      type = \"INSERT\";\n      table = query.match(insertTableRegex)?.[1];\n    } else {\n      return;\n    }\n\n    const key = `${type} ${table}`;\n    const newCount = (groupedQueryStats.get(key) ?? 0) + 1;\n    groupedQueryStats.set(key, newCount);\n  }\n}\n\nexport function consumeQueryStats() {\n  const map = groupedQueryStats;\n  groupedQueryStats = new Map();\n  return map;\n}\n"
  },
  {
    "path": "backend/src/data/redis.ts",
    "content": "import { createClient } from \"redis\";\nimport { env } from \"../env.js\";\n\n// Silly type inference issue... https://github.com/redis/node-redis/issues/1732#issuecomment-979977316\ntype RedisClient = ReturnType<typeof createClient>;\nexport const redis: RedisClient = await createClient({ url: env.REDIS_URL }).connect();\n"
  },
  {
    "path": "backend/src/debugCounters.ts",
    "content": "type DebugCounterValue = {\n  count: number;\n};\nconst debugCounterValueMap = new Map<string, DebugCounterValue>();\n\nexport function incrementDebugCounter(name: string) {\n  if (!debugCounterValueMap.has(name)) {\n    debugCounterValueMap.set(name, { count: 0 });\n  }\n  debugCounterValueMap.get(name)!.count++;\n}\n\nexport function getDebugCounterValues() {\n  return debugCounterValueMap;\n}\n"
  },
  {
    "path": "backend/src/env.ts",
    "content": "import dotenv from \"dotenv\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport { z } from \"zod\";\nimport { rootDir } from \"./paths.js\";\n\nconst envType = z.object({\n  KEY: z.string().length(32),\n\n  CLIENT_ID: z.string().min(16),\n  CLIENT_SECRET: z.string().length(32),\n  BOT_TOKEN: z.string().min(50),\n\n  DASHBOARD_URL: z.string().url(),\n  API_URL: z.string().url(),\n\n  STAFF: z\n    .preprocess(\n      (v) =>\n        String(v)\n          .split(\",\")\n          .map((s) => s.trim())\n          .filter((s) => s !== \"\"),\n      z.array(z.string()),\n    )\n    .optional(),\n\n  DEFAULT_ALLOWED_SERVERS: z\n    .preprocess(\n      (v) =>\n        String(v)\n          .split(\",\")\n          .map((s) => s.trim())\n          .filter((s) => s !== \"\"),\n      z.array(z.string()),\n    )\n    .optional(),\n\n  PHISHERMAN_API_KEY: z.string().optional(),\n  FISHFISH_API_KEY: z.string().optional(),\n\n  DEFAULT_SUCCESS_EMOJI: z.string().optional().default(\"✅\"),\n  DEFAULT_ERROR_EMOJI: z.string().optional().default(\"❌\"),\n\n  DB_HOST: z.string().optional(),\n  DB_PORT: z.preprocess((v) => Number(v), z.number()).optional(),\n  DB_USER: z.string().optional(),\n  DB_PASSWORD: z.string().optional(),\n  DB_DATABASE: z.string().optional(),\n\n  REDIS_URL: z.string().default(\"redis://redis:6379\"),\n\n  DEVELOPMENT_MYSQL_PASSWORD: z.string().optional(),\n\n  API_PATH_PREFIX: z.string().optional(),\n\n  DEBUG: z\n    .string()\n    .optional()\n    .transform((str) => str === \"true\"),\n\n  NODE_ENV: z.string().default(\"development\"),\n});\n\nlet toValidate = { ...process.env };\nconst envPath = path.join(rootDir, \".env\");\nif (fs.existsSync(envPath)) {\n  const buf = fs.readFileSync(envPath);\n  toValidate = { ...toValidate, ...dotenv.parse(buf) };\n}\n\nexport const env = envType.parse(toValidate);\n"
  },
  {
    "path": "backend/src/exportSchemas.ts",
    "content": "import fs from \"node:fs\";\nimport { z } from \"zod\";\nimport { availableGuildPlugins } from \"./plugins/availablePlugins.js\";\nimport { zZeppelinGuildConfig } from \"./types.js\";\nimport { deepPartial } from \"./utils/zodDeepPartial.js\";\n\nconst basePluginOverrideCriteriaSchema = z.strictObject({\n  channel: z\n    .union([z.string(), z.array(z.string())])\n    .nullable()\n    .optional(),\n  category: z\n    .union([z.string(), z.array(z.string())])\n    .nullable()\n    .optional(),\n  level: z\n    .union([z.string(), z.array(z.string())])\n    .nullable()\n    .optional(),\n  user: z\n    .union([z.string(), z.array(z.string())])\n    .nullable()\n    .optional(),\n  role: z\n    .union([z.string(), z.array(z.string())])\n    .nullable()\n    .optional(),\n  thread: z\n    .union([z.string(), z.array(z.string())])\n    .nullable()\n    .optional(),\n  is_thread: z.boolean().nullable().optional(),\n  thread_type: z.literal([\"public\", \"private\"]).nullable().optional(),\n  extra: z.any().optional(),\n});\n\nconst pluginOverrideCriteriaSchema = basePluginOverrideCriteriaSchema\n  .extend({\n    get zzz_dummy_property_do_not_use() {\n      return pluginOverrideCriteriaSchema.optional();\n    },\n    get all() {\n      return z.array(pluginOverrideCriteriaSchema).optional();\n    },\n    get any() {\n      return z.array(pluginOverrideCriteriaSchema).optional();\n    },\n    get not() {\n      return pluginOverrideCriteriaSchema.optional();\n    },\n  })\n  .meta({\n    id: \"overrideCriteria\",\n  });\n\nconst outputPath = process.argv[2];\nif (!outputPath) {\n  console.error(\"Output path required\");\n  process.exit(1);\n}\n\nconst partialConfigs = new Map<any, z.ZodType>();\nfunction getPartialConfig(configSchema: z.ZodType) {\n  if (!partialConfigs.has(configSchema)) {\n    partialConfigs.set(configSchema, deepPartial(configSchema));\n  }\n  return partialConfigs.get(configSchema)!;\n}\n\nfunction overrides(configSchema: z.ZodType): z.ZodType {\n  const partialConfig = getPartialConfig(configSchema);\n  return pluginOverrideCriteriaSchema.extend({\n    config: partialConfig,\n  });\n}\n\nconst pluginSchemaMap = availableGuildPlugins.reduce((map, pluginInfo) => {\n  map[pluginInfo.plugin.name] = z.object({\n    config: pluginInfo.docs.configSchema.optional(),\n    overrides: z.array(overrides(pluginInfo.docs.configSchema)).optional(),\n  });\n  return map;\n}, {});\n\nconst fullSchema = zZeppelinGuildConfig.omit({ plugins: true }).extend({\n  plugins: z.strictObject(pluginSchemaMap).partial().optional(),\n});\n\nconst jsonSchema = z.toJSONSchema(fullSchema, { io: \"input\", cycles: \"ref\" });\n\nfs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2), { encoding: \"utf8\" });\n\nprocess.exit(0);\n"
  },
  {
    "path": "backend/src/globals.ts",
    "content": "let isAPIValue = false;\n\nexport function isAPI() {\n  return isAPIValue;\n}\n\nexport function setIsAPI(value: boolean) {\n  isAPIValue = value;\n}\n"
  },
  {
    "path": "backend/src/humanizeDuration.ts",
    "content": "import humanizeduration from \"humanize-duration\";\n\nexport const delayStringMultipliers = {\n  y: 1000 * 60 * 60 * 24 * (365 + 1 / 4 - 1 / 100 + 1 / 400),\n  mo: (1000 * 60 * 60 * 24 * (365 + 1 / 4 - 1 / 100 + 1 / 400)) / 12,\n  w: 1000 * 60 * 60 * 24 * 7,\n  d: 1000 * 60 * 60 * 24,\n  h: 1000 * 60 * 60,\n  m: 1000 * 60,\n  s: 1000,\n  x: 1,\n};\n\nexport const humanizeDurationShort = humanizeduration.humanizer({\n  language: \"shortEn\",\n  languages: {\n    shortEn: {\n      y: () => \"y\",\n      mo: () => \"mo\",\n      w: () => \"w\",\n      d: () => \"d\",\n      h: () => \"h\",\n      m: () => \"m\",\n      s: () => \"s\",\n      ms: () => \"ms\",\n    },\n  },\n  spacer: \"\",\n  unitMeasures: delayStringMultipliers,\n});\n\nexport const humanizeDuration = humanizeduration.humanizer({\n  unitMeasures: delayStringMultipliers,\n});\n"
  },
  {
    "path": "backend/src/index.ts",
    "content": "// KEEP THIS AS FIRST IMPORT\n// See comment in module for details\nimport \"./threadsSignalFix.js\";\n\nimport {\n  Client,\n  Events,\n  GatewayIntentBits,\n  Options,\n  Partials,\n  RESTEvents,\n  TextChannel,\n  ThreadChannel,\n} from \"discord.js\";\nimport { Vety, PluginError, PluginLoadError, PluginNotLoadedError } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { performance } from \"perf_hooks\";\nimport process from \"process\";\nimport { DiscordJSError } from \"./DiscordJSError.js\";\nimport { RecoverablePluginError } from \"./RecoverablePluginError.js\";\nimport { SimpleError } from \"./SimpleError.js\";\nimport { AllowedGuilds } from \"./data/AllowedGuilds.js\";\nimport { Configs } from \"./data/Configs.js\";\nimport { FishFishError, initFishFish } from \"./data/FishFish.js\";\nimport { GuildLogs } from \"./data/GuildLogs.js\";\nimport { LogType } from \"./data/LogType.js\";\nimport { dataSource } from \"./data/dataSource.js\";\nimport { connect } from \"./data/db.js\";\nimport { runExpiredArchiveDeletionLoop } from \"./data/loops/expiredArchiveDeletionLoop.js\";\nimport { runExpiredMemberCacheDeletionLoop } from \"./data/loops/expiredMemberCacheDeletionLoop.js\";\nimport { runExpiringMutesLoop } from \"./data/loops/expiringMutesLoop.js\";\nimport { runExpiringTempbansLoop } from \"./data/loops/expiringTempbansLoop.js\";\nimport { runExpiringVCAlertsLoop } from \"./data/loops/expiringVCAlertsLoop.js\";\nimport { runMemberCacheDeletionLoop } from \"./data/loops/memberCacheDeletionLoop.js\";\nimport { runSavedMessageCleanupLoop } from \"./data/loops/savedMessageCleanupLoop.js\";\nimport { runUpcomingRemindersLoop } from \"./data/loops/upcomingRemindersLoop.js\";\nimport { runUpcomingScheduledPostsLoop } from \"./data/loops/upcomingScheduledPostsLoop.js\";\nimport { consumeQueryStats } from \"./data/queryLogger.js\";\nimport { env } from \"./env.js\";\nimport { logger } from \"./logger.js\";\nimport { availableGlobalPlugins, availableGuildPlugins } from \"./plugins/availablePlugins.js\";\nimport { setProfiler } from \"./profiler.js\";\nimport { logRateLimit } from \"./rateLimitStats.js\";\nimport { startUptimeCounter } from \"./uptime.js\";\nimport {\n  MINUTES,\n  SECONDS,\n  errorMessage,\n  isDiscordAPIError,\n  isDiscordHTTPError,\n  sleep,\n  successMessage,\n} from \"./utils.js\";\nimport { DecayingCounter } from \"./utils/DecayingCounter.js\";\nimport { enableProfiling } from \"./utils/easyProfiler.js\";\nimport { loadYamlSafely } from \"./utils/loadYamlSafely.js\";\n\n// Error handling\nlet recentPluginErrors = 0;\nconst RECENT_PLUGIN_ERROR_EXIT_THRESHOLD = 5;\n\nlet recentDiscordErrors = 0;\nconst RECENT_DISCORD_ERROR_EXIT_THRESHOLD = 5;\n\nsetInterval(() => (recentPluginErrors = Math.max(0, recentPluginErrors - 1)), 2000);\nsetInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), 2000);\n\n// Eris handles these internally, so we don't need to panic if we get one of them\nconst SAFE_TO_IGNORE_ERIS_ERROR_CODES = [\n  1001, // \"CloudFlare WebSocket proxy restarting\"\n  1006, // \"Connection reset by peer\"\n  \"ECONNRESET\", // Pretty much the same as above\n];\n\nconst SAFE_TO_IGNORE_ERIS_ERROR_MESSAGES = [\"Server didn't acknowledge previous heartbeat, possible lost connection\"];\n\n// Ignore plugin load errors during initial startup to avoid noise in the logs\nlet ignorePluginLoadErrors = true;\n\nfunction errorHandler(err) {\n  const guildId = err.guild?.id || err.guildId || \"0\";\n  const guildName = err.guild?.name || (guildId && guildId !== \"0\" ? \"Unknown\" : \"Global\");\n\n  if (err instanceof RecoverablePluginError) {\n    // Recoverable plugin errors can be, well, recovered from.\n    // Log it in the console as a warning and post a warning to the guild's log.\n\n    // tslint:disable:no-console\n    console.warn(`${guildId} ${guildName}: [${err.code}] ${err.message}`);\n\n    if (err.guild) {\n      const logs = new GuildLogs(err.guild.id);\n      logs.log(LogType.BOT_ALERT, { body: `\\`[${err.code}]\\` ${err.message}` });\n    }\n\n    return;\n  }\n\n  if (err instanceof PluginLoadError) {\n    if (!ignorePluginLoadErrors) {\n      // tslint:disable:no-console\n      console.warn(`${guildName} (${guildId}): Failed to load plugin '${err.pluginName}': ${err.message}`);\n    }\n    return;\n  }\n\n  if (err instanceof DiscordJSError) {\n    if (err.code && SAFE_TO_IGNORE_ERIS_ERROR_CODES.includes(err.code)) {\n      return;\n    }\n\n    if (err.message && SAFE_TO_IGNORE_ERIS_ERROR_MESSAGES.includes(err.message)) {\n      return;\n    }\n  }\n\n  if (isDiscordHTTPError(err) && err.code >= 500) {\n    // Don't need stack traces on HTTP 500 errors\n    // These also shouldn't count towards RECENT_DISCORD_ERROR_EXIT_THRESHOLD because they don't indicate an error in our code\n    console.error(err.message);\n    return;\n  }\n\n  if (err.message && err.message.startsWith(\"Request timed out\")) {\n    // These are very noisy, so just print the message without stack. The stack trace doesn't really help here anyway.\n    console.error(err.message);\n    return;\n  }\n\n  // FIXME: Hotfix\n  if (err.message && err.message.startsWith(\"Unknown custom override criteria\")) {\n    // console.warn(err.message);\n    return;\n  }\n\n  // FIXME: Hotfix\n  if (err.message && err.message.startsWith(\"Unknown override criteria\")) {\n    // console.warn(err.message);\n    return;\n  }\n\n  if (err instanceof PluginNotLoadedError) {\n    // We don't want to crash the bot here, although this *should not happen*\n    // TODO: Proper system for preventing plugin load/unload race conditions\n    console.error(err);\n    return;\n  }\n\n  if (err instanceof FishFishError) {\n    // FishFish errors are not critical, so we just log them\n    console.error(`[FISHFISH] ${err.message}`);\n    return;\n  }\n\n  // tslint:disable:no-console\n  console.error(err);\n\n  if (err instanceof PluginError) {\n    // Tolerate a few recent plugin errors before crashing\n    if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) {\n      console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`);\n      process.exit(1);\n    }\n  } else if (isDiscordAPIError(err) || isDiscordHTTPError(err)) {\n    // Discord API errors, usually safe to just log instead of crash\n    // We still bail if we get a ton of them in a short amount of time\n    if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) {\n      console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`);\n      process.exit(1);\n    }\n  } else {\n    // On other errors, crash immediately\n    process.exit(1);\n  }\n  // tslint:enable:no-console\n}\n\nprocess.on(\"uncaughtException\", errorHandler);\nprocess.on(\"unhandledRejection\", errorHandler);\n\n// Verify required Node.js version\nconst REQUIRED_NODE_VERSION = \"16.9.0\";\nconst requiredParts = REQUIRED_NODE_VERSION.split(\".\").map((v) => parseInt(v, 10));\nconst actualVersionParts = process.versions.node.split(\".\").map((v) => parseInt(v, 10));\nfor (const [i, part] of actualVersionParts.entries()) {\n  if (part > requiredParts[i]) break;\n  if (part === requiredParts[i]) continue;\n  throw new SimpleError(`Unsupported Node.js version! Must be at least ${REQUIRED_NODE_VERSION}`);\n}\n\n// Always use UTC internally\n// This is also enforced for the database in data/db.ts\nmoment.tz.setDefault(\"UTC\");\n\n// Blocking check\nlet avgTotal = 0;\nlet avgCount = 0;\nlet lastCheck = performance.now();\nsetInterval(() => {\n  const now = performance.now();\n  let diff = Math.max(0, now - lastCheck);\n  if (diff < 5) diff = 0;\n  avgTotal += diff;\n  avgCount++;\n  lastCheck = now;\n}, 500);\nsetInterval(\n  () => {\n    const avgBlocking = avgTotal / (avgCount || 1);\n    // FIXME: Debug\n    // tslint:disable-next-line:no-console\n    console.log(`Average blocking in the last 5min: ${avgBlocking / avgTotal}ms`);\n    avgTotal = 0;\n    avgCount = 0;\n  },\n  5 * 60 * 1000,\n);\n\nif (env.DEBUG) {\n  logger.info(\"NOTE: Bot started in DEBUG mode\");\n}\n\nlogger.info(\"Connecting to database\");\nconnect().then(async () => {\n  const client = new Client({\n    partials: [Partials.User, Partials.Channel, Partials.GuildMember, Partials.Message, Partials.Reaction],\n\n    makeCache: Options.cacheWithLimits({\n      ...Options.DefaultMakeCacheSettings,\n      MessageManager: 1,\n      // GuildMemberManager: 15000,\n      GuildInviteManager: 0,\n    }),\n\n    rest: {\n      // globalRequestsPerSecond: 50,\n      // offset: 1000,\n    },\n\n    // Disable mentions by default\n    allowedMentions: {\n      parse: [],\n      users: [],\n      roles: [],\n      repliedUser: false,\n    },\n    intents: [\n      // Privileged\n      GatewayIntentBits.GuildMembers,\n      GatewayIntentBits.MessageContent,\n      // GatewayIntentBits.GuildPresences,\n\n      // Regular\n      GatewayIntentBits.GuildMessageTyping,\n      GatewayIntentBits.DirectMessages,\n      GatewayIntentBits.GuildModeration,\n      GatewayIntentBits.GuildEmojisAndStickers,\n      GatewayIntentBits.GuildInvites,\n      GatewayIntentBits.GuildMessageReactions,\n      GatewayIntentBits.GuildMessages,\n      GatewayIntentBits.Guilds,\n      GatewayIntentBits.GuildVoiceStates,\n    ],\n  });\n\n  client.setMaxListeners(200);\n\n  const safe429DecayInterval = 5 * SECONDS;\n  const safe429MaxCount = 5;\n  const safe429Counter = new DecayingCounter(safe429DecayInterval);\n  client.on(Events.Debug, (errorText) => {\n    if (!errorText.includes(\"429\")) {\n      return;\n    }\n\n    // tslint:disable-next-line:no-console\n    console.warn(`[DEBUG] [WARN] [429] ${errorText}`);\n\n    const value = safe429Counter.add(1);\n    if (value > safe429MaxCount) {\n      // tslint:disable-next-line:no-console\n      console.error(`Too many 429s (over ${safe429MaxCount} in ${safe429MaxCount * safe429DecayInterval}ms), exiting`);\n      process.exit(1);\n    }\n  });\n\n  client.on(\"error\", (err) => {\n    if (err instanceof PluginLoadError) {\n      errorHandler(err);\n      return;\n    }\n    errorHandler(new DiscordJSError(err.message, (err as any).code, 0));\n  });\n\n  const allowedGuilds = new AllowedGuilds();\n  const guildConfigs = new Configs();\n\n  const bot = new Vety(client, {\n    guildPlugins: availableGuildPlugins.map((obj) => obj.plugin),\n    globalPlugins: availableGlobalPlugins.map((obj) => obj.plugin),\n\n    options: {\n      canLoadGuild(guildId): Promise<boolean> {\n        return allowedGuilds.isAllowed(guildId);\n      },\n\n      /**\n       * Plugins are enabled if they...\n       * - are marked to be autoloaded, or\n       * - are explicitly enabled in the guild config\n       * Dependencies are also automatically loaded by Vety.\n       */\n      async getEnabledGuildPlugins(ctx, plugins): Promise<string[]> {\n        if (!ctx.config || !ctx.config.plugins) {\n          return [];\n        }\n\n        const configuredPlugins = ctx.config.plugins;\n        const autoloadPluginNames = availableGuildPlugins.filter((obj) => obj.autoload).map((obj) => obj.plugin.name);\n\n        return Array.from(plugins.keys()).filter((pluginName) => {\n          if (autoloadPluginNames.includes(pluginName)) return true;\n          return configuredPlugins[pluginName] && (configuredPlugins[pluginName] as any).enabled !== false;\n        });\n      },\n\n      async getConfig(id) {\n        const key = id === \"global\" ? \"global\" : `guild-${id}`;\n        if (id !== \"global\") {\n          const allowedGuild = await allowedGuilds.find(id);\n          if (!allowedGuild) {\n            return {};\n          }\n        }\n\n        const row = await guildConfigs.getActiveByKey(key);\n        if (row) {\n          try {\n            const loaded = loadYamlSafely(row.config);\n\n            if (loaded.success_emoji || loaded.error_emoji) {\n              const deprecatedKeys = [] as string[];\n              // const exampleConfig = `plugins:\\n  common:\\n    config:\\n      success_emoji: \"👍\"\\n      error_emoji: \"👎\"`;\n\n              if (loaded.success_emoji) {\n                deprecatedKeys.push(\"success_emoji\");\n              }\n\n              if (loaded.error_emoji) {\n                deprecatedKeys.push(\"error_emoji\");\n              }\n\n              // logger.warn(`Deprecated config properties found in \"${key}\": ${deprecatedKeys.join(\", \")}`);\n              // logger.warn(`You can now configure those emojis in the \"common\" plugin config\\n${exampleConfig}`);\n            }\n\n            // Remove deprecated properties some may still have in their config\n            delete loaded.success_emoji;\n            delete loaded.error_emoji;\n\n            return loaded;\n          } catch (err) {\n            logger.error(`Error while loading config \"${key}\"`);\n            return {};\n          }\n        }\n\n        logger.warn(`No config with key \"${key}\"`);\n        return {};\n      },\n\n      logFn: (level, msg) => {\n        if (level === \"debug\") return;\n\n        if (logger[level]) {\n          logger[level](msg);\n        } else {\n          logger.log(`[${level.toUpperCase()}] ${msg}`);\n        }\n      },\n\n      performanceDebug: {\n        enabled: false,\n        size: 30,\n        threshold: 200,\n      },\n\n      sendSuccessMessageFn(channel, body) {\n        const guildId =\n          channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined;\n        // @ts-expect-error\n        const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.success_emoji : undefined;\n        channel.send(successMessage(body, emoji));\n      },\n\n      sendErrorMessageFn(channel, body) {\n        const guildId =\n          channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined;\n        // @ts-expect-error\n        const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.error_emoji : undefined;\n        channel.send(errorMessage(body, emoji));\n      },\n    },\n  });\n\n  client.once(\"clientReady\", () => {\n    startUptimeCounter();\n  });\n\n  client.rest.on(RESTEvents.RateLimited, (data) => {\n    logRateLimit(data);\n  });\n\n  bot.on(\"error\", errorHandler);\n\n  bot.on(\"loadingFinished\", async () => {\n    setProfiler(bot.profiler);\n    if (process.env.PROFILING === \"true\") {\n      enableProfiling();\n    }\n\n    ignorePluginLoadErrors = false;\n\n    initFishFish();\n\n    runExpiringMutesLoop();\n    await sleep(10 * SECONDS);\n    runExpiringTempbansLoop();\n    await sleep(10 * SECONDS);\n    runUpcomingScheduledPostsLoop();\n    await sleep(10 * SECONDS);\n    runUpcomingRemindersLoop();\n    await sleep(10 * SECONDS);\n    runExpiringVCAlertsLoop();\n    await sleep(10 * SECONDS);\n    runExpiredArchiveDeletionLoop();\n    await sleep(10 * SECONDS);\n    runSavedMessageCleanupLoop();\n    await sleep(10 * SECONDS);\n    runExpiredMemberCacheDeletionLoop();\n    await sleep(10 * SECONDS);\n    runMemberCacheDeletionLoop();\n  });\n\n  let lowestGlobalRemaining = Infinity;\n  setInterval(() => {\n    lowestGlobalRemaining = Math.min(lowestGlobalRemaining, (client as any).rest.globalRemaining);\n  }, 100);\n  setInterval(() => {\n    // FIXME: Debug\n    if (lowestGlobalRemaining < 30) {\n      // tslint:disable-next-line:no-console\n      console.log(\"[DEBUG] Lowest global remaining in the past 15 seconds:\", lowestGlobalRemaining);\n    }\n    lowestGlobalRemaining = Infinity;\n  }, 15000);\n\n  setInterval(() => {\n    const queryStatsMap = consumeQueryStats();\n    const entries = Array.from(queryStatsMap.entries());\n    entries.sort((a, b) => b[1] - a[1]);\n    const topEntriesStr = entries\n      .slice(0, 5)\n      .map(([key, count]) => `${count}x ${key}`)\n      .join(\"\\n\");\n    // FIXME: Debug\n    // tslint:disable-next-line:no-console\n    console.log(`Top query entries in the past 5 minutes:\\n${topEntriesStr}`);\n  }, 5 * MINUTES);\n\n  bot.initialize();\n  logger.info(\"Bot Initialized\");\n  logger.info(\"Logging in...\");\n  await client.login(env.BOT_TOKEN);\n\n  // Don't intercept any signals in DEBUG mode: https://github.com/clinicjs/node-clinic/issues/444#issuecomment-1474997090\n  if (!env.DEBUG) {\n    let stopping = false;\n    const cleanupAndStop = async (code) => {\n      if (stopping) {\n        return;\n      }\n      stopping = true;\n      logger.info(\"Cleaning up before exit...\");\n      // Force exit after 10sec\n      setTimeout(() => process.exit(code), 10 * SECONDS);\n      await bot.destroy();\n      await dataSource.destroy();\n      logger.info(\"Done! Exiting now.\");\n      process.exit(code);\n    };\n    process.on(\"beforeExit\", () => cleanupAndStop(0));\n    process.on(\"SIGINT\", () => {\n      logger.info(\"Received SIGINT, exiting...\");\n      cleanupAndStop(0);\n    });\n    process.on(\"SIGTERM\", () => {\n      logger.info(\"Received SIGTERM, exiting...\");\n      cleanupAndStop(0);\n    });\n  }\n});\n"
  },
  {
    "path": "backend/src/logger.ts",
    "content": "// tslint:disable:no-console\n\nexport const logger = {\n  info(...args: Parameters<typeof console.log>) {\n    console.log(\"[INFO]\", ...args);\n  },\n\n  warn(...args: Parameters<typeof console.warn>) {\n    console.warn(\"[WARN]\", ...args);\n  },\n\n  error(...args: Parameters<typeof console.error>) {\n    console.error(\"[ERROR]\", ...args);\n  },\n\n  debug(...args: Parameters<typeof console.log>) {\n    console.log(\"[DEBUG]\", ...args);\n  },\n\n  log(...args: Parameters<typeof console.log>) {\n    console.log(...args);\n  },\n};\n"
  },
  {
    "path": "backend/src/migrateConfigsToDB.ts",
    "content": "// tslint:disable:no-console\nimport * as _fs from \"fs\";\nimport path from \"path\";\nimport { Configs } from \"./data/Configs.js\";\nimport { connect } from \"./data/db.js\";\n\nconst fs = _fs.promises;\n\nconst authorId = process.argv[2];\nif (!authorId) {\n  console.error(\"No author id specified\");\n  process.exit(1);\n}\n\nconsole.log(\"Connecting to database\");\nconnect().then(async () => {\n  const configs = new Configs();\n\n  console.log(\"Loading config files\");\n  const configDir = path.join(import.meta.dirname, \"..\", \"config\");\n  const configFiles = await fs.readdir(configDir);\n\n  console.log(\"Looping through config files\");\n  for (const configFile of configFiles) {\n    const parts = configFile.split(\".\");\n    const ext = parts[parts.length - 1];\n    if (ext !== \"yml\") continue;\n\n    const id = parts.slice(0, -1).join(\".\");\n    const key = id === \"global\" ? \"global\" : `guild-${id}`;\n    if (await configs.hasConfig(key)) continue;\n\n    const content = await fs.readFile(path.join(configDir, configFile), { encoding: \"utf8\" });\n\n    console.log(`Migrating config for ${key}`);\n    await configs.saveNewRevision(key, content, authorId);\n  }\n\n  console.log(\"Done!\");\n  process.exit(0);\n});\n"
  },
  {
    "path": "backend/src/migrations/1540519249973-CreatePreTypeORMTables.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class CreatePreTypeORMTables1540519249973 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`\n        CREATE TABLE IF NOT EXISTS \\`archives\\` (\n          \\`id\\`         VARCHAR(36) NOT NULL,\n          \\`guild_id\\`   VARCHAR(20) NOT NULL,\n          \\`body\\`       MEDIUMTEXT  NOT NULL,\n          \\`created_at\\` DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,\n          \\`expires_at\\` DATETIME    NULL     DEFAULT NULL,\n          PRIMARY KEY (\\`id\\`)\n        )\n          COLLATE='utf8mb4_general_ci'\n      `);\n\n    await queryRunner.query(`\n        CREATE TABLE IF NOT EXISTS \\`cases\\` (\n          \\`id\\`           INT(10) UNSIGNED    NOT NULL AUTO_INCREMENT,\n          \\`guild_id\\`     BIGINT(20) UNSIGNED NOT NULL,\n          \\`case_number\\`  INT(10) UNSIGNED    NOT NULL,\n          \\`user_id\\`      BIGINT(20) UNSIGNED NOT NULL,\n          \\`user_name\\`    VARCHAR(128)        NOT NULL,\n          \\`mod_id\\`       BIGINT(20) UNSIGNED NULL     DEFAULT NULL,\n          \\`mod_name\\`     VARCHAR(128)        NULL     DEFAULT NULL,\n          \\`type\\`         INT(10) UNSIGNED    NOT NULL,\n          \\`audit_log_id\\` BIGINT(20)          NULL     DEFAULT NULL,\n          \\`created_at\\`   DATETIME            NOT NULL DEFAULT CURRENT_TIMESTAMP,\n          PRIMARY KEY (\\`id\\`),\n          UNIQUE INDEX \\`mod_actions_guild_id_case_number_unique\\` (\\`guild_id\\`, \\`case_number\\`),\n          UNIQUE INDEX \\`mod_actions_audit_log_id_unique\\` (\\`audit_log_id\\`),\n          INDEX \\`mod_actions_user_id_index\\` (\\`user_id\\`),\n          INDEX \\`mod_actions_mod_id_index\\` (\\`mod_id\\`),\n          INDEX \\`mod_actions_created_at_index\\` (\\`created_at\\`)\n        )\n          COLLATE = 'utf8mb4_general_ci'\n      `);\n\n    await queryRunner.query(`\n        CREATE TABLE IF NOT EXISTS \\`case_notes\\` (\n          \\`id\\`         INT(10) UNSIGNED    NOT NULL AUTO_INCREMENT,\n          \\`case_id\\`    INT(10) UNSIGNED    NOT NULL,\n          \\`mod_id\\`     BIGINT(20) UNSIGNED NULL     DEFAULT NULL,\n          \\`mod_name\\`   VARCHAR(128)        NULL     DEFAULT NULL,\n          \\`body\\`       TEXT                NOT NULL,\n          \\`created_at\\` DATETIME            NOT NULL DEFAULT CURRENT_TIMESTAMP,\n          PRIMARY KEY (\\`id\\`),\n          INDEX \\`mod_action_notes_mod_action_id_index\\` (\\`case_id\\`),\n          INDEX \\`mod_action_notes_mod_id_index\\` (\\`mod_id\\`),\n          INDEX \\`mod_action_notes_created_at_index\\` (\\`created_at\\`)\n        )\n          COLLATE = 'utf8mb4_general_ci'\n      `);\n\n    await queryRunner.query(`\n        CREATE TABLE IF NOT EXISTS \\`mutes\\` (\n          \\`guild_id\\`   BIGINT(20) UNSIGNED NOT NULL,\n          \\`user_id\\`    BIGINT(20) UNSIGNED NOT NULL,\n          \\`created_at\\` DATETIME            NULL DEFAULT CURRENT_TIMESTAMP,\n          \\`expires_at\\` DATETIME            NULL DEFAULT NULL,\n          \\`case_id\\`    INT(10) UNSIGNED    NULL DEFAULT NULL,\n          PRIMARY KEY (\\`guild_id\\`, \\`user_id\\`),\n          INDEX \\`mutes_expires_at_index\\` (\\`expires_at\\`),\n          INDEX \\`mutes_case_id_foreign\\` (\\`case_id\\`),\n          CONSTRAINT \\`mutes_case_id_foreign\\` FOREIGN KEY (\\`case_id\\`) REFERENCES \\`cases\\` (\\`id\\`)\n            ON DELETE SET NULL\n        )\n          COLLATE = 'utf8mb4_general_ci'\n      `);\n\n    await queryRunner.query(`\n        CREATE TABLE IF NOT EXISTS \\`persisted_data\\` (\n          \\`guild_id\\`       VARCHAR(20)   NOT NULL,\n          \\`user_id\\`        VARCHAR(20)   NOT NULL,\n          \\`roles\\`          VARCHAR(1024) NULL     DEFAULT NULL,\n          \\`nickname\\`       VARCHAR(255)  NULL     DEFAULT NULL,\n          \\`is_voice_muted\\` INT(11)       NOT NULL DEFAULT '0',\n          PRIMARY KEY (\\`guild_id\\`, \\`user_id\\`)\n        )\n          COLLATE = 'utf8mb4_general_ci'\n      `);\n\n    await queryRunner.query(`\n        CREATE TABLE IF NOT EXISTS \\`reaction_roles\\` (\n          \\`guild_id\\`   VARCHAR(20) NOT NULL,\n          \\`channel_id\\` VARCHAR(20) NOT NULL,\n          \\`message_id\\` VARCHAR(20) NOT NULL,\n          \\`emoji\\`      VARCHAR(20) NOT NULL,\n          \\`role_id\\`    VARCHAR(20) NOT NULL,\n          PRIMARY KEY (\\`guild_id\\`, \\`channel_id\\`, \\`message_id\\`, \\`emoji\\`),\n          INDEX \\`reaction_roles_message_id_emoji_index\\` (\\`message_id\\`, \\`emoji\\`)\n        )\n          COLLATE = 'utf8mb4_general_ci'\n      `);\n\n    await queryRunner.query(`\n        CREATE TABLE IF NOT EXISTS \\`tags\\` (\n          \\`guild_id\\`   BIGINT(20) UNSIGNED NOT NULL,\n          \\`tag\\`        VARCHAR(64)         NOT NULL,\n          \\`user_id\\`    BIGINT(20) UNSIGNED NOT NULL,\n          \\`body\\`       TEXT                NOT NULL,\n          \\`created_at\\` DATETIME            NULL DEFAULT CURRENT_TIMESTAMP,\n          PRIMARY KEY (\\`guild_id\\`, \\`tag\\`)\n        )\n          COLLATE = 'utf8mb4_general_ci'\n      `);\n  }\n\n  public async down(): Promise<any> {\n    // No down function since we're migrating (hehe) from another migration system (knex)\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1543053430712-CreateMessagesTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateMessagesTable1543053430712 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"messages\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"is_bot\",\n            type: \"tinyint\",\n            unsigned: true,\n          },\n          {\n            name: \"data\",\n            type: \"mediumtext\",\n          },\n          {\n            name: \"posted_at\",\n            type: \"datetime(3)\",\n          },\n          {\n            name: \"deleted_at\",\n            type: \"datetime(3)\",\n            isNullable: true,\n            default: null,\n          },\n          {\n            name: \"is_permanent\",\n            type: \"tinyint\",\n            unsigned: true,\n            default: 0,\n          },\n        ],\n        indices: [\n          { columnNames: [\"guild_id\"] },\n          { columnNames: [\"channel_id\"] },\n          { columnNames: [\"user_id\"] },\n          { columnNames: [\"is_bot\"] },\n          { columnNames: [\"posted_at\"] },\n          { columnNames: [\"deleted_at\"] },\n          { columnNames: [\"is_permanent\"] },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"messages\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1544877081073-CreateSlowmodeTables.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateSlowmodeTables1544877081073 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"slowmode_channels\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"slowmode_seconds\",\n            type: \"int\",\n            unsigned: true,\n          },\n        ],\n        indices: [],\n      }),\n    );\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"slowmode_users\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"expires_at\",\n            type: \"datetime\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"expires_at\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await Promise.all([\n      queryRunner.dropTable(\"slowmode_channels\", true),\n      queryRunner.dropTable(\"slowmode_users\", true),\n    ]);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1544887946307-CreateStarboardTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateStarboardTable1544887946307 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"starboards\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"channel_whitelist\",\n            type: \"text\",\n            isNullable: true,\n            default: null,\n          },\n          {\n            name: \"emoji\",\n            type: \"varchar\",\n            length: \"64\",\n          },\n          {\n            name: \"reactions_required\",\n            type: \"smallint\",\n            unsigned: true,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"emoji\"],\n          },\n          {\n            columnNames: [\"guild_id\", \"channel_id\"],\n            isUnique: true,\n          },\n        ],\n      }),\n    );\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"starboard_messages\",\n        columns: [\n          {\n            name: \"starboard_id\",\n            type: \"int\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"message_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"starboard_message_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"starboards\", true);\n    await queryRunner.dropTable(\"starboard_messages\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1546770935261-CreateTagResponsesTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateTagResponsesTable1546770935261 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"tag_responses\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"command_message_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"response_message_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\"],\n          },\n        ],\n        foreignKeys: [\n          {\n            columnNames: [\"command_message_id\"],\n            referencedTableName: \"messages\",\n            referencedColumnNames: [\"id\"],\n            onDelete: \"CASCADE\",\n          },\n          {\n            columnNames: [\"response_message_id\"],\n            referencedTableName: \"messages\",\n            referencedColumnNames: [\"id\"],\n            onDelete: \"CASCADE\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"tag_responses\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1546778415930-CreateNameHistoryTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateNameHistoryTable1546778415930 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"name_history\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"type\",\n            type: \"tinyint\",\n            unsigned: true,\n          },\n          {\n            name: \"value\",\n            type: \"varchar\",\n            length: \"128\",\n            isNullable: true,\n          },\n          {\n            name: \"timestamp\",\n            type: \"datetime\",\n            default: \"CURRENT_TIMESTAMP\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"user_id\"],\n          },\n          {\n            columnNames: [\"type\"],\n          },\n          {\n            columnNames: [\"timestamp\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"name_history\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1546788508314-MakeNameHistoryValueLengthLonger.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class MakeNameHistoryValueLengthLonger1546788508314 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`\n        ALTER TABLE \\`name_history\\`\n\t        CHANGE COLUMN \\`value\\` \\`value\\` VARCHAR(160) NULL DEFAULT NULL COLLATE 'utf8mb4_swedish_ci' AFTER \\`type\\`;\n      `);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`\n        ALTER TABLE \\`name_history\\`\n\t        CHANGE COLUMN \\`value\\` \\`value\\` VARCHAR(128) NULL DEFAULT NULL COLLATE 'utf8mb4_swedish_ci' AFTER \\`type\\`;\n      `);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1547290549908-CreateAutoReactionsTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateAutoReactionsTable1547290549908 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"auto_reactions\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"reactions\",\n            type: \"text\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"auto_reactions\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1547293464842-CreatePingableRolesTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreatePingableRolesTable1547293464842 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"pingable_roles\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"role_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"channel_id\"],\n          },\n          {\n            columnNames: [\"guild_id\", \"channel_id\", \"role_id\"],\n            isUnique: true,\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"pingable_roles\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1547392046629-AddIndexToArchivesExpiresAt.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class AddIndexToArchivesExpiresAt1547392046629 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createIndex(\n      \"archives\",\n      new TableIndex({\n        columnNames: [\"expires_at\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\n      \"archives\",\n      new TableIndex({\n        columnNames: [\"expires_at\"],\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1547393619900-AddIsHiddenToCases.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from \"typeorm\";\n\nexport class AddIsHiddenToCases1547393619900 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\n      \"cases\",\n      new TableColumn({\n        name: \"is_hidden\",\n        type: \"tinyint\",\n        unsigned: true,\n        default: 0,\n      }),\n    );\n    await queryRunner.createIndex(\n      \"cases\",\n      new TableIndex({\n        columnNames: [\"is_hidden\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"cases\", \"is_hidden\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1549649586803-AddPPFieldsToCases.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class AddPPFieldsToCases1549649586803 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`\n        ALTER TABLE \\`cases\\`\n          ADD COLUMN \\`pp_id\\` BIGINT NULL,\n          ADD COLUMN \\`pp_name\\` VARCHAR(128) NULL\n      `);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`\n        ALTER TABLE \\`cases\\`\n          DROP COLUMN \\`pp_id\\`,\n          DROP COLUMN \\`pp_name\\`\n      `);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1550409894008-FixEmojiIndexInReactionRoles.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class FixEmojiIndexInReactionRoles1550409894008 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // In utf8mb4_swedish_ci, different native emojis are counted as the same char for indexes, which means we can't\n    // have multiple native emojis on a single message since the emoji field is part of the primary key\n    await queryRunner.query(`\n        ALTER TABLE \\`reaction_roles\\`\n\t        CHANGE COLUMN \\`emoji\\` \\`emoji\\` VARCHAR(64) NOT NULL COLLATE 'utf8mb4_bin' AFTER \\`message_id\\`\n      `);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`\n        ALTER TABLE \\`reaction_roles\\`\n\t        CHANGE COLUMN \\`emoji\\` \\`emoji\\` VARCHAR(64) NOT NULL AFTER \\`message_id\\`\n      `);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1550521627877-CreateSelfGrantableRolesTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateSelfGrantableRolesTable1550521627877 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"self_grantable_roles\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"role_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"aliases\",\n            type: \"varchar\",\n            length: \"255\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"channel_id\", \"role_id\"],\n            isUnique: true,\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"self_grantable_roles\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1550609900261-CreateRemindersTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateRemindersTable1550609900261 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"reminders\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"remind_at\",\n            type: \"datetime\",\n          },\n          {\n            name: \"body\",\n            type: \"text\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"user_id\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"reminders\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1556908589679-CreateUsernameHistoryTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateUsernameHistoryTable1556908589679 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"username_history\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"username\",\n            type: \"varchar\",\n            length: \"160\",\n            isNullable: true,\n          },\n          {\n            name: \"timestamp\",\n            type: \"datetime\",\n            default: \"CURRENT_TIMESTAMP\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"user_id\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"username_history\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nconst BATCH_SIZE = 200;\n\nexport class MigrateUsernamesToNewHistoryTable1556909512501 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // Start by ending the migration transaction because this is gonna be a looooooooot of data\n    await queryRunner.query(\"COMMIT\");\n\n    const migratedUsernames = new Set();\n\n    await new Promise<void>(async (resolve) => {\n      const stream = await queryRunner.stream(\"SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history\");\n      stream.on(\"data\", (row: any) => {\n        migratedUsernames.add(row.key);\n      });\n      stream.on(\"end\", resolve);\n    });\n\n    const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => {\n      return new Promise(async (resolve) => {\n        const toInsert: any[][] = [];\n        const toDelete: number[] = [];\n\n        const stream = await queryRunner.stream(\n          `SELECT * FROM name_history WHERE type=1 ORDER BY timestamp ASC LIMIT ${BATCH_SIZE}`,\n        );\n        stream.on(\"data\", (row: any) => {\n          const key = `${row.user_id}-${row.value}`;\n\n          if (!migratedUsernames.has(key)) {\n            migratedUsernames.add(key);\n            toInsert.push([row.user_id, row.value, row.timestamp]);\n          }\n\n          toDelete.push(row.id);\n        });\n        stream.on(\"end\", async () => {\n          if (toInsert.length || toDelete.length) {\n            await queryRunner.query(\"START TRANSACTION\");\n\n            if (toInsert.length) {\n              await queryRunner.query(\n                \"INSERT INTO username_history (user_id, username, timestamp) VALUES \" +\n                  Array.from({ length: toInsert.length }, () => \"(?, ?, ?)\").join(\",\"),\n                toInsert.flat(),\n              );\n            }\n\n            if (toDelete.length) {\n              await queryRunner.query(\n                \"DELETE FROM name_history WHERE id IN (\" + Array.from(\"?\".repeat(toDelete.length)).join(\", \") + \")\",\n                toDelete,\n              );\n            }\n\n            await queryRunner.query(\"COMMIT\");\n\n            resolve({ finished: false, migrated: toInsert.length });\n          } else {\n            resolve({ finished: true });\n          }\n        });\n      });\n    };\n\n    while (true) {\n      const result = await migrateNextBatch();\n      if (result.finished) {\n        break;\n      } else {\n        // tslint:disable-next-line:no-console\n        console.log(`Migrated ${result.migrated} usernames`);\n      }\n    }\n\n    await queryRunner.query(\"START TRANSACTION\");\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-empty-function\n  public async down(): Promise<any> {}\n}\n"
  },
  {
    "path": "backend/src/migrations/1556913287547-TurnNameHistoryToNicknameHistory.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class TurnNameHistoryToNicknameHistory1556913287547 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"name_history\", \"type\");\n\n    // As a raw query because of some bug with renameColumn that generated an invalid query\n    await queryRunner.query(`\n        ALTER TABLE \\`name_history\\`\n          CHANGE COLUMN \\`value\\` \\`nickname\\` VARCHAR(160) NULL DEFAULT 'NULL' COLLATE 'utf8mb4_swedish_ci' AFTER \\`user_id\\`;\n      `);\n\n    // Drop unneeded timestamp column index\n    await queryRunner.dropIndex(\"name_history\", \"IDX_6bd0600f9d55d4e4a08b508999\");\n\n    await queryRunner.renameTable(\"name_history\", \"nickname_history\");\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\n      \"nickname_history\",\n      new TableColumn({\n        name: \"type\",\n        type: \"tinyint\",\n        unsigned: true,\n      }),\n    );\n\n    // As a raw query because of some bug with renameColumn that generated an invalid query\n    await queryRunner.query(`\n        ALTER TABLE \\`nickname_history\\`\n          CHANGE COLUMN \\`nickname\\` \\`value\\` VARCHAR(160) NULL DEFAULT 'NULL' COLLATE 'utf8mb4_swedish_ci' AFTER \\`user_id\\`\n      `);\n\n    await queryRunner.renameTable(\"nickname_history\", \"name_history\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1556973844545-CreateScheduledPostsTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateScheduledPostsTable1556973844545 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"scheduled_posts\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"author_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"author_name\",\n            type: \"varchar\",\n            length: \"160\",\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"content\",\n            type: \"text\",\n          },\n          {\n            name: \"attachments\",\n            type: \"text\",\n          },\n          {\n            name: \"post_at\",\n            type: \"datetime\",\n          },\n          {\n            name: \"enable_mentions\",\n            type: \"tinyint\",\n            unsigned: true,\n            default: 0,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"post_at\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"scheduled_posts\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1558804433320-CreateDashboardLoginsTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateDashboardLoginsTable1558804433320 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"dashboard_logins\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"varchar\",\n            length: \"36\",\n            isPrimary: true,\n            collation: \"ascii_bin\",\n          },\n          {\n            name: \"token\",\n            type: \"varchar\",\n            length: \"64\",\n            collation: \"ascii_bin\",\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"user_data\",\n            type: \"text\",\n          },\n          {\n            name: \"logged_in_at\",\n            type: \"DATETIME\",\n          },\n          {\n            name: \"expires_at\",\n            type: \"DATETIME\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"user_id\"],\n          },\n          {\n            columnNames: [\"expires_at\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"dashboard_logins\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1558804449510-CreateDashboardUsersTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table, TableIndex } from \"typeorm\";\n\nexport class CreateDashboardUsersTable1558804449510 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"dashboard_users\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"username\",\n            type: \"varchar\",\n            length: \"255\",\n          },\n          {\n            name: \"role\",\n            type: \"varchar\",\n            length: \"32\",\n          },\n        ],\n      }),\n    );\n\n    await queryRunner.createIndex(\n      \"dashboard_users\",\n      new TableIndex({\n        columnNames: [\"user_id\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"dashboard_users\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1561111990357-CreateConfigsTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateConfigsTable1561111990357 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"configs\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"key\",\n            type: \"varchar\",\n            length: \"48\",\n          },\n          {\n            name: \"config\",\n            type: \"mediumtext\",\n          },\n          {\n            name: \"is_active\",\n            type: \"tinyint\",\n          },\n          {\n            name: \"edited_by\",\n            type: \"bigint\",\n          },\n          {\n            name: \"edited_at\",\n            type: \"datetime\",\n            default: \"now()\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"key\", \"is_active\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"configs\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1561117545258-CreateAllowedGuildsTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateAllowedGuildsTable1561117545258 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"allowed_guilds\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"name\",\n            type: \"varchar\",\n            length: \"255\",\n          },\n          {\n            name: \"icon\",\n            type: \"varchar\",\n            length: \"255\",\n            collation: \"ascii_general_ci\",\n            isNullable: true,\n          },\n          {\n            name: \"owner_id\",\n            type: \"bigint\",\n          },\n        ],\n        indices: [{ columnNames: [\"owner_id\"] }],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"allowed_guilds\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1561282151982-RenameBackendDashboardStuffToAPI.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class RenameBackendDashboardStuffToAPI1561282151982 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`ALTER TABLE dashboard_users RENAME api_users`);\n    await queryRunner.query(`ALTER TABLE dashboard_logins RENAME api_logins`);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`ALTER TABLE api_users RENAME dashboard_users`);\n    await queryRunner.query(`ALTER TABLE api_logins RENAME dashboard_logins`);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1561282552734-RenameAllowedGuildGuildIdToId.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class RenameAllowedGuildGuildIdToId1561282552734 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(\"ALTER TABLE `allowed_guilds` CHANGE COLUMN `guild_id` `id` BIGINT(20) NOT NULL FIRST;\");\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(\"ALTER TABLE `allowed_guilds` CHANGE COLUMN `id` `guild_id` BIGINT(20) NOT NULL FIRST;\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1561282950483-CreateApiUserInfoTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateApiUserInfoTable1561282950483 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"api_user_info\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"data\",\n            type: \"text\",\n          },\n          {\n            name: \"updated_at\",\n            type: \"datetime\",\n            default: \"now()\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"api_user_info\", true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1561283165823-RenameApiUsersToApiPermissions.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class RenameApiUsersToApiPermissions1561283165823 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`ALTER TABLE api_users RENAME api_permissions`);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`ALTER TABLE api_permissions RENAME api_users`);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1561283405201-DropUserDataFromLoginsAndPermissions.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class DropUserDataFromLoginsAndPermissions1561283405201 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(\"ALTER TABLE `api_logins` DROP COLUMN `user_data`\");\n    await queryRunner.query(\"ALTER TABLE `api_permissions` DROP COLUMN `username`\");\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(\n      \"ALTER TABLE `api_logins` ADD COLUMN `user_data` TEXT NOT NULL COLLATE 'utf8mb4_swedish_ci' AFTER `user_id`\",\n    );\n    await queryRunner.query(\n      \"ALTER TABLE `api_permissions` ADD COLUMN `username` VARCHAR(255) NOT NULL COLLATE 'utf8mb4_swedish_ci' AFTER `user_id`\",\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1561391921385-AddVCAlertTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class AddVCAlertTable1561391921385 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"vc_alerts\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"requestor_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"expires_at\",\n            type: \"datetime\",\n          },\n          {\n            name: \"body\",\n            type: \"text\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"user_id\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"vc_alerts\", true, false, true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1562838838927-AddMoreIndicesToVCAlerts.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class AddMoreIndicesToVCAlerts1562838838927 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const table = (await queryRunner.getTable(\"vc_alerts\"))!;\n    await table.addIndex(\n      new TableIndex({\n        columnNames: [\"requestor_id\"],\n      }),\n    );\n    await table.addIndex(\n      new TableIndex({\n        columnNames: [\"expires_at\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    const table = (await queryRunner.getTable(\"vc_alerts\"))!;\n    await table.removeIndex(\n      new TableIndex({\n        columnNames: [\"requestor_id\"],\n      }),\n    );\n    await table.removeIndex(\n      new TableIndex({\n        columnNames: [\"expires_at\"],\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1573158035867-AddTypeAndPermissionsToApiPermissions.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from \"typeorm\";\n\nexport class AddTypeAndPermissionsToApiPermissions1573158035867 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // We can't use a TableIndex object in dropIndex directly as the table name is included in the generated index name\n    // and the table name has changed since the original index was created\n    const originalIndexName = queryRunner.connection.namingStrategy.indexName(\"dashboard_users\", [\"user_id\"]);\n    await queryRunner.dropIndex(\"api_permissions\", originalIndexName);\n\n    await queryRunner.addColumn(\n      \"api_permissions\",\n      new TableColumn({\n        name: \"type\",\n        type: \"varchar\",\n        length: \"16\",\n      }),\n    );\n\n    await queryRunner.renameColumn(\"api_permissions\", \"user_id\", \"target_id\");\n\n    await queryRunner.query(`\n      ALTER TABLE api_permissions\n        DROP PRIMARY KEY,\n        ADD PRIMARY KEY(\\`guild_id\\`, \\`type\\`, \\`target_id\\`);\n    `);\n\n    await queryRunner.dropColumn(\"api_permissions\", \"role\");\n\n    await queryRunner.addColumn(\n      \"api_permissions\",\n      new TableColumn({\n        name: \"permissions\",\n        type: \"text\",\n      }),\n    );\n\n    await queryRunner.query(`\n        UPDATE api_permissions\n        SET type='USER',\n            permissions='EDIT_CONFIG'\n      `);\n\n    await queryRunner.createIndex(\n      \"api_permissions\",\n      new TableIndex({\n        columnNames: [\"type\", \"target_id\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\n      \"api_permissions\",\n      new TableIndex({\n        columnNames: [\"type\", \"target_id\"],\n      }),\n    );\n\n    await queryRunner.dropColumn(\"api_permissions\", \"permissions\");\n\n    await queryRunner.addColumn(\n      \"api_permissions\",\n      new TableColumn({\n        name: \"role\",\n        type: \"varchar\",\n        length: \"32\",\n      }),\n    );\n\n    await queryRunner.query(`\n      ALTER TABLE api_permissions\n        DROP PRIMARY KEY,\n        ADD PRIMARY KEY(\\`guild_id\\`, \\`type\\`);\n    `);\n\n    await queryRunner.renameColumn(\"api_permissions\", \"target_id\", \"user_id\");\n\n    await queryRunner.dropColumn(\"api_permissions\", \"type\");\n\n    await queryRunner.createIndex(\n      \"api_permissions\",\n      new TableIndex({\n        columnNames: [\"user_id\"],\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1573248462469-MoveStarboardsToConfig.ts",
    "content": "import { MigrationInterface, QueryRunner, Table, TableColumn } from \"typeorm\";\n\nexport class MoveStarboardsToConfig1573248462469 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // Create a new column for the channel's id\n    await queryRunner.addColumn(\n      \"starboard_messages\",\n      new TableColumn({\n        name: \"starboard_channel_id\",\n        type: \"bigint\",\n        unsigned: true,\n      }),\n    );\n\n    // Since we are removing the guild_id with the starboards table, we might want it here\n    await queryRunner.addColumn(\n      \"starboard_messages\",\n      new TableColumn({\n        name: \"guild_id\",\n        type: \"bigint\",\n        unsigned: true,\n      }),\n    );\n\n    // Migrate the old starboard_id to the new starboard_channel_id\n    await queryRunner.query(`\n      UPDATE starboard_messages AS sm\n      JOIN starboards AS sb\n      ON sm.starboard_id = sb.id\n      SET sm.starboard_channel_id = sb.channel_id, sm.guild_id = sb.guild_id;\n    `);\n\n    // Set new Primary Key\n    await queryRunner.query(`\n      ALTER TABLE starboard_messages\n        DROP PRIMARY KEY,\n        ADD PRIMARY KEY(\\`starboard_message_id\\`);\n    `);\n    // Drop the starboard_id column as it is now obsolete\n    // We can't use queyrRunner.dropColumn() here because TypeORM helpfully thinks that\n    // starboard_id is still part of the primary key and tries to drop the PK first\n    await queryRunner.query(\"ALTER TABLE starboard_messages DROP COLUMN starboard_id\");\n\n    // Finally, drop the starboards table as it is now obsolete\n    await queryRunner.dropTable(\"starboards\", true);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"starboard_messages\", \"starboard_channel_id\");\n    await queryRunner.dropColumn(\"starboard_messages\", \"guild_id\");\n\n    await queryRunner.addColumn(\n      \"starboard_messages\",\n      new TableColumn({\n        name: \"starboard_id\",\n        type: \"int\",\n        unsigned: true,\n      }),\n    );\n\n    await queryRunner.query(`\n      ALTER TABLE starboard_messages\n        DROP PRIMARY KEY,\n        ADD PRIMARY KEY(\\`starboard_id\\`, \\`message_id\\`);\n    `);\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"starboards\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"channel_whitelist\",\n            type: \"text\",\n            isNullable: true,\n            default: null,\n          },\n          {\n            name: \"emoji\",\n            type: \"varchar\",\n            length: \"64\",\n          },\n          {\n            name: \"reactions_required\",\n            type: \"smallint\",\n            unsigned: true,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"emoji\"],\n          },\n          {\n            columnNames: [\"guild_id\", \"channel_id\"],\n            isUnique: true,\n          },\n        ],\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1573248794313-CreateStarboardReactionsTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateStarboardReactionsTable1573248794313 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"starboard_reactions\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"message_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"reactor_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"reactor_id\", \"message_id\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"starboard_reactions\", true, false, true);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1575145703039-AddIsExclusiveToReactionRoles.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class AddIsExclusiveToReactionRoles1575145703039 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\n      \"reaction_roles\",\n      new TableColumn({\n        name: \"is_exclusive\",\n        type: \"tinyint\",\n        unsigned: true,\n        default: 0,\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"reaction_roles\", \"is_exclusive\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1575199835233-CreateStatsTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateStatsTable1575199835233 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"stats\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n          },\n          {\n            name: \"source\",\n            type: \"varchar\",\n            length: \"64\",\n            collation: \"ascii_bin\",\n          },\n          {\n            name: \"key\",\n            type: \"varchar\",\n            length: \"64\",\n            collation: \"ascii_bin\",\n          },\n          {\n            name: \"value\",\n            type: \"integer\",\n            unsigned: true,\n          },\n          {\n            name: \"created_at\",\n            type: \"datetime\",\n            default: \"NOW()\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"source\", \"key\"],\n          },\n          {\n            columnNames: [\"created_at\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"stats\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1575230079526-AddRepeatColumnsToScheduledPosts.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class AddRepeatColumnsToScheduledPosts1575230079526 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumns(\"scheduled_posts\", [\n      new TableColumn({\n        name: \"repeat_interval\",\n        type: \"integer\",\n        unsigned: true,\n        isNullable: true,\n      }),\n      new TableColumn({\n        name: \"repeat_until\",\n        type: \"datetime\",\n        isNullable: true,\n      }),\n      new TableColumn({\n        name: \"repeat_times\",\n        type: \"integer\",\n        unsigned: true,\n        isNullable: true,\n      }),\n    ]);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"scheduled_posts\", \"repeat_interval\");\n    await queryRunner.dropColumn(\"scheduled_posts\", \"repeat_until\");\n    await queryRunner.dropColumn(\"scheduled_posts\", \"repeat_times\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1578445483917-CreateReminderCreatedAtField.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class CreateReminderCreatedAtField1578445483917 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\n      \"reminders\",\n      new TableColumn({\n        name: \"created_at\",\n        type: \"datetime\",\n        isNullable: false,\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"reminders\", \"created_at\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1580038836906-CreateAntiraidLevelsTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateAntiraidLevelsTable1580038836906 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"antiraid_levels\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"level\",\n            type: \"varchar\",\n            length: \"64\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"antiraid_levels\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1580654617890-AddActiveFollowsToLocateUser.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class AddActiveFollowsToLocateUser1580654617890 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\n      \"vc_alerts\",\n      new TableColumn({\n        name: \"active\",\n        type: \"boolean\",\n        isNullable: false,\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"vc_alerts\", \"active\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1590616691907-CreateSupportersTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateSupportersTable1590616691907 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"supporters\",\n        columns: [\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n            unsigned: true,\n            isPrimary: true,\n          },\n          {\n            name: \"name\",\n            type: \"varchar\",\n            length: \"255\",\n          },\n          {\n            name: \"amount\",\n            type: \"decimal\",\n            precision: 6,\n            scale: 2,\n            isNullable: true,\n            default: null,\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"supporters\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1591036185142-OptimizeMessageIndices.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class OptimizeMessageIndices1591036185142 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // guild_id, channel_id, user_id indices -> composite(guild_id, channel_id, user_id)\n    await queryRunner.dropIndex(\"messages\", \"IDX_b193588441b085352a4c010942\"); // guild_id\n    await queryRunner.dropIndex(\"messages\", \"IDX_86b9109b155eb70c0a2ca3b4b6\"); // channel_id\n    await queryRunner.dropIndex(\"messages\", \"IDX_830a3c1d92614d1495418c4673\"); // user_id\n    await queryRunner.createIndex(\n      \"messages\",\n      new TableIndex({\n        columnNames: [\"guild_id\", \"channel_id\", \"user_id\"],\n      }),\n    );\n\n    // posted_at, is_permanent indices -> composite(posted_at, is_permanent)\n    await queryRunner.dropIndex(\"messages\", \"IDX_08e1f5a0fef0175ea402c6b2ac\"); // posted_at\n    await queryRunner.dropIndex(\"messages\", \"IDX_f520029c07824f8d96c6cd98e8\"); // is_permanent\n    await queryRunner.createIndex(\n      \"messages\",\n      new TableIndex({\n        columnNames: [\"posted_at\", \"is_permanent\"],\n      }),\n    );\n\n    // is_bot -> no index (the database doesn't appear to use this index anyway)\n    await queryRunner.dropIndex(\"messages\", \"IDX_eec2c581ff6f13595902c31840\");\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    // no index -> is_bot index\n    await queryRunner.createIndex(\"messages\", new TableIndex({ columnNames: [\"is_bot\"] }));\n\n    // composite(posted_at, is_permanent) -> posted_at, is_permanent indices\n    await queryRunner.dropIndex(\"messages\", \"IDX_afe125bfd65341cd90eee0b310\"); // composite(posted_at, is_permanent)\n    await queryRunner.createIndex(\"messages\", new TableIndex({ columnNames: [\"posted_at\"] }));\n    await queryRunner.createIndex(\"messages\", new TableIndex({ columnNames: [\"is_permanent\"] }));\n\n    // composite(guild_id, channel_id, user_id) -> guild_id, channel_id, user_id indices\n    await queryRunner.dropIndex(\"messages\", \"IDX_dedc3ea6396e1de8ac75533589\"); // composite(guild_id, channel_id, user_id)\n    await queryRunner.createIndex(\"messages\", new TableIndex({ columnNames: [\"guild_id\"] }));\n    await queryRunner.createIndex(\"messages\", new TableIndex({ columnNames: [\"channel_id\"] }));\n    await queryRunner.createIndex(\"messages\", new TableIndex({ columnNames: [\"user_id\"] }));\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1591038041635-OptimizeMessageTimestamps.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class OptimizeMessageTimestamps1591038041635 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // DATETIME(3) -> DATETIME(0)\n    await queryRunner.query(`\n        ALTER TABLE \\`messages\\`\n          CHANGE COLUMN \\`posted_at\\` \\`posted_at\\` DATETIME(0) NOT NULL AFTER \\`data\\`,\n          CHANGE COLUMN \\`deleted_at\\` \\`deleted_at\\` DATETIME(0) NULL DEFAULT NULL AFTER \\`posted_at\\`\n      `);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    // DATETIME(0) -> DATETIME(3)\n    await queryRunner.query(`\n        ALTER TABLE \\`messages\\`\n          CHANGE COLUMN \\`posted_at\\` \\`posted_at\\` DATETIME(3) NOT NULL AFTER \\`data\\`,\n          CHANGE COLUMN \\`deleted_at\\` \\`deleted_at\\` DATETIME(3) NULL DEFAULT NULL AFTER \\`posted_at\\`\n      `);\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1596994103885-AddCaseNotesForeignKey.ts",
    "content": "import { MigrationInterface, QueryRunner, TableForeignKey } from \"typeorm\";\n\nexport class AddCaseNotesForeignKey1596994103885 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createForeignKey(\n      \"case_notes\",\n      new TableForeignKey({\n        name: \"case_notes_case_id_fk\",\n        columnNames: [\"case_id\"],\n        referencedTableName: \"cases\",\n        referencedColumnNames: [\"id\"],\n        onDelete: \"CASCADE\",\n        onUpdate: \"CASCADE\",\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropForeignKey(\"case_notes\", \"case_notes_case_id_fk\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1597015567215-AddLogMessageIdToCases.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class AddLogMessageIdToCases1597015567215 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\n      \"cases\",\n      new TableColumn({\n        name: \"log_message_id\",\n        type: \"varchar\",\n        length: \"64\",\n        isNullable: true,\n        default: null,\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"cases\", \"log_message_id\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1597109357201-CreateMemberTimezonesTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateMemberTimezonesTable1597109357201 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"member_timezones\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"member_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"timezone\",\n            type: \"varchar\",\n            length: \"255\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"member_timezones\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1600283341726-EncryptExistingMessages.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\nimport { decrypt, encrypt } from \"../utils/crypt.js\";\n\nexport class EncryptExistingMessages1600283341726 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // 1. Delete non-permanent messages\n    await queryRunner.query(\"DELETE FROM messages WHERE is_permanent = 0\");\n\n    // 2. Encrypt all permanent messages\n    const messages = await queryRunner.query(\"SELECT id, data FROM messages\");\n    for (const message of messages) {\n      const encryptedData = await encrypt(message.data);\n      await queryRunner.query(\"UPDATE messages SET data = ? WHERE id = ?\", [encryptedData, message.id]);\n    }\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    // Decrypt all messages\n    const messages = await queryRunner.query(\"SELECT id, data FROM messages\");\n    for (const message of messages) {\n      const decryptedData = await decrypt(message.data);\n      await queryRunner.query(\"UPDATE messages SET data = ? WHERE id = ?\", [decryptedData, message.id]);\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1600285077890-EncryptArchives.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\nimport { decrypt, encrypt } from \"../utils/crypt.js\";\n\nexport class EncryptArchives1600285077890 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const archives = await queryRunner.query(\"SELECT id, body FROM archives\");\n    for (const archive of archives) {\n      const encryptedBody = await encrypt(archive.body);\n      await queryRunner.query(\"UPDATE archives SET body = ? WHERE id = ?\", [encryptedBody, archive.id]);\n    }\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    const archives = await queryRunner.query(\"SELECT id, body FROM archives\");\n    for (const archive of archives) {\n      const decryptedBody = await decrypt(archive.body);\n      await queryRunner.query(\"UPDATE archives SET body = ? WHERE id = ?\", [decryptedBody, archive.id]);\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1608608903570-CreateRestoredRolesColumn.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class CreateRestoredRolesColumn1608608903570 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\n      \"mutes\",\n      new TableColumn({\n        name: \"roles_to_restore\",\n        type: \"text\",\n        isNullable: true,\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"mutes\", \"roles_to_restore\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1608692857722-FixStarboardReactionsIndices.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class FixStarboardReactionsIndices1608692857722 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // Remove previously-added duplicate stars\n    await queryRunner.query(`\n        DELETE r1.* FROM starboard_reactions AS r1\n        INNER JOIN starboard_reactions AS r2\n          ON r2.guild_id = r1.guild_id AND r2.message_id = r1.message_id AND r2.reactor_id = r1.reactor_id AND r2.id < r1.id\n      `);\n\n    await queryRunner.dropIndex(\"starboard_reactions\", \"IDX_dd871a4ef459dd294aa368e736\");\n    await queryRunner.createIndex(\n      \"starboard_reactions\",\n      new TableIndex({\n        isUnique: true,\n        columnNames: [\"guild_id\", \"message_id\", \"reactor_id\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\"starboard_reactions\", \"IDX_d08ee47552c92ec8afd1a5bd1b\");\n    await queryRunner.createIndex(\"starboard_reactions\", new TableIndex({ columnNames: [\"reactor_id\", \"message_id\"] }));\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1608753440716-CreateTempBansTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table, TableIndex } from \"typeorm\";\n\nexport class CreateTempBansTable1608753440716 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"tempbans\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"mod_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"created_at\",\n            type: \"datetime\",\n          },\n          {\n            name: \"expires_at\",\n            type: \"datetime\",\n          },\n        ],\n      }),\n    );\n    queryRunner.createIndex(\n      \"tempbans\",\n      new TableIndex({\n        columnNames: [\"expires_at\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"tempbans\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1612010765767-CreateCounterTables.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateCounterTables1612010765767 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"counters\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"name\",\n            type: \"varchar\",\n            length: \"255\",\n          },\n          {\n            name: \"per_channel\",\n            type: \"boolean\",\n          },\n          {\n            name: \"per_user\",\n            type: \"boolean\",\n          },\n          {\n            name: \"last_decay_at\",\n            type: \"datetime\",\n          },\n          {\n            name: \"delete_at\",\n            type: \"datetime\",\n            isNullable: true,\n            default: null,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"name\"],\n            isUnique: true,\n          },\n          {\n            columnNames: [\"delete_at\"],\n          },\n        ],\n      }),\n    );\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"counter_values\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"bigint\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"counter_id\",\n            type: \"int\",\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"value\",\n            type: \"int\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"counter_id\", \"channel_id\", \"user_id\"],\n            isUnique: true,\n          },\n        ],\n        foreignKeys: [\n          {\n            columnNames: [\"counter_id\"],\n            referencedTableName: \"counters\",\n            referencedColumnNames: [\"id\"],\n            onDelete: \"CASCADE\",\n            onUpdate: \"CASCADE\",\n          },\n        ],\n      }),\n    );\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"counter_triggers\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"counter_id\",\n            type: \"int\",\n          },\n          {\n            name: \"comparison_op\",\n            type: \"varchar\",\n            length: \"16\",\n          },\n          {\n            name: \"comparison_value\",\n            type: \"int\",\n          },\n          {\n            name: \"delete_at\",\n            type: \"datetime\",\n            isNullable: true,\n            default: null,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"counter_id\", \"comparison_op\", \"comparison_value\"],\n            isUnique: true,\n          },\n          {\n            columnNames: [\"delete_at\"],\n          },\n        ],\n        foreignKeys: [\n          {\n            columnNames: [\"counter_id\"],\n            referencedTableName: \"counters\",\n            referencedColumnNames: [\"id\"],\n            onDelete: \"CASCADE\",\n            onUpdate: \"CASCADE\",\n          },\n        ],\n      }),\n    );\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"counter_trigger_states\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"bigint\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"trigger_id\",\n            type: \"int\",\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"trigger_id\", \"channel_id\", \"user_id\"],\n            isUnique: true,\n          },\n        ],\n        foreignKeys: [\n          {\n            columnNames: [\"trigger_id\"],\n            referencedTableName: \"counter_triggers\",\n            referencedColumnNames: [\"id\"],\n            onDelete: \"CASCADE\",\n            onUpdate: \"CASCADE\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"counter_trigger_states\");\n    await queryRunner.dropTable(\"counter_triggers\");\n    await queryRunner.dropTable(\"counter_values\");\n    await queryRunner.dropTable(\"counters\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1617363975046-UpdateCounterTriggers.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey, TableIndex } from \"typeorm\";\n\nexport class UpdateCounterTriggers1617363975046 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    // Since we're adding a non-nullable unique name column and existing triggers won't have that, clear the table first\n    await queryRunner.query(\"DELETE FROM counter_triggers\");\n\n    await queryRunner.addColumns(\"counter_triggers\", [\n      new TableColumn({\n        name: \"name\",\n        type: \"varchar\",\n        length: \"255\",\n      }),\n\n      new TableColumn({\n        name: \"reverse_comparison_op\",\n        type: \"varchar\",\n        length: \"16\",\n      }),\n\n      new TableColumn({\n        name: \"reverse_comparison_value\",\n        type: \"int\",\n      }),\n    ]);\n\n    // Drop foreign key for counter_id -- needed to be able to drop the following unique index\n    await queryRunner.dropForeignKey(\"counter_triggers\", \"FK_6bb47849ec95c87e58c5d3e6ae1\");\n\n    // Index for [\"counter_id\", \"comparison_op\", \"comparison_value\"]\n    await queryRunner.dropIndex(\"counter_triggers\", \"IDX_ddc8a6701f1234b926d35aebf3\");\n\n    await queryRunner.createIndex(\n      \"counter_triggers\",\n      new TableIndex({\n        columnNames: [\"counter_id\", \"name\"],\n        isUnique: true,\n      }),\n    );\n\n    // Recreate foreign key for counter_id\n    await queryRunner.createForeignKey(\n      \"counter_triggers\",\n      new TableForeignKey({\n        columnNames: [\"counter_id\"],\n        referencedTableName: \"counters\",\n        referencedColumnNames: [\"id\"],\n        onDelete: \"CASCADE\",\n        onUpdate: \"CASCADE\",\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    // Since we're going back to unique comparison op and comparison value in this reverse-migration,\n    // clear table contents first so we don't run into any conflicts with triggers with different names but identical comparison op and comparison value\n    await queryRunner.query(\"DELETE FROM counter_triggers\");\n\n    // Drop foreign key for counter_id -- needed to be able to drop the following unique index\n    await queryRunner.dropForeignKey(\"counter_triggers\", \"FK_6bb47849ec95c87e58c5d3e6ae1\");\n\n    // Index for [\"counter_id\", \"name\"]\n    await queryRunner.dropIndex(\"counter_triggers\", \"IDX_2ec128e1d74bedd0288b60cdd1\");\n\n    await queryRunner.createIndex(\n      \"counter_triggers\",\n      new TableIndex({\n        columnNames: [\"counter_id\", \"comparison_op\", \"comparison_value\"],\n        isUnique: true,\n      }),\n    );\n\n    // Recreate foreign key for counter_id\n    await queryRunner.createForeignKey(\n      \"counter_triggers\",\n      new TableForeignKey({\n        columnNames: [\"counter_id\"],\n        referencedTableName: \"counters\",\n        referencedColumnNames: [\"id\"],\n        onDelete: \"CASCADE\",\n        onUpdate: \"CASCADE\",\n      }),\n    );\n\n    await queryRunner.dropColumn(\"counter_triggers\", \"reverse_comparison_value\");\n    await queryRunner.dropColumn(\"counter_triggers\", \"reverse_comparison_op\");\n    await queryRunner.dropColumn(\"counter_triggers\", \"name\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1622939525343-OrderReactionRoles.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class OrderReactionRoles1622939525343 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.addColumn(\n      \"reaction_roles\",\n      new TableColumn({\n        name: \"order\",\n        type: \"int\",\n        isNullable: true,\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumn(\"reaction_roles\", \"order\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1623018101018-CreateButtonRolesTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateButtonRolesTable1623018101018 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"button_roles\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"message_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"button_id\",\n            type: \"varchar\",\n            length: \"100\",\n            isPrimary: true,\n            isUnique: true,\n          },\n          {\n            name: \"button_group\",\n            type: \"varchar\",\n            length: \"100\",\n          },\n          {\n            name: \"button_name\",\n            type: \"varchar\",\n            length: \"100\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"button_roles\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1628809879962-CreateContextMenuTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateContextMenuTable1628809879962 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"context_menus\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"context_id\",\n            type: \"bigint\",\n            isPrimary: true,\n            isUnique: true,\n          },\n          {\n            name: \"action_name\",\n            type: \"varchar\",\n            length: \"100\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"context_menus\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1630837386329-AddExpiresAtToApiPermissions.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class AddExpiresAtToApiPermissions1630837386329 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.addColumns(\"api_permissions\", [\n      new TableColumn({\n        name: \"expires_at\",\n        type: \"datetime\",\n        isNullable: true,\n        default: null,\n      }),\n    ]);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumn(\"api_permissions\", \"expires_at\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1630837718830-CreateApiAuditLogTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table, TableIndex } from \"typeorm\";\n\nexport class CreateApiAuditLogTable1630837718830 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"api_audit_log\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            unsigned: true,\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"author_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"event_type\",\n            type: \"varchar\",\n            length: \"255\",\n          },\n          {\n            name: \"event_data\",\n            type: \"longtext\",\n          },\n          {\n            name: \"created_at\",\n            type: \"datetime\",\n            default: \"(NOW())\",\n          },\n        ],\n        indices: [\n          new TableIndex({\n            columnNames: [\"guild_id\", \"author_id\"],\n          }),\n          new TableIndex({\n            columnNames: [\"guild_id\", \"event_type\"],\n          }),\n          new TableIndex({\n            columnNames: [\"created_at\"],\n          }),\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"api_audit_log\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1630840428694-AddTimestampsToAllowedGuilds.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class AddTimestampsToAllowedGuilds1630840428694 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.addColumns(\"allowed_guilds\", [\n      new TableColumn({\n        name: \"created_at\",\n        type: \"datetime\",\n        default: \"(NOW())\",\n      }),\n      new TableColumn({\n        name: \"updated_at\",\n        type: \"datetime\",\n        default: \"(NOW())\",\n        onUpdate: \"CURRENT_TIMESTAMP\",\n      }),\n    ]);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumn(\"allowed_guilds\", \"updated_at\");\n    await queryRunner.dropColumn(\"allowed_guilds\", \"created_at\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1631474131804-AddIndexToIsBot.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class AddIndexToIsBot1631474131804 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createIndex(\n      \"messages\",\n      new TableIndex({\n        columnNames: [\"is_bot\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropIndex(\n      \"messages\",\n      new TableIndex({\n        columnNames: [\"is_bot\"],\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1632582078622-SplitScheduledPostsPostAtIndex.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class SplitScheduledPostsPostAtIndex1632582078622 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropIndex(\"scheduled_posts\", \"IDX_c383ecfbddd8b625a0912ded3e\");\n    await queryRunner.createIndex(\n      \"scheduled_posts\",\n      new TableIndex({\n        columnNames: [\"guild_id\"],\n      }),\n    );\n    await queryRunner.createIndex(\n      \"scheduled_posts\",\n      new TableIndex({\n        columnNames: [\"post_at\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropIndex(\"scheduled_posts\", \"IDX_e3ce9a618354f29256712abc5c\");\n    await queryRunner.dropIndex(\"scheduled_posts\", \"IDX_b30f532b58ec5caf116389486f\");\n    await queryRunner.createIndex(\n      \"scheduled_posts\",\n      new TableIndex({\n        columnNames: [\"guild_id\", \"post_at\"],\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1632582299400-AddIndexToRemindersRemindAt.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class AddIndexToRemindersRemindAt1632582299400 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createIndex(\n      \"reminders\",\n      new TableIndex({\n        columnNames: [\"remind_at\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropIndex(\"reminders\", \"IDX_6f4e1a9db3410c43c7545ff060\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1634459708599-RemoveTagResponsesForeignKeys.ts",
    "content": "import { MigrationInterface, QueryRunner, TableForeignKey } from \"typeorm\";\n\nexport class RemoveTagResponsesForeignKeys1634459708599 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropForeignKey(\"tag_responses\", \"FK_5f5cf713420286acfa714b98312\");\n    await queryRunner.dropForeignKey(\"tag_responses\", \"FK_a0da4586031d332a6bc298925e3\");\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createForeignKey(\n      \"tag_responses\",\n      new TableForeignKey({\n        name: \"FK_5f5cf713420286acfa714b98312\",\n        columnNames: [\"command_message_id\"],\n        referencedTableName: \"messages\",\n        referencedColumnNames: [\"id\"],\n        onDelete: \"CASCADE\",\n      }),\n    );\n\n    await queryRunner.createForeignKey(\n      \"tag_responses\",\n      new TableForeignKey({\n        name: \"FK_a0da4586031d332a6bc298925e3\",\n        columnNames: [\"response_message_id\"],\n        referencedTableName: \"messages\",\n        referencedColumnNames: [\"id\"],\n        onDelete: \"CASCADE\",\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1634563901575-CreatePhishermanCacheTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreatePhishermanCacheTable1634563901575 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"phisherman_cache\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"domain\",\n            type: \"varchar\",\n            length: \"255\",\n            isUnique: true,\n          },\n          {\n            name: \"data\",\n            type: \"text\",\n          },\n          {\n            name: \"expires_at\",\n            type: \"datetime\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"phisherman_cache\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1635596150234-CreatePhishermanKeyCacheTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreatePhishermanKeyCacheTable1635596150234 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"phisherman_key_cache\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"hash\",\n            type: \"varchar\",\n            length: \"255\",\n            isUnique: true,\n          },\n          {\n            name: \"is_valid\",\n            type: \"tinyint\",\n          },\n          {\n            name: \"expires_at\",\n            type: \"datetime\",\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"phisherman_key_cache\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1635779678653-CreateWebhooksTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateWebhooksTable1635779678653 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"webhooks\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"token\",\n            type: \"text\",\n          },\n          {\n            name: \"created_at\",\n            type: \"datetime\",\n            default: \"(NOW())\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"channel_id\"],\n            isUnique: true,\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"webhooks\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1650709103864-CreateRoleQueueTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateRoleQueueTable1650709103864 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"role_queue\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"role_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"should_add\",\n            type: \"boolean\",\n          },\n          {\n            name: \"priority\",\n            type: \"smallint\",\n            default: 0,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"role_queue\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1650712828384-CreateRoleButtonsTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateRoleButtonsTable1650712828384 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"role_buttons\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"name\",\n            type: \"varchar\",\n            length: \"255\",\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"message_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"hash\",\n            type: \"text\",\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"name\"],\n            isUnique: true,\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"role_buttons\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1650721020704-RemoveButtonRolesTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class RemoveButtonRolesTable1650721020704 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"button_roles\");\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"button_roles\",\n        columns: [\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"channel_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"message_id\",\n            type: \"bigint\",\n            isPrimary: true,\n          },\n          {\n            name: \"button_id\",\n            type: \"varchar\",\n            length: \"100\",\n            isPrimary: true,\n            isUnique: true,\n          },\n          {\n            name: \"button_group\",\n            type: \"varchar\",\n            length: \"100\",\n          },\n          {\n            name: \"button_name\",\n            type: \"varchar\",\n            length: \"100\",\n          },\n        ],\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1680354053183-AddTimeoutColumnsToMutes.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from \"typeorm\";\n\nexport class AddTimeoutColumnsToMutes1680354053183 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.addColumns(\"mutes\", [\n      new TableColumn({\n        name: \"type\",\n        type: \"tinyint\",\n        unsigned: true,\n        default: 1, // The value for \"Role\" mute at the time of this migration\n      }),\n      new TableColumn({\n        name: \"mute_role\",\n        type: \"bigint\",\n        unsigned: true,\n        isNullable: true,\n        default: null,\n      }),\n      new TableColumn({\n        name: \"timeout_expires_at\",\n        type: \"datetime\",\n        isNullable: true,\n        default: null,\n      }),\n    ]);\n    await queryRunner.createIndex(\n      \"mutes\",\n      new TableIndex({\n        columnNames: [\"type\"],\n      }),\n    );\n    await queryRunner.createIndex(\n      \"mutes\",\n      new TableIndex({\n        columnNames: [\"timeout_expires_at\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumn(\"mutes\", \"type\");\n    await queryRunner.dropColumn(\"mutes\", \"mute_role\");\n  }\n}\n"
  },
  {
    "path": "backend/src/migrations/1682788165866-CreateMemberCacheTable.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class CreateMemberCacheTable1682788165866 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(\n      new Table({\n        name: \"member_cache\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"int\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"guild_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"user_id\",\n            type: \"bigint\",\n          },\n          {\n            name: \"username\",\n            type: \"varchar\",\n            length: \"255\",\n          },\n          {\n            name: \"nickname\",\n            type: \"varchar\",\n            length: \"255\",\n            isNullable: true,\n          },\n          {\n            name: \"roles\",\n            type: \"text\",\n          },\n          {\n            name: \"last_seen\",\n            type: \"date\",\n          },\n          {\n            name: \"delete_at\",\n            type: \"datetime\",\n            isNullable: true,\n            default: null,\n          },\n        ],\n        indices: [\n          {\n            columnNames: [\"guild_id\", \"user_id\"],\n            isUnique: true,\n          },\n          {\n            columnNames: [\"last_seen\"],\n          },\n          {\n            columnNames: [\"delete_at\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"member_cache\");\n  }\n}\n"
  },
  {
    "path": "backend/src/paths.ts",
    "content": "import path from \"path\";\nimport pkgUp from \"pkg-up\";\n\nconst backendPackageJson = pkgUp.sync({\n  cwd: import.meta.dirname,\n}) as string;\n\nexport const backendDir = path.dirname(backendPackageJson);\nexport const rootDir = path.resolve(backendDir, \"..\");\n"
  },
  {
    "path": "backend/src/pluginUtils.ts",
    "content": "/**\n * @file Utility functions that are plugin-instance-specific (i.e. use PluginData)\n */\n\nimport {\n  BitField,\n  BitFieldResolvable,\n  ChatInputCommandInteraction,\n  CommandInteraction,\n  GuildMember,\n  InteractionEditReplyOptions,\n  InteractionReplyOptions,\n  InteractionResponse,\n  Message,\n  MessageCreateOptions,\n  MessageEditOptions,\n  MessageFlags,\n  MessageFlagsString,\n  ModalSubmitInteraction,\n  PermissionsBitField,\n  TextBasedChannel,\n} from \"discord.js\";\nimport {\n  AnyPluginData,\n  BasePluginData,\n  CommandContext,\n  ExtendedMatchParams,\n  GuildPluginData,\n  helpers,\n  PluginConfigManager,\n  Vety,\n} from \"vety\";\nimport { z } from \"zod\";\nimport { isStaff } from \"./staff.js\";\nimport { Tail } from \"./utils/typeUtils.js\";\n\nconst { getMemberLevel } = helpers;\n\nexport function canActOn(\n  pluginData: GuildPluginData<any>,\n  member1: GuildMember,\n  member2: GuildMember,\n  allowSameLevel = false,\n  allowAdmins = false,\n) {\n  if (member2.id === pluginData.client.user!.id) {\n    return false;\n  }\n  const isOwnerOrAdmin =\n    member2.id === member2.guild.ownerId || member2.permissions.has(PermissionsBitField.Flags.Administrator);\n  if (isOwnerOrAdmin && !allowAdmins) {\n    return false;\n  }\n\n  const ourLevel = getMemberLevel(pluginData, member1);\n  const memberLevel = getMemberLevel(pluginData, member2);\n  return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel;\n}\n\nexport async function hasPermission(\n  pluginData: AnyPluginData<any>,\n  permission: string,\n  matchParams: ExtendedMatchParams,\n) {\n  const config = await pluginData.config.getMatchingConfig(matchParams);\n  return helpers.hasPermission(config, permission);\n}\n\nexport type GenericCommandSource = Message | CommandInteraction | ModalSubmitInteraction;\n\nexport function isContextInteraction(\n  context: GenericCommandSource,\n): context is CommandInteraction | ModalSubmitInteraction {\n  return context instanceof CommandInteraction || context instanceof ModalSubmitInteraction;\n}\n\nexport function isContextMessage(context: GenericCommandSource): context is Message {\n  return context instanceof Message;\n}\n\nexport async function getContextChannel(context: GenericCommandSource): Promise<TextBasedChannel | null> {\n  if (isContextInteraction(context)) {\n    return context.channel;\n  }\n  if (context instanceof Message) {\n    return context.channel;\n  }\n  throw new Error(\"Unknown context type\");\n}\n\nexport function getContextChannelId(context: GenericCommandSource): string | null {\n  return context.channelId;\n}\n\nexport async function fetchContextChannel(context: GenericCommandSource) {\n  if (!context.guild) {\n    throw new Error(\"Missing context guild\");\n  }\n  const channelId = getContextChannelId(context);\n  if (!channelId) {\n    throw new Error(\"Missing context channel ID\");\n  }\n  return (await context.guild.channels.fetch(channelId))!;\n}\n\nfunction flagsWithEphemeral<TFlags extends string, TType extends number | bigint>(\n  flags: BitFieldResolvable<TFlags, any>,\n  ephemeral: boolean,\n): BitFieldResolvable<TFlags | Extract<MessageFlagsString, \"Ephemeral\">, TType | MessageFlags.Ephemeral> {\n  if (!ephemeral) {\n    return flags;\n  }\n  return new BitField(flags).add(MessageFlags.Ephemeral) as any;\n}\n\nexport type ContextResponseOptions = MessageCreateOptions & InteractionReplyOptions & InteractionEditReplyOptions;\nexport type ContextResponse = Message | InteractionResponse;\n\nexport async function sendContextResponse(\n  context: GenericCommandSource,\n  content: string | ContextResponseOptions,\n  ephemeral = false,\n): Promise<Message> {\n  if (isContextInteraction(context)) {\n    const options = { ...(typeof content === \"string\" ? { content: content } : content), fetchReply: true };\n\n    if (context.replied) {\n      return context.followUp({\n        ...options,\n        flags: flagsWithEphemeral(options.flags, ephemeral),\n      });\n    }\n    if (context.deferred) {\n      return context.editReply(options);\n    }\n\n    const replyResult = await context.reply({\n      ...options,\n      flags: flagsWithEphemeral(options.flags, ephemeral),\n      withResponse: true,\n    });\n    return replyResult.resource!.message!;\n  }\n\n  const contextChannel = await fetchContextChannel(context);\n  if (!contextChannel?.isSendable()) {\n    throw new Error(\"Context channel does not exist or is not sendable\");\n  }\n\n  return contextChannel.send(content);\n}\n\nexport type ContextResponseEditOptions = MessageEditOptions & InteractionEditReplyOptions;\n\nexport function editContextResponse(\n  response: ContextResponse,\n  content: string | ContextResponseEditOptions,\n): Promise<ContextResponse> {\n  return response.edit(content);\n}\n\nexport async function deleteContextResponse(response: ContextResponse): Promise<void> {\n  await response.delete();\n}\n\nexport async function getConfigForContext<TPluginData extends BasePluginData<any>>(\n  config: PluginConfigManager<TPluginData>,\n  context: GenericCommandSource,\n): Promise<z.output<TPluginData[\"_pluginType\"][\"configSchema\"]>> {\n  if (context instanceof ChatInputCommandInteraction) {\n    // TODO: Support for modal interactions (here and Vety)\n    return config.getForInteraction(context);\n  }\n  const channel = await getContextChannel(context);\n  const member = isContextMessage(context) && context.inGuild() ? await resolveMessageMember(context) : null;\n\n  return config.getMatchingConfig({\n    channel,\n    member,\n  });\n}\n\nexport function getBaseUrl(pluginData: AnyPluginData<any>) {\n  const vety = pluginData.getVetyInstance() as Vety;\n  // @ts-expect-error\n  return vety.getGlobalConfig().url;\n}\n\nexport function isOwner(pluginData: AnyPluginData<any>, userId: string) {\n  const vety = pluginData.getVetyInstance() as Vety;\n  // @ts-expect-error\n  const owners = vety.getGlobalConfig()?.owners;\n  if (!owners) {\n    return false;\n  }\n\n  return owners.includes(userId);\n}\n\nexport const isStaffPreFilter = (_, context: CommandContext<any>) => {\n  return isStaff(context.message.author.id);\n};\n\ntype AnyFn = (...args: any[]) => any;\n\n/**\n * Creates a public plugin function out of a function with pluginData as the first parameter\n */\nexport function mapToPublicFn<T extends AnyFn>(inputFn: T) {\n  return (pluginData) => {\n    return (...args: Tail<Parameters<typeof inputFn>>): ReturnType<typeof inputFn> => {\n      return inputFn(pluginData, ...args);\n    };\n  };\n}\n\ntype FnWithPluginData<TPluginData> = (pluginData: TPluginData, ...args: any[]) => any;\n\nexport function makePublicFn<TPluginData extends BasePluginData<any>, T extends FnWithPluginData<TPluginData>>(\n  pluginData: TPluginData,\n  fn: T,\n) {\n  return (...args: Tail<Parameters<T>>): ReturnType<T> => {\n    return fn(pluginData, ...args);\n  };\n}\n\nexport function resolveMessageMember(message: Message<true>) {\n  return Promise.resolve(message.member || message.guild.members.fetch(message.author.id));\n}\n"
  },
  {
    "path": "backend/src/plugins/AutoDelete/AutoDeletePlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { TimeAndDatePlugin } from \"../TimeAndDate/TimeAndDatePlugin.js\";\nimport { AutoDeletePluginType, zAutoDeleteConfig } from \"./types.js\";\nimport { onMessageCreate } from \"./util/onMessageCreate.js\";\nimport { onMessageDelete } from \"./util/onMessageDelete.js\";\nimport { onMessageDeleteBulk } from \"./util/onMessageDeleteBulk.js\";\n\nexport const AutoDeletePlugin = guildPlugin<AutoDeletePluginType>()({\n  name: \"auto_delete\",\n\n  dependencies: () => [TimeAndDatePlugin, LogsPlugin],\n  configSchema: zAutoDeleteConfig,\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.guildSavedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.guildLogs = new GuildLogs(guild.id);\n\n    state.deletionQueue = [];\n    state.nextDeletion = null;\n    state.nextDeletionTimeout = null;\n\n    state.maxDelayWarningSent = false;\n  },\n\n  afterLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg);\n    state.guildSavedMessages.events.on(\"create\", state.onMessageCreateFn);\n\n    state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg);\n    state.guildSavedMessages.events.on(\"delete\", state.onMessageDeleteFn);\n\n    state.onMessageDeleteBulkFn = (msgs) => onMessageDeleteBulk(pluginData, msgs);\n    state.guildSavedMessages.events.on(\"deleteBulk\", state.onMessageDeleteBulkFn);\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.guildSavedMessages.events.off(\"create\", state.onMessageCreateFn);\n    state.guildSavedMessages.events.off(\"delete\", state.onMessageDeleteFn);\n    state.guildSavedMessages.events.off(\"deleteBulk\", state.onMessageDeleteBulkFn);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/AutoDelete/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zAutoDeleteConfig } from \"./types.js\";\n\nexport const autoDeletePluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zAutoDeleteConfig,\n\n  prettyName: \"Auto-delete\",\n  description: \"Allows Zeppelin to auto-delete messages from a channel after a delay\",\n  configurationGuide: \"Maximum deletion delay is currently 5 minutes\",\n};\n"
  },
  {
    "path": "backend/src/plugins/AutoDelete/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { SavedMessage } from \"../../data/entities/SavedMessage.js\";\nimport { MINUTES, zDelayString } from \"../../utils.js\";\nimport Timeout = NodeJS.Timeout;\n\nexport const MAX_DELAY = 5 * MINUTES;\n\nexport interface IDeletionQueueItem {\n  deleteAt: number;\n  message: SavedMessage;\n}\n\nexport const zAutoDeleteConfig = z.strictObject({\n  enabled: z.boolean().default(false),\n  delay: zDelayString.default(\"5s\"),\n});\n\nexport interface AutoDeletePluginType extends BasePluginType {\n  configSchema: typeof zAutoDeleteConfig;\n  state: {\n    guildSavedMessages: GuildSavedMessages;\n    guildLogs: GuildLogs;\n\n    deletionQueue: IDeletionQueueItem[];\n    nextDeletion: number | null;\n    nextDeletionTimeout: Timeout | null;\n\n    maxDelayWarningSent: boolean;\n\n    onMessageCreateFn;\n    onMessageDeleteFn;\n    onMessageDeleteBulkFn;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/AutoDelete/util/addMessageToDeletionQueue.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { sorter } from \"../../../utils.js\";\nimport { AutoDeletePluginType } from \"../types.js\";\nimport { scheduleNextDeletion } from \"./scheduleNextDeletion.js\";\n\nexport function addMessageToDeletionQueue(\n  pluginData: GuildPluginData<AutoDeletePluginType>,\n  msg: SavedMessage,\n  delay: number,\n) {\n  const deleteAt = Date.now() + delay;\n  pluginData.state.deletionQueue.push({ deleteAt, message: msg });\n  pluginData.state.deletionQueue.sort(sorter(\"deleteAt\"));\n\n  scheduleNextDeletion(pluginData);\n}\n"
  },
  {
    "path": "backend/src/plugins/AutoDelete/util/deleteNextItem.ts",
    "content": "import { PermissionsBitField, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { logger } from \"../../../logger.js\";\nimport { resolveUser, verboseChannelMention } from \"../../../utils.js\";\nimport { hasDiscordPermissions } from \"../../../utils/hasDiscordPermissions.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { AutoDeletePluginType } from \"../types.js\";\nimport { scheduleNextDeletion } from \"./scheduleNextDeletion.js\";\n\nexport async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePluginType>) {\n  const [itemToDelete] = pluginData.state.deletionQueue.splice(0, 1);\n  if (!itemToDelete) return;\n\n  scheduleNextDeletion(pluginData);\n\n  const channel = pluginData.guild.channels.cache.get(itemToDelete.message.channel_id as Snowflake);\n  if (!channel || !(\"messages\" in channel)) {\n    // Channel does not exist or does not support messages, ignore\n    return;\n  }\n\n  const logs = pluginData.getPlugin(LogsPlugin);\n  const perms = channel.permissionsFor(pluginData.client.user!.id);\n\n  if (\n    !hasDiscordPermissions(perms, PermissionsBitField.Flags.ViewChannel | PermissionsBitField.Flags.ReadMessageHistory)\n  ) {\n    logs.logBotAlert({\n      body: `Missing permissions to read messages or message history in auto-delete channel ${verboseChannelMention(\n        channel,\n      )}`,\n    });\n    return;\n  }\n\n  if (!hasDiscordPermissions(perms, PermissionsBitField.Flags.ManageMessages)) {\n    logs.logBotAlert({\n      body: `Missing permissions to delete messages in auto-delete channel ${verboseChannelMention(channel)}`,\n    });\n    return;\n  }\n\n  const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n\n  pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id);\n  channel.messages.delete(itemToDelete.message.id as Snowflake).catch((err) => {\n    if (err.code === 10008) {\n      // \"Unknown Message\", probably already deleted by automod or another bot, ignore\n      return;\n    }\n\n    logger.warn(err);\n  });\n\n  const user = await resolveUser(pluginData.client, itemToDelete.message.user_id, \"AutoDelete:deleteNextItem\");\n  const messageDate = timeAndDate\n    .inGuildTz(moment.utc(itemToDelete.message.data.timestamp, \"x\"))\n    .format(timeAndDate.getDateFormat(\"pretty_datetime\"));\n\n  pluginData.getPlugin(LogsPlugin).logMessageDeleteAuto({\n    message: itemToDelete.message,\n    user,\n    channel,\n    messageDate,\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/AutoDelete/util/onMessageCreate.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { convertDelayStringToMS, resolveMember } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { AutoDeletePluginType, MAX_DELAY } from \"../types.js\";\nimport { addMessageToDeletionQueue } from \"./addMessageToDeletionQueue.js\";\n\nexport async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {\n  const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);\n  const config = await pluginData.config.getMatchingConfig({ member, channelId: msg.channel_id });\n  if (config.enabled) {\n    let delay = convertDelayStringToMS(config.delay)!;\n\n    if (delay > MAX_DELAY) {\n      delay = MAX_DELAY;\n      if (!pluginData.state.maxDelayWarningSent) {\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`,\n        });\n        pluginData.state.maxDelayWarningSent = true;\n      }\n    }\n\n    addMessageToDeletionQueue(pluginData, msg, delay);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/AutoDelete/util/onMessageDelete.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { AutoDeletePluginType } from \"../types.js\";\nimport { scheduleNextDeletion } from \"./scheduleNextDeletion.js\";\n\nexport function onMessageDelete(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {\n  const indexToDelete = pluginData.state.deletionQueue.findIndex((item) => item.message.id === msg.id);\n  if (indexToDelete > -1) {\n    pluginData.state.deletionQueue.splice(indexToDelete, 1);\n    scheduleNextDeletion(pluginData);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/AutoDelete/util/onMessageDeleteBulk.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { AutoDeletePluginType } from \"../types.js\";\nimport { onMessageDelete } from \"./onMessageDelete.js\";\n\nexport function onMessageDeleteBulk(pluginData: GuildPluginData<AutoDeletePluginType>, messages: SavedMessage[]) {\n  for (const msg of messages) {\n    onMessageDelete(pluginData, msg);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/AutoDelete/util/scheduleNextDeletion.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { AutoDeletePluginType } from \"../types.js\";\nimport { deleteNextItem } from \"./deleteNextItem.js\";\n\nexport function scheduleNextDeletion(pluginData: GuildPluginData<AutoDeletePluginType>) {\n  if (pluginData.state.deletionQueue.length === 0) {\n    clearTimeout(pluginData.state.nextDeletionTimeout!);\n    return;\n  }\n\n  const firstDeleteAt = pluginData.state.deletionQueue[0].deleteAt;\n  clearTimeout(pluginData.state.nextDeletionTimeout!);\n  pluginData.state.nextDeletionTimeout = setTimeout(() => deleteNextItem(pluginData), firstDeleteAt - Date.now());\n}\n"
  },
  {
    "path": "backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts",
    "content": "import { PluginOverride, guildPlugin } from \"vety\";\nimport { GuildAutoReactions } from \"../../data/GuildAutoReactions.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { DisableAutoReactionsCmd } from \"./commands/DisableAutoReactionsCmd.js\";\nimport { NewAutoReactionsCmd } from \"./commands/NewAutoReactionsCmd.js\";\nimport { AddReactionsEvt } from \"./events/AddReactionsEvt.js\";\nimport { AutoReactionsPluginType, zAutoReactionsConfig } from \"./types.js\";\n\nconst defaultOverrides: Array<PluginOverride<AutoReactionsPluginType>> = [\n  {\n    level: \">=100\",\n    config: {\n      can_manage: true,\n    },\n  },\n];\n\nexport const AutoReactionsPlugin = guildPlugin<AutoReactionsPluginType>()({\n  name: \"auto_reactions\",\n\n  // prettier-ignore\n  dependencies: () => [\n    LogsPlugin,\n  ],\n\n  configSchema: zAutoReactionsConfig,\n  defaultOverrides,\n\n  // prettier-ignore\n  messageCommands: [\n    NewAutoReactionsCmd,\n    DisableAutoReactionsCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    AddReactionsEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.autoReactions = GuildAutoReactions.getGuildInstance(guild.id);\n    state.cache = new Map();\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { autoReactionsCmd } from \"../types.js\";\n\nexport const DisableAutoReactionsCmd = autoReactionsCmd({\n  trigger: \"auto_reactions disable\",\n  permission: \"can_manage\",\n  usage: \"!auto_reactions disable 629990160477585428\",\n\n  signature: {\n    channelId: ct.channelId(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const autoReaction = await pluginData.state.autoReactions.getForChannel(args.channelId);\n    if (!autoReaction) {\n      void pluginData.state.common.sendErrorMessage(msg, `Auto-reactions aren't enabled in <#${args.channelId}>`);\n      return;\n    }\n\n    await pluginData.state.autoReactions.removeFromChannel(args.channelId);\n    pluginData.state.cache.delete(args.channelId);\n    void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions disabled in <#${args.channelId}>`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts",
    "content": "import { PermissionsBitField } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { canUseEmoji, customEmojiRegex, isEmoji } from \"../../../utils.js\";\nimport { getMissingChannelPermissions } from \"../../../utils/getMissingChannelPermissions.js\";\nimport { missingPermissionError } from \"../../../utils/missingPermissionError.js\";\nimport { readChannelPermissions } from \"../../../utils/readChannelPermissions.js\";\nimport { autoReactionsCmd } from \"../types.js\";\n\nconst requiredPermissions = readChannelPermissions | PermissionsBitField.Flags.AddReactions;\n\nexport const NewAutoReactionsCmd = autoReactionsCmd({\n  trigger: \"auto_reactions\",\n  permission: \"can_manage\",\n  usage: \"!auto_reactions 629990160477585428 👍 👎\",\n\n  signature: {\n    channel: ct.guildTextBasedChannel(),\n    reactions: ct.string({ rest: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const finalReactions: string[] = [];\n\n    const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;\n    const missingPermissions = getMissingChannelPermissions(me, args.channel, requiredPermissions);\n    if (missingPermissions) {\n      pluginData.state.common.sendErrorMessage(\n        msg,\n        `Cannot set auto-reactions for that channel. ${missingPermissionError(missingPermissions)}`,\n      );\n      return;\n    }\n\n    for (const reaction of args.reactions) {\n      if (!isEmoji(reaction)) {\n        void pluginData.state.common.sendErrorMessage(msg, \"One or more of the specified reactions were invalid!\");\n        return;\n      }\n\n      let savedValue;\n\n      const customEmojiMatch = reaction.match(customEmojiRegex);\n      if (customEmojiMatch) {\n        // Custom emoji\n        if (!canUseEmoji(pluginData.client, customEmojiMatch[2])) {\n          pluginData.state.common.sendErrorMessage(\n            msg,\n            \"I can only use regular emojis and custom emojis from this server\",\n          );\n          return;\n        }\n\n        savedValue = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`;\n      } else {\n        // Unicode emoji\n        savedValue = reaction;\n      }\n\n      finalReactions.push(savedValue);\n    }\n\n    await pluginData.state.autoReactions.set(args.channel.id, finalReactions);\n    pluginData.state.cache.delete(args.channel.id);\n    void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions set for <#${args.channel.id}>`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/AutoReactions/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zAutoReactionsConfig } from \"./types.js\";\n\nexport const autoReactionsPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zAutoReactionsConfig,\n\n  prettyName: \"Auto-reactions\",\n  description: trimPluginDescription(`\n    Allows setting up automatic reactions to all new messages on a channel\n  `),\n};\n"
  },
  {
    "path": "backend/src/plugins/AutoReactions/events/AddReactionsEvt.ts",
    "content": "import { GuildTextBasedChannel, PermissionsBitField } from \"discord.js\";\nimport { AutoReaction } from \"../../../data/entities/AutoReaction.js\";\nimport { isDiscordAPIError, isDiscordJsTypeError } from \"../../../utils.js\";\nimport { getMissingChannelPermissions } from \"../../../utils/getMissingChannelPermissions.js\";\nimport { missingPermissionError } from \"../../../utils/missingPermissionError.js\";\nimport { readChannelPermissions } from \"../../../utils/readChannelPermissions.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { autoReactionsEvt } from \"../types.js\";\n\nconst p = PermissionsBitField.Flags;\n\nexport const AddReactionsEvt = autoReactionsEvt({\n  event: \"messageCreate\",\n  allowBots: true,\n  allowSelf: true,\n\n  async listener({ pluginData, args: { message } }) {\n    const channel = (await message.guild?.channels.fetch(message.channelId)) as\n      | GuildTextBasedChannel\n      | null\n      | undefined;\n    if (!channel) {\n      return;\n    }\n\n    let autoReaction: AutoReaction | null = null;\n    const lock = await pluginData.locks.acquire(`auto-reactions-${channel.id}`);\n    if (pluginData.state.cache.has(channel.id)) {\n      autoReaction = pluginData.state.cache.get(channel.id) ?? null;\n    } else {\n      autoReaction = (await pluginData.state.autoReactions.getForChannel(channel.id)) ?? null;\n      pluginData.state.cache.set(channel.id, autoReaction);\n    }\n    lock.unlock();\n\n    if (!autoReaction) {\n      return;\n    }\n\n    const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;\n    if (me) {\n      const missingPermissions = getMissingChannelPermissions(me, channel, readChannelPermissions | p.AddReactions);\n      if (missingPermissions) {\n        const logs = pluginData.getPlugin(LogsPlugin);\n        logs.logBotAlert({\n          body: `Cannot apply auto-reactions in <#${channel.id}>. ${missingPermissionError(missingPermissions)}`,\n        });\n        return;\n      }\n    }\n\n    for (const reaction of autoReaction.reactions) {\n      try {\n        await message.react(reaction);\n      } catch (e) {\n        if (isDiscordJsTypeError(e)) {\n          const logs = pluginData.getPlugin(LogsPlugin);\n          logs.logBotAlert({\n            body: `Could not apply auto-reactions in <#${channel.id}> for message \\`${message.id}\\`: ${e.message}.`,\n          });\n        } else if (isDiscordAPIError(e)) {\n          const logs = pluginData.getPlugin(LogsPlugin);\n          if (e.code === 10008) {\n            logs.logBotAlert({\n              body: `Could not apply auto-reactions in <#${channel.id}> for message \\`${message.id}\\`. Make sure nothing is deleting the message before the reactions are applied.`,\n            });\n          } else {\n            logs.logBotAlert({\n              body: `Could not apply auto-reactions in <#${channel.id}> for message \\`${message.id}\\`. Error code ${e.code}.`,\n            });\n          }\n\n          break;\n        } else {\n          throw e;\n        }\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/AutoReactions/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildAutoReactions } from \"../../data/GuildAutoReactions.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { AutoReaction } from \"../../data/entities/AutoReaction.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zAutoReactionsConfig = z.strictObject({\n  can_manage: z.boolean().default(false),\n});\n\nexport interface AutoReactionsPluginType extends BasePluginType {\n  configSchema: typeof zAutoReactionsConfig;\n  state: {\n    logs: GuildLogs;\n    savedMessages: GuildSavedMessages;\n    autoReactions: GuildAutoReactions;\n    cache: Map<string, AutoReaction | null>;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const autoReactionsCmd = guildPluginMessageCommand<AutoReactionsPluginType>();\nexport const autoReactionsEvt = guildPluginEventListener<AutoReactionsPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Automod/AutomodPlugin.ts",
    "content": "import { CooldownManager, guildPlugin } from \"vety\";\nimport { Queue } from \"../../Queue.js\";\nimport { GuildAntiraidLevels } from \"../../data/GuildAntiraidLevels.js\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { discardRegExpRunner, getRegExpRunner } from \"../../regExpRunners.js\";\nimport { MINUTES, SECONDS } from \"../../utils.js\";\nimport { registerEventListenersFromMap } from \"../../utils/registerEventListenersFromMap.js\";\nimport { unregisterEventListenersFromMap } from \"../../utils/unregisterEventListenersFromMap.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { CountersPlugin } from \"../Counters/CountersPlugin.js\";\nimport { InternalPosterPlugin } from \"../InternalPoster/InternalPosterPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { ModActionsPlugin } from \"../ModActions/ModActionsPlugin.js\";\nimport { MutesPlugin } from \"../Mutes/MutesPlugin.js\";\nimport { PhishermanPlugin } from \"../Phisherman/PhishermanPlugin.js\";\nimport { RoleManagerPlugin } from \"../RoleManager/RoleManagerPlugin.js\";\nimport { AntiraidClearCmd } from \"./commands/AntiraidClearCmd.js\";\nimport { SetAntiraidCmd } from \"./commands/SetAntiraidCmd.js\";\nimport { ViewAntiraidCmd } from \"./commands/ViewAntiraidCmd.js\";\nimport { RunAutomodOnJoinEvt, RunAutomodOnLeaveEvt } from \"./events/RunAutomodOnJoinLeaveEvt.js\";\nimport { RunAutomodOnMemberUpdate } from \"./events/RunAutomodOnMemberUpdate.js\";\nimport { runAutomodOnCounterTrigger } from \"./events/runAutomodOnCounterTrigger.js\";\nimport { runAutomodOnMessage } from \"./events/runAutomodOnMessage.js\";\nimport { runAutomodOnModAction } from \"./events/runAutomodOnModAction.js\";\nimport {\n  RunAutomodOnThreadCreate,\n  RunAutomodOnThreadDelete,\n  RunAutomodOnThreadUpdate,\n} from \"./events/runAutomodOnThreadEvents.js\";\nimport { clearOldRecentNicknameChanges } from \"./functions/clearOldNicknameChanges.js\";\nimport { clearOldRecentActions } from \"./functions/clearOldRecentActions.js\";\nimport { clearOldRecentSpam } from \"./functions/clearOldRecentSpam.js\";\nimport { AutomodPluginType, zAutomodConfig } from \"./types.js\";\nimport { DebugAutomodCmd } from \"./commands/DebugAutomodCmd.js\";\n\nexport const AutomodPlugin = guildPlugin<AutomodPluginType>()({\n  name: \"automod\",\n\n  // prettier-ignore\n  dependencies: () => [\n    LogsPlugin,\n    ModActionsPlugin,\n    MutesPlugin,\n    CountersPlugin,\n    PhishermanPlugin,\n    InternalPosterPlugin,\n    RoleManagerPlugin,\n  ],\n\n  configSchema: zAutomodConfig,\n\n  customOverrideCriteriaFunctions: {\n    antiraid_level: (pluginData, matchParams, value) => {\n      return value ? value === pluginData.state.cachedAntiraidLevel : false;\n    },\n  },\n\n  // prettier-ignore\n  events: [\n    RunAutomodOnJoinEvt,\n    RunAutomodOnMemberUpdate,\n    RunAutomodOnLeaveEvt,\n    RunAutomodOnThreadCreate,\n    RunAutomodOnThreadDelete,\n    RunAutomodOnThreadUpdate\n    // Messages use message events from SavedMessages, see onLoad below\n  ],\n\n  messageCommands: [AntiraidClearCmd, SetAntiraidCmd, ViewAntiraidCmd, DebugAutomodCmd],\n\n  async beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.queue = new Queue();\n\n    state.regexRunner = getRegExpRunner(`guild-${guild.id}`);\n\n    state.recentActions = [];\n\n    state.recentSpam = [];\n\n    state.recentNicknameChanges = new Map();\n\n    state.ignoredRoleChanges = new Set();\n\n    state.cooldownManager = new CooldownManager();\n\n    state.logs = new GuildLogs(guild.id);\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.antiraidLevels = GuildAntiraidLevels.getGuildInstance(guild.id);\n    state.archives = GuildArchives.getGuildInstance(guild.id);\n\n    state.cachedAntiraidLevel = await state.antiraidLevels.get();\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  async afterLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.clearRecentActionsInterval = setInterval(() => clearOldRecentActions(pluginData), 1 * MINUTES);\n    state.clearRecentSpamInterval = setInterval(() => clearOldRecentSpam(pluginData), 1 * SECONDS);\n    state.clearRecentNicknameChangesInterval = setInterval(\n      () => clearOldRecentNicknameChanges(pluginData),\n      30 * SECONDS,\n    );\n\n    state.onMessageCreateFn = (message) => runAutomodOnMessage(pluginData, message, false);\n    state.savedMessages.events.on(\"create\", state.onMessageCreateFn);\n\n    state.onMessageUpdateFn = (message) => runAutomodOnMessage(pluginData, message, true);\n    state.savedMessages.events.on(\"update\", state.onMessageUpdateFn);\n    const countersPlugin = pluginData.getPlugin(CountersPlugin);\n\n    state.onCounterTrigger = (name, triggerName, channelId, userId) => {\n      runAutomodOnCounterTrigger(pluginData, name, triggerName, channelId, userId, false);\n    };\n\n    state.onCounterReverseTrigger = (name, triggerName, channelId, userId) => {\n      runAutomodOnCounterTrigger(pluginData, name, triggerName, channelId, userId, true);\n    };\n    countersPlugin.onCounterEvent(\"trigger\", state.onCounterTrigger);\n    countersPlugin.onCounterEvent(\"reverseTrigger\", state.onCounterReverseTrigger);\n\n    const modActionsEvents = pluginData.getPlugin(ModActionsPlugin).getEventEmitter();\n    state.modActionsListeners = new Map();\n    state.modActionsListeners.set(\"note\", (userId: string) => runAutomodOnModAction(pluginData, \"note\", userId));\n    state.modActionsListeners.set(\"warn\", (userId: string, reason: string | undefined, isAutomodAction: boolean) =>\n      runAutomodOnModAction(pluginData, \"warn\", userId, reason, isAutomodAction),\n    );\n    state.modActionsListeners.set(\"kick\", (userId: string, reason: string | undefined, isAutomodAction: boolean) =>\n      runAutomodOnModAction(pluginData, \"kick\", userId, reason, isAutomodAction),\n    );\n    state.modActionsListeners.set(\"ban\", (userId: string, reason: string | undefined, isAutomodAction: boolean) =>\n      runAutomodOnModAction(pluginData, \"ban\", userId, reason, isAutomodAction),\n    );\n    state.modActionsListeners.set(\"unban\", (userId: string) => runAutomodOnModAction(pluginData, \"unban\", userId));\n    registerEventListenersFromMap(modActionsEvents, state.modActionsListeners);\n\n    const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter();\n    state.mutesListeners = new Map();\n    state.mutesListeners.set(\"mute\", (userId: string, reason: string | undefined, isAutomodAction: boolean) =>\n      runAutomodOnModAction(pluginData, \"mute\", userId, reason, isAutomodAction),\n    );\n    state.mutesListeners.set(\"unmute\", (userId: string) => runAutomodOnModAction(pluginData, \"unmute\", userId));\n    registerEventListenersFromMap(mutesEvents, state.mutesListeners);\n  },\n\n  async beforeUnload(pluginData) {\n    const { state, guild } = pluginData;\n\n    const countersPlugin = pluginData.getPlugin(CountersPlugin);\n    if (state.onCounterTrigger) {\n      countersPlugin.offCounterEvent(\"trigger\", state.onCounterTrigger);\n    }\n    if (state.onCounterReverseTrigger) {\n      countersPlugin.offCounterEvent(\"reverseTrigger\", state.onCounterReverseTrigger);\n    }\n\n    const modActionsEvents = pluginData.getPlugin(ModActionsPlugin).getEventEmitter();\n    if (state.modActionsListeners) {\n      unregisterEventListenersFromMap(modActionsEvents, state.modActionsListeners);\n    }\n\n    const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter();\n    if (state.mutesListeners) {\n      unregisterEventListenersFromMap(mutesEvents, state.mutesListeners);\n    }\n\n    state.queue.clear();\n\n    discardRegExpRunner(`guild-${guild.id}`);\n\n    if (state.clearRecentActionsInterval) {\n      clearInterval(state.clearRecentActionsInterval);\n    }\n\n    if (state.clearRecentSpamInterval) {\n      clearInterval(state.clearRecentSpamInterval);\n    }\n\n    if (state.clearRecentNicknameChangesInterval) {\n      clearInterval(state.clearRecentNicknameChangesInterval);\n    }\n\n    if (state.onMessageCreateFn) {\n      state.savedMessages.events.off(\"create\", state.onMessageCreateFn);\n    }\n    if (state.onMessageUpdateFn) {\n      state.savedMessages.events.off(\"update\", state.onMessageUpdateFn);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/addRoles.ts",
    "content": "import { PermissionFlagsBits, Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { nonNullish, unique, zSnowflake } from \"../../../utils.js\";\nimport { canAssignRole } from \"../../../utils/canAssignRole.js\";\nimport { getMissingPermissions } from \"../../../utils/getMissingPermissions.js\";\nimport { missingPermissionError } from \"../../../utils/missingPermissionError.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { ignoreRoleChange } from \"../functions/ignoredRoleChanges.js\";\nimport { automodAction } from \"../helpers.js\";\n\nconst p = PermissionFlagsBits;\n\nconst configSchema = z.array(zSnowflake);\n\nexport const AddRolesAction = automodAction({\n  configSchema,\n\n  async apply({ pluginData, contexts, actionConfig, ruleName }) {\n    const members = unique(contexts.map((c) => c.member).filter(nonNullish));\n    const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;\n\n    const missingPermissions = getMissingPermissions(me.permissions, p.ManageRoles);\n    if (missingPermissions) {\n      const logs = pluginData.getPlugin(LogsPlugin);\n      logs.logBotAlert({\n        body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,\n      });\n      return;\n    }\n\n    const rolesToAssign: string[] = [];\n    const rolesWeCannotAssign: string[] = [];\n    for (const roleId of actionConfig) {\n      if (canAssignRole(pluginData.guild, me, roleId)) {\n        rolesToAssign.push(roleId);\n      } else {\n        rolesWeCannotAssign.push(roleId);\n      }\n    }\n\n    if (rolesWeCannotAssign.length) {\n      const roleNamesWeCannotAssign = rolesWeCannotAssign.map(\n        (roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,\n      );\n      const logs = pluginData.getPlugin(LogsPlugin);\n      logs.logBotAlert({\n        body: `Unable to assign the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotAssign.join(\n          \"**, **\",\n        )}**`,\n      });\n    }\n\n    await Promise.all(\n      members.map(async (member) => {\n        const currentMemberRoles = new Set(member.roles.cache.keys());\n        for (const roleId of rolesToAssign) {\n          if (!currentMemberRoles.has(roleId)) {\n            pluginData.getPlugin(RoleManagerPlugin).addRole(member.id, roleId);\n            // TODO: Remove this and just ignore bot changes in general?\n            ignoreRoleChange(pluginData, member.id, roleId);\n          }\n        }\n      }),\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/addToCounter.ts",
    "content": "import { z } from \"zod\";\nimport { zBoundedCharacters } from \"../../../utils.js\";\nimport { CountersPlugin } from \"../../Counters/CountersPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { automodAction } from \"../helpers.js\";\n\nconst configSchema = z.object({\n  counter: zBoundedCharacters(0, 100),\n  amount: z.number(),\n});\n\nexport const AddToCounterAction = automodAction({\n  configSchema,\n\n  async apply({ pluginData, contexts, actionConfig, ruleName }) {\n    const countersPlugin = pluginData.getPlugin(CountersPlugin);\n    if (!countersPlugin.counterExists(actionConfig.counter)) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Unknown counter \\`${actionConfig.counter}\\` in \\`add_to_counter\\` action of Automod rule \\`${ruleName}\\``,\n      });\n      return;\n    }\n\n    countersPlugin.changeCounterValue(\n      actionConfig.counter,\n      contexts[0].message?.channel_id || null,\n      contexts[0].user?.id || null,\n      actionConfig.amount,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/alert.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport {\n  createTypedTemplateSafeValueContainer,\n  renderTemplate,\n  TemplateParseError,\n  TemplateSafeValueContainer,\n} from \"../../../templateFormatter.js\";\nimport {\n  chunkMessageLines,\n  isTruthy,\n  messageLink,\n  validateAndParseMessageContent,\n  verboseChannelMention,\n  zAllowedMentions,\n  zBoundedCharacters,\n  zNullishToUndefined,\n  zSnowflake,\n} from \"../../../utils.js\";\nimport { erisAllowedMentionsToDjsMentionOptions } from \"../../../utils/erisAllowedMentionsToDjsMentionOptions.js\";\nimport { messageIsEmpty } from \"../../../utils/messageIsEmpty.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { InternalPosterPlugin } from \"../../InternalPoster/InternalPosterPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { automodAction } from \"../helpers.js\";\n\nconst configSchema = z.object({\n  channel: zSnowflake,\n  text: zBoundedCharacters(0, 4000),\n  allowed_mentions: zNullishToUndefined(zAllowedMentions.nullable().default(null)),\n});\n\nexport const AlertAction = automodAction({\n  configSchema,\n\n  async apply({ pluginData, contexts, actionConfig, ruleName, matchResult, prettyName }) {\n    const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake);\n    const logs = pluginData.getPlugin(LogsPlugin);\n\n    if (channel?.isTextBased()) {\n      const text = actionConfig.text;\n      const theMessageLink =\n        contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id);\n\n      const safeUsers = contexts.map((c) => (c.user ? userToTemplateSafeUser(c.user) : null)).filter(isTruthy);\n      const safeUser = safeUsers[0];\n      const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(\", \");\n\n      const logMessage = await logs.getLogMessage(\n        LogType.AUTOMOD_ACTION,\n        createTypedTemplateSafeValueContainer({\n          rule: ruleName,\n          user: safeUser,\n          users: safeUsers,\n          actionsTaken,\n          matchSummary: matchResult.summary ?? \"\",\n          prettyName,\n        }),\n      );\n\n      let rendered;\n      try {\n        rendered = await renderTemplate(\n          actionConfig.text,\n          new TemplateSafeValueContainer({\n            rule: ruleName,\n            user: safeUser,\n            users: safeUsers,\n            text,\n            actionsTaken,\n            matchSummary: matchResult.summary,\n            prettyName,\n            messageLink: theMessageLink,\n            logMessage: validateAndParseMessageContent(logMessage)?.content,\n          }),\n        );\n      } catch (err) {\n        if (err instanceof TemplateParseError) {\n          pluginData.getPlugin(LogsPlugin).logBotAlert({\n            body: `Error in alert format of automod rule ${ruleName}: ${err.message}`,\n          });\n          return;\n        }\n\n        throw err;\n      }\n\n      if (messageIsEmpty(rendered)) {\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Tried to send alert with an empty message for automod rule ${ruleName}`,\n        });\n        return;\n      }\n\n      try {\n        const poster = pluginData.getPlugin(InternalPosterPlugin);\n        const chunks = chunkMessageLines(rendered);\n        for (const chunk of chunks) {\n          await poster.sendMessage(channel, {\n            content: chunk,\n            allowedMentions: erisAllowedMentionsToDjsMentionOptions(actionConfig.allowed_mentions),\n          });\n        }\n      } catch (err) {\n        if (err.code === 50001) {\n          logs.logBotAlert({\n            body: `Missing access to send alert to channel ${verboseChannelMention(\n              channel,\n            )} in automod rule **${ruleName}**`,\n          });\n        } else {\n          logs.logBotAlert({\n            body: `Error ${err.code || \"UNKNOWN\"} when sending alert to channel ${verboseChannelMention(\n              channel,\n            )} in automod rule **${ruleName}**`,\n          });\n        }\n      }\n    } else {\n      logs.logBotAlert({\n        body: `Invalid channel id \\`${actionConfig.channel}\\` for alert action in automod rule **${ruleName}**`,\n      });\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/archiveThread.ts",
    "content": "import { AnyThreadChannel } from \"discord.js\";\nimport { z } from \"zod\";\nimport { noop } from \"../../../utils.js\";\nimport { automodAction } from \"../helpers.js\";\n\nconst configSchema = z.strictObject({});\n\nexport const ArchiveThreadAction = automodAction({\n  configSchema,\n\n  async apply({ pluginData, contexts }) {\n    const threads = contexts\n      .filter((c) => c.message?.channel_id)\n      .map((c) => pluginData.guild.channels.cache.get(c.message!.channel_id))\n      .filter((c): c is AnyThreadChannel => c?.isThread() ?? false);\n\n    for (const thread of threads) {\n      await thread.setArchived().catch(noop);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/availableActions.ts",
    "content": "import { AutomodActionBlueprint } from \"../helpers.js\";\nimport { AddRolesAction } from \"./addRoles.js\";\nimport { AddToCounterAction } from \"./addToCounter.js\";\nimport { AlertAction } from \"./alert.js\";\nimport { ArchiveThreadAction } from \"./archiveThread.js\";\nimport { BanAction } from \"./ban.js\";\nimport { ChangeNicknameAction } from \"./changeNickname.js\";\nimport { ChangePermsAction } from \"./changePerms.js\";\nimport { CleanAction } from \"./clean.js\";\nimport { KickAction } from \"./kick.js\";\nimport { LogAction } from \"./log.js\";\nimport { MuteAction } from \"./mute.js\";\nimport { PauseInvitesAction } from \"./pauseInvites.js\";\nimport { RemoveRolesAction } from \"./removeRoles.js\";\nimport { ReplyAction } from \"./reply.js\";\nimport { SetAntiraidLevelAction } from \"./setAntiraidLevel.js\";\nimport { SetCounterAction } from \"./setCounter.js\";\nimport { SetSlowmodeAction } from \"./setSlowmode.js\";\nimport { StartThreadAction } from \"./startThread.js\";\nimport { WarnAction } from \"./warn.js\";\n\nexport const availableActions = {\n  clean: CleanAction,\n  warn: WarnAction,\n  mute: MuteAction,\n  kick: KickAction,\n  ban: BanAction,\n  alert: AlertAction,\n  change_nickname: ChangeNicknameAction,\n  log: LogAction,\n  add_roles: AddRolesAction,\n  remove_roles: RemoveRolesAction,\n  set_antiraid_level: SetAntiraidLevelAction,\n  reply: ReplyAction,\n  add_to_counter: AddToCounterAction,\n  set_counter: SetCounterAction,\n  set_slowmode: SetSlowmodeAction,\n  start_thread: StartThreadAction,\n  archive_thread: ArchiveThreadAction,\n  change_perms: ChangePermsAction,\n  pause_invites: PauseInvitesAction,\n} satisfies Record<string, AutomodActionBlueprint<any>>;\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/ban.ts",
    "content": "import { z } from \"zod\";\nimport {\n  convertDelayStringToMS,\n  nonNullish,\n  unique,\n  zBoundedCharacters,\n  zDelayString,\n  zSnowflake,\n} from \"../../../utils.js\";\nimport { CaseArgs } from \"../../Cases/types.js\";\nimport { ModActionsPlugin } from \"../../ModActions/ModActionsPlugin.js\";\nimport { zNotify } from \"../constants.js\";\nimport { resolveActionContactMethods } from \"../functions/resolveActionContactMethods.js\";\nimport { automodAction } from \"../helpers.js\";\n\nconst configSchema = z.strictObject({\n  reason: zBoundedCharacters(0, 4000).nullable().default(null),\n  duration: zDelayString.nullable().default(null),\n  notify: zNotify.nullable().default(null),\n  notifyChannel: zSnowflake.nullable().default(null),\n  deleteMessageDays: z.number().nullable().default(null),\n  postInCaseLog: z.boolean().nullable().default(null),\n  hide_case: z.boolean().nullable().default(false),\n});\n\nexport const BanAction = automodAction({\n  configSchema,\n\n  async apply({ pluginData, contexts, actionConfig, matchResult }) {\n    const reason = actionConfig.reason || \"Kicked automatically\";\n    const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined;\n    const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;\n    const deleteMessageDays = actionConfig.deleteMessageDays ?? undefined;\n\n    const caseArgs: Partial<CaseArgs> = {\n      modId: pluginData.client.user!.id,\n      extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [],\n      automatic: true,\n      postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined,\n      hide: Boolean(actionConfig.hide_case),\n    };\n\n    const userIdsToBan = unique(contexts.map((c) => c.user?.id).filter(nonNullish));\n\n    const modActions = pluginData.getPlugin(ModActionsPlugin);\n    for (const userId of userIdsToBan) {\n      await modActions.banUserId(\n        userId,\n        reason,\n        reason,\n        {\n          contactMethods,\n          caseArgs,\n          deleteMessageDays,\n          isAutomodAction: true,\n        },\n        duration,\n      );\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/changeNickname.ts",
    "content": "import { z } from \"zod\";\nimport { nonNullish, unique, zBoundedCharacters } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const ChangeNicknameAction = automodAction({\n  configSchema: z.union([\n    zBoundedCharacters(0, 32),\n    z.strictObject({\n      name: zBoundedCharacters(0, 32),\n    }),\n  ]),\n\n  async apply({ pluginData, contexts, actionConfig }) {\n    const members = unique(contexts.map((c) => c.member).filter(nonNullish));\n\n    for (const member of members) {\n      if (pluginData.state.recentNicknameChanges.has(member.id)) continue;\n      const newName = typeof actionConfig === \"string\" ? actionConfig : actionConfig.name;\n\n      member.edit({ nick: newName }).catch(() => {\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Failed to change the nickname of \\`${member.id}\\``,\n        });\n      });\n\n      pluginData.state.recentNicknameChanges.set(member.id, { timestamp: Date.now() });\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/changePerms.ts",
    "content": "import { PermissionsBitField, PermissionsString } from \"discord.js\";\nimport { U } from \"ts-toolbelt\";\nimport { z } from \"zod\";\nimport { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport { isValidSnowflake, keys, noop, zBoundedCharacters } from \"../../../utils.js\";\nimport {\n  guildToTemplateSafeGuild,\n  savedMessageToTemplateSafeSavedMessage,\n  userToTemplateSafeUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { automodAction } from \"../helpers.js\";\n\ntype LegacyPermMap = Record<string, keyof (typeof PermissionsBitField)[\"Flags\"]>;\nconst legacyPermMap = {\n  CREATE_INSTANT_INVITE: \"CreateInstantInvite\",\n  KICK_MEMBERS: \"KickMembers\",\n  BAN_MEMBERS: \"BanMembers\",\n  ADMINISTRATOR: \"Administrator\",\n  MANAGE_CHANNELS: \"ManageChannels\",\n  MANAGE_GUILD: \"ManageGuild\",\n  ADD_REACTIONS: \"AddReactions\",\n  VIEW_AUDIT_LOG: \"ViewAuditLog\",\n  PRIORITY_SPEAKER: \"PrioritySpeaker\",\n  STREAM: \"Stream\",\n  VIEW_CHANNEL: \"ViewChannel\",\n  SEND_MESSAGES: \"SendMessages\",\n  SEND_TTSMESSAGES: \"SendTTSMessages\",\n  MANAGE_MESSAGES: \"ManageMessages\",\n  EMBED_LINKS: \"EmbedLinks\",\n  ATTACH_FILES: \"AttachFiles\",\n  READ_MESSAGE_HISTORY: \"ReadMessageHistory\",\n  MENTION_EVERYONE: \"MentionEveryone\",\n  USE_EXTERNAL_EMOJIS: \"UseExternalEmojis\",\n  VIEW_GUILD_INSIGHTS: \"ViewGuildInsights\",\n  CONNECT: \"Connect\",\n  SPEAK: \"Speak\",\n  MUTE_MEMBERS: \"MuteMembers\",\n  DEAFEN_MEMBERS: \"DeafenMembers\",\n  MOVE_MEMBERS: \"MoveMembers\",\n  USE_VAD: \"UseVAD\",\n  CHANGE_NICKNAME: \"ChangeNickname\",\n  MANAGE_NICKNAMES: \"ManageNicknames\",\n  MANAGE_ROLES: \"ManageRoles\",\n  MANAGE_WEBHOOKS: \"ManageWebhooks\",\n  MANAGE_EMOJIS_AND_STICKERS: \"ManageEmojisAndStickers\",\n  USE_APPLICATION_COMMANDS: \"UseApplicationCommands\",\n  REQUEST_TO_SPEAK: \"RequestToSpeak\",\n  MANAGE_EVENTS: \"ManageEvents\",\n  MANAGE_THREADS: \"ManageThreads\",\n  CREATE_PUBLIC_THREADS: \"CreatePublicThreads\",\n  CREATE_PRIVATE_THREADS: \"CreatePrivateThreads\",\n  USE_EXTERNAL_STICKERS: \"UseExternalStickers\",\n  SEND_MESSAGES_IN_THREADS: \"SendMessagesInThreads\",\n  USE_EMBEDDED_ACTIVITIES: \"UseEmbeddedActivities\",\n  MODERATE_MEMBERS: \"ModerateMembers\",\n} satisfies LegacyPermMap;\n\nconst realToLegacyMap = Object.entries(legacyPermMap).reduce((map, pair) => {\n  map[pair[1]] = pair[0];\n  return map;\n}, {}) as Record<keyof typeof PermissionsBitField.Flags, keyof typeof legacyPermMap>;\n\nconst permissionNames = keys(PermissionsBitField.Flags) as U.ListOf<keyof typeof PermissionsBitField.Flags>;\nconst legacyPermissionNames = keys(legacyPermMap) as U.ListOf<keyof typeof legacyPermMap>;\nconst allPermissionNames = [...permissionNames, ...legacyPermissionNames] as const;\n\nconst permissionTypeMap = allPermissionNames.reduce(\n  (map, permName) => {\n    map[permName] = z.boolean().nullable();\n    return map;\n  },\n  {} as Record<(typeof allPermissionNames)[number], z.ZodNullable<z.ZodBoolean>>,\n);\nconst zPermissionsMap = z.strictObject(permissionTypeMap);\n\nexport const ChangePermsAction = automodAction({\n  configSchema: z.strictObject({\n    target: zBoundedCharacters(1, 2000),\n    channel: zBoundedCharacters(1, 2000).nullable().default(null),\n    perms: zPermissionsMap.partial(),\n  }),\n\n  async apply({ pluginData, contexts, actionConfig, ruleName }) {\n    const user = contexts.find((c) => c.user)?.user;\n    const message = contexts.find((c) => c.message)?.message;\n\n    let target: string;\n    try {\n      target = await renderTemplate(\n        actionConfig.target,\n        new TemplateSafeValueContainer({\n          user: user ? userToTemplateSafeUser(user) : null,\n          guild: guildToTemplateSafeGuild(pluginData.guild),\n          message: message ? savedMessageToTemplateSafeSavedMessage(message) : null,\n        }),\n      );\n    } catch (err) {\n      if (err instanceof TemplateParseError) {\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Error in target format of automod rule ${ruleName}: ${err.message}`,\n        });\n        return;\n      }\n      throw err;\n    }\n\n    let channelId: string | null = null;\n    if (actionConfig.channel) {\n      try {\n        channelId = await renderTemplate(\n          actionConfig.channel,\n          new TemplateSafeValueContainer({\n            user: user ? userToTemplateSafeUser(user) : null,\n            guild: guildToTemplateSafeGuild(pluginData.guild),\n            message: message ? savedMessageToTemplateSafeSavedMessage(message) : null,\n          }),\n        );\n      } catch (err) {\n        if (err instanceof TemplateParseError) {\n          pluginData.getPlugin(LogsPlugin).logBotAlert({\n            body: `Error in channel format of automod rule ${ruleName}: ${err.message}`,\n          });\n          return;\n        }\n        throw err;\n      }\n    }\n\n    const role = pluginData.guild.roles.resolve(target);\n    if (!role) {\n      const member = await pluginData.guild.members.fetch(target).catch(noop);\n      if (!member) return;\n    }\n\n    if (channelId && isValidSnowflake(channelId)) {\n      const channel = pluginData.guild.channels.resolve(channelId);\n      if (!channel || channel.isThread()) return;\n      const overwrite = channel.permissionOverwrites.cache.find((pw) => pw.id === target);\n      const allow = new PermissionsBitField(overwrite?.allow ?? 0n).serialize();\n      const deny = new PermissionsBitField(overwrite?.deny ?? 0n).serialize();\n      const newPerms: Partial<Record<PermissionsString, boolean | null>> = {};\n\n      for (const key in allow) {\n        const legacyKey = realToLegacyMap[key];\n        const configEntry = actionConfig.perms[key] ?? actionConfig.perms[legacyKey];\n        if (typeof configEntry !== \"undefined\") {\n          newPerms[key] = configEntry;\n          continue;\n        }\n        if (allow[key]) {\n          newPerms[key] = true;\n        } else if (deny[key]) {\n          newPerms[key] = false;\n        }\n      }\n\n      // takes more code lines but looks cleaner imo\n      let hasPerms = false;\n      for (const key in newPerms) {\n        if (typeof newPerms[key] === \"boolean\") {\n          hasPerms = true;\n          break;\n        }\n      }\n      if (overwrite && !hasPerms) {\n        await channel.permissionOverwrites.delete(target).catch(noop);\n        return;\n      }\n      await channel.permissionOverwrites.edit(target, newPerms).catch(noop);\n      return;\n    }\n\n    if (!role) return;\n\n    const perms = new PermissionsBitField(role.permissions).serialize();\n    for (const key in actionConfig.perms) {\n      const realKey = legacyPermMap[key] ?? key;\n      perms[realKey] = actionConfig.perms[key];\n    }\n    const permsArray = <PermissionsString[]>Object.keys(perms).filter((key) => perms[key]);\n    await role.setPermissions(new PermissionsBitField(permsArray)).catch(noop);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/clean.ts",
    "content": "import { GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { noop } from \"../../../utils.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const CleanAction = automodAction({\n  configSchema: z.boolean().default(false),\n\n  async apply({ pluginData, contexts, ruleName }) {\n    const messageIdsToDeleteByChannelId: Map<string, string[]> = new Map();\n    for (const context of contexts) {\n      if (context.message) {\n        if (!messageIdsToDeleteByChannelId.has(context.message.channel_id)) {\n          messageIdsToDeleteByChannelId.set(context.message.channel_id, []);\n        }\n\n        if (messageIdsToDeleteByChannelId.get(context.message.channel_id)!.includes(context.message.id)) {\n          // FIXME: Debug\n          // tslint:disable-next-line:no-console\n          console.warn(`Message ID to delete was already present: ${pluginData.guild.name}, rule ${ruleName}`);\n          continue;\n        }\n\n        messageIdsToDeleteByChannelId.get(context.message.channel_id)!.push(context.message.id);\n      }\n    }\n\n    for (const [channelId, messageIds] of messageIdsToDeleteByChannelId.entries()) {\n      for (const id of messageIds) {\n        pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id);\n      }\n\n      const channel = pluginData.guild.channels.cache.get(channelId as Snowflake) as GuildTextBasedChannel;\n      await channel.bulkDelete(messageIds as Snowflake[]).catch(noop);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/exampleAction.ts",
    "content": "import { z } from \"zod\";\nimport { zBoundedCharacters } from \"../../../utils.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const ExampleAction = automodAction({\n  configSchema: z.strictObject({\n    someValue: zBoundedCharacters(0, 1000),\n  }),\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  async apply({ pluginData, contexts, actionConfig }) {\n    // TODO: Everything\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/kick.ts",
    "content": "import { z } from \"zod\";\nimport { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from \"../../../utils.js\";\nimport { CaseArgs } from \"../../Cases/types.js\";\nimport { ModActionsPlugin } from \"../../ModActions/ModActionsPlugin.js\";\nimport { zNotify } from \"../constants.js\";\nimport { resolveActionContactMethods } from \"../functions/resolveActionContactMethods.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const KickAction = automodAction({\n  configSchema: z.strictObject({\n    reason: zBoundedCharacters(0, 4000).nullable().default(null),\n    notify: zNotify.nullable().default(null),\n    notifyChannel: zSnowflake.nullable().default(null),\n    postInCaseLog: z.boolean().nullable().default(null),\n    hide_case: z.boolean().nullable().default(false),\n  }),\n\n  async apply({ pluginData, contexts, actionConfig, matchResult }) {\n    const reason = actionConfig.reason || \"Kicked automatically\";\n    const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;\n\n    const caseArgs: Partial<CaseArgs> = {\n      modId: pluginData.client.user!.id,\n      extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [],\n      automatic: true,\n      postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined,\n      hide: Boolean(actionConfig.hide_case),\n    };\n\n    const userIdsToKick = unique(contexts.map((c) => c.user?.id).filter(nonNullish));\n    const membersToKick = await asyncMap(userIdsToKick, (id) => resolveMember(pluginData.client, pluginData.guild, id));\n\n    const modActions = pluginData.getPlugin(ModActionsPlugin);\n    for (const member of membersToKick) {\n      if (!member) continue;\n      await modActions.kickMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true });\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/log.ts",
    "content": "import { z } from \"zod\";\nimport { isTruthy, unique } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const LogAction = automodAction({\n  configSchema: z.boolean().default(true),\n\n  async apply({ pluginData, contexts, ruleName, matchResult, prettyName }) {\n    const users = unique(contexts.map((c) => c.user)).filter(isTruthy);\n    const user = users[0];\n    const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(\", \");\n\n    pluginData.getPlugin(LogsPlugin).logAutomodAction({\n      rule: ruleName,\n      prettyName,\n      user,\n      users,\n      actionsTaken,\n      matchSummary: matchResult.summary ?? \"\",\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/mute.ts",
    "content": "import { z } from \"zod\";\nimport { ERRORS, RecoverablePluginError } from \"../../../RecoverablePluginError.js\";\nimport {\n  convertDelayStringToMS,\n  nonNullish,\n  unique,\n  zBoundedCharacters,\n  zDelayString,\n  zSnowflake,\n} from \"../../../utils.js\";\nimport { CaseArgs } from \"../../Cases/types.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { MutesPlugin } from \"../../Mutes/MutesPlugin.js\";\nimport { zNotify } from \"../constants.js\";\nimport { resolveActionContactMethods } from \"../functions/resolveActionContactMethods.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const MuteAction = automodAction({\n  configSchema: z.strictObject({\n    reason: zBoundedCharacters(0, 4000).nullable().default(null),\n    duration: zDelayString.nullable().default(null),\n    notify: zNotify.nullable().default(null),\n    notifyChannel: zSnowflake.nullable().default(null),\n    remove_roles_on_mute: z\n      .union([z.boolean(), z.array(zSnowflake)])\n      .nullable()\n      .default(null),\n    restore_roles_on_mute: z\n      .union([z.boolean(), z.array(zSnowflake)])\n      .nullable()\n      .default(null),\n    postInCaseLog: z.boolean().nullable().default(null),\n    hide_case: z.boolean().nullable().default(false),\n  }),\n\n  async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) {\n    const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined;\n    const reason = actionConfig.reason || \"Muted automatically\";\n    const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;\n    const rolesToRemove = actionConfig.remove_roles_on_mute;\n    const rolesToRestore = actionConfig.restore_roles_on_mute;\n\n    const caseArgs: Partial<CaseArgs> = {\n      modId: pluginData.client.user!.id,\n      extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [],\n      automatic: true,\n      postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined,\n      hide: Boolean(actionConfig.hide_case),\n    };\n\n    const userIdsToMute = unique(contexts.map((c) => c.user?.id).filter(nonNullish));\n\n    const mutes = pluginData.getPlugin(MutesPlugin);\n    for (const userId of userIdsToMute) {\n      try {\n        await mutes.muteUser(\n          userId,\n          duration,\n          reason,\n          reason,\n          { contactMethods, caseArgs, isAutomodAction: true },\n          rolesToRemove,\n          rolesToRestore,\n        );\n      } catch (e) {\n        if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {\n          pluginData.getPlugin(LogsPlugin).logBotAlert({\n            body: `Failed to mute <@!${userId}> in Automod rule \\`${ruleName}\\` because a mute role has not been specified in server config`,\n          });\n        } else {\n          throw e;\n        }\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/pauseInvites.ts",
    "content": "import { GuildFeature } from \"discord.js\";\nimport { z } from \"zod\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const PauseInvitesAction = automodAction({\n  configSchema: z.strictObject({\n    paused: z.boolean(),\n  }),\n\n  async apply({ pluginData, actionConfig }) {\n    const hasInvitesDisabled = pluginData.guild.features.includes(GuildFeature.InvitesDisabled);\n\n    if (actionConfig.paused !== hasInvitesDisabled) {\n      await pluginData.guild.disableInvites(actionConfig.paused);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/removeRoles.ts",
    "content": "import { PermissionFlagsBits, Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { nonNullish, unique, zSnowflake } from \"../../../utils.js\";\nimport { canAssignRole } from \"../../../utils/canAssignRole.js\";\nimport { getMissingPermissions } from \"../../../utils/getMissingPermissions.js\";\nimport { memberRolesLock } from \"../../../utils/lockNameHelpers.js\";\nimport { missingPermissionError } from \"../../../utils/missingPermissionError.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { ignoreRoleChange } from \"../functions/ignoredRoleChanges.js\";\nimport { automodAction } from \"../helpers.js\";\n\nconst p = PermissionFlagsBits;\n\nexport const RemoveRolesAction = automodAction({\n  configSchema: z.array(zSnowflake).default([]),\n\n  async apply({ pluginData, contexts, actionConfig, ruleName }) {\n    const members = unique(contexts.map((c) => c.member).filter(nonNullish));\n    const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;\n\n    const missingPermissions = getMissingPermissions(me.permissions, p.ManageRoles);\n    if (missingPermissions) {\n      const logs = pluginData.getPlugin(LogsPlugin);\n      logs.logBotAlert({\n        body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,\n      });\n      return;\n    }\n\n    const rolesToRemove: string[] = [];\n    const rolesWeCannotRemove: string[] = [];\n    for (const roleId of actionConfig) {\n      if (canAssignRole(pluginData.guild, me, roleId)) {\n        rolesToRemove.push(roleId);\n      } else {\n        rolesWeCannotRemove.push(roleId);\n      }\n    }\n\n    if (rolesWeCannotRemove.length) {\n      const roleNamesWeCannotRemove = rolesWeCannotRemove.map(\n        (roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,\n      );\n      const logs = pluginData.getPlugin(LogsPlugin);\n      logs.logBotAlert({\n        body: `Unable to remove the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotRemove.join(\n          \"**, **\",\n        )}**`,\n      });\n    }\n\n    await Promise.all(\n      members.map(async (member) => {\n        const memberRoles = new Set(member.roles.cache.keys());\n        for (const roleId of rolesToRemove) {\n          memberRoles.delete(roleId as Snowflake);\n          ignoreRoleChange(pluginData, member.id, roleId);\n        }\n\n        if (memberRoles.size === member.roles.cache.size) {\n          // No role changes\n          return;\n        }\n\n        const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member));\n\n        const rolesArr = Array.from(memberRoles.values());\n        await member.edit({\n          roles: rolesArr,\n        });\n\n        memberRoleLock.unlock();\n      }),\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/reply.ts",
    "content": "import { GuildTextBasedChannel, MessageCreateOptions, PermissionsBitField, Snowflake, User } from \"discord.js\";\nimport { z } from \"zod\";\nimport { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport {\n  convertDelayStringToMS,\n  noop,\n  renderRecursively,\n  unique,\n  validateAndParseMessageContent,\n  verboseChannelMention,\n  zBoundedCharacters,\n  zDelayString,\n  zMessageContent,\n} from \"../../../utils.js\";\nimport { hasDiscordPermissions } from \"../../../utils/hasDiscordPermissions.js\";\nimport { messageIsEmpty } from \"../../../utils/messageIsEmpty.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { automodAction } from \"../helpers.js\";\nimport { AutomodContext } from \"../types.js\";\n\nexport const ReplyAction = automodAction({\n  configSchema: z.union([\n    zBoundedCharacters(0, 4000),\n    z.strictObject({\n      text: zMessageContent,\n      auto_delete: z.union([zDelayString, z.number()]).nullable().default(null),\n      inline: z.boolean().default(false),\n    }),\n  ]),\n\n  async apply({ pluginData, contexts, actionConfig, ruleName }) {\n    const contextsWithTextChannels = contexts\n      .filter((c) => c.message?.channel_id)\n      .filter((c) => {\n        const channel = pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake);\n        return channel?.isTextBased();\n      });\n\n    const contextsByChannelId = contextsWithTextChannels.reduce((map: Map<string, AutomodContext[]>, context) => {\n      if (!map.has(context.message!.channel_id)) {\n        map.set(context.message!.channel_id, []);\n      }\n\n      map.get(context.message!.channel_id)!.push(context);\n      return map;\n    }, new Map());\n\n    for (const [channelId, _contexts] of contextsByChannelId.entries()) {\n      const users = unique(Array.from(new Set(_contexts.map((c) => c.user).filter(Boolean)))) as User[];\n      const user = users[0];\n\n      const renderReplyText = async (str: string) =>\n        renderTemplate(\n          str,\n          new TemplateSafeValueContainer({\n            user: userToTemplateSafeUser(user),\n          }),\n        );\n\n      let formatted: string | MessageCreateOptions;\n      try {\n        formatted =\n          typeof actionConfig === \"string\"\n            ? await renderReplyText(actionConfig)\n            : ((await renderRecursively(actionConfig.text, renderReplyText)) as MessageCreateOptions);\n      } catch (err) {\n        if (err instanceof TemplateParseError) {\n          pluginData.getPlugin(LogsPlugin).logBotAlert({\n            body: `Error in reply format of automod rule \\`${ruleName}\\`: ${err.message}`,\n          });\n          return;\n        }\n        throw err;\n      }\n\n      if (formatted) {\n        const channel = pluginData.guild.channels.cache.get(channelId as Snowflake) as GuildTextBasedChannel;\n\n        // Check for basic Send Messages and View Channel permissions\n        if (\n          !hasDiscordPermissions(\n            channel.permissionsFor(pluginData.client.user!.id),\n            PermissionsBitField.Flags.SendMessages | PermissionsBitField.Flags.ViewChannel,\n          )\n        ) {\n          pluginData.getPlugin(LogsPlugin).logBotAlert({\n            body: `Missing permissions to reply in ${verboseChannelMention(channel)} in Automod rule \\`${ruleName}\\``,\n          });\n          continue;\n        }\n\n        // If the message is an embed, check for embed permissions\n        if (\n          typeof formatted !== \"string\" &&\n          !hasDiscordPermissions(\n            channel.permissionsFor(pluginData.client.user!.id),\n            PermissionsBitField.Flags.EmbedLinks,\n          )\n        ) {\n          pluginData.getPlugin(LogsPlugin).logBotAlert({\n            body: `Missing permissions to reply **with an embed** in ${verboseChannelMention(\n              channel,\n            )} in Automod rule \\`${ruleName}\\``,\n          });\n          continue;\n        }\n\n        const messageContent = validateAndParseMessageContent(formatted);\n\n        const messageOpts: MessageCreateOptions = {\n          ...messageContent,\n          allowedMentions: {\n            users: [user.id],\n          },\n        };\n\n        if (typeof actionConfig !== \"string\" && actionConfig.inline) {\n          messageOpts.reply = {\n            failIfNotExists: false,\n            messageReference: _contexts[0].message!.id,\n          };\n        }\n\n        if (messageIsEmpty(messageOpts)) {\n          return;\n        }\n\n        const replyMsg = await channel.send(messageOpts);\n\n        if (typeof actionConfig === \"object\" && actionConfig.auto_delete) {\n          const delay = convertDelayStringToMS(String(actionConfig.auto_delete))!;\n          setTimeout(() => replyMsg.deletable && replyMsg.delete().catch(noop), delay);\n        }\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/setAntiraidLevel.ts",
    "content": "import { zBoundedCharacters } from \"../../../utils.js\";\nimport { setAntiraidLevel } from \"../functions/setAntiraidLevel.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const SetAntiraidLevelAction = automodAction({\n  configSchema: zBoundedCharacters(0, 100).nullable(),\n\n  async apply({ pluginData, actionConfig }) {\n    setAntiraidLevel(pluginData, actionConfig ?? null);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/setCounter.ts",
    "content": "import { z } from \"zod\";\nimport { MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from \"../../../data/GuildCounters.js\";\nimport { zBoundedCharacters } from \"../../../utils.js\";\nimport { CountersPlugin } from \"../../Counters/CountersPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const SetCounterAction = automodAction({\n  configSchema: z.strictObject({\n    counter: zBoundedCharacters(0, 100),\n    value: z.number().min(MIN_COUNTER_VALUE).max(MAX_COUNTER_VALUE),\n  }),\n\n  async apply({ pluginData, contexts, actionConfig, ruleName }) {\n    const countersPlugin = pluginData.getPlugin(CountersPlugin);\n    if (!countersPlugin.counterExists(actionConfig.counter)) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Unknown counter \\`${actionConfig.counter}\\` in \\`set_counter\\` action of Automod rule \\`${ruleName}\\``,\n      });\n      return;\n    }\n\n    countersPlugin.setCounterValue(\n      actionConfig.counter,\n      contexts[0].message?.channel_id || null,\n      contexts[0].user?.id || null,\n      actionConfig.value,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/setSlowmode.ts",
    "content": "import { ChannelType, GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { convertDelayStringToMS, isDiscordAPIError, zDelayString, zSnowflake } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const SetSlowmodeAction = automodAction({\n  configSchema: z.strictObject({\n    channels: z.array(zSnowflake).nullable().default([]),\n    duration: zDelayString.nullable().default(\"10s\"),\n  }),\n\n  async apply({ pluginData, actionConfig, contexts }) {\n    const slowmodeMs = Math.max(actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : 0, 0);\n    const channels: Snowflake[] = actionConfig.channels ?? [];\n    if (channels.length === 0) {\n      channels.push(...contexts.filter((c) => c.message?.channel_id).map((c) => c.message!.channel_id));\n    }\n    for (const channelId of channels) {\n      const channel = pluginData.guild.channels.cache.get(channelId as Snowflake);\n      // Only text channels and text channels within categories support slowmodes\n\n      if (!channel?.isTextBased() && channel?.type !== ChannelType.GuildCategory) {\n        continue;\n      }\n\n      const channelsToSlowmode: GuildTextBasedChannel[] = [];\n      if (channel.type === ChannelType.GuildCategory) {\n        // Find all text channels within the category\n        for (const ch of pluginData.guild.channels.cache.values()) {\n          if (ch.parentId === channel.id && ch.type === ChannelType.GuildText) {\n            channelsToSlowmode.push(ch);\n          }\n        }\n      } else {\n        channelsToSlowmode.push(channel);\n      }\n\n      const slowmodeSeconds = Math.ceil(slowmodeMs / 1000);\n\n      try {\n        for (const chan of channelsToSlowmode) {\n          await chan.setRateLimitPerUser(slowmodeSeconds);\n        }\n      } catch (e) {\n        // Check for invalid form body -> indicates duration was too large\n        const errorMessage =\n          isDiscordAPIError(e) && e.code === 50035\n            ? `Duration is greater than maximum native slowmode duration`\n            : e.message;\n\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Unable to set slowmode for channel ${channel.id} to ${slowmodeSeconds} seconds: ${errorMessage}`,\n        });\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/startThread.ts",
    "content": "import { ChannelType, GuildTextThreadCreateOptions, ThreadAutoArchiveDuration, ThreadChannel } from \"discord.js\";\nimport { z } from \"zod\";\nimport { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from \"../../../utils.js\";\nimport { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { automodAction } from \"../helpers.js\";\n\nconst validThreadAutoArchiveDurations: ThreadAutoArchiveDuration[] = [\n  ThreadAutoArchiveDuration.OneHour,\n  ThreadAutoArchiveDuration.OneDay,\n  ThreadAutoArchiveDuration.ThreeDays,\n  ThreadAutoArchiveDuration.OneWeek,\n];\n\nexport const StartThreadAction = automodAction({\n  configSchema: z.strictObject({\n    name: zBoundedCharacters(1, 100).nullable(),\n    auto_archive: zDelayString,\n    private: z.boolean().default(false),\n    slowmode: zDelayString.nullable().default(null),\n    limit_per_channel: z.number().nullable().default(5),\n  }),\n\n  async apply({ pluginData, contexts, actionConfig, ruleName }) {\n    // check if the message still exists, we don't want to create threads for deleted messages\n    const threads = contexts.filter((c) => {\n      if (!c.message || !c.user) return false;\n      const channel = pluginData.guild.channels.cache.get(c.message.channel_id);\n      if (channel?.type !== ChannelType.GuildText || !channel.isTextBased()) return false; // for some reason the typing here for channel.type defaults to ThreadChannelTypes (?)\n      // check against max threads per channel\n      if (actionConfig.limit_per_channel && actionConfig.limit_per_channel > 0) {\n        const threadCount = channel.threads.cache.filter(\n          (tr) => tr.ownerId === pluginData.client.user!.id && !tr.archived && tr.parentId === channel.id,\n        ).size;\n        if (threadCount >= actionConfig.limit_per_channel) return false;\n      }\n      return true;\n    });\n\n    const archiveSet = actionConfig.auto_archive\n      ? Math.ceil(Math.max(convertDelayStringToMS(actionConfig.auto_archive) ?? 0, 0) / MINUTES)\n      : ThreadAutoArchiveDuration.OneDay;\n    const autoArchive = validThreadAutoArchiveDurations.includes(archiveSet)\n      ? (archiveSet as ThreadAutoArchiveDuration)\n      : ThreadAutoArchiveDuration.OneHour;\n\n    for (const threadContext of threads) {\n      const channel = pluginData.guild.channels.cache.get(threadContext.message!.channel_id);\n      if (!channel || !(\"threads\" in channel) || channel.isThreadOnly()) continue;\n\n      let threadName: string;\n      try {\n        threadName = await renderTemplate(\n          actionConfig.name ?? \"{user.renderedUsername}'s thread\",\n          new TemplateSafeValueContainer({\n            user: userToTemplateSafeUser(threadContext.user!),\n            msg: savedMessageToTemplateSafeSavedMessage(threadContext.message!),\n          }),\n        );\n      } catch (err) {\n        if (err instanceof TemplateParseError) {\n          pluginData.getPlugin(LogsPlugin).logBotAlert({\n            body: `Error in thread name format of automod rule ${ruleName}: ${err.message}`,\n          });\n          return;\n        }\n        throw err;\n      }\n\n      const threadOptions: GuildTextThreadCreateOptions<unknown> = {\n        name: threadName,\n        autoArchiveDuration: autoArchive,\n        startMessage: !actionConfig.private ? threadContext.message!.id : undefined,\n      };\n\n      let thread: ThreadChannel | undefined;\n      if (channel.type === ChannelType.GuildNews) {\n        thread = await channel.threads\n          .create({\n            ...threadOptions,\n            type: ChannelType.AnnouncementThread,\n          })\n          .catch(() => undefined);\n      } else {\n        thread = await channel.threads\n          .create({\n            ...threadOptions,\n            type: actionConfig.private ? ChannelType.PrivateThread : ChannelType.PublicThread,\n            startMessage: !actionConfig.private ? threadContext.message!.id : undefined,\n          })\n          .catch(() => undefined);\n      }\n      if (actionConfig.slowmode && thread) {\n        const dur = Math.ceil(Math.max(convertDelayStringToMS(actionConfig.slowmode) ?? 0, 0) / 1000);\n        if (dur > 0) {\n          await thread.setRateLimitPerUser(dur).catch(noop);\n        }\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/actions/warn.ts",
    "content": "import { z } from \"zod\";\nimport { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from \"../../../utils.js\";\nimport { CaseArgs } from \"../../Cases/types.js\";\nimport { ModActionsPlugin } from \"../../ModActions/ModActionsPlugin.js\";\nimport { zNotify } from \"../constants.js\";\nimport { resolveActionContactMethods } from \"../functions/resolveActionContactMethods.js\";\nimport { automodAction } from \"../helpers.js\";\n\nexport const WarnAction = automodAction({\n  configSchema: z.strictObject({\n    reason: zBoundedCharacters(0, 4000).nullable().default(null),\n    notify: zNotify.nullable().default(null),\n    notifyChannel: zSnowflake.nullable().default(null),\n    postInCaseLog: z.boolean().nullable().default(null),\n    hide_case: z.boolean().nullable().default(false),\n  }),\n\n  async apply({ pluginData, contexts, actionConfig, matchResult }) {\n    const reason = actionConfig.reason || \"Warned automatically\";\n    const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;\n\n    const caseArgs: Partial<CaseArgs> = {\n      modId: pluginData.client.user!.id,\n      extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [],\n      automatic: true,\n      postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined,\n      hide: Boolean(actionConfig.hide_case),\n    };\n\n    const userIdsToWarn = unique(contexts.map((c) => c.user?.id).filter(nonNullish));\n    const membersToWarn = await asyncMap(userIdsToWarn, (id) => resolveMember(pluginData.client, pluginData.guild, id));\n\n    const modActions = pluginData.getPlugin(ModActionsPlugin);\n    for (const member of membersToWarn) {\n      if (!member) continue;\n      await modActions.warnMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true });\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/commands/AntiraidClearCmd.ts",
    "content": "import { guildPluginMessageCommand } from \"vety\";\nimport { setAntiraidLevel } from \"../functions/setAntiraidLevel.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport const AntiraidClearCmd = guildPluginMessageCommand<AutomodPluginType>()({\n  trigger: [\"antiraid clear\", \"antiraid reset\", \"antiraid none\", \"antiraid off\"],\n  permission: \"can_set_antiraid\",\n\n  async run({ pluginData, message }) {\n    await setAntiraidLevel(pluginData, null, message.author);\n    void pluginData.state.common.sendSuccessMessage(message, \"Anti-raid turned **off**\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/commands/DebugAutomodCmd.ts",
    "content": "import { guildPluginMessageCommand } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\nimport { runAutomod } from \"../functions/runAutomod.js\";\nimport { createChunkedMessage } from \"../../../utils.js\";\nimport { getOrFetchGuildMember } from \"../../../utils/getOrFetchGuildMember.js\";\nimport { getOrFetchUser } from \"../../../utils/getOrFetchUser.js\";\n\nexport const DebugAutomodCmd = guildPluginMessageCommand<AutomodPluginType>()({\n  trigger: \"debug_automod\",\n  permission: \"can_debug_automod\",\n\n  signature: {\n    messageId: ct.string(),\n  },\n\n  async run({ pluginData, message, args }) {\n    const targetMessage = await pluginData.state.savedMessages.find(args.messageId);\n    if (!targetMessage || targetMessage.guild_id !== pluginData.guild.id) {\n      pluginData.state.common.sendErrorMessage(message, \"Message not found\");\n      return;\n    }\n\n    const member = await getOrFetchGuildMember(pluginData.guild, targetMessage.user_id);\n    const user = await getOrFetchUser(pluginData.client, targetMessage.user_id);\n    const context: AutomodContext = {\n      timestamp: moment.utc(targetMessage.posted_at).valueOf(),\n      message: targetMessage,\n      user,\n      member,\n    };\n\n    const result = await runAutomod(pluginData, context, true);\n\n    let resultText = `**${result.triggered ? \"✔️ Triggered\" : \"❌ Not triggered\"}**\\n\\nRules checked:\\n\\n`;\n    for (const ruleResult of result.rulesChecked) {\n      resultText += `**${ruleResult.ruleName}**\\n`;\n      if (ruleResult.outcome.success) {\n        resultText += `\\\\- Matched trigger: ${ruleResult.outcome.matchedTrigger.name} (trigger #${ruleResult.outcome.matchedTrigger.num})\\n`;\n      } else {\n        resultText += `\\\\- No match (${ruleResult.outcome.reason})\\n`;\n      }\n    }\n\n    createChunkedMessage(message.channel, resultText.trim());\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/commands/SetAntiraidCmd.ts",
    "content": "import { guildPluginMessageCommand } from \"vety\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { setAntiraidLevel } from \"../functions/setAntiraidLevel.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport const SetAntiraidCmd = guildPluginMessageCommand<AutomodPluginType>()({\n  trigger: \"antiraid\",\n  permission: \"can_set_antiraid\",\n\n  signature: {\n    level: ct.string(),\n  },\n\n  async run({ pluginData, message, args }) {\n    const config = pluginData.config.get();\n    if (!config.antiraid_levels.includes(args.level)) {\n      pluginData.state.common.sendErrorMessage(message, \"Unknown anti-raid level\");\n      return;\n    }\n\n    await setAntiraidLevel(pluginData, args.level, message.author);\n    pluginData.state.common.sendSuccessMessage(message, `Anti-raid level set to **${args.level}**`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/commands/ViewAntiraidCmd.ts",
    "content": "import { guildPluginMessageCommand } from \"vety\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport const ViewAntiraidCmd = guildPluginMessageCommand<AutomodPluginType>()({\n  trigger: \"antiraid\",\n  permission: \"can_view_antiraid\",\n\n  async run({ pluginData, message }) {\n    if (pluginData.state.cachedAntiraidLevel) {\n      message.channel.send(`Anti-raid is set to **${pluginData.state.cachedAntiraidLevel}**`);\n    } else {\n      message.channel.send(`Anti-raid is **off**`);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/constants.ts",
    "content": "import { z } from \"zod\";\nimport { MINUTES, SECONDS } from \"../../utils.js\";\n\nexport const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS;\nexport const RECENT_ACTION_EXPIRY_TIME = 5 * MINUTES;\nexport const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES;\n\nexport enum RecentActionType {\n  Message = 1,\n  Mention,\n  Link,\n  Attachment,\n  Emoji,\n  Line,\n  Character,\n  VoiceChannelMove,\n  MemberJoin,\n  Sticker,\n  MemberLeave,\n  ThreadCreate,\n}\n\nexport const zNotify = z.union([z.literal(\"dm\"), z.literal(\"channel\")]);\n"
  },
  {
    "path": "backend/src/plugins/Automod/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zAutomodConfig } from \"./types.js\";\n\nexport const automodPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zAutomodConfig,\n\n  prettyName: \"Automod\",\n  description: trimPluginDescription(`\n      Allows specifying automated actions in response to triggers. Example use cases include word filtering and spam prevention.\n  `),\n  configurationGuide: trimPluginDescription(`\n      The automod plugin is very customizable. For a full list of available triggers, actions, and their options, see Config schema at the bottom of this page.    \n    \n      ### Simple word filter\n      Removes any messages that contain the word 'banana' and sends a warning to the user.\n      Moderators (level >= 50) are ignored by the filter based on the override.\n      \n      ~~~yml\n      automod:\n        config:\n          rules:\n            my_filter:\n              triggers:\n              - match_words:\n                  words: ['banana']\n                  case_sensitive: false\n                  only_full_words: true\n              actions:\n                clean: true\n                warn:\n                  reason: 'Do not talk about bananas!'\n        overrides:\n        - level: '>=50'\n          config:\n            rules:\n              my_filter:\n                enabled: false\n      ~~~\n      \n      ### Spam detection\n      This example includes 2 filters:\n      \n      - The first one is triggered if a user sends 5 messages within 10 seconds OR 3 attachments within 60 seconds.\n        The messages are deleted and the user is muted for 5 minutes.\n      - The second filter is triggered if a user sends more than 2 emoji within 5 seconds.\n        The messages are deleted but the user is not muted.\n      \n      Moderators are ignored by both filters based on the override.\n      \n      ~~~yml\n      automod:\n        config:\n          rules:\n            my_spam_filter:\n              triggers:\n              - message_spam:\n                  amount: 5\n                  within: 10s\n              - attachment_spam:\n                  amount: 3\n                  within: 60s\n              actions:\n                clean: true\n                mute:\n                  duration: 5m\n                  reason: 'Auto-muted for spam'\n            my_second_filter:\n              triggers:\n              - emoji_spam:\n                  amount: 2\n                  within: 5s\n              actions:\n                clean: true\n        overrides:\n        - level: '>=50'\n          config:\n            rules:\n              my_spam_filter:\n                enabled: false\n              my_second_filter:\n                enabled: false\n      ~~~\n      \n      ### Custom status alerts\n      This example sends an alert any time a user with a matching custom status sends a message.\n      \n      ~~~yml\n      automod:\n        config:\n          rules:\n            bad_custom_statuses:\n              triggers:\n              - match_words:\n                  words: ['banana']\n                  match_custom_status: true\n              actions:\n                alert:\n                  channel: \"473087035574321152\"\n                  text: |-\n                    Bad custom status on user <@!{user.id}>:\n                    {matchSummary}\n      ~~~\n  `),\n};\n"
  },
  {
    "path": "backend/src/plugins/Automod/events/RunAutomodOnJoinLeaveEvt.ts",
    "content": "import { guildPluginEventListener } from \"vety\";\nimport { RecentActionType } from \"../constants.js\";\nimport { runAutomod } from \"../functions/runAutomod.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\n\nexport const RunAutomodOnJoinEvt = guildPluginEventListener<AutomodPluginType>()({\n  event: \"guildMemberAdd\",\n  listener({ pluginData, args: { member } }) {\n    const context: AutomodContext = {\n      timestamp: Date.now(),\n      user: member.user,\n      member,\n      joined: true,\n    };\n\n    pluginData.state.queue.add(() => {\n      pluginData.state.recentActions.push({\n        type: RecentActionType.MemberJoin,\n        context,\n        count: 1,\n        identifier: null,\n      });\n\n      runAutomod(pluginData, context);\n    });\n  },\n});\n\nexport const RunAutomodOnLeaveEvt = guildPluginEventListener<AutomodPluginType>()({\n  event: \"guildMemberRemove\",\n  listener({ pluginData, args: { member } }) {\n    const context: AutomodContext = {\n      timestamp: Date.now(),\n      partialMember: member,\n      joined: true,\n    };\n\n    pluginData.state.queue.add(() => {\n      pluginData.state.recentActions.push({\n        type: RecentActionType.MemberLeave,\n        context,\n        count: 1,\n        identifier: null,\n      });\n\n      runAutomod(pluginData, context);\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts",
    "content": "import { guildPluginEventListener } from \"vety\";\nimport { difference, isEqual } from \"lodash-es\";\nimport { runAutomod } from \"../functions/runAutomod.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\n\nexport const RunAutomodOnMemberUpdate = guildPluginEventListener<AutomodPluginType>()({\n  event: \"guildMemberUpdate\",\n  listener({ pluginData, args: { oldMember, newMember } }) {\n    if (!oldMember) return;\n    if (oldMember.partial) return;\n\n    const oldRoles = [...oldMember.roles.cache.keys()];\n    const newRoles = [...newMember.roles.cache.keys()];\n\n    if (isEqual(oldRoles, newRoles)) return;\n\n    const addedRoles = difference(newRoles, oldRoles);\n    const removedRoles = difference(oldRoles, newRoles);\n\n    if (addedRoles.length || removedRoles.length) {\n      const context: AutomodContext = {\n        timestamp: Date.now(),\n        user: newMember.user,\n        member: newMember,\n        rolesChanged: {\n          added: addedRoles,\n          removed: removedRoles,\n        },\n      };\n\n      pluginData.state.queue.add(() => {\n        runAutomod(pluginData, context);\n      });\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/events/runAutomodOnAntiraidLevel.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { runAutomod } from \"../functions/runAutomod.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\n\nexport async function runAutomodOnAntiraidLevel(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  newLevel: string | null,\n  oldLevel: string | null,\n  user?: User,\n) {\n  const context: AutomodContext = {\n    timestamp: Date.now(),\n    antiraid: {\n      level: newLevel,\n      oldLevel,\n    },\n    user,\n  };\n\n  pluginData.state.queue.add(async () => {\n    await runAutomod(pluginData, context);\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { resolveMember, resolveUser, UnknownUser } from \"../../../utils.js\";\nimport { CountersPlugin } from \"../../Counters/CountersPlugin.js\";\nimport { runAutomod } from \"../functions/runAutomod.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\n\nexport async function runAutomodOnCounterTrigger(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  counterName: string,\n  triggerName: string,\n  channelId: string | null,\n  userId: string | null,\n  reverse: boolean,\n) {\n  const user = userId ? await resolveUser(pluginData.client, userId, \"Automod:runAutomodOnCounterTrigger\") : undefined;\n  const member = (userId && (await resolveMember(pluginData.client, pluginData.guild, userId))) || undefined;\n  const prettyCounterName = pluginData.getPlugin(CountersPlugin).getPrettyNameForCounter(counterName);\n  const prettyTriggerName = pluginData\n    .getPlugin(CountersPlugin)\n    .getPrettyNameForCounterTrigger(counterName, triggerName);\n\n  const context: AutomodContext = {\n    timestamp: Date.now(),\n    counterTrigger: {\n      counter: counterName,\n      trigger: triggerName,\n      prettyCounter: prettyCounterName,\n      prettyTrigger: prettyTriggerName,\n      channelId,\n      userId,\n      reverse,\n    },\n    user: user instanceof UnknownUser ? undefined : user,\n    member,\n    // TODO: Channel\n  };\n\n  pluginData.state.queue.add(async () => {\n    await runAutomod(pluginData, context);\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/events/runAutomodOnMessage.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { performance } from \"perf_hooks\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { profilingEnabled } from \"../../../utils/easyProfiler.js\";\nimport { addRecentActionsFromMessage } from \"../functions/addRecentActionsFromMessage.js\";\nimport { clearRecentActionsForMessage } from \"../functions/clearRecentActionsForMessage.js\";\nimport { runAutomod } from \"../functions/runAutomod.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\nimport { getOrFetchGuildMember } from \"../../../utils/getOrFetchGuildMember.js\";\nimport { getOrFetchUser } from \"../../../utils/getOrFetchUser.js\";\nimport { incrementDebugCounter } from \"../../../debugCounters.js\";\n\nexport async function runAutomodOnMessage(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  message: SavedMessage,\n  isEdit: boolean,\n) {\n  incrementDebugCounter(\"automod:runAutomodOnMessage\");\n  \n  const member = await getOrFetchGuildMember(pluginData.guild, message.user_id);\n  const user = await getOrFetchUser(pluginData.client, message.user_id);\n\n  const context: AutomodContext = {\n    timestamp: moment.utc(message.posted_at).valueOf(),\n    message,\n    user,\n    member,\n  };\n\n  pluginData.state.queue.add(async () => {\n    const startTime = performance.now();\n\n    if (isEdit) {\n      clearRecentActionsForMessage(pluginData, context);\n    }\n\n    addRecentActionsFromMessage(pluginData, context);\n\n    await runAutomod(pluginData, context);\n\n    if (profilingEnabled()) {\n      pluginData\n        .getVetyInstance()\n        .profiler.addDataPoint(`automod:${pluginData.guild.id}`, performance.now() - startTime);\n    }\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/events/runAutomodOnModAction.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { resolveMember, resolveUser, UnknownUser } from \"../../../utils.js\";\nimport { ModActionType } from \"../../ModActions/types.js\";\nimport { runAutomod } from \"../functions/runAutomod.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\n\nexport async function runAutomodOnModAction(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  modAction: ModActionType,\n  userId: string,\n  reason?: string,\n  isAutomodAction = false,\n) {\n  const [user, member] = await Promise.all([\n    resolveUser(pluginData.client, userId, \"Automod:runAutomodOnModAction\"),\n    resolveMember(pluginData.client, pluginData.guild, userId),\n  ]);\n\n  const context: AutomodContext = {\n    timestamp: Date.now(),\n    user: user instanceof UnknownUser ? undefined : user,\n    member: member ?? undefined,\n    modAction: {\n      type: modAction,\n      reason,\n      isAutomodAction,\n    },\n  };\n\n  pluginData.state.queue.add(async () => {\n    await runAutomod(pluginData, context);\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/events/runAutomodOnThreadEvents.ts",
    "content": "import { guildPluginEventListener } from \"vety\";\nimport { RecentActionType } from \"../constants.js\";\nimport { runAutomod } from \"../functions/runAutomod.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\n\nexport const RunAutomodOnThreadCreate = guildPluginEventListener<AutomodPluginType>()({\n  event: \"threadCreate\",\n  async listener({ pluginData, args: { thread } }) {\n    const user = thread.ownerId\n      ? await pluginData.client.users.fetch(thread.ownerId).catch(() => undefined)\n      : undefined;\n\n    const context: AutomodContext = {\n      timestamp: Date.now(),\n      threadChange: {\n        created: thread,\n      },\n      user,\n      channel: thread,\n    };\n\n    pluginData.state.queue.add(() => {\n      pluginData.state.recentActions.push({\n        type: RecentActionType.ThreadCreate,\n        context,\n        count: 1,\n        identifier: null,\n      });\n\n      runAutomod(pluginData, context);\n    });\n  },\n});\n\nexport const RunAutomodOnThreadDelete = guildPluginEventListener<AutomodPluginType>()({\n  event: \"threadDelete\",\n  async listener({ pluginData, args: { thread } }) {\n    const user = thread.ownerId\n      ? await pluginData.client.users.fetch(thread.ownerId).catch(() => undefined)\n      : undefined;\n\n    const context: AutomodContext = {\n      timestamp: Date.now(),\n      threadChange: {\n        deleted: thread,\n      },\n      user,\n      channel: thread,\n    };\n\n    pluginData.state.queue.add(() => {\n      runAutomod(pluginData, context);\n    });\n  },\n});\n\nexport const RunAutomodOnThreadUpdate = guildPluginEventListener<AutomodPluginType>()({\n  event: \"threadUpdate\",\n  async listener({ pluginData, args: { oldThread, newThread: thread } }) {\n    const user = thread.ownerId\n      ? await pluginData.client.users.fetch(thread.ownerId).catch(() => undefined)\n      : undefined;\n\n    const changes: AutomodContext[\"threadChange\"] = {};\n    if (oldThread.archived !== thread.archived) {\n      changes.archived = thread.archived ? thread : undefined;\n      changes.unarchived = !thread.archived ? thread : undefined;\n    }\n    if (oldThread.locked !== thread.locked) {\n      changes.locked = thread.locked ? thread : undefined;\n      changes.unlocked = !thread.locked ? thread : undefined;\n    }\n\n    if (Object.keys(changes).length === 0) return;\n\n    const context: AutomodContext = {\n      timestamp: Date.now(),\n      threadChange: changes,\n      user,\n      channel: thread,\n    };\n\n    pluginData.state.queue.add(() => {\n      runAutomod(pluginData, context);\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/addRecentActionsFromMessage.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { getEmojiInString, getRoleMentions, getUrlsInString, getUserMentions } from \"../../../utils.js\";\nimport { RecentActionType } from \"../constants.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\n\nexport function addRecentActionsFromMessage(pluginData: GuildPluginData<AutomodPluginType>, context: AutomodContext) {\n  const message = context.message!;\n  const globalIdentifier = message.user_id;\n  const perChannelIdentifier = `${message.channel_id}-${message.user_id}`;\n\n  pluginData.state.recentActions.push({\n    context,\n    type: RecentActionType.Message,\n    identifier: globalIdentifier,\n    count: 1,\n  });\n\n  pluginData.state.recentActions.push({\n    context,\n    type: RecentActionType.Message,\n    identifier: perChannelIdentifier,\n    count: 1,\n  });\n\n  const mentionCount =\n    getUserMentions(message.data.content || \"\").length + getRoleMentions(message.data.content || \"\").length;\n  if (mentionCount) {\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Mention,\n      identifier: globalIdentifier,\n      count: mentionCount,\n    });\n\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Mention,\n      identifier: perChannelIdentifier,\n      count: mentionCount,\n    });\n  }\n\n  const linkCount = getUrlsInString(message.data.content || \"\").length;\n  if (linkCount) {\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Link,\n      identifier: globalIdentifier,\n      count: linkCount,\n    });\n\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Link,\n      identifier: perChannelIdentifier,\n      count: linkCount,\n    });\n  }\n\n  const attachmentCount = message.data.attachments && message.data.attachments.length;\n  if (attachmentCount) {\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Attachment,\n      identifier: globalIdentifier,\n      count: attachmentCount,\n    });\n\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Attachment,\n      identifier: perChannelIdentifier,\n      count: attachmentCount,\n    });\n  }\n\n  const emojiCount = getEmojiInString(message.data.content || \"\").length;\n  if (emojiCount) {\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Emoji,\n      identifier: globalIdentifier,\n      count: emojiCount,\n    });\n\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Emoji,\n      identifier: perChannelIdentifier,\n      count: emojiCount,\n    });\n  }\n\n  // + 1 is for the first line of the message (which doesn't have a line break)\n  const lineCount = message.data.content ? (message.data.content.match(/\\n/g) || []).length + 1 : 0;\n  if (lineCount) {\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Line,\n      identifier: globalIdentifier,\n      count: lineCount,\n    });\n\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Line,\n      identifier: perChannelIdentifier,\n      count: lineCount,\n    });\n  }\n\n  const characterCount = [...(message.data.content || \"\")].length;\n  if (characterCount) {\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Character,\n      identifier: globalIdentifier,\n      count: characterCount,\n    });\n\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Character,\n      identifier: perChannelIdentifier,\n      count: characterCount,\n    });\n  }\n\n  const stickerCount = (message.data.stickers || []).length;\n  if (stickerCount) {\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Sticker,\n      identifier: globalIdentifier,\n      count: stickerCount,\n    });\n\n    pluginData.state.recentActions.push({\n      context,\n      type: RecentActionType.Sticker,\n      identifier: perChannelIdentifier,\n      count: stickerCount,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/applyCooldown.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { convertDelayStringToMS } from \"../../../utils.js\";\nimport { AutomodContext, AutomodPluginType, TRule } from \"../types.js\";\n\nexport function applyCooldown(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  rule: TRule,\n  ruleName: string,\n  context: AutomodContext,\n) {\n  const cooldownKey = `${ruleName}-${context.user?.id}`;\n\n  const cooldownTime = convertDelayStringToMS(rule.cooldown, \"s\");\n  if (cooldownTime) pluginData.state.cooldownManager.setCooldown(cooldownKey, cooldownTime);\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/checkCooldown.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { AutomodContext, AutomodPluginType, TRule } from \"../types.js\";\n\nexport function checkCooldown(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  rule: TRule,\n  ruleName: string,\n  context: AutomodContext,\n) {\n  const cooldownKey = `${ruleName}-${context.user?.id}`;\n\n  return pluginData.state.cooldownManager.isOnCooldown(cooldownKey);\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/clearOldNicknameChanges.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { RECENT_NICKNAME_CHANGE_EXPIRY_TIME } from \"../constants.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport function clearOldRecentNicknameChanges(pluginData: GuildPluginData<AutomodPluginType>) {\n  const now = Date.now();\n  for (const [userId, { timestamp }] of pluginData.state.recentNicknameChanges) {\n    if (timestamp + RECENT_NICKNAME_CHANGE_EXPIRY_TIME <= now) {\n      pluginData.state.recentNicknameChanges.delete(userId);\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/clearOldRecentActions.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { startProfiling } from \"../../../utils/easyProfiler.js\";\nimport { RECENT_ACTION_EXPIRY_TIME } from \"../constants.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport function clearOldRecentActions(pluginData: GuildPluginData<AutomodPluginType>) {\n  const stopProfiling = startProfiling(pluginData.getVetyInstance().profiler, \"automod:fns:clearOldRecentActions\");\n  const now = Date.now();\n  pluginData.state.recentActions = pluginData.state.recentActions.filter((info) => {\n    return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now;\n  });\n  stopProfiling();\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/clearOldRecentSpam.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { startProfiling } from \"../../../utils/easyProfiler.js\";\nimport { RECENT_SPAM_EXPIRY_TIME } from \"../constants.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport function clearOldRecentSpam(pluginData: GuildPluginData<AutomodPluginType>) {\n  const stopProfiling = startProfiling(pluginData.getVetyInstance().profiler, \"automod:fns:clearOldRecentSpam\");\n  const now = Date.now();\n  pluginData.state.recentSpam = pluginData.state.recentSpam.filter((spam) => {\n    return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now;\n  });\n  stopProfiling();\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/clearRecentActionsForMessage.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { startProfiling } from \"../../../utils/easyProfiler.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\n\nexport function clearRecentActionsForMessage(pluginData: GuildPluginData<AutomodPluginType>, context: AutomodContext) {\n  const stopProfiling = startProfiling(\n    pluginData.getVetyInstance().profiler,\n    \"automod:fns:clearRecentActionsForMessage\",\n  );\n  const message = context.message!;\n  const globalIdentifier = message.user_id;\n  const perChannelIdentifier = `${message.channel_id}-${message.user_id}`;\n\n  pluginData.state.recentActions = pluginData.state.recentActions.filter((act) => {\n    return act.identifier !== globalIdentifier && act.identifier !== perChannelIdentifier;\n  });\n  stopProfiling();\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts",
    "content": "import { z } from \"zod\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { humanizeDurationShort } from \"../../../humanizeDuration.js\";\nimport { getBaseUrl } from \"../../../pluginUtils.js\";\nimport { convertDelayStringToMS, sorter, zDelayString } from \"../../../utils.js\";\nimport { RecentActionType } from \"../constants.js\";\nimport { automodTrigger } from \"../helpers.js\";\nimport { findRecentSpam } from \"./findRecentSpam.js\";\nimport { getMatchingMessageRecentActions } from \"./getMatchingMessageRecentActions.js\";\nimport { getMessageSpamIdentifier } from \"./getSpamIdentifier.js\";\n\nexport interface TMessageSpamMatchResultType {\n  archiveId: string;\n}\n\nconst configSchema = z.strictObject({\n  amount: z.number().int(),\n  within: zDelayString,\n  per_channel: z.boolean().nullable().default(false),\n});\n\nexport function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) {\n  return automodTrigger<TMessageSpamMatchResultType>()({\n    configSchema,\n\n    async match({ pluginData, context, triggerConfig }) {\n      if (!context.message) {\n        return;\n      }\n\n      const spamIdentifier = getMessageSpamIdentifier(context.message, Boolean(triggerConfig.per_channel));\n\n      const recentSpam = findRecentSpam(pluginData, spamType, spamIdentifier);\n      if (recentSpam) {\n        if (recentSpam.archiveId) {\n          await pluginData.state.archives.addSavedMessagesToArchive(\n            recentSpam.archiveId,\n            [context.message],\n            pluginData.guild,\n          );\n        }\n\n        return {\n          silentClean: true,\n          extra: { archiveId: \"\" }, // FIXME: Fix up automod trigger match() typings so extra is not required when doing a silentClean\n        };\n      }\n\n      const within = convertDelayStringToMS(triggerConfig.within) ?? 0;\n      const matchedSpam = getMatchingMessageRecentActions(\n        pluginData,\n        context.message,\n        spamType,\n        spamIdentifier,\n        triggerConfig.amount,\n        within,\n      );\n\n      if (matchedSpam) {\n        const messages = matchedSpam.recentActions\n          .map((action) => action.context.message)\n          .filter(Boolean)\n          .sort(sorter(\"posted_at\")) as SavedMessage[];\n\n        const archiveId = await pluginData.state.archives.createFromSavedMessages(messages, pluginData.guild);\n\n        pluginData.state.recentSpam.push({\n          type: spamType,\n          identifiers: [spamIdentifier],\n          archiveId,\n          timestamp: Date.now(),\n        });\n\n        return {\n          extraContexts: matchedSpam.recentActions\n            .map((action) => action.context)\n            .filter((_context) => _context !== context),\n\n          extra: {\n            archiveId,\n          },\n        };\n      }\n    },\n\n    renderMatchInformation({ pluginData, matchResult, triggerConfig }) {\n      const baseUrl = getBaseUrl(pluginData);\n      const archiveUrl = pluginData.state.archives.getUrl(baseUrl, matchResult.extra.archiveId);\n      const withinMs = convertDelayStringToMS(triggerConfig.within);\n      const withinStr = humanizeDurationShort(withinMs);\n\n      return `Matched ${prettyName} spam (${triggerConfig.amount} in ${withinStr}): ${archiveUrl}`;\n    },\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/findRecentSpam.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { startProfiling } from \"../../../utils/easyProfiler.js\";\nimport { RecentActionType } from \"../constants.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport function findRecentSpam(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  type: RecentActionType,\n  identifier?: string,\n) {\n  const stopProfiling = startProfiling(pluginData.getVetyInstance().profiler, \"automod:fns:findRecentSpam\");\n  const result = pluginData.state.recentSpam.find((spam) => {\n    return spam.type === type && (!identifier || spam.identifiers.includes(identifier));\n  });\n  stopProfiling();\n  return result;\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/getMatchingMessageRecentActions.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { startProfiling } from \"../../../utils/easyProfiler.js\";\nimport { RecentActionType } from \"../constants.js\";\nimport { AutomodPluginType } from \"../types.js\";\nimport { getMatchingRecentActions } from \"./getMatchingRecentActions.js\";\n\nexport function getMatchingMessageRecentActions(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  message: SavedMessage,\n  type: RecentActionType,\n  identifier: string,\n  count: number,\n  within: number,\n) {\n  const stopProfiling = startProfiling(\n    pluginData.getVetyInstance().profiler,\n    \"automod:fns:getMatchingMessageRecentActions\",\n  );\n  const since = moment.utc(message.posted_at).valueOf() - within;\n  const to = moment.utc(message.posted_at).valueOf();\n  const recentActions = getMatchingRecentActions(pluginData, type, identifier, since, to);\n  const totalCount = recentActions.reduce((total, action) => total + action.count, 0);\n\n  stopProfiling();\n  if (totalCount >= count) {\n    return {\n      recentActions,\n    };\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/getMatchingRecentActions.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { startProfiling } from \"../../../utils/easyProfiler.js\";\nimport { RecentActionType } from \"../constants.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport function getMatchingRecentActions(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  type: RecentActionType,\n  identifier: string | null,\n  since: number,\n  to?: number,\n) {\n  const stopProfiling = startProfiling(pluginData.getVetyInstance().profiler, \"automod:fns:getMatchingRecentActions\");\n  to = to || Date.now();\n\n  const result = pluginData.state.recentActions.filter((action) => {\n    return (\n      action.type === type &&\n      (!identifier || action.identifier === identifier) &&\n      action.context.timestamp >= since &&\n      action.context.timestamp <= to! &&\n      !action.context.actioned\n    );\n  });\n  stopProfiling();\n  return result;\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/getSpamIdentifier.ts",
    "content": "import { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\n\nexport function getMessageSpamIdentifier(message: SavedMessage, perChannel: boolean) {\n  return perChannel ? `${message.channel_id}-${message.user_id}` : message.user_id;\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/getTextMatchPartialSummary.ts",
    "content": "import { ActivityType, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { messageSummary, verboseChannelMention } from \"../../../utils.js\";\nimport { AutomodContext, AutomodPluginType } from \"../types.js\";\nimport { MatchableTextType } from \"./matchMultipleTextTypesOnMessage.js\";\n\nexport function getTextMatchPartialSummary(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  type: MatchableTextType,\n  context: AutomodContext,\n) {\n  if (type === \"message\") {\n    const message = context.message!;\n    const channel = pluginData.guild.channels.cache.get(message.channel_id as Snowflake);\n    const channelMention = channel ? verboseChannelMention(channel) : `\\`#${message.channel_id}\\``;\n\n    return `message in ${channelMention}:\\n${messageSummary(message)}`;\n  } else if (type === \"embed\") {\n    const message = context.message!;\n    const channel = pluginData.guild.channels.cache.get(message.channel_id as Snowflake);\n    const channelMention = channel ? verboseChannelMention(channel) : `\\`#${message.channel_id}\\``;\n\n    return `message embed in ${channelMention}:\\n${messageSummary(message)}`;\n  } else if (type === \"username\") {\n    return `username: ${context.user!.username}`;\n  } else if (type === \"nickname\") {\n    return `nickname: ${context.member!.nickname}`;\n  } else if (type === \"visiblename\") {\n    const visibleName = context.member?.nickname || context.user!.username;\n    return `visible name: ${visibleName}`;\n  } else if (type === \"customstatus\") {\n    return `custom status: ${context.member!.presence?.activities.find((a) => a.type === ActivityType.Custom)?.name}`;\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/ignoredRoleChanges.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { MINUTES } from \"../../../utils.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nconst IGNORED_ROLE_CHANGE_LIFETIME = 5 * MINUTES;\n\nfunction cleanupIgnoredRoleChanges(pluginData: GuildPluginData<AutomodPluginType>) {\n  const cutoff = Date.now() - IGNORED_ROLE_CHANGE_LIFETIME;\n  for (const ignoredChange of pluginData.state.ignoredRoleChanges.values()) {\n    if (ignoredChange.timestamp < cutoff) {\n      pluginData.state.ignoredRoleChanges.delete(ignoredChange);\n    }\n  }\n}\n\nexport function ignoreRoleChange(pluginData: GuildPluginData<AutomodPluginType>, memberId: string, roleId: string) {\n  pluginData.state.ignoredRoleChanges.add({\n    memberId,\n    roleId,\n    timestamp: Date.now(),\n  });\n\n  cleanupIgnoredRoleChanges(pluginData);\n}\n\n/**\n * @return Whether the role change should be ignored\n */\nexport function consumeIgnoredRoleChange(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  memberId: string,\n  roleId: string,\n) {\n  for (const ignoredChange of pluginData.state.ignoredRoleChanges.values()) {\n    if (ignoredChange.memberId === memberId && ignoredChange.roleId === roleId) {\n      pluginData.state.ignoredRoleChanges.delete(ignoredChange);\n      return true;\n    }\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts",
    "content": "import { ActivityType, Embed } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { renderUsername, resolveMember } from \"../../../utils.js\";\nimport { DeepMutable } from \"../../../utils/typeUtils.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\ntype TextTriggerWithMultipleMatchTypes = {\n  match_messages: boolean;\n  match_embeds: boolean;\n  match_visible_names: boolean;\n  match_usernames: boolean;\n  match_nicknames: boolean;\n  match_custom_status: boolean;\n};\n\nexport type MatchableTextType = \"message\" | \"embed\" | \"visiblename\" | \"username\" | \"nickname\" | \"customstatus\";\n\ntype YieldedContent = [MatchableTextType, string];\n\n/**\n * Generator function that allows iterating through matchable pieces of text of a SavedMessage\n */\nexport async function* matchMultipleTextTypesOnMessage(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  trigger: TextTriggerWithMultipleMatchTypes,\n  msg: SavedMessage,\n): AsyncIterableIterator<YieldedContent> {\n  const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);\n  if (!member) return;\n\n  if (trigger.match_messages && msg.data.content) {\n    yield [\"message\", msg.data.content];\n  }\n\n  if (trigger.match_embeds && msg.data.embeds?.length) {\n    const copiedEmbed: DeepMutable<Embed> = JSON.parse(JSON.stringify(msg.data.embeds[0]));\n    if (copiedEmbed.video) {\n      copiedEmbed.description = \"\"; // The description is not rendered, hence it doesn't need to be matched\n    }\n    yield [\"embed\", JSON.stringify(copiedEmbed)];\n  }\n\n  if (trigger.match_visible_names) {\n    yield [\"visiblename\", member.displayName || msg.data.author.username];\n  }\n\n  if (trigger.match_usernames) {\n    yield [\"username\", renderUsername(msg.data.author.username, msg.data.author.discriminator)];\n  }\n\n  if (trigger.match_nicknames && member.nickname) {\n    yield [\"nickname\", member.nickname];\n  }\n\n  for (const activity of member.presence?.activities ?? []) {\n    if (activity.type === ActivityType.Custom) {\n      yield [\"customstatus\", `${activity.emoji} ${activity.name}`];\n      break;\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/resolveActionContactMethods.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { ERRORS, RecoverablePluginError } from \"../../../RecoverablePluginError.js\";\nimport { UserNotificationMethod, disableUserNotificationStrings } from \"../../../utils.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport function resolveActionContactMethods(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  actionConfig: {\n    notify?: string | null;\n    notifyChannel?: string | null;\n  },\n): UserNotificationMethod[] {\n  if (actionConfig.notify === \"dm\") {\n    return [{ type: \"dm\" }];\n  } else if (actionConfig.notify === \"channel\") {\n    if (!actionConfig.notifyChannel) {\n      throw new RecoverablePluginError(ERRORS.NO_USER_NOTIFICATION_CHANNEL);\n    }\n\n    const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake);\n    if (!channel?.isTextBased()) {\n      throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL);\n    }\n\n    return [{ type: \"channel\", channel }];\n  } else if (actionConfig.notify && disableUserNotificationStrings.includes(actionConfig.notify)) {\n    return [];\n  }\n\n  return [];\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/runAutomod.ts",
    "content": "import { GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { performance } from \"perf_hooks\";\nimport { calculateBlocking, profilingEnabled } from \"../../../utils/easyProfiler.js\";\nimport { availableActions } from \"../actions/availableActions.js\";\nimport { CleanAction } from \"../actions/clean.js\";\nimport { AutomodTriggerBlueprint, AutomodTriggerMatchResult } from \"../helpers.js\";\nimport { availableTriggers } from \"../triggers/availableTriggers.js\";\nimport { AutomodContext, AutomodPluginType, TRule } from \"../types.js\";\nimport { applyCooldown } from \"./applyCooldown.js\";\nimport { checkCooldown } from \"./checkCooldown.js\";\n\nconst ruleFailReason = {\n  disabled: \"rule is disabled\",\n  cooldown: \"rule is on cooldown\",\n  doesNotAffectBots: \"rule does not affect bots\",\n  doesNotAffectSelf: \"rule does not affect self\",\n  unknownUser: \"rule does not affect bots, and user is unknown\",\n  noMatch: \"no triggers matched\",\n};\n\ninterface MatchedTriggerResult {\n  name: string;\n  num: number;\n  config: AutomodTriggerBlueprint<any, any>;\n}\n\ninterface RuleResultOutcomeSuccess {\n  success: true;\n  matchedTrigger: MatchedTriggerResult;\n}\n\ninterface RuleResultOutcomeFailure {\n  success: false;\n  reason: (typeof ruleFailReason)[keyof typeof ruleFailReason];\n}\n\ntype RuleResultOutcome = RuleResultOutcomeSuccess | RuleResultOutcomeFailure;\n\ninterface RuleResult {\n  ruleName: string;\n  config: TRule;\n  outcome: RuleResultOutcome;\n}\n\ninterface AutomodRunResult {\n  triggered: boolean;\n  rulesChecked: RuleResult[];\n}\n\nexport async function runAutomod(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  context: AutomodContext,\n  dryRun = false,\n): Promise<AutomodRunResult> {\n  const userId = context.user?.id || context.member?.id || context.message?.user_id;\n  const user = context.user || (userId && pluginData.client.users!.cache.get(userId as Snowflake));\n  const member = context.member || (userId && pluginData.guild.members.cache.get(userId as Snowflake)) || null;\n\n  const channelIdOrThreadId = context.message?.channel_id;\n  const channelOrThread =\n    context.channel ??\n    (channelIdOrThreadId\n      ? (pluginData.guild.channels.cache.get(channelIdOrThreadId as Snowflake) as GuildTextBasedChannel)\n      : null);\n  const channelId = channelOrThread?.isThread() ? channelOrThread.parent?.id : channelIdOrThreadId;\n  const threadId = channelOrThread?.isThread() ? channelOrThread.id : null;\n  const channel = channelOrThread?.isThread() ? channelOrThread.parent : channelOrThread;\n  const categoryId = channel?.parentId;\n\n  const config = await pluginData.config.getMatchingConfig({\n    channelId,\n    categoryId,\n    threadId,\n    userId,\n    member,\n  });\n\n  const result: AutomodRunResult = {\n    triggered: false,\n    rulesChecked: [],\n  };\n\n  for (const [ruleName, rule] of Object.entries(config.rules)) {\n    const prettyName = rule.pretty_name;\n\n    const ruleResult: RuleResult = {\n      ruleName,\n      config: rule,\n      outcome: { success: false, reason: ruleFailReason.noMatch },\n    };\n    result.rulesChecked.push(ruleResult);\n\n    if (rule.enabled === false) {\n      ruleResult.outcome = { success: false, reason: ruleFailReason.disabled };\n      continue;\n    }\n    if (\n      !rule.affects_bots &&\n      (!user || user.bot) &&\n      !context.counterTrigger &&\n      !context.antiraid &&\n      !context.threadChange?.deleted\n    ) {\n      if (user) {\n        ruleResult.outcome = { success: false, reason: ruleFailReason.doesNotAffectBots };\n      } else {\n        ruleResult.outcome = { success: false, reason: ruleFailReason.unknownUser };\n      }\n      continue;\n    }\n    if (!rule.affects_self && userId && userId === pluginData.client.user?.id) {\n      ruleResult.outcome = { success: false, reason: ruleFailReason.doesNotAffectSelf };\n      continue;\n    }\n\n    if (rule.cooldown && checkCooldown(pluginData, rule, ruleName, context)) {\n      ruleResult.outcome = { success: false, reason: ruleFailReason.cooldown };\n      continue;\n    }\n\n    const ruleStartTime = performance.now();\n\n    let matchResult: AutomodTriggerMatchResult<any> | null | undefined;\n    let contexts: AutomodContext[] = [];\n\n    let triggerNum = 0;\n    triggerLoop: for (const triggerItem of rule.triggers) {\n      for (const [triggerName, triggerConfig] of Object.entries(triggerItem)) {\n        const triggerStartTime = performance.now();\n\n        const trigger = availableTriggers[triggerName];\n        triggerNum++;\n\n        let getBlockingTime: ReturnType<typeof calculateBlocking> | null = null;\n        if (profilingEnabled()) {\n          getBlockingTime = calculateBlocking();\n        }\n\n        matchResult = await trigger.match({\n          ruleName,\n          pluginData,\n          context,\n          triggerConfig,\n        });\n\n        if (profilingEnabled()) {\n          const blockingTime = getBlockingTime?.() || 0;\n          pluginData\n            .getVetyInstance()\n            .profiler.addDataPoint(\n              `automod:${pluginData.guild.id}:${ruleName}:triggers:${triggerName}:blocking`,\n              blockingTime,\n            );\n        }\n\n        if (matchResult) {\n          if (rule.cooldown) applyCooldown(pluginData, rule, ruleName, context);\n\n          contexts = [context, ...(matchResult.extraContexts || [])];\n\n          for (const _context of contexts) {\n            _context.actioned = true;\n          }\n\n          if (matchResult.silentClean) {\n            await CleanAction.apply({\n              ruleName,\n              pluginData,\n              contexts,\n              actionConfig: true,\n              matchResult,\n              prettyName,\n            });\n            return result;\n          }\n\n          matchResult.summary =\n            (await trigger.renderMatchInformation({\n              ruleName,\n              pluginData,\n              contexts,\n              matchResult,\n              triggerConfig,\n            })) ?? \"\";\n\n          matchResult.fullSummary = `Triggered automod rule **${prettyName ?? ruleName}**\\n${\n            matchResult.summary\n          }`.trim();\n        }\n\n        if (profilingEnabled()) {\n          pluginData\n            .getVetyInstance()\n            .profiler.addDataPoint(\n              `automod:${pluginData.guild.id}:${ruleName}:triggers:${triggerName}`,\n              performance.now() - triggerStartTime,\n            );\n        }\n\n        if (matchResult) {\n          ruleResult.outcome = {\n            success: true,\n            matchedTrigger: {\n              name: triggerName,\n              num: triggerNum,\n              config: trigger,\n            },\n          };\n\n          break triggerLoop;\n        }\n      }\n    }\n\n    if (matchResult && !dryRun) {\n      for (const [actionName, actionConfig] of Object.entries(rule.actions)) {\n        if (actionConfig == null || actionConfig === false) {\n          continue;\n        }\n\n        const actionStartTime = performance.now();\n\n        const action = availableActions[actionName];\n\n        action.apply({\n          ruleName,\n          pluginData,\n          contexts,\n          actionConfig,\n          matchResult,\n          prettyName,\n        });\n\n        if (profilingEnabled()) {\n          pluginData\n            .getVetyInstance()\n            .profiler.addDataPoint(\n              `automod:${pluginData.guild.id}:${ruleName}:actions:${actionName}`,\n              performance.now() - actionStartTime,\n            );\n        }\n      }\n\n      // Log all automod rules by default\n      if (rule.actions.log == null) {\n        availableActions.log.apply({\n          ruleName,\n          pluginData,\n          contexts,\n          actionConfig: true,\n          matchResult,\n          prettyName,\n        });\n      }\n    }\n\n    if (profilingEnabled()) {\n      pluginData\n        .getVetyInstance()\n        .profiler.addDataPoint(`automod:${pluginData.guild.id}:${ruleName}`, performance.now() - ruleStartTime);\n    }\n\n    if (matchResult && !rule.allow_further_rules) {\n      break;\n    }\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/setAntiraidLevel.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { runAutomodOnAntiraidLevel } from \"../events/runAutomodOnAntiraidLevel.js\";\nimport { AutomodPluginType } from \"../types.js\";\n\nexport async function setAntiraidLevel(\n  pluginData: GuildPluginData<AutomodPluginType>,\n  newLevel: string | null,\n  user?: User,\n) {\n  const oldLevel = pluginData.state.cachedAntiraidLevel;\n  pluginData.state.cachedAntiraidLevel = newLevel;\n  await pluginData.state.antiraidLevels.set(newLevel);\n\n  runAutomodOnAntiraidLevel(pluginData, newLevel, oldLevel, user);\n\n  const logs = pluginData.getPlugin(LogsPlugin);\n\n  if (user) {\n    logs.logSetAntiraidUser({\n      level: newLevel ?? \"off\",\n      user,\n    });\n  } else {\n    logs.logSetAntiraidAuto({\n      level: newLevel ?? \"off\",\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/functions/sumRecentActionCounts.ts",
    "content": "import { RecentAction } from \"../types.js\";\n\nexport function sumRecentActionCounts(actions: RecentAction[]) {\n  return actions.reduce((total, action) => total + action.count, 0);\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/helpers.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { z, type ZodTypeAny } from \"zod\";\nimport { Awaitable } from \"../../utils/typeUtils.js\";\nimport { AutomodContext, AutomodPluginType } from \"./types.js\";\n\ninterface BaseAutomodTriggerMatchResult {\n  extraContexts?: AutomodContext[];\n\n  silentClean?: boolean; // TODO: Maybe generalize to a \"silent\" value in general, which mutes alert/log\n\n  summary?: string;\n  fullSummary?: string;\n}\n\nexport type AutomodTriggerMatchResult<TExtra = unknown> = unknown extends TExtra\n  ? BaseAutomodTriggerMatchResult\n  : BaseAutomodTriggerMatchResult & { extra: TExtra };\n\ntype AutomodTriggerMatchFn<TConfigType, TMatchResultExtra> = (meta: {\n  ruleName: string;\n  pluginData: GuildPluginData<AutomodPluginType>;\n  context: AutomodContext;\n  triggerConfig: TConfigType;\n}) => Awaitable<null | undefined | AutomodTriggerMatchResult<TMatchResultExtra>>;\n\ntype AutomodTriggerRenderMatchInformationFn<TConfigType, TMatchResultExtra> = (meta: {\n  ruleName: string;\n  pluginData: GuildPluginData<AutomodPluginType>;\n  contexts: AutomodContext[];\n  triggerConfig: TConfigType;\n  matchResult: AutomodTriggerMatchResult<TMatchResultExtra>;\n}) => Awaitable<string>;\n\nexport interface AutomodTriggerBlueprint<TConfigSchema extends ZodTypeAny, TMatchResultExtra> {\n  configSchema: TConfigSchema;\n  match: AutomodTriggerMatchFn<z.output<TConfigSchema>, TMatchResultExtra>;\n  renderMatchInformation: AutomodTriggerRenderMatchInformationFn<z.output<TConfigSchema>, TMatchResultExtra>;\n}\n\nexport function automodTrigger<TMatchResultExtra>(): <TConfigSchema extends ZodTypeAny>(\n  blueprint: AutomodTriggerBlueprint<TConfigSchema, TMatchResultExtra>,\n) => AutomodTriggerBlueprint<TConfigSchema, TMatchResultExtra>;\n\nexport function automodTrigger<TConfigSchema extends ZodTypeAny>(\n  blueprint: AutomodTriggerBlueprint<TConfigSchema, unknown>,\n): AutomodTriggerBlueprint<TConfigSchema, unknown>;\n\nexport function automodTrigger(...args) {\n  if (args.length) {\n    return args[0];\n  } else {\n    return automodTrigger;\n  }\n}\n\ntype AutomodActionApplyFn<TConfigType> = (meta: {\n  ruleName: string;\n  pluginData: GuildPluginData<AutomodPluginType>;\n  contexts: AutomodContext[];\n  actionConfig: TConfigType;\n  matchResult: AutomodTriggerMatchResult;\n  prettyName: string | undefined;\n}) => Awaitable<void>;\n\nexport interface AutomodActionBlueprint<TConfigSchema extends ZodTypeAny> {\n  configSchema: TConfigSchema;\n  apply: AutomodActionApplyFn<z.output<TConfigSchema>>;\n}\n\nexport function automodAction<TConfigSchema extends ZodTypeAny>(\n  blueprint: AutomodActionBlueprint<TConfigSchema>,\n): AutomodActionBlueprint<TConfigSchema> {\n  return blueprint;\n}\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/antiraidLevel.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface AntiraidLevelTriggerResult {}\n\nconst configSchema = z.strictObject({\n  level: z.nullable(z.string().max(100)),\n  only_on_change: z.nullable(z.boolean()),\n});\n\nexport const AntiraidLevelTrigger = automodTrigger<AntiraidLevelTriggerResult>()({\n  configSchema,\n\n  async match({ triggerConfig, context }) {\n    if (!context.antiraid) {\n      return;\n    }\n\n    if (context.antiraid.level !== triggerConfig.level) {\n      return;\n    }\n\n    if (\n      triggerConfig.only_on_change &&\n      context.antiraid.oldLevel !== undefined &&\n      context.antiraid.level === context.antiraid.oldLevel\n    ) {\n      return;\n    }\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation({ contexts }) {\n    const newLevel = contexts[0].antiraid!.level;\n    return newLevel ? `Antiraid level was set to ${newLevel}` : `Antiraid was turned off`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/anyMessage.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { verboseChannelMention } from \"../../../utils.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface AnyMessageResultType {}\n\nconst configSchema = z.strictObject({});\n\nexport const AnyMessageTrigger = automodTrigger<AnyMessageResultType>()({\n  configSchema,\n\n  async match({ context }) {\n    if (!context.message) {\n      return;\n    }\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation({ pluginData, contexts }) {\n    const channel = pluginData.guild.channels.cache.get(contexts[0].message!.channel_id as Snowflake);\n    return `Matched message (\\`${contexts[0].message!.id}\\`) in ${\n      channel ? verboseChannelMention(channel) : \"Unknown Channel\"\n    }`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/attachmentSpam.ts",
    "content": "import { RecentActionType } from \"../constants.js\";\nimport { createMessageSpamTrigger } from \"../functions/createMessageSpamTrigger.js\";\n\nexport const AttachmentSpamTrigger = createMessageSpamTrigger(RecentActionType.Attachment, \"attachment\");\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/availableTriggers.ts",
    "content": "import { AutomodTriggerBlueprint } from \"../helpers.js\";\nimport { AntiraidLevelTrigger } from \"./antiraidLevel.js\";\nimport { AnyMessageTrigger } from \"./anyMessage.js\";\nimport { AttachmentSpamTrigger } from \"./attachmentSpam.js\";\nimport { BanTrigger } from \"./ban.js\";\nimport { CharacterSpamTrigger } from \"./characterSpam.js\";\nimport { CounterTrigger } from \"./counterTrigger.js\";\nimport { EmojiSpamTrigger } from \"./emojiSpam.js\";\nimport { HasAttachmentsTrigger } from \"./hasAttachments.js\";\nimport { KickTrigger } from \"./kick.js\";\nimport { LineSpamTrigger } from \"./lineSpam.js\";\nimport { LinkSpamTrigger } from \"./linkSpam.js\";\nimport { MatchAttachmentTypeTrigger } from \"./matchAttachmentType.js\";\nimport { MatchInvitesTrigger } from \"./matchInvites.js\";\nimport { MatchLinksTrigger } from \"./matchLinks.js\";\nimport { MatchMimeTypeTrigger } from \"./matchMimeType.js\";\nimport { MatchRegexTrigger } from \"./matchRegex.js\";\nimport { MatchWordsTrigger } from \"./matchWords.js\";\nimport { MemberJoinTrigger } from \"./memberJoin.js\";\nimport { MemberJoinSpamTrigger } from \"./memberJoinSpam.js\";\nimport { MemberLeaveTrigger } from \"./memberLeave.js\";\nimport { MentionSpamTrigger } from \"./mentionSpam.js\";\nimport { MessageSpamTrigger } from \"./messageSpam.js\";\nimport { MuteTrigger } from \"./mute.js\";\nimport { NoteTrigger } from \"./note.js\";\nimport { RoleAddedTrigger } from \"./roleAdded.js\";\nimport { RoleRemovedTrigger } from \"./roleRemoved.js\";\nimport { StickerSpamTrigger } from \"./stickerSpam.js\";\nimport { ThreadArchiveTrigger } from \"./threadArchive.js\";\nimport { ThreadCreateTrigger } from \"./threadCreate.js\";\nimport { ThreadCreateSpamTrigger } from \"./threadCreateSpam.js\";\nimport { ThreadDeleteTrigger } from \"./threadDelete.js\";\nimport { ThreadUnarchiveTrigger } from \"./threadUnarchive.js\";\nimport { UnbanTrigger } from \"./unban.js\";\nimport { UnmuteTrigger } from \"./unmute.js\";\nimport { WarnTrigger } from \"./warn.js\";\n\nexport const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = {\n  any_message: AnyMessageTrigger,\n\n  match_words: MatchWordsTrigger,\n  match_regex: MatchRegexTrigger,\n  match_invites: MatchInvitesTrigger,\n  match_links: MatchLinksTrigger,\n  has_attachments: HasAttachmentsTrigger,\n  match_attachment_type: MatchAttachmentTypeTrigger,\n  match_mime_type: MatchMimeTypeTrigger,\n  member_join: MemberJoinTrigger,\n  member_leave: MemberLeaveTrigger,\n  role_added: RoleAddedTrigger,\n  role_removed: RoleRemovedTrigger,\n\n  message_spam: MessageSpamTrigger,\n  mention_spam: MentionSpamTrigger,\n  link_spam: LinkSpamTrigger,\n  attachment_spam: AttachmentSpamTrigger,\n  emoji_spam: EmojiSpamTrigger,\n  line_spam: LineSpamTrigger,\n  character_spam: CharacterSpamTrigger,\n  member_join_spam: MemberJoinSpamTrigger,\n  sticker_spam: StickerSpamTrigger,\n  thread_create_spam: ThreadCreateSpamTrigger,\n\n  counter_trigger: CounterTrigger,\n\n  note: NoteTrigger,\n  warn: WarnTrigger,\n  mute: MuteTrigger,\n  unmute: UnmuteTrigger,\n  kick: KickTrigger,\n  ban: BanTrigger,\n  unban: UnbanTrigger,\n\n  antiraid_level: AntiraidLevelTrigger,\n\n  thread_create: ThreadCreateTrigger,\n  thread_delete: ThreadDeleteTrigger,\n  thread_archive: ThreadArchiveTrigger,\n  thread_unarchive: ThreadUnarchiveTrigger,\n};\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/ban.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\n// tslint:disable-next-line:no-empty-interface\ninterface BanTriggerResultType {}\n\nconst configSchema = z.strictObject({\n  manual: z.boolean().default(true),\n  automatic: z.boolean().default(true),\n});\n\nexport const BanTrigger = automodTrigger<BanTriggerResultType>()({\n  configSchema,\n\n  async match({ context, triggerConfig }) {\n    if (context.modAction?.type !== \"ban\") {\n      return;\n    }\n\n    // If automatic && automatic turned off -> return\n    if (context.modAction.isAutomodAction && !triggerConfig.automatic) return;\n    // If manual && manual turned off -> return\n    if (!context.modAction.isAutomodAction && !triggerConfig.manual) return;\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation() {\n    return `User was banned`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/characterSpam.ts",
    "content": "import { RecentActionType } from \"../constants.js\";\nimport { createMessageSpamTrigger } from \"../functions/createMessageSpamTrigger.js\";\n\nexport const CharacterSpamTrigger = createMessageSpamTrigger(RecentActionType.Character, \"character\");\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/counterTrigger.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\n// tslint:disable-next-line\ninterface CounterTriggerResult {}\n\nconst configSchema = z.strictObject({\n  counter: z.string().max(100),\n  trigger: z.string().max(100),\n  reverse: z.boolean().optional(),\n});\n\nexport const CounterTrigger = automodTrigger<CounterTriggerResult>()({\n  configSchema,\n\n  async match({ triggerConfig, context }) {\n    if (!context.counterTrigger) {\n      return;\n    }\n\n    if (context.counterTrigger.counter !== triggerConfig.counter) {\n      return;\n    }\n\n    if (context.counterTrigger.trigger !== triggerConfig.trigger) {\n      return;\n    }\n\n    const reverse = triggerConfig.reverse ?? false;\n    if (context.counterTrigger.reverse !== reverse) {\n      return;\n    }\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation({ contexts }) {\n    let str = `Matched counter trigger \\`${contexts[0].counterTrigger!.prettyCounter} / ${\n      contexts[0].counterTrigger!.prettyTrigger\n    }\\``;\n    if (contexts[0].counterTrigger!.reverse) {\n      str += \" (reverse)\";\n    }\n\n    return str;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/emojiSpam.ts",
    "content": "import { RecentActionType } from \"../constants.js\";\nimport { createMessageSpamTrigger } from \"../functions/createMessageSpamTrigger.js\";\n\nexport const EmojiSpamTrigger = createMessageSpamTrigger(RecentActionType.Emoji, \"emoji\");\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/exampleTrigger.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface ExampleMatchResultType {\n  isBanana: boolean;\n}\n\nconst configSchema = z.strictObject({\n  allowedFruits: z.array(z.string().max(100)).max(50).default([\"peach\", \"banana\"]),\n});\n\nexport const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({\n  configSchema,\n\n  async match({ triggerConfig, context }) {\n    const foundFruit = triggerConfig.allowedFruits.find((fruit) => context.message?.data.content === fruit);\n    if (foundFruit) {\n      return {\n        extra: {\n          isBanana: foundFruit === \"banana\",\n        },\n      };\n    }\n  },\n\n  renderMatchInformation({ matchResult }) {\n    return `Matched fruit, isBanana: ${matchResult.extra.isBanana ? \"yes\" : \"no\"}`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/hasAttachments.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport z from \"zod\";\nimport { asSingleLine, messageSummary, verboseChannelMention } from \"../../../utils.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface HasAttachmentsMatchResult {\n  hasAttachments: boolean;\n  attachmentCount: number;\n}\n\nconst configSchema = z.strictObject({\n  min_count: z.number().int().min(0).nullable().default(1),\n  max_count: z.number().int().nullable().default(null),\n});\n\nexport const HasAttachmentsTrigger = automodTrigger<HasAttachmentsMatchResult>()({\n  configSchema,\n\n  async match({ context, triggerConfig }) {\n    if (!context.message) {\n      return;\n    }\n    if (triggerConfig.min_count == null && triggerConfig.max_count == null) {\n      return;\n    }\n\n    const attachments = context.message.data.attachments;\n    const attachmentCount = attachments?.length ?? 0;\n    const hasAttachments = attachmentCount > 0;\n    const matchesMinCount = triggerConfig.min_count != null ? attachmentCount >= triggerConfig.min_count : true;\n    const matchesMaxCount = triggerConfig.max_count != null ? attachmentCount <= triggerConfig.max_count : true;\n\n    if (matchesMinCount && matchesMaxCount) {\n      return {\n        extra: {\n          hasAttachments,\n          attachmentCount,\n        },\n      };\n    }\n\n    return null;\n  },\n\n  renderMatchInformation({ pluginData, contexts, matchResult }) {\n    const message = contexts[0].message!;\n    const channel = pluginData.guild.channels.cache.get(message.channel_id as Snowflake);\n    const prettyChannel = channel ? verboseChannelMention(channel) : \"Unknown Channel\";\n    const descriptor = matchResult.extra.hasAttachments ? \"has\" : \"does not have\";\n\n    return (\n      asSingleLine(`\n        Matched message (\\`${message.id}\\`) that ${descriptor} attachments\n        (${matchResult.extra.attachmentCount}) in ${prettyChannel}:\n      `) + messageSummary(message)\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/kick.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\n// tslint:disable-next-line:no-empty-interface\ninterface KickTriggerResultType {}\n\nconst configSchema = z.strictObject({\n  manual: z.boolean().default(true),\n  automatic: z.boolean().default(true),\n});\n\nexport const KickTrigger = automodTrigger<KickTriggerResultType>()({\n  configSchema,\n\n  async match({ context, triggerConfig }) {\n    if (context.modAction?.type !== \"kick\") {\n      return;\n    }\n    // If automatic && automatic turned off -> return\n    if (context.modAction.isAutomodAction && !triggerConfig.automatic) return;\n    // If manual && manual turned off -> return\n    if (!context.modAction.isAutomodAction && !triggerConfig.manual) return;\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation() {\n    return `User was kicked`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/lineSpam.ts",
    "content": "import { RecentActionType } from \"../constants.js\";\nimport { createMessageSpamTrigger } from \"../functions/createMessageSpamTrigger.js\";\n\nexport const LineSpamTrigger = createMessageSpamTrigger(RecentActionType.Line, \"line\");\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/linkSpam.ts",
    "content": "import { RecentActionType } from \"../constants.js\";\nimport { createMessageSpamTrigger } from \"../functions/createMessageSpamTrigger.js\";\n\nexport const LinkSpamTrigger = createMessageSpamTrigger(RecentActionType.Link, \"link\");\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/matchAttachmentType.ts",
    "content": "import { escapeInlineCode, Snowflake } from \"discord.js\";\nimport { extname } from \"path\";\nimport { z } from \"zod\";\nimport { asSingleLine, messageSummary, verboseChannelMention } from \"../../../utils.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface MatchResultType {\n  matchedType: string;\n  mode: \"blacklist\" | \"whitelist\";\n}\n\nconst configSchema = z.strictObject({\n  whitelist_enabled: z.boolean().default(false),\n  filetype_whitelist: z.array(z.string().max(32)).max(255).default([]),\n  blacklist_enabled: z.boolean().default(false),\n  filetype_blacklist: z.array(z.string().max(32)).max(255).default([]),\n});\n\nexport const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({\n  configSchema,\n\n  async match({ context, triggerConfig: trigger }) {\n    if (!context.message) {\n      return;\n    }\n\n    if (!context.message.data.attachments) {\n      return null;\n    }\n\n    for (const attachment of context.message.data.attachments) {\n      const attachmentType = extname(new URL(attachment.url).pathname).slice(1).toLowerCase();\n\n      const blacklist = trigger.blacklist_enabled\n        ? (trigger.filetype_blacklist || []).map((_t) => _t.toLowerCase())\n        : null;\n\n      if (blacklist && blacklist.includes(attachmentType)) {\n        return {\n          extra: {\n            matchedType: attachmentType,\n            mode: \"blacklist\",\n          },\n        };\n      }\n\n      const whitelist = trigger.whitelist_enabled\n        ? (trigger.filetype_whitelist || []).map((_t) => _t.toLowerCase())\n        : null;\n\n      if (whitelist && !whitelist.includes(attachmentType)) {\n        return {\n          extra: {\n            matchedType: attachmentType,\n            mode: \"whitelist\",\n          },\n        };\n      }\n    }\n\n    return null;\n  },\n\n  renderMatchInformation({ pluginData, contexts, matchResult }) {\n    const channel = pluginData.guild.channels.cache.get(contexts[0].message!.channel_id as Snowflake)!;\n    const prettyChannel = verboseChannelMention(channel);\n\n    return (\n      asSingleLine(`\n        Matched attachment type \\`${escapeInlineCode(matchResult.extra.matchedType)}\\`\n        (${matchResult.extra.mode === \"blacklist\" ? \"blacklisted\" : \"not in whitelist\"})\n        in message (\\`${contexts[0].message!.id}\\`) in ${prettyChannel}:\n      `) + messageSummary(contexts[0].message!)\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/matchInvites.ts",
    "content": "import { z } from \"zod\";\nimport { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, zSnowflake } from \"../../../utils.js\";\nimport { getTextMatchPartialSummary } from \"../functions/getTextMatchPartialSummary.js\";\nimport { MatchableTextType, matchMultipleTextTypesOnMessage } from \"../functions/matchMultipleTextTypesOnMessage.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface MatchResultType {\n  type: MatchableTextType;\n  code: string;\n  invite?: GuildInvite;\n}\n\nconst configSchema = z.strictObject({\n  include_guilds: z.array(zSnowflake).max(255).optional(),\n  exclude_guilds: z.array(zSnowflake).max(255).optional(),\n  include_invite_codes: z.array(z.string().max(32)).max(255).optional(),\n  exclude_invite_codes: z.array(z.string().max(32)).max(255).optional(),\n  include_custom_invite_codes: z\n    .array(z.string().max(32))\n    .max(255)\n    .transform((arr) => arr.map((str) => str.toLowerCase()))\n    .optional(),\n  exclude_custom_invite_codes: z\n    .array(z.string().max(32))\n    .max(255)\n    .transform((arr) => arr.map((str) => str.toLowerCase()))\n    .optional(),\n  allow_group_dm_invites: z.boolean().default(false),\n  match_messages: z.boolean().default(true),\n  match_embeds: z.boolean().default(false),\n  match_visible_names: z.boolean().default(false),\n  match_usernames: z.boolean().default(false),\n  match_nicknames: z.boolean().default(false),\n  match_custom_status: z.boolean().default(false),\n});\n\nexport const MatchInvitesTrigger = automodTrigger<MatchResultType>()({\n  configSchema,\n\n  async match({ pluginData, context, triggerConfig: trigger }) {\n    if (!context.message) {\n      return;\n    }\n\n    for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {\n      const inviteCodes = getInviteCodesInString(str);\n      if (inviteCodes.length === 0) continue;\n\n      const uniqueInviteCodes = Array.from(new Set(inviteCodes));\n\n      for (const code of uniqueInviteCodes) {\n        if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) {\n          return { extra: { type, code } };\n        }\n        if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) {\n          return { extra: { type, code } };\n        }\n        if (trigger.include_custom_invite_codes && trigger.include_custom_invite_codes.includes(code.toLowerCase())) {\n          return { extra: { type, code } };\n        }\n        if (trigger.exclude_custom_invite_codes && !trigger.exclude_custom_invite_codes.includes(code.toLowerCase())) {\n          return { extra: { type, code } };\n        }\n      }\n\n      for (const code of uniqueInviteCodes) {\n        const invite = await resolveInvite(pluginData.client, code);\n        if (!invite || !isGuildInvite(invite)) return { extra: { type, code } };\n\n        if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) {\n          return { extra: { type, code, invite } };\n        }\n        if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) {\n          return { extra: { type, code, invite } };\n        }\n      }\n    }\n\n    return null;\n  },\n\n  renderMatchInformation({ pluginData, contexts, matchResult }) {\n    let matchedText;\n\n    if (matchResult.extra.invite) {\n      const invite = matchResult.extra.invite as GuildInvite;\n      matchedText = `invite code \\`${matchResult.extra.code}\\` (**${invite.guild.name}**, \\`${invite.guild.id}\\`)`;\n    } else {\n      matchedText = `invite code \\`${matchResult.extra.code}\\``;\n    }\n\n    const partialSummary = getTextMatchPartialSummary(pluginData, matchResult.extra.type, contexts[0]);\n    return `Matched ${matchedText} in ${partialSummary}`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/matchLinks.ts",
    "content": "import { escapeInlineCode } from \"discord.js\";\nimport { z } from \"zod\";\nimport { allowTimeout } from \"../../../RegExpRunner.js\";\nimport { getFishFishDomain } from \"../../../data/FishFish.js\";\nimport { getUrlsInString, inputPatternToRegExp, zRegex } from \"../../../utils.js\";\nimport { mergeRegexes } from \"../../../utils/mergeRegexes.js\";\nimport { mergeWordsIntoRegex } from \"../../../utils/mergeWordsIntoRegex.js\";\nimport { getTextMatchPartialSummary } from \"../functions/getTextMatchPartialSummary.js\";\nimport { MatchableTextType, matchMultipleTextTypesOnMessage } from \"../functions/matchMultipleTextTypesOnMessage.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface MatchResultType {\n  type: MatchableTextType;\n  link: string;\n  details?: string;\n}\n\nconst regexCache = new WeakMap<any, RegExp[]>();\n\nconst quickLinkCheck = /^https?:\\/\\//i;\n\nconst configSchema = z.strictObject({\n  include_domains: z.array(z.string().max(255)).max(700).optional(),\n  exclude_domains: z.array(z.string().max(255)).max(700).optional(),\n  include_subdomains: z.boolean().default(true),\n  include_words: z.array(z.string().max(2000)).max(700).optional(),\n  exclude_words: z.array(z.string().max(2000)).max(700).optional(),\n  include_regex: z\n    .array(zRegex(z.string().max(2000)))\n    .max(512)\n    .optional(),\n  exclude_regex: z\n    .array(zRegex(z.string().max(2000)))\n    .max(512)\n    .optional(),\n  phisherman: z\n    .strictObject({\n      include_suspected: z.boolean().optional(),\n      include_verified: z.boolean().optional(),\n    })\n    .optional(),\n  include_malicious: z.boolean().default(false),\n  only_real_links: z.boolean().default(true),\n  match_messages: z.boolean().default(true),\n  match_embeds: z.boolean().default(true),\n  match_visible_names: z.boolean().default(false),\n  match_usernames: z.boolean().default(false),\n  match_nicknames: z.boolean().default(false),\n  match_custom_status: z.boolean().default(false),\n});\n\nexport const MatchLinksTrigger = automodTrigger<MatchResultType>()({\n  configSchema,\n\n  async match({ pluginData, context, triggerConfig: trigger }) {\n    if (!context.message) {\n      return;\n    }\n\n    typeLoop: for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {\n      const links = getUrlsInString(str, true);\n\n      for (const link of links) {\n        // \"real link\" = a link that Discord highlights\n        if (trigger.only_real_links && !quickLinkCheck.test(link.input)) {\n          continue;\n        }\n\n        const normalizedHostname = link.hostname.toLowerCase();\n\n        // Exclude > Include\n        // In order of specificity, regex > word > domain\n\n        if (trigger.exclude_regex) {\n          if (!regexCache.has(trigger.exclude_regex)) {\n            const toCache = mergeRegexes(\n              trigger.exclude_regex.map((pattern) => inputPatternToRegExp(pattern)),\n              \"i\",\n            );\n            regexCache.set(trigger.exclude_regex, toCache);\n          }\n          const regexes = regexCache.get(trigger.exclude_regex)!;\n\n          for (const sourceRegex of regexes) {\n            const matches = await pluginData.state.regexRunner.exec(sourceRegex, link.input).catch(allowTimeout);\n            if (matches) {\n              continue typeLoop;\n            }\n          }\n        }\n\n        if (trigger.include_regex) {\n          if (!regexCache.has(trigger.include_regex)) {\n            const toCache = mergeRegexes(\n              trigger.include_regex.map((pattern) => inputPatternToRegExp(pattern)),\n              \"i\",\n            );\n            regexCache.set(trigger.include_regex, toCache);\n          }\n          const regexes = regexCache.get(trigger.include_regex)!;\n\n          for (const sourceRegex of regexes) {\n            const matches = await pluginData.state.regexRunner.exec(sourceRegex, link.input).catch(allowTimeout);\n            if (matches) {\n              return { extra: { type, link: link.input } };\n            }\n          }\n        }\n\n        if (trigger.exclude_words) {\n          if (!regexCache.has(trigger.exclude_words)) {\n            const toCache = mergeWordsIntoRegex(trigger.exclude_words, \"i\");\n            regexCache.set(trigger.exclude_words, [toCache]);\n          }\n          const regexes = regexCache.get(trigger.exclude_words)!;\n\n          for (const regex of regexes) {\n            if (regex.test(link.input)) {\n              continue typeLoop;\n            }\n          }\n        }\n\n        if (trigger.include_words) {\n          if (!regexCache.has(trigger.include_words)) {\n            const toCache = mergeWordsIntoRegex(trigger.include_words, \"i\");\n            regexCache.set(trigger.include_words, [toCache]);\n          }\n          const regexes = regexCache.get(trigger.include_words)!;\n\n          for (const regex of regexes) {\n            if (regex.test(link.input)) {\n              return { extra: { type, link: link.input } };\n            }\n          }\n        }\n\n        if (trigger.exclude_domains) {\n          for (const domain of trigger.exclude_domains) {\n            const normalizedDomain = domain.toLowerCase();\n            if (normalizedDomain === normalizedHostname) {\n              continue typeLoop;\n            }\n            if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {\n              continue typeLoop;\n            }\n          }\n\n          return { extra: { type, link: link.toString() } };\n        }\n\n        if (trigger.include_domains) {\n          for (const domain of trigger.include_domains) {\n            const normalizedDomain = domain.toLowerCase();\n            if (normalizedDomain === normalizedHostname) {\n              return { extra: { type, link: domain } };\n            }\n            if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {\n              return { extra: { type, link: domain } };\n            }\n          }\n        }\n\n        const includeMalicious =\n          trigger.include_malicious || trigger.phisherman?.include_suspected || trigger.phisherman?.include_verified;\n        if (includeMalicious) {\n          const domainInfo = getFishFishDomain(normalizedHostname);\n          if (domainInfo && domainInfo.category !== \"safe\") {\n            return {\n              extra: {\n                type,\n                link: link.input,\n                details: `(known ${domainInfo.category} domain)`,\n              },\n            };\n          }\n        }\n      }\n    }\n\n    return null;\n  },\n\n  renderMatchInformation({ pluginData, contexts, matchResult }) {\n    const partialSummary = getTextMatchPartialSummary(pluginData, matchResult.extra.type, contexts[0]);\n    let information = `Matched link \\`${escapeInlineCode(matchResult.extra.link)}\\``;\n    if (matchResult.extra.details) {\n      information += ` ${matchResult.extra.details}`;\n    }\n    information += ` in ${partialSummary}`;\n    return information;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/matchMimeType.ts",
    "content": "import { escapeInlineCode } from \"discord.js\";\nimport { z } from \"zod\";\nimport { asSingleLine, messageSummary, verboseChannelMention } from \"../../../utils.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface MatchResultType {\n  matchedType: string;\n  mode: \"blacklist\" | \"whitelist\";\n}\n\nconst configSchema = z.strictObject({\n  whitelist_enabled: z.boolean().default(false),\n  mime_type_whitelist: z.array(z.string().max(32)).max(255).default([]),\n  blacklist_enabled: z.boolean().default(false),\n  mime_type_blacklist: z.array(z.string().max(32)).max(255).default([]),\n});\n\nexport const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({\n  configSchema,\n\n  async match({ context, triggerConfig: trigger }) {\n    if (!context.message) return;\n\n    const { attachments } = context.message.data;\n    if (!attachments) return null;\n\n    for (const attachment of attachments) {\n      const { contentType: rawContentType } = attachment;\n      const contentType = (rawContentType || \"\").split(\";\")[0]; // Remove \"; charset=utf8\" and similar from the end\n\n      const blacklist = trigger.blacklist_enabled\n        ? (trigger.mime_type_blacklist ?? []).map((_t) => _t.toLowerCase())\n        : null;\n\n      if (contentType && blacklist?.includes(contentType)) {\n        return {\n          extra: {\n            matchedType: contentType,\n            mode: \"blacklist\",\n          },\n        };\n      }\n\n      const whitelist = trigger.whitelist_enabled\n        ? (trigger.mime_type_whitelist ?? []).map((_t) => _t.toLowerCase())\n        : null;\n\n      if (whitelist && (!contentType || !whitelist.includes(contentType))) {\n        return {\n          extra: {\n            matchedType: contentType || \"<unknown>\",\n            mode: \"whitelist\",\n          },\n        };\n      }\n\n      return null;\n    }\n  },\n\n  renderMatchInformation({ pluginData, contexts, matchResult }) {\n    const { message } = contexts[0];\n    const channel = pluginData.guild.channels.resolve(message!.channel_id)!;\n    const prettyChannel = verboseChannelMention(channel);\n    const { matchedType, mode } = matchResult.extra;\n\n    return (\n      asSingleLine(`\n        Matched MIME type \\`${escapeInlineCode(matchedType)}\\`\n        (${mode === \"blacklist\" ? \"blacklisted\" : \"not in whitelist\"})\n        in message (\\`${message!.id}\\`) in ${prettyChannel}\n      `) + messageSummary(message!)\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/matchRegex.ts",
    "content": "import { z } from \"zod\";\nimport { allowTimeout } from \"../../../RegExpRunner.js\";\nimport { inputPatternToRegExp, zRegex } from \"../../../utils.js\";\nimport { mergeRegexes } from \"../../../utils/mergeRegexes.js\";\nimport { normalizeText } from \"../../../utils/normalizeText.js\";\nimport { stripMarkdown } from \"../../../utils/stripMarkdown.js\";\nimport { getTextMatchPartialSummary } from \"../functions/getTextMatchPartialSummary.js\";\nimport { MatchableTextType, matchMultipleTextTypesOnMessage } from \"../functions/matchMultipleTextTypesOnMessage.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface MatchResultType {\n  pattern: string;\n  type: MatchableTextType;\n}\n\nconst configSchema = z.strictObject({\n  patterns: z.array(zRegex(z.string().max(2000))).max(512),\n  case_sensitive: z.boolean().default(false),\n  normalize: z.boolean().default(false),\n  strip_markdown: z.boolean().default(false),\n  match_messages: z.boolean().default(true),\n  match_embeds: z.boolean().default(false),\n  match_visible_names: z.boolean().default(false),\n  match_usernames: z.boolean().default(false),\n  match_nicknames: z.boolean().default(false),\n  match_custom_status: z.boolean().default(false),\n});\n\nconst regexCache = new WeakMap<any, RegExp[]>();\n\nexport const MatchRegexTrigger = automodTrigger<MatchResultType>()({\n  configSchema,\n\n  async match({ pluginData, context, triggerConfig: trigger }) {\n    if (!context.message) {\n      return;\n    }\n\n    if (!regexCache.has(trigger)) {\n      const flags = trigger.case_sensitive ? \"\" : \"i\";\n      const toCache = mergeRegexes(\n        trigger.patterns.map((pattern) => inputPatternToRegExp(pattern)),\n        flags,\n      );\n      regexCache.set(trigger, toCache);\n    }\n    const regexes = regexCache.get(trigger)!;\n\n    for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {\n      if (trigger.strip_markdown) {\n        str = stripMarkdown(str);\n      }\n\n      if (trigger.normalize) {\n        str = normalizeText(str);\n      }\n\n      for (const regex of regexes) {\n        const matches = await pluginData.state.regexRunner.exec(regex, str).catch(allowTimeout);\n        if (matches?.length) {\n          return {\n            extra: {\n              pattern: regex.source,\n              type,\n            },\n          };\n        }\n      }\n    }\n\n    return null;\n  },\n\n  renderMatchInformation({ pluginData, contexts, matchResult }) {\n    const partialSummary = getTextMatchPartialSummary(pluginData, matchResult.extra.type, contexts[0]);\n    return `Matched regex in ${partialSummary}`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/matchWords.ts",
    "content": "import escapeStringRegexp from \"escape-string-regexp\";\nimport { z } from \"zod\";\nimport { normalizeText } from \"../../../utils/normalizeText.js\";\nimport { stripMarkdown } from \"../../../utils/stripMarkdown.js\";\nimport { getTextMatchPartialSummary } from \"../functions/getTextMatchPartialSummary.js\";\nimport { MatchableTextType, matchMultipleTextTypesOnMessage } from \"../functions/matchMultipleTextTypesOnMessage.js\";\nimport { automodTrigger } from \"../helpers.js\";\nimport { escapeInlineCode } from \"discord.js\";\n\ninterface MatchResultType {\n  word: string;\n  type: MatchableTextType;\n}\n\nconst regexCache = new WeakMap<any, RegExp[]>();\n\nconst configSchema = z.strictObject({\n  words: z.array(z.string().max(2000)).max(1024),\n  case_sensitive: z.boolean().default(false),\n  only_full_words: z.boolean().default(true),\n  normalize: z.boolean().default(false),\n  loose_matching: z.boolean().default(false),\n  loose_matching_threshold: z.number().int().default(1),\n  strip_markdown: z.boolean().default(false),\n  match_messages: z.boolean().default(true),\n  match_embeds: z.boolean().default(false),\n  match_visible_names: z.boolean().default(false),\n  match_usernames: z.boolean().default(false),\n  match_nicknames: z.boolean().default(false),\n  match_custom_status: z.boolean().default(false),\n});\n\nexport const MatchWordsTrigger = automodTrigger<MatchResultType>()({\n  configSchema,\n  async match({ pluginData, context, triggerConfig: trigger }) {\n    if (!context.message) {\n      return;\n    }\n\n    if (!regexCache.has(trigger)) {\n      const looseMatchingThreshold = Math.min(Math.max(trigger.loose_matching_threshold, 1), 64);\n\n      const patterns = trigger.words.map((word) => {\n        let pattern;\n\n        if (trigger.loose_matching) {\n          pattern = [...word].map((c) => escapeStringRegexp(c)).join(`[\\\\s\\\\-_.,!?]{0,${looseMatchingThreshold}}`);\n        } else {\n          pattern = escapeStringRegexp(word);\n        }\n\n        if (trigger.only_full_words) {\n          if (trigger.loose_matching) {\n            pattern = `\\\\b(?:${pattern})\\\\b`;\n          } else {\n            pattern = `\\\\b${pattern}\\\\b`;\n          }\n        }\n\n        return pattern;\n      });\n\n      const mergedRegex = new RegExp(patterns.map((p) => `(${p})`).join(\"|\"), trigger.case_sensitive ? \"\" : \"i\");\n\n      regexCache.set(trigger, [mergedRegex]);\n    }\n\n    const regexes = regexCache.get(trigger)!;\n\n    for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {\n      if (trigger.strip_markdown) {\n        str = stripMarkdown(str);\n      }\n\n      if (trigger.normalize) {\n        str = normalizeText(str);\n      }\n\n      for (const regex of regexes) {\n        const match = regex.exec(str);\n        if (match) {\n          const matchedWordIndex = match.slice(1).findIndex((group) => group !== undefined);\n          const matchedWord = trigger.words[matchedWordIndex];\n\n          return {\n            extra: {\n              type,\n              word: matchedWord,\n            },\n          };\n        }\n      }\n    }\n\n    return null;\n  },\n\n  renderMatchInformation({ pluginData, contexts, matchResult }) {\n    const partialSummary = getTextMatchPartialSummary(pluginData, matchResult.extra.type, contexts[0]);\n    const wordInfo = matchResult.extra.word ? ` (\\`${escapeInlineCode(matchResult.extra.word)}\\`)` : \"\";\n    return `Matched word${wordInfo} in ${partialSummary}`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/memberJoin.ts",
    "content": "import { z } from \"zod\";\nimport { convertDelayStringToMS, zDelayString } from \"../../../utils.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\nconst configSchema = z.strictObject({\n  only_new: z.boolean().default(false),\n  new_threshold: zDelayString.default(\"1h\"),\n});\n\nexport const MemberJoinTrigger = automodTrigger<unknown>()({\n  configSchema,\n\n  async match({ context, triggerConfig }) {\n    if (!context.joined || !context.member) {\n      return;\n    }\n\n    if (triggerConfig.only_new) {\n      const threshold = Date.now() - convertDelayStringToMS(triggerConfig.new_threshold)!;\n      return context.member.user.createdTimestamp >= threshold ? {} : null;\n    }\n\n    return {};\n  },\n\n  renderMatchInformation() {\n    return \"\";\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/memberJoinSpam.ts",
    "content": "import { z } from \"zod\";\nimport { convertDelayStringToMS, zDelayString } from \"../../../utils.js\";\nimport { RecentActionType } from \"../constants.js\";\nimport { findRecentSpam } from \"../functions/findRecentSpam.js\";\nimport { getMatchingRecentActions } from \"../functions/getMatchingRecentActions.js\";\nimport { sumRecentActionCounts } from \"../functions/sumRecentActionCounts.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\nconst configSchema = z.strictObject({\n  amount: z.number().int(),\n  within: zDelayString,\n});\n\nexport const MemberJoinSpamTrigger = automodTrigger<unknown>()({\n  configSchema,\n\n  async match({ pluginData, context, triggerConfig }) {\n    if (!context.joined || !context.member) {\n      return;\n    }\n\n    const recentSpam = findRecentSpam(pluginData, RecentActionType.MemberJoin);\n    if (recentSpam) {\n      context.actioned = true;\n      return {};\n    }\n\n    const since = Date.now() - convertDelayStringToMS(triggerConfig.within)!;\n    const matchingActions = getMatchingRecentActions(pluginData, RecentActionType.MemberJoin, null, since);\n    const totalCount = sumRecentActionCounts(matchingActions);\n\n    if (totalCount >= triggerConfig.amount) {\n      const extraContexts = matchingActions.map((a) => a.context).filter((c) => c !== context);\n\n      pluginData.state.recentSpam.push({\n        type: RecentActionType.MemberJoin,\n        timestamp: Date.now(),\n        archiveId: null,\n        identifiers: [],\n      });\n\n      return {\n        extraContexts,\n      };\n    }\n  },\n\n  renderMatchInformation() {\n    return \"\";\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/memberLeave.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\nconst configSchema = z.strictObject({});\n\nexport const MemberLeaveTrigger = automodTrigger<unknown>()({\n  configSchema,\n\n  async match({ context }) {\n    if (!context.joined || !context.member) {\n      return;\n    }\n\n    return {};\n  },\n\n  renderMatchInformation() {\n    return \"\";\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/mentionSpam.ts",
    "content": "import { RecentActionType } from \"../constants.js\";\nimport { createMessageSpamTrigger } from \"../functions/createMessageSpamTrigger.js\";\n\nexport const MentionSpamTrigger = createMessageSpamTrigger(RecentActionType.Mention, \"mention\");\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/messageSpam.ts",
    "content": "import { RecentActionType } from \"../constants.js\";\nimport { createMessageSpamTrigger } from \"../functions/createMessageSpamTrigger.js\";\n\nexport const MessageSpamTrigger = createMessageSpamTrigger(RecentActionType.Message, \"message\");\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/mute.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\n// tslint:disable-next-line:no-empty-interface\ninterface MuteTriggerResultType {}\n\nconst configSchema = z.strictObject({\n  manual: z.boolean().default(true),\n  automatic: z.boolean().default(true),\n});\n\nexport const MuteTrigger = automodTrigger<MuteTriggerResultType>()({\n  configSchema,\n\n  async match({ context, triggerConfig }) {\n    if (context.modAction?.type !== \"mute\") {\n      return;\n    }\n    // If automatic && automatic turned off -> return\n    if (context.modAction.isAutomodAction && !triggerConfig.automatic) return;\n    // If manual && manual turned off -> return\n    if (!context.modAction.isAutomodAction && !triggerConfig.manual) return;\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation() {\n    return `User was muted`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/note.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\n// tslint:disable-next-line:no-empty-interface\ninterface NoteTriggerResultType {}\n\nconst configSchema = z.strictObject({});\n\nexport const NoteTrigger = automodTrigger<NoteTriggerResultType>()({\n  configSchema,\n\n  async match({ context }) {\n    if (context.modAction?.type !== \"note\") {\n      return;\n    }\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation() {\n    return `Note was added on user`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/roleAdded.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { renderUsername, zSnowflake } from \"../../../utils.js\";\nimport { consumeIgnoredRoleChange } from \"../functions/ignoredRoleChanges.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface RoleAddedMatchResult {\n  matchedRoleId: string;\n}\n\nconst configSchema = z.union([zSnowflake, z.array(zSnowflake).max(255)]).default([]);\n\nexport const RoleAddedTrigger = automodTrigger<RoleAddedMatchResult>()({\n  configSchema,\n\n  async match({ triggerConfig, context, pluginData }) {\n    if (!context.member || !context.rolesChanged || context.rolesChanged.added!.length === 0) {\n      return;\n    }\n\n    const triggerRoles = Array.isArray(triggerConfig) ? triggerConfig : [triggerConfig];\n    for (const roleId of triggerRoles) {\n      if (context.rolesChanged.added!.includes(roleId)) {\n        if (consumeIgnoredRoleChange(pluginData, context.member.id, roleId)) {\n          continue;\n        }\n\n        return {\n          extra: {\n            matchedRoleId: roleId,\n          },\n        };\n      }\n    }\n  },\n\n  renderMatchInformation({ matchResult, pluginData, contexts }) {\n    const role = pluginData.guild.roles.cache.get(matchResult.extra.matchedRoleId as Snowflake);\n    const roleName = role?.name || \"Unknown\";\n    const member = contexts[0].member!;\n    const memberName = `**${renderUsername(member)}** (\\`${member.id}\\`)`;\n    return `Role ${roleName} (\\`${matchResult.extra.matchedRoleId}\\`) was added to ${memberName}`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/roleRemoved.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { renderUsername, zSnowflake } from \"../../../utils.js\";\nimport { consumeIgnoredRoleChange } from \"../functions/ignoredRoleChanges.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface RoleAddedMatchResult {\n  matchedRoleId: string;\n}\n\nconst configSchema = z.union([zSnowflake, z.array(zSnowflake).max(255)]).default([]);\n\nexport const RoleRemovedTrigger = automodTrigger<RoleAddedMatchResult>()({\n  configSchema,\n\n  async match({ triggerConfig, context, pluginData }) {\n    if (!context.member || !context.rolesChanged || context.rolesChanged.removed!.length === 0) {\n      return;\n    }\n\n    const triggerRoles = Array.isArray(triggerConfig) ? triggerConfig : [triggerConfig];\n    for (const roleId of triggerRoles) {\n      if (consumeIgnoredRoleChange(pluginData, context.member.id, roleId)) {\n        continue;\n      }\n\n      if (context.rolesChanged.removed!.includes(roleId)) {\n        return {\n          extra: {\n            matchedRoleId: roleId,\n          },\n        };\n      }\n    }\n  },\n\n  renderMatchInformation({ matchResult, pluginData, contexts }) {\n    const role = pluginData.guild.roles.cache.get(matchResult.extra.matchedRoleId as Snowflake);\n    const roleName = role?.name || \"Unknown\";\n    const member = contexts[0].member!;\n    const memberName = `**${renderUsername(member)}** (\\`${member.id}\\`)`;\n    return `Role ${roleName} (\\`${matchResult.extra.matchedRoleId}\\`) was removed from ${memberName}`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/stickerSpam.ts",
    "content": "import { RecentActionType } from \"../constants.js\";\nimport { createMessageSpamTrigger } from \"../functions/createMessageSpamTrigger.js\";\n\nexport const StickerSpamTrigger = createMessageSpamTrigger(RecentActionType.Sticker, \"sticker\");\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/threadArchive.ts",
    "content": "import { User, escapeBold, type Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { renderUsername } from \"../../../utils.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface ThreadArchiveResult {\n  matchedThreadId: Snowflake;\n  matchedThreadName: string;\n  matchedThreadParentId: Snowflake;\n  matchedThreadParentName: string;\n  matchedThreadOwner: User | undefined;\n}\n\nconst configSchema = z.strictObject({\n  locked: z.boolean().optional(),\n});\n\nexport const ThreadArchiveTrigger = automodTrigger<ThreadArchiveResult>()({\n  configSchema,\n\n  async match({ context, triggerConfig }) {\n    if (!context.threadChange?.archived) {\n      return;\n    }\n\n    const thread = context.threadChange.archived;\n\n    if (typeof triggerConfig.locked === \"boolean\" && thread.locked !== triggerConfig.locked) {\n      return;\n    }\n\n    return {\n      extra: {\n        matchedThreadId: thread.id,\n        matchedThreadName: thread.name,\n        matchedThreadParentId: thread.parentId ?? \"Unknown\",\n        matchedThreadParentName: thread.parent?.name ?? \"Unknown\",\n        matchedThreadOwner: context.user,\n      },\n    };\n  },\n\n  async renderMatchInformation({ matchResult }) {\n    const threadId = matchResult.extra.matchedThreadId;\n    const threadName = matchResult.extra.matchedThreadName;\n    const threadOwner = matchResult.extra.matchedThreadOwner;\n    const parentId = matchResult.extra.matchedThreadParentId;\n    const parentName = matchResult.extra.matchedThreadParentName;\n    const base = `Thread **#${threadName}** (\\`${threadId}\\`) has been archived in the **#${parentName}** (\\`${parentId}\\`) channel`;\n    if (threadOwner) {\n      return `${base} by **${escapeBold(renderUsername(threadOwner))}** (\\`${threadOwner.id}\\`)`;\n    }\n    return base;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/threadCreate.ts",
    "content": "import { User, escapeBold, type Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { renderUsername } from \"../../../utils.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface ThreadCreateResult {\n  matchedThreadId: Snowflake;\n  matchedThreadName: string;\n  matchedThreadParentId: Snowflake;\n  matchedThreadParentName: string;\n  matchedThreadOwner: User | undefined;\n}\n\nconst configSchema = z.strictObject({});\n\nexport const ThreadCreateTrigger = automodTrigger<ThreadCreateResult>()({\n  configSchema,\n\n  async match({ context }) {\n    if (!context.threadChange?.created) {\n      return;\n    }\n\n    const thread = context.threadChange.created;\n\n    return {\n      extra: {\n        matchedThreadId: thread.id,\n        matchedThreadName: thread.name,\n        matchedThreadParentId: thread.parentId ?? \"Unknown\",\n        matchedThreadParentName: thread.parent?.name ?? \"Unknown\",\n        matchedThreadOwner: context.user,\n      },\n    };\n  },\n\n  async renderMatchInformation({ matchResult }) {\n    const threadId = matchResult.extra.matchedThreadId;\n    const threadName = matchResult.extra.matchedThreadName;\n    const threadOwner = matchResult.extra.matchedThreadOwner;\n    const parentId = matchResult.extra.matchedThreadParentId;\n    const parentName = matchResult.extra.matchedThreadParentName;\n    const base = `Thread **#${threadName}** (\\`${threadId}\\`) has been created in the **#${parentName}** (\\`${parentId}\\`) channel`;\n    if (threadOwner) {\n      return `${base} by **${escapeBold(renderUsername(threadOwner))}** (\\`${threadOwner.id}\\`)`;\n    }\n    return base;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/threadCreateSpam.ts",
    "content": "import { z } from \"zod\";\nimport { convertDelayStringToMS, zDelayString } from \"../../../utils.js\";\nimport { RecentActionType } from \"../constants.js\";\nimport { findRecentSpam } from \"../functions/findRecentSpam.js\";\nimport { getMatchingRecentActions } from \"../functions/getMatchingRecentActions.js\";\nimport { sumRecentActionCounts } from \"../functions/sumRecentActionCounts.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\nconst configSchema = z.strictObject({\n  amount: z.number().int(),\n  within: zDelayString,\n});\n\nexport const ThreadCreateSpamTrigger = automodTrigger<unknown>()({\n  configSchema,\n\n  async match({ pluginData, context, triggerConfig }) {\n    if (!context.threadChange?.created) {\n      return;\n    }\n\n    const recentSpam = findRecentSpam(pluginData, RecentActionType.ThreadCreate);\n    if (recentSpam) {\n      context.actioned = true;\n      return {};\n    }\n\n    const since = Date.now() - convertDelayStringToMS(triggerConfig.within)!;\n    const matchingActions = getMatchingRecentActions(pluginData, RecentActionType.ThreadCreate, null, since);\n    const totalCount = sumRecentActionCounts(matchingActions);\n\n    if (totalCount >= triggerConfig.amount) {\n      const extraContexts = matchingActions.map((a) => a.context).filter((c) => c !== context);\n\n      pluginData.state.recentSpam.push({\n        type: RecentActionType.ThreadCreate,\n        timestamp: Date.now(),\n        archiveId: null,\n        identifiers: [],\n      });\n\n      return {\n        extraContexts,\n      };\n    }\n  },\n\n  renderMatchInformation() {\n    return \"\";\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/threadDelete.ts",
    "content": "import { User, escapeBold, type Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { renderUsername } from \"../../../utils.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface ThreadDeleteResult {\n  matchedThreadId: Snowflake;\n  matchedThreadName: string;\n  matchedThreadParentId: Snowflake;\n  matchedThreadParentName: string;\n  matchedThreadOwner: User | undefined;\n}\n\nconst configSchema = z.strictObject({});\n\nexport const ThreadDeleteTrigger = automodTrigger<ThreadDeleteResult>()({\n  configSchema,\n\n  async match({ context }) {\n    if (!context.threadChange?.deleted) {\n      return;\n    }\n\n    const thread = context.threadChange.deleted;\n\n    return {\n      extra: {\n        matchedThreadId: thread.id,\n        matchedThreadName: thread.name,\n        matchedThreadParentId: thread.parentId ?? \"Unknown\",\n        matchedThreadParentName: thread.parent?.name ?? \"Unknown\",\n        matchedThreadOwner: context.user,\n      },\n    };\n  },\n\n  renderMatchInformation({ matchResult }) {\n    const threadId = matchResult.extra.matchedThreadId;\n    const threadOwner = matchResult.extra.matchedThreadOwner;\n    const threadName = matchResult.extra.matchedThreadName;\n    const parentId = matchResult.extra.matchedThreadParentId;\n    const parentName = matchResult.extra.matchedThreadParentName;\n    if (threadOwner) {\n      return `Thread **#${threadName ?? \"Unknown\"}** (\\`${threadId}\\`) created by **${escapeBold(\n        renderUsername(threadOwner),\n      )}** (\\`${threadOwner.id}\\`) in the **#${parentName}** (\\`${parentId}\\`) channel has been deleted`;\n    }\n    return `Thread **#${\n      threadName ?? \"Unknown\"\n    }** (\\`${threadId}\\`) from the **#${parentName}** (\\`${parentId}\\`) channel has been deleted`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/threadUnarchive.ts",
    "content": "import { User, escapeBold, type Snowflake } from \"discord.js\";\nimport { z } from \"zod\";\nimport { renderUsername } from \"../../../utils.js\";\nimport { automodTrigger } from \"../helpers.js\";\n\ninterface ThreadUnarchiveResult {\n  matchedThreadId: Snowflake;\n  matchedThreadName: string;\n  matchedThreadParentId: Snowflake;\n  matchedThreadParentName: string;\n  matchedThreadOwner: User | undefined;\n}\n\nconst configSchema = z.strictObject({\n  locked: z.boolean().optional(),\n});\n\nexport const ThreadUnarchiveTrigger = automodTrigger<ThreadUnarchiveResult>()({\n  configSchema,\n\n  async match({ context, triggerConfig }) {\n    if (!context.threadChange?.unarchived) {\n      return;\n    }\n\n    const thread = context.threadChange.unarchived;\n\n    if (typeof triggerConfig.locked === \"boolean\" && thread.locked !== triggerConfig.locked) {\n      return;\n    }\n\n    return {\n      extra: {\n        matchedThreadId: thread.id,\n        matchedThreadName: thread.name,\n        matchedThreadParentId: thread.parentId ?? \"Unknown\",\n        matchedThreadParentName: thread.parent?.name ?? \"Unknown\",\n        matchedThreadOwner: context.user,\n      },\n    };\n  },\n\n  async renderMatchInformation({ matchResult }) {\n    const threadId = matchResult.extra.matchedThreadId;\n    const threadName = matchResult.extra.matchedThreadName;\n    const threadOwner = matchResult.extra.matchedThreadOwner;\n    const parentId = matchResult.extra.matchedThreadParentId;\n    const parentName = matchResult.extra.matchedThreadParentName;\n    const base = `Thread **#${threadName}** (\\`${threadId}\\`) has been unarchived in the **#${parentName}** (\\`${parentId}\\`) channel`;\n    if (threadOwner) {\n      return `${base} by **${escapeBold(renderUsername(threadOwner))}** (\\`${threadOwner.id}\\`)`;\n    }\n    return base;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/unban.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\n// tslint:disable-next-line:no-empty-interface\ninterface UnbanTriggerResultType {}\n\nconst configSchema = z.strictObject({});\n\nexport const UnbanTrigger = automodTrigger<UnbanTriggerResultType>()({\n  configSchema,\n\n  async match({ context }) {\n    if (context.modAction?.type !== \"unban\") {\n      return;\n    }\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation() {\n    return `User was unbanned`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/unmute.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\n// tslint:disable-next-line:no-empty-interface\ninterface UnmuteTriggerResultType {}\n\nconst configSchema = z.strictObject({});\n\nexport const UnmuteTrigger = automodTrigger<UnmuteTriggerResultType>()({\n  configSchema,\n\n  async match({ context }) {\n    if (context.modAction?.type !== \"unmute\") {\n      return;\n    }\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation() {\n    return `User was unmuted`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/triggers/warn.ts",
    "content": "import { z } from \"zod\";\nimport { automodTrigger } from \"../helpers.js\";\n\n// tslint:disable-next-line:no-empty-interface\ninterface WarnTriggerResultType {}\n\nconst configSchema = z.strictObject({\n  manual: z.boolean().default(true),\n  automatic: z.boolean().default(true),\n});\n\nexport const WarnTrigger = automodTrigger<WarnTriggerResultType>()({\n  configSchema,\n\n  async match({ context, triggerConfig }) {\n    if (context.modAction?.type !== \"warn\") {\n      return;\n    }\n    // If automatic && automatic turned off -> return\n    if (context.modAction.isAutomodAction && !triggerConfig.automatic) return;\n    // If manual && manual turned off -> return\n    if (!context.modAction.isAutomodAction && !triggerConfig.manual) return;\n\n    return {\n      extra: {},\n    };\n  },\n\n  renderMatchInformation() {\n    return `User was warned`;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Automod/types.ts",
    "content": "import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from \"discord.js\";\nimport { BasePluginType, CooldownManager, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { Queue } from \"../../Queue.js\";\nimport { RegExpRunner } from \"../../RegExpRunner.js\";\nimport { GuildAntiraidLevels } from \"../../data/GuildAntiraidLevels.js\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { SavedMessage } from \"../../data/entities/SavedMessage.js\";\nimport { entries, zBoundedRecord, zDelayString } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { CounterEvents } from \"../Counters/types.js\";\nimport { ModActionType, ModActionsEvents } from \"../ModActions/types.js\";\nimport { MutesEvents } from \"../Mutes/types.js\";\nimport { availableActions } from \"./actions/availableActions.js\";\nimport { RecentActionType } from \"./constants.js\";\nimport { availableTriggers } from \"./triggers/availableTriggers.js\";\n\nimport Timeout = NodeJS.Timeout;\n\nexport type ZTriggersMapHelper = {\n  [TriggerName in keyof typeof availableTriggers]: (typeof availableTriggers)[TriggerName][\"configSchema\"];\n};\nconst zTriggersMap = z\n  .strictObject(\n    entries(availableTriggers).reduce((map, [triggerName, trigger]) => {\n      map[triggerName] = trigger.configSchema;\n      return map;\n    }, {} as ZTriggersMapHelper),\n  )\n  .partial();\n\ntype ZActionsMapHelper = {\n  [ActionName in keyof typeof availableActions]: (typeof availableActions)[ActionName][\"configSchema\"];\n};\nconst zActionsMap = z\n  .strictObject(\n    entries(availableActions).reduce((map, [actionName, action]) => {\n      // @ts-expect-error TS can't infer this properly but it works fine thanks to our helper\n      map[actionName] = action.configSchema;\n      return map;\n    }, {} as ZActionsMapHelper),\n  )\n  .partial();\n\nconst zRule = z.strictObject({\n  enabled: z.boolean().default(true),\n  pretty_name: z.string().optional(),\n  presets: z.array(z.string().max(100)).max(25).default([]),\n  affects_bots: z.boolean().default(false),\n  affects_self: z.boolean().default(false),\n  cooldown: zDelayString.nullable().default(null),\n  allow_further_rules: z.boolean().default(false),\n  triggers: z.array(zTriggersMap),\n  actions: zActionsMap,\n});\nexport type TRule = z.infer<typeof zRule>;\n\nexport const zAutomodConfig = z.strictObject({\n  rules: zBoundedRecord(z.record(z.string().max(100), zRule), 0, 255).default({}),\n  antiraid_levels: z.array(z.string().max(100)).max(10).default([\"low\", \"medium\", \"high\"]),\n  can_set_antiraid: z.boolean().default(false),\n  can_view_antiraid: z.boolean().default(false),\n  can_debug_automod: z.boolean().default(false),\n});\n\nexport interface AutomodPluginType extends BasePluginType {\n  configSchema: typeof zAutomodConfig;\n\n  customOverrideCriteria: {\n    antiraid_level?: string;\n  };\n\n  state: {\n    /**\n     * Automod checks/actions are handled in a queue so we don't get overlap on the same user\n     */\n    queue: Queue;\n\n    /**\n     * Per-server regex runner\n     */\n    regexRunner: RegExpRunner;\n\n    /**\n     * Recent actions are used for spam triggers\n     */\n    recentActions: RecentAction[];\n    clearRecentActionsInterval: Timeout;\n\n    /**\n     * After a spam trigger is tripped and the rule's action carried out, a unique identifier is placed here so further\n     * spam (either messages that were sent before the bot managed to mute the user or, with global spam, other users\n     * continuing to spam) is \"included\" in the same match and doesn't generate duplicate cases or logs.\n     * Key: rule_name-match_identifier\n     */\n    recentSpam: RecentSpam[];\n    clearRecentSpamInterval: Timeout;\n\n    recentNicknameChanges: Map<string, { timestamp: number }>;\n    clearRecentNicknameChangesInterval: Timeout;\n\n    ignoredRoleChanges: Set<{\n      memberId: string;\n      roleId: string;\n      timestamp: number;\n    }>;\n\n    cachedAntiraidLevel: string | null;\n\n    cooldownManager: CooldownManager;\n\n    savedMessages: GuildSavedMessages;\n    logs: GuildLogs;\n    antiraidLevels: GuildAntiraidLevels;\n    archives: GuildArchives;\n\n    onMessageCreateFn: any;\n    onMessageUpdateFn: any;\n\n    onCounterTrigger: CounterEvents[\"trigger\"];\n    onCounterReverseTrigger: CounterEvents[\"reverseTrigger\"];\n\n    modActionsListeners: Map<keyof ModActionsEvents, any>;\n    mutesListeners: Map<keyof MutesEvents, any>;\n\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport interface AutomodContext {\n  timestamp: number;\n  actioned?: boolean;\n\n  counterTrigger?: {\n    counter: string;\n    trigger: string;\n    prettyCounter: string;\n    prettyTrigger: string;\n    channelId: string | null;\n    userId: string | null;\n    reverse: boolean;\n  };\n  user?: User;\n  message?: SavedMessage;\n  member?: GuildMember;\n  partialMember?: GuildMember | PartialGuildMember;\n  joined?: boolean;\n  rolesChanged?: {\n    added?: string[];\n    removed?: string[];\n  };\n  modAction?: {\n    type: ModActionType;\n    reason?: string;\n    isAutomodAction: boolean;\n  };\n  antiraid?: {\n    level: string | null;\n    oldLevel?: string | null;\n  };\n  threadChange?: {\n    created?: ThreadChannel;\n    deleted?: ThreadChannel;\n    archived?: ThreadChannel;\n    unarchived?: ThreadChannel;\n    locked?: ThreadChannel;\n    unlocked?: ThreadChannel;\n  };\n  channel?: GuildTextBasedChannel;\n}\n\nexport interface RecentAction {\n  type: RecentActionType;\n  identifier: string | null;\n  count: number;\n  context: AutomodContext;\n}\n\nexport interface RecentSpam {\n  archiveId: string | null;\n  type: RecentActionType;\n  identifiers: string[];\n  timestamp: number;\n}\n"
  },
  {
    "path": "backend/src/plugins/BotControl/BotControlPlugin.ts",
    "content": "import { Snowflake, TextChannel } from \"discord.js\";\nimport { globalPlugin } from \"vety\";\nimport { AllowedGuilds } from \"../../data/AllowedGuilds.js\";\nimport { ApiPermissionAssignments } from \"../../data/ApiPermissionAssignments.js\";\nimport { Configs } from \"../../data/Configs.js\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { getActiveReload, resetActiveReload } from \"./activeReload.js\";\nimport { AddDashboardUserCmd } from \"./commands/AddDashboardUserCmd.js\";\nimport { AddServerFromInviteCmd } from \"./commands/AddServerFromInviteCmd.js\";\nimport { AllowServerCmd } from \"./commands/AllowServerCmd.js\";\nimport { ChannelToServerCmd } from \"./commands/ChannelToServerCmd.js\";\nimport { DisallowServerCmd } from \"./commands/DisallowServerCmd.js\";\nimport { EligibleCmd } from \"./commands/EligibleCmd.js\";\nimport { LeaveServerCmd } from \"./commands/LeaveServerCmd.js\";\nimport { ListDashboardPermsCmd } from \"./commands/ListDashboardPermsCmd.js\";\nimport { ListDashboardUsersCmd } from \"./commands/ListDashboardUsersCmd.js\";\nimport { ProfilerDataCmd } from \"./commands/ProfilerDataCmd.js\";\nimport { RateLimitPerformanceCmd } from \"./commands/RateLimitPerformanceCmd.js\";\nimport { ReloadGlobalPluginsCmd } from \"./commands/ReloadGlobalPluginsCmd.js\";\nimport { ReloadServerCmd } from \"./commands/ReloadServerCmd.js\";\nimport { RemoveDashboardUserCmd } from \"./commands/RemoveDashboardUserCmd.js\";\nimport { RestPerformanceCmd } from \"./commands/RestPerformanceCmd.js\";\nimport { ServersCmd } from \"./commands/ServersCmd.js\";\nimport { BotControlPluginType, zBotControlConfig } from \"./types.js\";\nimport { DebugCountersCmd } from \"./commands/DebugCountersCmd.js\";\n\nexport const BotControlPlugin = globalPlugin<BotControlPluginType>()({\n  name: \"bot_control\",\n  configSchema: zBotControlConfig,\n\n  // prettier-ignore\n  messageCommands: [\n    ReloadGlobalPluginsCmd,\n    ServersCmd,\n    LeaveServerCmd,\n    ReloadServerCmd,\n    AllowServerCmd,\n    DisallowServerCmd,\n    AddDashboardUserCmd,\n    RemoveDashboardUserCmd,\n    ListDashboardUsersCmd,\n    ListDashboardPermsCmd,\n    EligibleCmd,\n    ProfilerDataCmd,\n    RestPerformanceCmd,\n    RateLimitPerformanceCmd,\n    AddServerFromInviteCmd,\n    ChannelToServerCmd,\n    DebugCountersCmd,\n  ],\n\n  async afterLoad(pluginData) {\n    const { state, client } = pluginData;\n\n    state.archives = new GuildArchives(0);\n    state.allowedGuilds = new AllowedGuilds();\n    state.configs = new Configs();\n    state.apiPermissionAssignments = new ApiPermissionAssignments();\n\n    const activeReload = getActiveReload();\n    if (activeReload) {\n      const [guildId, channelId] = activeReload;\n      resetActiveReload();\n\n      const guild = await client.guilds.fetch(guildId as Snowflake);\n      if (guild) {\n        const channel = guild.channels.cache.get(channelId as Snowflake);\n        if (channel instanceof TextChannel) {\n          void channel.send(\"Global plugins reloaded!\");\n        }\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/activeReload.ts",
    "content": "let activeReload: [string, string] | null = null;\n\nexport function getActiveReload() {\n  return activeReload;\n}\n\nexport function setActiveReload(guildId: string, channelId: string) {\n  activeReload = [guildId, channelId];\n}\n\nexport function resetActiveReload() {\n  activeReload = null;\n}\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts",
    "content": "import { ApiPermissions } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isStaffPreFilter } from \"../../../pluginUtils.js\";\nimport { renderUsername } from \"../../../utils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const AddDashboardUserCmd = botControlCmd({\n  trigger: [\"add_dashboard_user\"],\n  permission: null,\n  config: {\n    preFilters: [isStaffPreFilter],\n  },\n\n  signature: {\n    guildId: ct.string(),\n    users: ct.resolvedUser({ rest: true }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const guild = await pluginData.state.allowedGuilds.find(args.guildId);\n    if (!guild) {\n      void msg.channel.send(\"Server is not using Zeppelin\");\n      return;\n    }\n\n    for (const user of args.users) {\n      const existingAssignment = await pluginData.state.apiPermissionAssignments.getByGuildAndUserId(\n        args.guildId,\n        user.id,\n      );\n      if (existingAssignment) {\n        continue;\n      }\n\n      await pluginData.state.apiPermissionAssignments.addUser(args.guildId, user.id, [ApiPermissions.EditConfig]);\n    }\n\n    const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \\`${user.id}\\`)`);\n\n    msg.channel.send(`The following users were given dashboard access for **${guild.name}**:\\n\\n${userNameList}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts",
    "content": "import { ApiPermissions } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { DBDateFormat, isGuildInvite, resolveInvite } from \"../../../utils.js\";\nimport { isEligible } from \"../functions/isEligible.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const AddServerFromInviteCmd = botControlCmd({\n  trigger: [\"add_server_from_invite\", \"allow_server_from_invite\", \"adv\"],\n  permission: \"can_add_server_from_invite\",\n\n  signature: {\n    user: ct.resolvedUser(),\n    inviteCode: ct.string(),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const invite = await resolveInvite(pluginData.client, args.inviteCode, true);\n    if (!invite || !isGuildInvite(invite)) {\n      void msg.channel.send(\"Could not resolve invite\"); // :D\n      return;\n    }\n\n    const existing = await pluginData.state.allowedGuilds.find(invite.guild.id);\n    if (existing) {\n      void msg.channel.send(\"Server is already allowed!\");\n      return;\n    }\n\n    const { result, explanation } = await isEligible(pluginData, args.user, invite);\n    if (!result) {\n      msg.channel.send(`Could not add server because it's not eligible: ${explanation}`);\n      return;\n    }\n\n    await pluginData.state.allowedGuilds.add(invite.guild.id, { name: invite.guild.name });\n    await pluginData.state.configs.saveNewRevision(`guild-${invite.guild.id}`, \"plugins: {}\", msg.author.id);\n\n    await pluginData.state.apiPermissionAssignments.addUser(invite.guild.id, args.user.id, [\n      ApiPermissions.ManageAccess,\n    ]);\n\n    if (args.user.id !== msg.author.id) {\n      // Add temporary access to user who added server\n      await pluginData.state.apiPermissionAssignments.addUser(\n        invite.guild.id,\n        msg.author.id,\n        [ApiPermissions.ManageAccess],\n        moment.utc().add(1, \"hour\").format(DBDateFormat),\n      );\n    }\n\n    msg.channel.send(\"Server was eligible and is now allowed to use Zeppelin!\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/AllowServerCmd.ts",
    "content": "import { ApiPermissions } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isStaffPreFilter } from \"../../../pluginUtils.js\";\nimport { DBDateFormat, isSnowflake } from \"../../../utils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const AllowServerCmd = botControlCmd({\n  trigger: [\"allow_server\", \"allowserver\", \"add_server\", \"addserver\"],\n  permission: null,\n  config: {\n    preFilters: [isStaffPreFilter],\n  },\n\n  signature: {\n    guildId: ct.string(),\n    userId: ct.string({ required: false }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const existing = await pluginData.state.allowedGuilds.find(args.guildId);\n    if (existing) {\n      void msg.channel.send(\"Server is already allowed!\");\n      return;\n    }\n\n    if (!isSnowflake(args.guildId)) {\n      void msg.channel.send(\"Invalid server ID!\");\n      return;\n    }\n\n    if (args.userId && !isSnowflake(args.userId)) {\n      void msg.channel.send(\"Invalid user ID!\");\n      return;\n    }\n\n    await pluginData.state.allowedGuilds.add(args.guildId);\n    await pluginData.state.configs.saveNewRevision(`guild-${args.guildId}`, \"plugins: {}\", msg.author.id);\n\n    if (args.userId) {\n      await pluginData.state.apiPermissionAssignments.addUser(args.guildId, args.userId, [ApiPermissions.ManageAccess]);\n    }\n\n    if (args.userId !== msg.author.id) {\n      // Add temporary access to user who added server\n      await pluginData.state.apiPermissionAssignments.addUser(\n        args.guildId,\n        msg.author.id,\n        [ApiPermissions.ManageAccess],\n        moment.utc().add(1, \"hour\").format(DBDateFormat),\n      );\n    }\n\n    void msg.channel.send(\"Server is now allowed to use Zeppelin!\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isStaffPreFilter } from \"../../../pluginUtils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const ChannelToServerCmd = botControlCmd({\n  trigger: [\"channel_to_server\", \"channel2server\"],\n  permission: null,\n  config: {\n    preFilters: [isStaffPreFilter],\n  },\n\n  signature: {\n    channelId: ct.string(),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const channel = pluginData.client.channels.cache.get(args.channelId);\n    if (!channel) {\n      void msg.channel.send(\"Channel not found in cache!\");\n      return;\n    }\n\n    const channelName = channel.isVoiceBased() ? channel.name : `#${\"name\" in channel ? channel.name : channel.id}`;\n\n    const guild = \"guild\" in channel ? channel.guild : null;\n    const guildInfo = guild ? `${guild.name} (\\`${guild.id}\\`)` : \"Not a server\";\n\n    msg.channel.send(`**Channel:** ${channelName} (\\`${channel.type}\\`) (<#${channel.id}>)\\n**Server:** ${guildInfo}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/DebugCountersCmd.ts",
    "content": "import moment from \"moment-timezone\";\nimport { GuildArchives } from \"../../../data/GuildArchives.js\";\nimport { getDebugCounterValues } from \"../../../debugCounters.js\";\nimport { getBaseUrl } from \"../../../pluginUtils.js\";\nimport { botControlCmd } from \"../types.js\";\n\ntype SortableDebugCounter = {\n  name: string;\n  count: number;\n};\n\nexport const DebugCountersCmd = botControlCmd({\n  trigger: [\"debug_counters\"],\n  permission: \"can_performance\",\n\n  signature: {},\n\n  async run({ pluginData, message: msg }) {\n    const debugCounterValueMap = getDebugCounterValues();\n    const sortableDebugCounters: SortableDebugCounter[] = [];\n    for (const [name, value] of debugCounterValueMap) {\n      sortableDebugCounters.push({ name, count: value.count });\n    }\n\n    sortableDebugCounters.sort((a, b) => b.count - a.count);\n\n    const archives = GuildArchives.getGuildInstance(\"0\");\n    const archiveId = await archives.create(JSON.stringify(sortableDebugCounters, null, 2), moment().add(1, \"hour\"));\n    const archiveUrl = archives.getUrl(getBaseUrl(pluginData), archiveId);\n    msg.channel.send(`Link: ${archiveUrl}`);\n  },\n});\n//\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/DisallowServerCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isStaffPreFilter } from \"../../../pluginUtils.js\";\nimport { noop } from \"../../../utils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const DisallowServerCmd = botControlCmd({\n  trigger: [\"disallow_server\", \"disallowserver\", \"remove_server\", \"removeserver\"],\n  permission: null,\n  config: {\n    preFilters: [isStaffPreFilter],\n  },\n\n  signature: {\n    guildId: ct.string(),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const existing = await pluginData.state.allowedGuilds.find(args.guildId);\n    if (!existing) {\n      void msg.channel.send(\"That server is not allowed in the first place!\");\n      return;\n    }\n\n    await pluginData.state.allowedGuilds.remove(args.guildId);\n    await pluginData.client.guilds.cache\n      .get(args.guildId as Snowflake)\n      ?.leave()\n      .catch(noop);\n    void msg.channel.send(\"Server removed!\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/EligibleCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isGuildInvite, resolveInvite } from \"../../../utils.js\";\nimport { isEligible } from \"../functions/isEligible.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const EligibleCmd = botControlCmd({\n  trigger: [\"eligible\", \"is_eligible\", \"iseligible\"],\n  permission: \"can_eligible\",\n\n  signature: {\n    user: ct.resolvedUser(),\n    inviteCode: ct.string(),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const invite = await resolveInvite(pluginData.client, args.inviteCode, true);\n    if (!invite || !isGuildInvite(invite)) {\n      void msg.channel.send(\"Could not resolve invite\");\n      return;\n    }\n\n    const { result, explanation } = await isEligible(pluginData, args.user, invite);\n\n    if (result) {\n      void msg.channel.send(`Server is eligible: ${explanation}`);\n      return;\n    }\n\n    void msg.channel.send(`Server is **NOT** eligible: ${explanation}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/LeaveServerCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isStaffPreFilter } from \"../../../pluginUtils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const LeaveServerCmd = botControlCmd({\n  trigger: [\"leave_server\", \"leave_guild\"],\n  permission: null,\n  config: {\n    preFilters: [isStaffPreFilter],\n  },\n\n  signature: {\n    guildId: ct.string(),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    if (!pluginData.client.guilds.cache.has(args.guildId as Snowflake)) {\n      void msg.channel.send(\"I am not in that guild\");\n      return;\n    }\n\n    const guildToLeave = await pluginData.client.guilds.fetch(args.guildId as Snowflake)!;\n    const guildName = guildToLeave.name;\n\n    try {\n      await pluginData.client.guilds.cache.get(args.guildId as Snowflake)?.leave();\n    } catch (e) {\n      void msg.channel.send(`Failed to leave guild: ${e.message}`);\n      return;\n    }\n\n    void msg.channel.send(`Left guild **${guildName}**`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { AllowedGuild } from \"../../../data/entities/AllowedGuild.js\";\nimport { ApiPermissionAssignment } from \"../../../data/entities/ApiPermissionAssignment.js\";\nimport { renderUsername, resolveUser } from \"../../../utils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const ListDashboardPermsCmd = botControlCmd({\n  trigger: [\"list_dashboard_permissions\", \"list_dashboard_perms\", \"list_dash_permissions\", \"list_dash_perms\"],\n  permission: \"can_list_dashboard_perms\",\n\n  signature: {\n    guildId: ct.string({ option: true, shortcut: \"g\" }),\n    user: ct.resolvedUser({ option: true, shortcut: \"u\" }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    if (!args.user && !args.guildId) {\n      void msg.channel.send(\"Must specify at least guildId, user, or both.\");\n      return;\n    }\n\n    let guild: AllowedGuild | null = null;\n    if (args.guildId) {\n      guild = await pluginData.state.allowedGuilds.find(args.guildId);\n      if (!guild) {\n        void msg.channel.send(\"Server is not using Zeppelin\");\n        return;\n      }\n    }\n\n    let existingUserAssignment: ApiPermissionAssignment[];\n    if (args.user) {\n      existingUserAssignment = await pluginData.state.apiPermissionAssignments.getByUserId(args.user.id);\n      if (existingUserAssignment.length === 0) {\n        void msg.channel.send(\"The user has no assigned permissions.\");\n        return;\n      }\n    }\n\n    let finalMessage = \"\";\n\n    // If we have user, always display which guilds they have permissions in (or only specified guild permissions)\n    if (args.user) {\n      const userInfo = `**${renderUsername(args.user)}** (\\`${args.user.id}\\`)`;\n\n      for (const assignment of existingUserAssignment!) {\n        if (guild != null && assignment.guild_id !== args.guildId) continue;\n        const assignmentGuild = await pluginData.state.allowedGuilds.find(assignment.guild_id);\n        const guildName = assignmentGuild?.name ?? \"Unknown\";\n        const guildInfo = `**${guildName}** (\\`${assignment.guild_id}\\`)`;\n        finalMessage += `The user ${userInfo} has the following permissions on server ${guildInfo}:`;\n        finalMessage += `\\n${assignment.permissions.join(\"\\n\")}\\n\\n`;\n      }\n\n      if (finalMessage === \"\") {\n        msg.channel.send(`The user ${userInfo} has no assigned permissions on the specified server.`);\n        return;\n      }\n      // Else display all users that have permissions on the specified guild\n    } else if (guild) {\n      const guildInfo = `**${guild.name}** (\\`${guild.id}\\`)`;\n\n      const existingGuildAssignment = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id);\n      if (existingGuildAssignment.length === 0) {\n        msg.channel.send(`The server ${guildInfo} has no assigned permissions.`);\n        return;\n      }\n\n      finalMessage += `The server ${guildInfo} has the following assigned permissions:\\n`; // Double \\n for consistency with AddDashboardUserCmd\n      for (const assignment of existingGuildAssignment) {\n        const user = await resolveUser(pluginData.client, assignment.target_id, \"BotControl:ListDashboardPermsCmd\");\n        finalMessage += `\\n**${renderUsername(user)}**, \\`${assignment.target_id}\\`: ${assignment.permissions.join(\n          \", \",\n        )}`;\n      }\n    }\n\n    await msg.channel.send({\n      content: finalMessage.trim(),\n      allowedMentions: {},\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { renderUsername, resolveUser } from \"../../../utils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const ListDashboardUsersCmd = botControlCmd({\n  trigger: [\"list_dashboard_users\"],\n  permission: \"can_list_dashboard_perms\",\n\n  signature: {\n    guildId: ct.string(),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const guild = await pluginData.state.allowedGuilds.find(args.guildId);\n    if (!guild) {\n      void msg.channel.send(\"Server is not using Zeppelin\");\n      return;\n    }\n\n    const dashboardUsers = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id);\n    const users = await Promise.all(\n      dashboardUsers.map(async (perm) => ({\n        user: await resolveUser(pluginData.client, perm.target_id, \"BotControl:ListDashboardUsersCmd\"),\n        permission: perm,\n      })),\n    );\n    const userNameList = users.map(\n      ({ user, permission }) =>\n        `<@!${user.id}> (**${renderUsername(user)}**, \\`${user.id}\\`): ${permission.permissions.join(\", \")}`,\n    );\n\n    msg.channel.send({\n      content: `The following users have dashboard access for **${guild.name}**:\\n\\n${userNameList.join(\"\\n\")}`,\n      allowedMentions: {},\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/ProfilerDataCmd.ts",
    "content": "import moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { GuildArchives } from \"../../../data/GuildArchives.js\";\nimport { getBaseUrl } from \"../../../pluginUtils.js\";\nimport { sorter } from \"../../../utils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nconst sortProps = {\n  totalTime: \"TOTAL TIME\",\n  averageTime: \"AVERAGE TIME\",\n  count: \"SAMPLES\",\n};\n\nexport const ProfilerDataCmd = botControlCmd({\n  trigger: [\"profiler_data\"],\n  permission: \"can_performance\",\n\n  signature: {\n    filter: ct.string({ required: false }),\n    sort: ct.string({ option: true, required: false }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    if (args.sort === \"samples\") {\n      args.sort = \"count\";\n    }\n    const sortProp = args.sort && sortProps[args.sort] ? args.sort : \"totalTime\";\n\n    const headerInfoItems = [`sorted by ${sortProps[sortProp]}`];\n\n    const profilerData = pluginData.getVetyInstance().profiler.getData();\n    let entries = Object.entries(profilerData);\n    entries.sort(sorter((entry) => entry[1][sortProp], \"DESC\"));\n\n    if (args.filter) {\n      entries = entries.filter(([key]) => key.includes(args.filter));\n      headerInfoItems.push(`matching \"${args.filter}\"`);\n    }\n\n    const formattedEntries = entries.map(([key, data]) => {\n      const dataLines = [\n        [\"Total time\", `${Math.round(data.totalTime)} ms`],\n        [\"Average time\", `${Math.round(data.averageTime)} ms`],\n        [\"Samples\", data.count],\n      ];\n      return `${key}\\n${dataLines.map((v) => `  ${v[0]}: ${v[1]}`).join(\"\\n\")}`;\n    });\n    const formatted = `Profiler data, ${headerInfoItems.join(\", \")}:\\n\\n${formattedEntries.join(\"\\n\\n\")}`;\n\n    const archives = GuildArchives.getGuildInstance(\"0\");\n    const archiveId = await archives.create(formatted, moment().add(1, \"hour\"));\n    const archiveUrl = archives.getUrl(getBaseUrl(pluginData), archiveId);\n\n    msg.channel.send(`Link: ${archiveUrl}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts",
    "content": "import moment from \"moment-timezone\";\nimport { GuildArchives } from \"../../../data/GuildArchives.js\";\nimport { getBaseUrl } from \"../../../pluginUtils.js\";\nimport { getRateLimitStats } from \"../../../rateLimitStats.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const RateLimitPerformanceCmd = botControlCmd({\n  trigger: [\"rate_limit_performance\"],\n  permission: \"can_performance\",\n\n  signature: {},\n\n  async run({ pluginData, message: msg }) {\n    const logItems = getRateLimitStats();\n    if (logItems.length === 0) {\n      void msg.channel.send(`No rate limits hit`);\n      return;\n    }\n\n    logItems.reverse();\n    const formatted = logItems.map((item) => {\n      const formattedTime = moment.utc(item.timestamp).format(\"YYYY-MM-DD HH:mm:ss.SSS\");\n      const items: string[] = [`[${formattedTime}]`];\n      if (item.data.global) items.push(\"GLOBAL\");\n      items.push(item.data.method.toUpperCase());\n      items.push(item.data.route);\n      items.push(`stalled for ${item.data.timeToReset}ms`);\n      items.push(`(max requests ${item.data.limit})`);\n      return items.join(\" \");\n    });\n\n    const fullText = `Last ${logItems.length} rate limits hit:\\n\\n${formatted.join(\"\\n\")}`;\n\n    const archives = GuildArchives.getGuildInstance(\"0\");\n    const archiveId = await archives.create(fullText, moment().add(1, \"hour\"));\n    const archiveUrl = archives.getUrl(getBaseUrl(pluginData), archiveId);\n    msg.channel.send(`Link: ${archiveUrl}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts",
    "content": "import { isStaffPreFilter } from \"../../../pluginUtils.js\";\nimport { getActiveReload, setActiveReload } from \"../activeReload.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const ReloadGlobalPluginsCmd = botControlCmd({\n  trigger: \"bot_reload_global_plugins\",\n  permission: null,\n  config: {\n    preFilters: [isStaffPreFilter],\n  },\n\n  async run({ pluginData, message }) {\n    if (getActiveReload()) return;\n\n    const guildId = \"guild\" in message.channel ? message.channel.guild.id : null;\n    if (!guildId) {\n      void message.channel.send(\"This command can only be used in a server\");\n      return;\n    }\n\n    setActiveReload(guildId, message.channel.id);\n    await message.channel.send(\"Reloading global plugins...\");\n\n    pluginData.getVetyInstance().reloadGlobalContext();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/ReloadServerCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isStaffPreFilter } from \"../../../pluginUtils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const ReloadServerCmd = botControlCmd({\n  trigger: [\"reload_server\", \"reload_guild\"],\n  permission: null,\n  config: {\n    preFilters: [isStaffPreFilter],\n  },\n\n  signature: {\n    guildId: ct.anyId(),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    if (!pluginData.client.guilds.cache.has(args.guildId as Snowflake)) {\n      void msg.channel.send(\"I am not in that guild\");\n      return;\n    }\n\n    try {\n      await pluginData.getVetyInstance().reloadGuild(args.guildId);\n    } catch (e) {\n      void msg.channel.send(`Failed to reload guild: ${e.message}`);\n      return;\n    }\n\n    const guild = await pluginData.client.guilds.fetch(args.guildId as Snowflake);\n    void msg.channel.send(`Reloaded guild **${guild?.name || \"???\"}**`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isStaffPreFilter } from \"../../../pluginUtils.js\";\nimport { renderUsername } from \"../../../utils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const RemoveDashboardUserCmd = botControlCmd({\n  trigger: [\"remove_dashboard_user\"],\n  permission: null,\n  config: {\n    preFilters: [isStaffPreFilter],\n  },\n\n  signature: {\n    guildId: ct.string(),\n    users: ct.user({ rest: true }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const guild = await pluginData.state.allowedGuilds.find(args.guildId);\n    if (!guild) {\n      void msg.channel.send(\"Server is not using Zeppelin\");\n      return;\n    }\n\n    for (const user of args.users) {\n      const existingAssignment = await pluginData.state.apiPermissionAssignments.getByGuildAndUserId(\n        args.guildId,\n        user.id,\n      );\n      if (!existingAssignment) {\n        continue;\n      }\n\n      await pluginData.state.apiPermissionAssignments.removeUser(args.guildId, user.id);\n    }\n\n    const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \\`${user.id}\\`)`);\n\n    msg.channel.send(`The following users were removed from the dashboard for **${guild.name}**:\\n\\n${userNameList}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/RestPerformanceCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { getTopRestCallStats } from \"../../../restCallStats.js\";\nimport { createChunkedMessage } from \"../../../utils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nconst leadingPathRegex = /(?<=\\().+\\/backend\\//g;\n\nexport const RestPerformanceCmd = botControlCmd({\n  trigger: [\"rest_performance\"],\n  permission: \"can_performance\",\n\n  signature: {\n    count: ct.number({ required: false }),\n  },\n\n  async run({ message: msg, args }) {\n    const count = Math.max(1, Math.min(25, args.count || 5));\n    const stats = getTopRestCallStats(count);\n    const formatted = stats.map((callStats) => {\n      const cleanSource = callStats.source.replace(leadingPathRegex, \"\");\n      return `**${callStats.count} calls**\\n${callStats.method.toUpperCase()} ${callStats.path}\\n${cleanSource}`;\n    });\n    createChunkedMessage(msg.channel, `Top rest calls:\\n\\n${formatted.join(\"\\n\")}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/commands/ServersCmd.ts",
    "content": "import escapeStringRegexp from \"escape-string-regexp\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isStaffPreFilter } from \"../../../pluginUtils.js\";\nimport { createChunkedMessage, getUser, renderUsername, sorter } from \"../../../utils.js\";\nimport { botControlCmd } from \"../types.js\";\n\nexport const ServersCmd = botControlCmd({\n  trigger: [\"servers\", \"guilds\"],\n  permission: null,\n  config: {\n    preFilters: [isStaffPreFilter],\n  },\n\n  signature: {\n    search: ct.string({ catchAll: true, required: false }),\n\n    all: ct.switchOption({ def: false, shortcut: \"a\" }),\n    initialized: ct.switchOption({ def: false, shortcut: \"i\" }),\n    uninitialized: ct.switchOption({ def: false, shortcut: \"u\" }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const showList = Boolean(args.all || args.initialized || args.uninitialized || args.search);\n    const search = args.search ? new RegExp([...args.search].map((s) => escapeStringRegexp(s)).join(\".*\"), \"i\") : null;\n\n    const joinedGuilds = Array.from(pluginData.client.guilds.cache.values());\n    const loadedGuilds = pluginData.getVetyInstance().getLoadedGuilds();\n    const loadedGuildsMap = loadedGuilds.reduce((map, guildData) => map.set(guildData.guildId, guildData), new Map());\n\n    if (showList) {\n      let filteredGuilds = Array.from(joinedGuilds);\n\n      if (args.initialized) {\n        filteredGuilds = filteredGuilds.filter((g) => loadedGuildsMap.has(g.id));\n      }\n\n      if (args.uninitialized) {\n        filteredGuilds = filteredGuilds.filter((g) => !loadedGuildsMap.has(g.id));\n      }\n\n      if (args.search) {\n        filteredGuilds = filteredGuilds.filter((g) => search!.test(`${g.id} ${g.name}`));\n      }\n\n      if (filteredGuilds.length) {\n        filteredGuilds.sort(sorter((g) => g.name.toLowerCase()));\n        const longestId = filteredGuilds.reduce((longest, guild) => Math.max(longest, guild.id.length), 0);\n        const lines = filteredGuilds.map((g) => {\n          const paddedId = g.id.padEnd(longestId, \" \");\n          const owner = getUser(pluginData.client, g.ownerId);\n          return `\\`${paddedId}\\` **${g.name}** (${g.memberCount} members) (owner **${renderUsername(owner)}** \\`${\n            owner.id\n          }\\`)`;\n        });\n        createChunkedMessage(msg.channel, lines.join(\"\\n\"));\n      } else {\n        msg.channel.send(\"No servers matched the filters\");\n      }\n    } else {\n      const total = joinedGuilds.length;\n      const initialized = joinedGuilds.filter((g) => loadedGuildsMap.has(g.id)).length;\n      const unInitialized = total - initialized;\n\n      msg.channel.send(\n        `I am on **${total} total servers**, of which **${initialized} are initialized** and **${unInitialized} are not initialized**`,\n      );\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/BotControl/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zBotControlConfig } from \"./types.js\";\n\nexport const botControlPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zBotControlConfig,\n\n  prettyName: \"Bot control\",\n  description: trimPluginDescription(`\n    Contains commands to manage allowed servers\n  `),\n};\n"
  },
  {
    "path": "backend/src/plugins/BotControl/functions/isEligible.ts",
    "content": "import { User } from \"discord.js\";\nimport { GlobalPluginData } from \"vety\";\nimport { GuildInvite } from \"../../../utils.js\";\nimport { BotControlPluginType } from \"../types.js\";\n\nconst REQUIRED_MEMBER_COUNT = 5000;\n\nexport async function isEligible(\n  pluginData: GlobalPluginData<BotControlPluginType>,\n  user: User,\n  invite: GuildInvite,\n): Promise<{ result: boolean; explanation: string }> {\n  if ((await pluginData.state.apiPermissionAssignments.getByUserId(user.id)).length) {\n    return {\n      result: true,\n      explanation: \"User is an existing bot operator\",\n    };\n  }\n\n  if (invite.guild.features.includes(\"PARTNERED\")) {\n    return {\n      result: true,\n      explanation: \"Server is partnered\",\n    };\n  }\n\n  if (invite.guild.features.includes(\"VERIFIED\")) {\n    return {\n      result: true,\n      explanation: \"Server is verified\",\n    };\n  }\n\n  const memberCount = invite.memberCount || 0;\n  if (memberCount >= REQUIRED_MEMBER_COUNT) {\n    return {\n      result: true,\n      explanation: `Server has ${memberCount} members, which is equal or higher than the required ${REQUIRED_MEMBER_COUNT}`,\n    };\n  }\n\n  return {\n    result: false,\n    explanation: \"Server does not meet requirements\",\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/BotControl/types.ts",
    "content": "import { BasePluginType, globalPluginEventListener, globalPluginMessageCommand } from \"vety\";\nimport { z } from \"zod\";\nimport { AllowedGuilds } from \"../../data/AllowedGuilds.js\";\nimport { ApiPermissionAssignments } from \"../../data/ApiPermissionAssignments.js\";\nimport { Configs } from \"../../data/Configs.js\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { zBoundedCharacters } from \"../../utils.js\";\n\nexport const zBotControlConfig = z.strictObject({\n  can_use: z.boolean().default(false),\n  can_eligible: z.boolean().default(false),\n  can_performance: z.boolean().default(false),\n  can_add_server_from_invite: z.boolean().default(false),\n  can_list_dashboard_perms: z.boolean().default(false),\n  update_cmd: zBoundedCharacters(0, 2000).nullable().default(null),\n});\n\nexport interface BotControlPluginType extends BasePluginType {\n  configSchema: typeof zBotControlConfig;\n  state: {\n    archives: GuildArchives;\n    allowedGuilds: AllowedGuilds;\n    apiPermissionAssignments: ApiPermissionAssignments;\n    configs: Configs;\n  };\n}\n\nexport const botControlCmd = globalPluginMessageCommand<BotControlPluginType>();\nexport const botControlEvt = globalPluginEventListener<BotControlPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Cases/CasesPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { InternalPosterPlugin } from \"../InternalPoster/InternalPosterPlugin.js\";\nimport { TimeAndDatePlugin } from \"../TimeAndDate/TimeAndDatePlugin.js\";\nimport { createCase } from \"./functions/createCase.js\";\nimport { createCaseNote } from \"./functions/createCaseNote.js\";\nimport { getCaseEmbed } from \"./functions/getCaseEmbed.js\";\nimport { getCaseSummary } from \"./functions/getCaseSummary.js\";\nimport { getCaseTypeAmountForUserId } from \"./functions/getCaseTypeAmountForUserId.js\";\nimport { getRecentCasesByMod } from \"./functions/getRecentCasesByMod.js\";\nimport { getTotalCasesByMod } from \"./functions/getTotalCasesByMod.js\";\nimport { postCaseToCaseLogChannel } from \"./functions/postToCaseLogChannel.js\";\nimport { CasesPluginType, zCasesConfig } from \"./types.js\";\n\n// The `any` cast here is to prevent TypeScript from locking up from the circular dependency\nfunction getLogsPlugin(): Promise<any> {\n  return import(\"../Logs/LogsPlugin.js\") as Promise<any>;\n}\n\nexport const CasesPlugin = guildPlugin<CasesPluginType>()({\n  name: \"cases\",\n\n  dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getLogsPlugin()).LogsPlugin],\n  configSchema: zCasesConfig,\n\n  public(pluginData) {\n    return {\n      createCase: makePublicFn(pluginData, createCase),\n      createCaseNote: makePublicFn(pluginData, createCaseNote),\n      postCaseToCaseLogChannel: makePublicFn(pluginData, postCaseToCaseLogChannel),\n      getCaseTypeAmountForUserId: makePublicFn(pluginData, getCaseTypeAmountForUserId),\n      getTotalCasesByMod: makePublicFn(pluginData, getTotalCasesByMod),\n      getRecentCasesByMod: makePublicFn(pluginData, getRecentCasesByMod),\n      getCaseEmbed: makePublicFn(pluginData, getCaseEmbed),\n      getCaseSummary: makePublicFn(pluginData, getCaseSummary),\n    };\n  },\n\n  afterLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.logs = new GuildLogs(pluginData.guild.id);\n    state.archives = GuildArchives.getGuildInstance(guild.id);\n    state.cases = GuildCases.getGuildInstance(guild.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Cases/caseAbbreviations.ts",
    "content": "import { CaseTypes } from \"../../data/CaseTypes.js\";\n\nexport const caseAbbreviations = {\n  [CaseTypes.Ban]: \"BAN\",\n  [CaseTypes.Unban]: \"UNBN\",\n  [CaseTypes.Note]: \"NOTE\",\n  [CaseTypes.Warn]: \"WARN\",\n  [CaseTypes.Kick]: \"KICK\",\n  [CaseTypes.Mute]: \"MUTE\",\n  [CaseTypes.Unmute]: \"UNMT\",\n  [CaseTypes.Deleted]: \"DEL\",\n  [CaseTypes.Softban]: \"SFTB\",\n};\n"
  },
  {
    "path": "backend/src/plugins/Cases/caseColors.ts",
    "content": "import { CaseTypes } from \"../../data/CaseTypes.js\";\n\nexport const caseColors: Record<CaseTypes, number> = {\n  [CaseTypes.Ban]: 0xcb4314,\n  [CaseTypes.Unban]: 0x9b59b6,\n  [CaseTypes.Note]: 0x3498db,\n  [CaseTypes.Warn]: 0xdae622,\n  [CaseTypes.Mute]: 0xe6b122,\n  [CaseTypes.Unmute]: 0xa175b3,\n  [CaseTypes.Kick]: 0xe67e22,\n  [CaseTypes.Deleted]: 0x000000,\n  [CaseTypes.Softban]: 0xe67e22,\n};\n"
  },
  {
    "path": "backend/src/plugins/Cases/caseIcons.ts",
    "content": "import { CaseTypes } from \"../../data/CaseTypes.js\";\n\n// These emoji icons are hosted on the Hangar server\n// If you'd like your self-hosted instance to use these icons, check #add-your-bot on that server\nexport const caseIcons: Record<CaseTypes, string> = {\n  [CaseTypes.Ban]: \"<:case_ban:906897178176393246>\",\n  [CaseTypes.Unban]: \"<:case_unban:906897177824067665>\",\n  [CaseTypes.Note]: \"<:case_note:906897177832476743>\",\n  [CaseTypes.Warn]: \"<:case_warn:906897177840844832>\",\n  [CaseTypes.Kick]: \"<:case_kick:906897178310639646>\",\n  [CaseTypes.Mute]: \"<:case_mute:906897178147057664>\",\n  [CaseTypes.Unmute]: \"<:case_unmute:906897177819881523>\",\n  [CaseTypes.Deleted]: \"<:case_deleted:906897178209968148>\",\n  [CaseTypes.Softban]: \"<:case_softban:906897177828278274>\",\n};\n"
  },
  {
    "path": "backend/src/plugins/Cases/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zCasesConfig } from \"./types.js\";\n\nexport const casesPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zCasesConfig,\n\n  prettyName: \"Cases\",\n  description: trimPluginDescription(`\n    This plugin contains basic configuration for cases created by other plugins\n  `),\n};\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/createCase.ts",
    "content": "import type { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { logger } from \"../../../logger.js\";\nimport { renderUsername, resolveUser } from \"../../../utils.js\";\nimport { CaseArgs, CasesPluginType } from \"../types.js\";\nimport { createCaseNote } from \"./createCaseNote.js\";\nimport { postCaseToCaseLogChannel } from \"./postToCaseLogChannel.js\";\n\nexport async function createCase(pluginData: GuildPluginData<CasesPluginType>, args: CaseArgs) {\n  const user = await resolveUser(pluginData.client, args.userId, \"Cases:createCase\");\n  const name = renderUsername(user);\n\n  const mod = await resolveUser(pluginData.client, args.modId, \"Cases:createCase\");\n  const modName = renderUsername(mod);\n\n  let ppName: string | null = null;\n  let ppId: Snowflake | null = null;\n  if (args.ppId) {\n    const pp = await resolveUser(pluginData.client, args.ppId, \"Cases:createCase\");\n    ppName = renderUsername(pp);\n    ppId = pp.id;\n  }\n\n  if (args.auditLogId) {\n    const existingAuditLogCase = await pluginData.state.cases.findByAuditLogId(args.auditLogId);\n    if (existingAuditLogCase) {\n      delete args.auditLogId;\n      logger.warn(`Duplicate audit log ID for mod case: ${args.auditLogId}`);\n    }\n  }\n\n  const createdCase = await pluginData.state.cases.create({\n    type: args.type,\n    user_id: user.id,\n    user_name: name,\n    mod_id: mod.id,\n    mod_name: modName,\n    audit_log_id: args.auditLogId,\n    pp_id: ppId,\n    pp_name: ppName,\n    is_hidden: Boolean(args.hide),\n  });\n\n  if (args.reason || args.noteDetails?.length) {\n    await createCaseNote(pluginData, {\n      caseId: createdCase.id,\n      modId: mod.id,\n      body: args.reason || \"\",\n      automatic: args.automatic,\n      postInCaseLogOverride: false,\n      noteDetails: args.noteDetails,\n    });\n  }\n\n  if (args.extraNotes) {\n    for (const extraNote of args.extraNotes) {\n      await createCaseNote(pluginData, {\n        caseId: createdCase.id,\n        modId: mod.id,\n        body: extraNote,\n        automatic: args.automatic,\n        postInCaseLogOverride: false,\n      });\n    }\n  }\n\n  const config = pluginData.config.get();\n\n  const shouldPostToCaseLogChannel =\n    args.postInCaseLogOverride === true ||\n    ((!args.automatic || config.log_automatic_actions) && args.postInCaseLogOverride !== false);\n\n  if (config.case_log_channel && shouldPostToCaseLogChannel) {\n    await postCaseToCaseLogChannel(pluginData, createdCase);\n  }\n\n  return createdCase;\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/createCaseNote.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { ERRORS, RecoverablePluginError } from \"../../../RecoverablePluginError.js\";\nimport { UnknownUser, renderUsername, resolveUser } from \"../../../utils.js\";\nimport { CaseNoteArgs, CasesPluginType } from \"../types.js\";\nimport { postCaseToCaseLogChannel } from \"./postToCaseLogChannel.js\";\nimport { resolveCaseId } from \"./resolveCaseId.js\";\n\nexport async function createCaseNote(pluginData: GuildPluginData<CasesPluginType>, args: CaseNoteArgs): Promise<void> {\n  const theCase = await pluginData.state.cases.find(resolveCaseId(args.caseId));\n  if (!theCase) {\n    throw new RecoverablePluginError(ERRORS.UNKNOWN_NOTE_CASE);\n  }\n\n  const mod = await resolveUser(pluginData.client, args.modId, \"Cases:createCaseNote\");\n  if (mod instanceof UnknownUser) {\n    throw new RecoverablePluginError(ERRORS.INVALID_USER);\n  }\n\n  const modName = renderUsername(mod);\n\n  let body = args.body;\n\n  // Add note details to the beginning of the note\n  if (args.noteDetails && args.noteDetails.length) {\n    body = args.noteDetails.map((d) => `__[${d}]__`).join(\" \") + \" \" + body;\n  }\n\n  await pluginData.state.cases.createNote(theCase.id, {\n    mod_id: mod.id,\n    mod_name: modName,\n    body: body || \"\",\n  });\n\n  if (theCase.mod_id == null) {\n    // If the case has no moderator information, assume the first one to add a note to it did the action\n    await pluginData.state.cases.update(theCase.id, {\n      mod_id: mod.id,\n      mod_name: modName,\n    });\n  }\n\n  const archiveLinkMatch = body && body.match(/(?<=\\/archives\\/)[a-zA-Z0-9-]+/g);\n  if (archiveLinkMatch) {\n    for (const archiveId of archiveLinkMatch) {\n      pluginData.state.archives.makePermanent(archiveId);\n    }\n  }\n\n  const modConfig = await pluginData.config.getForUser(mod);\n  if (\n    args.postInCaseLogOverride === true ||\n    ((!args.automatic || modConfig.log_automatic_actions) && args.postInCaseLogOverride !== false)\n  ) {\n    await postCaseToCaseLogChannel(pluginData, theCase.id);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/getCaseColor.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CaseTypes, CaseTypeToName } from \"../../../data/CaseTypes.js\";\nimport { caseColors } from \"../caseColors.js\";\nimport { CasesPluginType } from \"../types.js\";\n\nexport function getCaseColor(pluginData: GuildPluginData<CasesPluginType>, caseType: CaseTypes) {\n  return pluginData.config.get().case_colors?.[CaseTypeToName[caseType]] ?? caseColors[caseType];\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/getCaseEmbed.ts",
    "content": "import {\n  escapeCodeBlock,\n  InteractionEditReplyOptions,\n  InteractionReplyOptions,\n  MessageCreateOptions,\n  MessageEditOptions,\n} from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { chunkMessageLines, emptyEmbedValue, messageLink } from \"../../../utils.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { CasesPluginType } from \"../types.js\";\nimport { getCaseColor } from \"./getCaseColor.js\";\nimport { resolveCaseId } from \"./resolveCaseId.js\";\n\nexport async function getCaseEmbed(\n  pluginData: GuildPluginData<CasesPluginType>,\n  caseOrCaseId: Case | number,\n  requestMemberId?: string,\n  noOriginalCaseLink?: boolean,\n): Promise<MessageCreateOptions & MessageEditOptions & InteractionReplyOptions & InteractionEditReplyOptions> {\n  const theCase = await pluginData.state.cases.with(\"notes\").find(resolveCaseId(caseOrCaseId));\n  if (!theCase) {\n    throw new Error(\"Unknown case\");\n  }\n\n  const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n\n  const createdAt = moment.utc(theCase.created_at);\n  const actionTypeStr = CaseTypes[theCase.type].toUpperCase();\n\n  let userName = theCase.user_name;\n  if (theCase.user_id && theCase.user_id !== \"0\") userName += `\\n<@!${theCase.user_id}>`;\n\n  let modName = theCase.mod_name;\n  if (theCase.mod_id) modName += `\\n<@!${theCase.mod_id}>`;\n\n  const createdAtWithTz = requestMemberId\n    ? await timeAndDate.inMemberTz(requestMemberId, createdAt)\n    : timeAndDate.inGuildTz(createdAt);\n\n  const embed: any = {\n    title: `${actionTypeStr} - Case #${theCase.case_number}`,\n    footer: {\n      text: `Case created on ${createdAtWithTz.format(timeAndDate.getDateFormat(\"pretty_datetime\"))}`,\n    },\n    fields: [\n      {\n        name: \"User\",\n        value: userName,\n        inline: true,\n      },\n      {\n        name: \"Moderator\",\n        value: modName,\n        inline: true,\n      },\n    ],\n  };\n\n  if (theCase.pp_id) {\n    embed.fields[1].value += `\\np.p. ${theCase.pp_name}\\n<@!${theCase.pp_id}>`;\n  }\n\n  if (theCase.is_hidden) {\n    embed.title += \" (hidden)\";\n  }\n\n  embed.color = getCaseColor(pluginData, theCase.type);\n\n  if (theCase.notes.length) {\n    for (const note of theCase.notes) {\n      const noteDate = moment.utc(note.created_at);\n      let noteBody = escapeCodeBlock(note.body.trim());\n      if (noteBody === \"\") {\n        noteBody = emptyEmbedValue;\n      }\n\n      const chunks = chunkMessageLines(noteBody, 1014);\n\n      for (let i = 0; i < chunks.length; i++) {\n        if (i === 0) {\n          const noteDateWithTz = requestMemberId\n            ? await timeAndDate.inMemberTz(requestMemberId, noteDate)\n            : timeAndDate.inGuildTz(noteDate);\n          const prettyNoteDate = noteDateWithTz.format(timeAndDate.getDateFormat(\"pretty_datetime\"));\n          embed.fields.push({\n            name: `${note.mod_name} at ${prettyNoteDate}:`,\n            value: chunks[i],\n          });\n        } else {\n          embed.fields.push({\n            name: emptyEmbedValue,\n            value: chunks[i],\n          });\n        }\n      }\n    }\n  } else {\n    embed.fields.push({\n      name: \"!!! THIS CASE HAS NO NOTES !!!\",\n      value: \"\\u200B\",\n    });\n  }\n\n  if (theCase.log_message_id && noOriginalCaseLink !== false) {\n    const [channelId, messageId] = theCase.log_message_id.split(\"-\");\n    const link = messageLink(pluginData.guild.id, channelId, messageId);\n    embed.fields.push({\n      name: emptyEmbedValue,\n      value: `[Go to original case in case log channel](${link})`,\n    });\n  }\n\n  return { embeds: [embed] };\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/getCaseIcon.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CaseTypes, CaseTypeToName } from \"../../../data/CaseTypes.js\";\nimport { caseIcons } from \"../caseIcons.js\";\nimport { CasesPluginType } from \"../types.js\";\n\nexport function getCaseIcon(pluginData: GuildPluginData<CasesPluginType>, caseType: CaseTypes) {\n  return pluginData.config.get().case_icons?.[CaseTypeToName[caseType]] ?? caseIcons[caseType];\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/getCaseSummary.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { splitMessageIntoChunks } from \"vety/helpers\";\nimport moment from \"moment-timezone\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { convertDelayStringToMS, DBDateFormat, messageLink } from \"../../../utils.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { caseAbbreviations } from \"../caseAbbreviations.js\";\nimport { CasesPluginType } from \"../types.js\";\nimport { getCaseIcon } from \"./getCaseIcon.js\";\n\nconst CASE_SUMMARY_REASON_MAX_LENGTH = 300;\nconst INCLUDE_MORE_NOTES_THRESHOLD = 20;\nconst UPDATE_STR = \"**[Update]**\";\n\nexport async function getCaseSummary(\n  pluginData: GuildPluginData<CasesPluginType>,\n  caseOrCaseId: Case | number,\n  withLinks = false,\n  requestMemberId?: string,\n): Promise<string | null> {\n  const config = pluginData.config.get();\n  const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n\n  const caseId = caseOrCaseId instanceof Case ? caseOrCaseId.id : caseOrCaseId;\n  const theCase = await pluginData.state.cases.with(\"notes\").find(caseId);\n  if (!theCase) return null;\n\n  const firstNote = theCase.notes[0];\n  let reason = firstNote ? firstNote.body : \"\";\n  let leftoverNotes = Math.max(0, theCase.notes.length - 1);\n\n  for (let i = 1; i < theCase.notes.length; i++) {\n    if (reason.length >= CASE_SUMMARY_REASON_MAX_LENGTH - UPDATE_STR.length - INCLUDE_MORE_NOTES_THRESHOLD) break;\n    reason += ` ${UPDATE_STR} ${theCase.notes[i].body}`;\n    leftoverNotes--;\n  }\n\n  if (reason.length > CASE_SUMMARY_REASON_MAX_LENGTH) {\n    const match = reason.slice(CASE_SUMMARY_REASON_MAX_LENGTH, 100).match(/(?:[.,!?\\s]|$)/);\n    const nextWhitespaceIndex = match ? CASE_SUMMARY_REASON_MAX_LENGTH + match.index! : CASE_SUMMARY_REASON_MAX_LENGTH;\n    const reasonChunks = splitMessageIntoChunks(reason, nextWhitespaceIndex);\n    reason = reasonChunks[0] + \"...\";\n  }\n\n  const timestamp = moment.utc(theCase.created_at, DBDateFormat);\n  const relativeTimeCutoff = convertDelayStringToMS(config.relative_time_cutoff)!;\n  const useRelativeTime = config.show_relative_times && Date.now() - timestamp.valueOf() < relativeTimeCutoff;\n  const timestampWithTz = requestMemberId\n    ? await timeAndDate.inMemberTz(requestMemberId, timestamp)\n    : timeAndDate.inGuildTz(timestamp);\n  const prettyTimestamp = useRelativeTime\n    ? moment.utc().to(timestamp)\n    : timestampWithTz.format(timeAndDate.getDateFormat(\"date\"));\n\n  const icon = getCaseIcon(pluginData, theCase.type);\n\n  let caseTitle = `\\`#${theCase.case_number}\\``;\n  if (withLinks && theCase.log_message_id) {\n    const [channelId, messageId] = theCase.log_message_id.split(\"-\");\n    caseTitle = `[${caseTitle}](${messageLink(pluginData.guild.id, channelId, messageId)})`;\n  } else {\n    caseTitle = `\\`${caseTitle}\\``;\n  }\n\n  let caseType = (caseAbbreviations[theCase.type] || String(theCase.type)).toUpperCase();\n  caseType = (caseType + \"    \").slice(0, 4);\n\n  let line = `${icon} **\\`${caseType}\\`** \\`[${prettyTimestamp}]\\` ${caseTitle} ${reason}`;\n  if (leftoverNotes > 1) {\n    line += ` *(+${leftoverNotes} ${leftoverNotes === 1 ? \"note\" : \"notes\"})*`;\n  }\n\n  if (theCase.is_hidden) {\n    line += \" *(hidden)*\";\n  }\n\n  return line.trim();\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/getCaseTypeAmountForUserId.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { CasesPluginType } from \"../types.js\";\n\nexport async function getCaseTypeAmountForUserId(\n  pluginData: GuildPluginData<CasesPluginType>,\n  userID: string,\n  type: CaseTypes,\n): Promise<number> {\n  const cases = (await pluginData.state.cases.getByUserId(userID)).filter((c) => !c.is_hidden);\n  let typeAmount = 0;\n\n  if (cases.length > 0) {\n    cases.forEach((singleCase) => {\n      if (singleCase.type === type.valueOf()) {\n        typeAmount++;\n      }\n    });\n  }\n\n  return typeAmount;\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/getRecentCasesByMod.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { FindOptionsWhere } from \"typeorm\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { CasesPluginType } from \"../types.js\";\n\nexport function getRecentCasesByMod(\n  pluginData: GuildPluginData<CasesPluginType>,\n  modId: string,\n  count: number,\n  skip = 0,\n  filters: Omit<FindOptionsWhere<Case>, \"guild_id\" | \"mod_id\" | \"is_hidden\"> = {},\n): Promise<Case[]> {\n  return pluginData.state.cases.getRecentByModId(modId, count, skip, filters);\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/getTotalCasesByMod.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { FindOptionsWhere } from \"typeorm\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { CasesPluginType } from \"../types.js\";\n\nexport function getTotalCasesByMod(\n  pluginData: GuildPluginData<CasesPluginType>,\n  modId: string,\n  filters: Omit<FindOptionsWhere<Case>, \"guild_id\" | \"mod_id\" | \"is_hidden\"> = {},\n): Promise<number> {\n  return pluginData.state.cases.getTotalCasesByModId(modId, filters);\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/postToCaseLogChannel.ts",
    "content": "import { MessageCreateOptions, NewsChannel, RESTJSONErrorCodes, Snowflake, TextChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { isDiscordAPIError } from \"../../../utils.js\";\nimport { InternalPosterPlugin } from \"../../InternalPoster/InternalPosterPlugin.js\";\nimport { InternalPosterMessageResult } from \"../../InternalPoster/functions/sendMessage.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { CasesPluginType } from \"../types.js\";\nimport { getCaseEmbed } from \"./getCaseEmbed.js\";\nimport { resolveCaseId } from \"./resolveCaseId.js\";\n\nexport async function postToCaseLogChannel(\n  pluginData: GuildPluginData<CasesPluginType>,\n  content: MessageCreateOptions,\n  files?: MessageCreateOptions[\"files\"],\n): Promise<InternalPosterMessageResult | null> {\n  const caseLogChannelId = pluginData.config.get().case_log_channel;\n  if (!caseLogChannelId) return null;\n\n  const caseLogChannel = pluginData.guild.channels.cache.get(caseLogChannelId as Snowflake);\n  // This doesn't use `!isText() || isThread()` because TypeScript had some issues inferring types from it\n  if (!caseLogChannel || !(caseLogChannel instanceof TextChannel || caseLogChannel instanceof NewsChannel)) return null;\n\n  let result: InternalPosterMessageResult | null = null;\n  try {\n    if (files != null) {\n      content.files = files;\n    }\n    const poster = pluginData.getPlugin(InternalPosterPlugin);\n    result = await poster.sendMessage(caseLogChannel, { ...content });\n  } catch (e) {\n    if (\n      isDiscordAPIError(e) &&\n      (e.code === RESTJSONErrorCodes.MissingPermissions || e.code === RESTJSONErrorCodes.MissingAccess)\n    ) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Missing permissions to post mod cases in <#${caseLogChannel.id}>`,\n      });\n      return null;\n    }\n\n    throw e;\n  }\n\n  return result;\n}\n\nexport async function postCaseToCaseLogChannel(\n  pluginData: GuildPluginData<CasesPluginType>,\n  caseOrCaseId: Case | number,\n): Promise<void> {\n  const theCase = await pluginData.state.cases.find(resolveCaseId(caseOrCaseId));\n  if (!theCase) return;\n\n  const caseEmbed = await getCaseEmbed(pluginData, caseOrCaseId, undefined, true);\n  if (!caseEmbed) return;\n\n  if (theCase.log_message_id) {\n    const [channelId, messageId] = theCase.log_message_id.split(\"-\");\n\n    try {\n      const poster = pluginData.getPlugin(InternalPosterPlugin);\n      const channel = pluginData.guild.channels.resolve(channelId as Snowflake);\n      if (channel?.isTextBased()) {\n        const message = await channel.messages.fetch(messageId);\n        if (message) {\n          await poster.editMessage(message, caseEmbed);\n        }\n      }\n      return;\n    } catch {} // eslint-disable-line no-empty\n  }\n\n  try {\n    const postedMessage = await postToCaseLogChannel(pluginData, caseEmbed);\n    if (postedMessage) {\n      await pluginData.state.cases.update(theCase.id, {\n        log_message_id: `${postedMessage.channelId}-${postedMessage.id}`,\n      });\n    }\n  } catch {\n    pluginData.getPlugin(LogsPlugin).logBotAlert({\n      body: `Failed to post case #${theCase.case_number} to the case log channel`,\n    });\n    return;\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/functions/resolveCaseId.ts",
    "content": "import { Case } from \"../../../data/entities/Case.js\";\n\nexport function resolveCaseId(caseOrCaseId: Case | number): number {\n  return caseOrCaseId instanceof Case ? caseOrCaseId.id : caseOrCaseId;\n}\n"
  },
  {
    "path": "backend/src/plugins/Cases/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { U } from \"ts-toolbelt\";\nimport { z } from \"zod\";\nimport { CaseNameToType, CaseTypes } from \"../../data/CaseTypes.js\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { keys, zBoundedCharacters, zDelayString, zSnowflake } from \"../../utils.js\";\nimport { zColor } from \"../../utils/zColor.js\";\n\nconst caseKeys = keys(CaseNameToType) as U.ListOf<keyof typeof CaseNameToType>;\n\nconst caseColorsTypeMap = caseKeys.reduce(\n  (map, key) => {\n    map[key] = zColor;\n    return map;\n  },\n  {} as Record<(typeof caseKeys)[number], typeof zColor>,\n);\n\nconst caseIconsTypeMap = caseKeys.reduce(\n  (map, key) => {\n    map[key] = zBoundedCharacters(0, 100);\n    return map;\n  },\n  {} as Record<(typeof caseKeys)[number], z.ZodString>,\n);\n\nexport const zCasesConfig = z.strictObject({\n  log_automatic_actions: z.boolean().default(true),\n  case_log_channel: zSnowflake.nullable().default(null),\n  show_relative_times: z.boolean().default(true),\n  relative_time_cutoff: zDelayString.default(\"1w\"),\n  case_colors: z.strictObject(caseColorsTypeMap).partial().nullable().default(null),\n  case_icons: z.strictObject(caseIconsTypeMap).partial().nullable().default(null),\n});\n\nexport interface CasesPluginType extends BasePluginType {\n  configSchema: typeof zCasesConfig;\n  state: {\n    logs: GuildLogs;\n    cases: GuildCases;\n    archives: GuildArchives;\n  };\n}\n\n/**\n * Can also be used as a config object for functions that create cases\n */\nexport type CaseArgs = {\n  userId: string;\n  modId: string;\n  ppId?: string;\n  type: CaseTypes;\n  auditLogId?: string;\n  reason?: string;\n  automatic?: boolean;\n  postInCaseLogOverride?: boolean;\n  noteDetails?: string[];\n  extraNotes?: string[];\n  hide?: boolean;\n};\n\nexport type CaseNoteArgs = {\n  caseId: number;\n  modId: string;\n  body: string;\n  automatic?: boolean;\n  postInCaseLogOverride?: boolean;\n  noteDetails?: string[];\n};\n"
  },
  {
    "path": "backend/src/plugins/Censor/CensorPlugin.ts",
    "content": "import { PluginOverride, guildPlugin } from \"vety\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { discardRegExpRunner, getRegExpRunner } from \"../../regExpRunners.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { CensorPluginType, zCensorConfig } from \"./types.js\";\nimport { onMessageCreate } from \"./util/onMessageCreate.js\";\nimport { onMessageUpdate } from \"./util/onMessageUpdate.js\";\n\nconst defaultOverrides: Array<PluginOverride<CensorPluginType>> = [\n  {\n    level: \">=50\",\n    config: {\n      filter_zalgo: false,\n      filter_invites: false,\n      filter_domains: false,\n      blocked_tokens: null,\n      blocked_words: null,\n      blocked_regex: null,\n    },\n  },\n];\n\nexport const CensorPlugin = guildPlugin<CensorPluginType>()({\n  name: \"censor\",\n\n  dependencies: () => [LogsPlugin],\n  configSchema: zCensorConfig,\n  defaultOverrides,\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.serverLogs = new GuildLogs(guild.id);\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n\n    state.regexRunner = getRegExpRunner(`guild-${pluginData.guild.id}`);\n  },\n\n  afterLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg);\n    state.savedMessages.events.on(\"create\", state.onMessageCreateFn);\n\n    state.onMessageUpdateFn = (msg) => onMessageUpdate(pluginData, msg);\n    state.savedMessages.events.on(\"update\", state.onMessageUpdateFn);\n  },\n\n  beforeUnload(pluginData) {\n    const { state, guild } = pluginData;\n\n    discardRegExpRunner(`guild-${guild.id}`);\n\n    state.savedMessages.events.off(\"create\", state.onMessageCreateFn);\n    state.savedMessages.events.off(\"update\", state.onMessageUpdateFn);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Censor/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zCensorConfig } from \"./types.js\";\n\nexport const censorPluginDocs: ZeppelinPluginDocs = {\n  type: \"legacy\",\n  configSchema: zCensorConfig,\n\n  prettyName: \"Censor\",\n  description: trimPluginDescription(`\n    Censor words, tokens, links, regex, etc.\n    For more advanced filtering, check out the Automod plugin!\n  `),\n};\n"
  },
  {
    "path": "backend/src/plugins/Censor/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\nimport { RegExpRunner } from \"../../RegExpRunner.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { zBoundedCharacters, zRegex, zSnowflake } from \"../../utils.js\";\n\nexport const zCensorConfig = z.strictObject({\n  filter_zalgo: z.boolean().default(false),\n  filter_invites: z.boolean().default(false),\n  invite_guild_whitelist: z.array(zSnowflake).nullable().default(null),\n  invite_guild_blacklist: z.array(zSnowflake).nullable().default(null),\n  invite_code_whitelist: z.array(zBoundedCharacters(0, 16)).nullable().default(null),\n  invite_code_blacklist: z.array(zBoundedCharacters(0, 16)).nullable().default(null),\n  allow_group_dm_invites: z.boolean().default(false),\n  filter_domains: z.boolean().default(false),\n  domain_whitelist: z.array(zBoundedCharacters(0, 255)).nullable().default(null),\n  domain_blacklist: z.array(zBoundedCharacters(0, 255)).nullable().default(null),\n  blocked_tokens: z.array(zBoundedCharacters(0, 2000)).nullable().default(null),\n  blocked_words: z.array(zBoundedCharacters(0, 2000)).nullable().default(null),\n  blocked_regex: z\n    .array(zRegex(z.string().max(1000)))\n    .nullable()\n    .default(null),\n});\n\nexport interface CensorPluginType extends BasePluginType {\n  configSchema: typeof zCensorConfig;\n  state: {\n    serverLogs: GuildLogs;\n    savedMessages: GuildSavedMessages;\n\n    regexRunner: RegExpRunner;\n\n    onMessageCreateFn;\n    onMessageUpdateFn;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/Censor/util/applyFiltersToMsg.ts",
    "content": "import { Invite } from \"discord.js\";\nimport escapeStringRegexp from \"escape-string-regexp\";\nimport { GuildPluginData } from \"vety\";\nimport { allowTimeout } from \"../../../RegExpRunner.js\";\nimport { ZalgoRegex } from \"../../../data/Zalgo.js\";\nimport { ISavedMessageEmbedData, SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport {\n  getInviteCodesInString,\n  getUrlsInString,\n  inputPatternToRegExp,\n  isGuildInvite,\n  resolveInvite,\n  resolveMember,\n} from \"../../../utils.js\";\nimport { CensorPluginType } from \"../types.js\";\nimport { censorMessage } from \"./censorMessage.js\";\n\ntype ManipulatedEmbedData = Partial<ISavedMessageEmbedData>;\n\nexport async function applyFiltersToMsg(\n  pluginData: GuildPluginData<CensorPluginType>,\n  savedMessage: SavedMessage,\n): Promise<boolean> {\n  const member = await resolveMember(pluginData.client, pluginData.guild, savedMessage.user_id);\n  const config = await pluginData.config.getMatchingConfig({ member, channelId: savedMessage.channel_id });\n\n  let messageContent = savedMessage.data.content || \"\";\n  if (savedMessage.data.attachments) messageContent += \" \" + JSON.stringify(savedMessage.data.attachments);\n  if (savedMessage.data.embeds) {\n    const embeds = (savedMessage.data.embeds as ManipulatedEmbedData[]).map((e) => structuredClone(e));\n    for (const embed of embeds) {\n      if (embed.type === \"video\") {\n        // Ignore video descriptions as they're not actually shown on the embed\n        delete embed.description;\n      }\n    }\n\n    messageContent += \" \" + JSON.stringify(embeds);\n  }\n\n  // Filter zalgo\n  const filterZalgo = config.filter_zalgo;\n  if (filterZalgo) {\n    const result = ZalgoRegex.exec(messageContent);\n    if (result) {\n      censorMessage(pluginData, savedMessage, \"zalgo detected\");\n      return true;\n    }\n  }\n\n  // Filter invites\n  const filterInvites = config.filter_invites;\n  if (filterInvites) {\n    const inviteGuildWhitelist = config.invite_guild_whitelist;\n    const inviteGuildBlacklist = config.invite_guild_blacklist;\n    const inviteCodeWhitelist = config.invite_code_whitelist;\n    const inviteCodeBlacklist = config.invite_code_blacklist;\n    const allowGroupDMInvites = config.allow_group_dm_invites;\n\n    const inviteCodes = getInviteCodesInString(messageContent);\n\n    const invites: Array<Invite | null> = await Promise.all(\n      inviteCodes.map((code) => resolveInvite(pluginData.client, code)),\n    );\n\n    for (const invite of invites) {\n      // Always filter unknown invites if invite filtering is enabled\n      if (invite == null) {\n        censorMessage(pluginData, savedMessage, `unknown invite not found in whitelist`);\n        return true;\n      }\n\n      if (!isGuildInvite(invite) && !allowGroupDMInvites) {\n        censorMessage(pluginData, savedMessage, `group dm invites are not allowed`);\n        return true;\n      }\n\n      if (isGuildInvite(invite)) {\n        if (inviteGuildWhitelist && !inviteGuildWhitelist.includes(invite.guild!.id)) {\n          censorMessage(\n            pluginData,\n            savedMessage,\n            `invite guild (**${invite.guild!.name}** \\`${invite.guild!.id}\\`) not found in whitelist`,\n          );\n          return true;\n        }\n\n        if (inviteGuildBlacklist && inviteGuildBlacklist.includes(invite.guild!.id)) {\n          censorMessage(\n            pluginData,\n            savedMessage,\n            `invite guild (**${invite.guild!.name}** \\`${invite.guild!.id}\\`) found in blacklist`,\n          );\n          return true;\n        }\n      }\n\n      if (inviteCodeWhitelist && !inviteCodeWhitelist.includes(invite.code)) {\n        censorMessage(pluginData, savedMessage, `invite code (\\`${invite.code}\\`) not found in whitelist`);\n        return true;\n      }\n\n      if (inviteCodeBlacklist && inviteCodeBlacklist.includes(invite.code)) {\n        censorMessage(pluginData, savedMessage, `invite code (\\`${invite.code}\\`) found in blacklist`);\n        return true;\n      }\n    }\n  }\n\n  // Filter domains\n  const filterDomains = config.filter_domains;\n  if (filterDomains) {\n    const domainWhitelist = config.domain_whitelist;\n    const domainBlacklist = config.domain_blacklist;\n\n    const urls = getUrlsInString(messageContent);\n    for (const thisUrl of urls) {\n      if (domainWhitelist && !domainWhitelist.includes(thisUrl.hostname)) {\n        censorMessage(pluginData, savedMessage, `domain (\\`${thisUrl.hostname}\\`) not found in whitelist`);\n        return true;\n      }\n\n      if (domainBlacklist && domainBlacklist.includes(thisUrl.hostname)) {\n        censorMessage(pluginData, savedMessage, `domain (\\`${thisUrl.hostname}\\`) found in blacklist`);\n        return true;\n      }\n    }\n  }\n\n  // Filter tokens\n  const blockedTokens = config.blocked_tokens || [];\n  for (const token of blockedTokens) {\n    if (messageContent.toLowerCase().includes(token.toLowerCase())) {\n      censorMessage(pluginData, savedMessage, `blocked token (\\`${token}\\`) found`);\n      return true;\n    }\n  }\n\n  // Filter words\n  const blockedWords = config.blocked_words || [];\n  for (const word of blockedWords) {\n    const regex = new RegExp(`\\\\b${escapeStringRegexp(word)}\\\\b`, \"i\");\n    if (regex.test(messageContent)) {\n      censorMessage(pluginData, savedMessage, `blocked word (\\`${word}\\`) found`);\n      return true;\n    }\n  }\n\n  // Filter regex\n  for (const pattern of config.blocked_regex || []) {\n    const regex = inputPatternToRegExp(pattern);\n    // We're testing both the original content and content + attachments/embeds here so regexes that use ^ and $ still match the regular content properly\n    const matches =\n      (await pluginData.state.regexRunner.exec(regex, savedMessage.data.content).catch(allowTimeout)) ||\n      (await pluginData.state.regexRunner.exec(regex, messageContent).catch(allowTimeout));\n\n    if (matches) {\n      censorMessage(pluginData, savedMessage, `blocked regex (\\`${regex.source}\\`) found`);\n      return true;\n    }\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "backend/src/plugins/Censor/util/censorMessage.ts",
    "content": "import { GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { resolveUser } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { CensorPluginType } from \"../types.js\";\n\nexport async function censorMessage(\n  pluginData: GuildPluginData<CensorPluginType>,\n  savedMessage: SavedMessage,\n  reason: string,\n) {\n  pluginData.state.serverLogs.ignoreLog(LogType.MESSAGE_DELETE, savedMessage.id);\n\n  try {\n    const resolvedChannel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake);\n    if (resolvedChannel?.isTextBased()) await resolvedChannel.messages.delete(savedMessage.id as Snowflake);\n  } catch {\n    return;\n  }\n\n  const user = await resolveUser(pluginData.client, savedMessage.user_id, \"Censor:censorMessage\");\n  const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake)! as GuildTextBasedChannel;\n\n  pluginData.getPlugin(LogsPlugin).logCensor({\n    user,\n    channel,\n    reason,\n    message: savedMessage,\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Censor/util/onMessageCreate.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { messageLock } from \"../../../utils/lockNameHelpers.js\";\nimport { CensorPluginType } from \"../types.js\";\nimport { applyFiltersToMsg } from \"./applyFiltersToMsg.js\";\n\nexport async function onMessageCreate(pluginData: GuildPluginData<CensorPluginType>, savedMessage: SavedMessage) {\n  if (savedMessage.is_bot) return;\n  const lock = await pluginData.locks.acquire(messageLock(savedMessage));\n\n  const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage);\n\n  if (wasDeleted) {\n    lock.interrupt();\n  } else {\n    lock.unlock();\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Censor/util/onMessageUpdate.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { messageLock } from \"../../../utils/lockNameHelpers.js\";\nimport { CensorPluginType } from \"../types.js\";\nimport { applyFiltersToMsg } from \"./applyFiltersToMsg.js\";\n\nexport async function onMessageUpdate(pluginData: GuildPluginData<CensorPluginType>, savedMessage: SavedMessage) {\n  if (savedMessage.is_bot) return;\n  const lock = await pluginData.locks.acquire(messageLock(savedMessage));\n\n  const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage);\n\n  if (wasDeleted) {\n    lock.interrupt();\n  } else {\n    lock.unlock();\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { TimeAndDatePlugin } from \"../TimeAndDate/TimeAndDatePlugin.js\";\nimport { ArchiveChannelCmd } from \"./commands/ArchiveChannelCmd.js\";\nimport { ChannelArchiverPluginType, zChannelArchiverPluginConfig } from \"./types.js\";\n\nexport const ChannelArchiverPlugin = guildPlugin<ChannelArchiverPluginType>()({\n  name: \"channel_archiver\",\n\n  dependencies: () => [TimeAndDatePlugin],\n  configSchema: zChannelArchiverPluginConfig,\n\n  // prettier-ignore\n  messageCommands: [\n      ArchiveChannelCmd,\n  ],\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isOwner } from \"../../../pluginUtils.js\";\nimport { SECONDS, confirm, noop, renderUsername } from \"../../../utils.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { rehostAttachment } from \"../rehostAttachment.js\";\nimport { channelArchiverCmd } from \"../types.js\";\n\nconst MAX_ARCHIVED_MESSAGES = 5000;\nconst MAX_MESSAGES_PER_FETCH = 100;\nconst PROGRESS_UPDATE_INTERVAL = 5 * SECONDS;\n\nexport const ArchiveChannelCmd = channelArchiverCmd({\n  trigger: \"archive_channel\",\n  permission: null,\n\n  config: {\n    preFilters: [\n      (command, context) => {\n        return isOwner(context.pluginData, context.message.author.id);\n      },\n    ],\n  },\n\n  signature: {\n    channel: ct.textChannel(),\n\n    \"attachment-channel\": ct.textChannel({ option: true }),\n    messages: ct.number({ option: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    if (!args[\"attachment-channel\"]) {\n      const confirmed = await confirm(msg, msg.author.id, {\n        content:\n          \"No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.\",\n      });\n      if (!confirmed) {\n        void pluginData.state.common.sendErrorMessage(msg, \"Canceled\");\n        return;\n      }\n    }\n\n    const maxMessagesToArchive = args.messages ? Math.min(args.messages, MAX_ARCHIVED_MESSAGES) : MAX_ARCHIVED_MESSAGES;\n    if (maxMessagesToArchive <= 0) return;\n\n    const archiveLines: string[] = [];\n    let archivedMessages = 0;\n    let previousId: string | undefined;\n\n    const startTime = Date.now();\n    const progressMsg = await msg.channel.send(\"Creating archive...\");\n    const progressUpdateInterval = setInterval(() => {\n      const secondsSinceStart = Math.round((Date.now() - startTime) / 1000);\n      progressMsg\n        .edit(`Creating archive...\\n**Status:** ${archivedMessages} messages archived in ${secondsSinceStart} seconds`)\n        .catch(() => clearInterval(progressUpdateInterval));\n    }, PROGRESS_UPDATE_INTERVAL);\n\n    while (archivedMessages < maxMessagesToArchive) {\n      const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages);\n      const messages = await args.channel.messages.fetch({\n        limit: messagesToFetch,\n        before: previousId as Snowflake,\n      });\n      if (messages.size === 0) break;\n\n      for (const message of messages.values()) {\n        const ts = moment.utc(message.createdTimestamp).format(\"YYYY-MM-DD HH:mm:ss\");\n        let content = `[${ts}] [${message.author.id}] [${renderUsername(message.author)}]: ${\n          message.content || \"<no text content>\"\n        }`;\n\n        if (message.attachments.size) {\n          if (args[\"attachment-channel\"]) {\n            const rehostedAttachmentUrl = await rehostAttachment(message.attachments[0], args[\"attachment-channel\"]);\n            content += `\\n-- Attachment: ${rehostedAttachmentUrl}`;\n          } else {\n            content += `\\n-- Attachment: ${message.attachments[0].url}`;\n          }\n        }\n\n        if (message.reactions.cache.size > 0) {\n          const reactionCounts: string[] = [];\n          for (const [emoji, info] of message.reactions.cache) {\n            reactionCounts.push(`${info.count}x ${emoji}`);\n          }\n          content += `\\n-- Reactions: ${reactionCounts.join(\", \")}`;\n        }\n\n        archiveLines.push(content);\n        previousId = message.id;\n        archivedMessages++;\n      }\n    }\n\n    clearInterval(progressUpdateInterval);\n\n    archiveLines.reverse();\n\n    const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n    const nowTs = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat(\"pretty_datetime\"));\n\n    let result = `Archived ${archiveLines.length} messages from #${args.channel.name} at ${nowTs}`;\n    result += `\\n\\n${archiveLines.join(\"\\n\")}\\n`;\n\n    progressMsg.delete().catch(noop);\n    msg.channel.send({\n      content: \"Archive created!\",\n      files: [\n        {\n          attachment: Buffer.from(result),\n          name: `archive-${args.channel.name}-${moment.utc().format(\"YYYY-MM-DD-HH-mm-ss\")}.txt`,\n        },\n      ],\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ChannelArchiver/rehostAttachment.ts",
    "content": "import { Attachment, GuildTextBasedChannel, MessageCreateOptions } from \"discord.js\";\nimport fs from \"fs\";\nimport { downloadFile } from \"../../utils.js\";\nconst fsp = fs.promises;\n\nconst MAX_ATTACHMENT_REHOST_SIZE = 1024 * 1024 * 8;\n\nexport async function rehostAttachment(attachment: Attachment, targetChannel: GuildTextBasedChannel): Promise<string> {\n  if (attachment.size > MAX_ATTACHMENT_REHOST_SIZE) {\n    return \"Attachment too big to rehost\";\n  }\n\n  let downloaded;\n  try {\n    downloaded = await downloadFile(attachment.url, 3);\n  } catch {\n    return \"Failed to download attachment after 3 tries\";\n  }\n\n  try {\n    const content: MessageCreateOptions = {\n      content: `Rehost of attachment ${attachment.id}`,\n      files: [{ name: attachment.name ? attachment.name : undefined, attachment: await fsp.readFile(downloaded.path) }],\n    };\n    const rehostMessage = await targetChannel.send(content);\n    return rehostMessage.attachments.values()[0].url;\n  } catch {\n    return \"Failed to rehost attachment\";\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/ChannelArchiver/types.ts",
    "content": "import { BasePluginType, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zChannelArchiverPluginConfig = z.strictObject({});\n\nexport interface ChannelArchiverPluginType extends BasePluginType {\n  configSchema: typeof zChannelArchiverPluginConfig;\n  state: {\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const channelArchiverCmd = guildPluginMessageCommand<ChannelArchiverPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/CommandAliases/CommandAliasesPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { DispatchAliasEvt } from \"./events/DispatchAliasEvt.js\";\nimport { CommandAliasesPluginType, zCommandAliasesConfig } from \"./types.js\";\nimport { normalizeAliases } from \"./functions/normalizeAliases.js\";\nimport { buildAliasMatchers } from \"./functions/buildAliasMatchers.js\";\nimport { getGuildPrefix } from \"../../utils/getGuildPrefix.js\";\n\nexport const CommandAliasesPlugin = guildPlugin<CommandAliasesPluginType>()({\n  name: \"command_aliases\",\n  configSchema: zCommandAliasesConfig,\n\n  beforeLoad(pluginData) {\n    const prefix = getGuildPrefix(pluginData);\n    const config = pluginData.config.get();\n    const normalizedAliases = normalizeAliases(config.aliases);\n\n    pluginData.state.matchers = buildAliasMatchers(prefix, normalizedAliases);\n  },\n\n  events: [DispatchAliasEvt],\n});\n"
  },
  {
    "path": "backend/src/plugins/CommandAliases/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zCommandAliasesConfig } from \"./types.js\";\n\nexport const commandAliasesPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Command Aliases\",\n  configSchema: zCommandAliasesConfig,\n  description: \"This plugin lets you create shortcuts for existing commands.\",\n  usageGuide: `\nFor example, you can make \\`!b\\` work the same as \\`!ban\\`, or \\`!c\\` work the same as \\`!cases\\`.\n\n### Example\n\n\\`\\`\\`yaml\nplugins:\n  command_aliases:\n    config:\n      aliases:\n        \"b\": \"ban\"\n        \"c\": \"cases\"\n        \"b2\": \"ban -d 2\"\n        \"ownerinfo\": \"info 754421392988045383\"\n\\`\\`\\`\n\nWith this setup:\n- \\`!b @User\\` runs \\`!ban @User\\`\n- \\`!c\\` runs \\`!cases\\`\n- \\`!b2 @User\\` runs \\`!ban -d 2 @User\\`\n- \\`!ownerinfo\\` runs \\`!info 754421392988045383\\`\n  `\n};\n"
  },
  {
    "path": "backend/src/plugins/CommandAliases/events/DispatchAliasEvt.ts",
    "content": "import { Message } from \"discord.js\";\nimport { commandAliasesEvt } from \"../types.js\";\n\nexport const DispatchAliasEvt = commandAliasesEvt({\n  event: \"messageCreate\",\n  async listener({ args: { message: msg }, pluginData }) {\n    if (!msg.guild || !msg.content) return;\n    if (msg.author.bot || msg.webhookId) return;\n\n    const matchers = pluginData.state.matchers ?? [];\n    if (matchers.length === 0) return;\n\n    const matchingAlias = matchers.find((matcher) => matcher.regex.test(msg.content));\n    if (!matchingAlias) return;\n\n    const newContent = msg.content.replace(matchingAlias.regex, matchingAlias.replacement);\n    if (newContent === msg.content) return;\n\n    const copiedMessage = Object.create(msg);\n    copiedMessage.content = newContent;\n\n    await pluginData.getVetyInstance().dispatchMessageCommands(copiedMessage as Message);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/CommandAliases/functions/buildAliasMatchers.ts",
    "content": "import escapeStringRegexp from \"escape-string-regexp\";\nimport { NormalizedAlias } from \"./normalizeAliases.js\";\n\nexport interface AliasMatcher {\n  regex: RegExp;\n  replacement: string;\n}\n\nexport function buildAliasMatchers(prefix: string, aliases: NormalizedAlias[]): AliasMatcher[] {\n  return aliases.map((alias) => {\n    const pattern = `^${escapeStringRegexp(prefix)}${escapeStringRegexp(alias.alias)}\\\\b`;\n    return {\n      regex: new RegExp(pattern, \"i\"),\n      replacement: `${prefix}${alias.target}`,\n    };\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/CommandAliases/functions/normalizeAliases.ts",
    "content": "export interface NormalizedAlias {\n  alias: string;\n  target: string;\n}\n\nexport function normalizeAliases(aliases: Record<string, string> | undefined | null): NormalizedAlias[] {\n  if (!aliases) {\n    return [];\n  }\n\n  const normalized: NormalizedAlias[] = [];\n  for (const [rawAlias, rawTarget] of Object.entries(aliases)) {\n    const alias = rawAlias.trim();\n    const target = rawTarget.trim();\n\n    if (!alias || !target) {\n      continue;\n    }\n\n    normalized.push({\n      alias,\n      target,\n    });\n  }\n\n  return normalized;\n}\n"
  },
  {
    "path": "backend/src/plugins/CommandAliases/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener } from \"vety\";\nimport z from \"zod\";\nimport { AliasMatcher } from \"./functions/buildAliasMatchers.js\";\n\nexport const zCommandAliasesConfig = z.strictObject({\n  aliases: z.record(z.string().min(1), z.string().min(1)).optional(),\n});\n\nexport interface CommandAliasesPluginType extends BasePluginType {\n  configSchema: typeof zCommandAliasesConfig;\n  state: {\n    matchers: AliasMatcher[];\n  };\n}\n\nexport const commandAliasesEvt = guildPluginEventListener<CommandAliasesPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Common/CommonPlugin.ts",
    "content": "import { Attachment, MessageMentionOptions, SendableChannels, TextBasedChannel } from \"discord.js\";\nimport { guildPlugin } from \"vety\";\nimport { GenericCommandSource, makePublicFn, sendContextResponse } from \"../../pluginUtils.js\";\nimport { errorMessage, successMessage } from \"../../utils.js\";\nimport { getErrorEmoji, getSuccessEmoji } from \"./functions/getEmoji.js\";\nimport { CommonPluginType, zCommonConfig } from \"./types.js\";\n\nexport const CommonPlugin = guildPlugin<CommonPluginType>()({\n  name: \"common\",\n  dependencies: () => [],\n  configSchema: zCommonConfig,\n  public(pluginData) {\n    return {\n      getSuccessEmoji: makePublicFn(pluginData, getSuccessEmoji),\n      getErrorEmoji: makePublicFn(pluginData, getErrorEmoji),\n\n      sendSuccessMessage: async (\n        context: GenericCommandSource | SendableChannels,\n        body: string,\n        allowedMentions?: MessageMentionOptions,\n        responseInteraction?: never,\n        ephemeral = true,\n      ) => {\n        const emoji = getSuccessEmoji(pluginData);\n        const formattedBody = successMessage(body, emoji);\n        const content = allowedMentions ? { content: formattedBody, allowedMentions } : { content: formattedBody };\n        if (\"isSendable\" in context) {\n          return context.send(content);\n        }\n        return sendContextResponse(context, content, ephemeral);\n      },\n\n      sendErrorMessage: async (\n        context: GenericCommandSource | SendableChannels,\n        body: string,\n        allowedMentions?: MessageMentionOptions,\n        responseInteraction?: never,\n        ephemeral = true,\n      ) => {\n        const emoji = getErrorEmoji(pluginData);\n        const formattedBody = errorMessage(body, emoji);\n        const content = allowedMentions ? { content: formattedBody, allowedMentions } : { content: formattedBody };\n        if (\"isSendable\" in context) {\n          return context.send(content);\n        }\n        return sendContextResponse(context, content, ephemeral);\n      },\n\n      storeAttachmentsAsMessage: async (attachments: Attachment[], backupChannel?: TextBasedChannel | null) => {\n        const attachmentChannelId = pluginData.config.get().attachment_storing_channel;\n        const channel = attachmentChannelId\n          ? ((pluginData.guild.channels.cache.get(attachmentChannelId) as TextBasedChannel) ?? backupChannel)\n          : backupChannel;\n\n        if (!channel) {\n          throw new Error(\n            \"Cannot store attachments: no attachment storing channel configured, and no backup channel passed\",\n          );\n        }\n        if (!channel.isSendable()) {\n          throw new Error(\"Passed attachment storage channel is not sendable\");\n        }\n\n        return channel.send({\n          content: `Storing ${attachments.length} attachment${attachments.length === 1 ? \"\" : \"s\"}`,\n          files: attachments.map((a) => a.url),\n        });\n      },\n    };\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Common/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zCommonConfig } from \"./types.js\";\n\nexport const commonPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zCommonConfig,\n\n  prettyName: \"Common\",\n};\n"
  },
  {
    "path": "backend/src/plugins/Common/functions/getEmoji.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { env } from \"../../../env.js\";\nimport { CommonPluginType } from \"../types.js\";\n\nexport function getSuccessEmoji(pluginData: GuildPluginData<CommonPluginType>) {\n  return pluginData.config.get().success_emoji ?? env.DEFAULT_SUCCESS_EMOJI;\n}\n\nexport function getErrorEmoji(pluginData: GuildPluginData<CommonPluginType>) {\n  return pluginData.config.get().error_emoji ?? env.DEFAULT_ERROR_EMOJI;\n}\n"
  },
  {
    "path": "backend/src/plugins/Common/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\n\nexport const zCommonConfig = z.strictObject({\n  success_emoji: z.string().nullable().default(null),\n  error_emoji: z.string().nullable().default(null),\n  attachment_storing_channel: z.nullable(z.string()).default(null),\n});\n\nexport interface CommonPluginType extends BasePluginType {\n  configSchema: typeof zCommonConfig;\n}\n"
  },
  {
    "path": "backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts",
    "content": "import { CooldownManager, guildPlugin } from \"vety\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { VoiceStateUpdateEvt } from \"./events/VoiceStateUpdateEvt.js\";\nimport { CompanionChannelsPluginType, zCompanionChannelsConfig } from \"./types.js\";\n\nexport const CompanionChannelsPlugin = guildPlugin<CompanionChannelsPluginType>()({\n  name: \"companion_channels\",\n\n  dependencies: () => [LogsPlugin],\n  configSchema: zCompanionChannelsConfig,\n\n  events: [VoiceStateUpdateEvt],\n\n  beforeLoad(pluginData) {\n    pluginData.state.errorCooldownManager = new CooldownManager();\n  },\n\n  afterLoad(pluginData) {\n    pluginData.state.serverLogs = new GuildLogs(pluginData.guild.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/CompanionChannels/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zCompanionChannelsConfig } from \"./types.js\";\n\nexport const companionChannelsPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zCompanionChannelsConfig,\n\n  prettyName: \"Companion channels\",\n  description: trimPluginDescription(`\n    Set up 'companion channels' between text and voice channels.\n    Once set up, any time a user joins one of the specified voice channels,\n    they'll get channel permissions applied to them for the text channels.\n  `),\n};\n"
  },
  {
    "path": "backend/src/plugins/CompanionChannels/events/VoiceStateUpdateEvt.ts",
    "content": "import { handleCompanionPermissions } from \"../functions/handleCompanionPermissions.js\";\nimport { companionChannelsEvt } from \"../types.js\";\n\nexport const VoiceStateUpdateEvt = companionChannelsEvt({\n  event: \"voiceStateUpdate\",\n  listener({ pluginData, args: { oldState, newState } }) {\n    const oldChannel = oldState.channel;\n    const newChannel = newState.channel;\n\n    const memberId = newState.member?.id ?? oldState.member?.id;\n    if (!memberId) {\n      return;\n    }\n\n    handleCompanionPermissions(pluginData, memberId, newChannel, oldChannel);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/CompanionChannels/functions/getCompanionChannelOptsForVoiceChannelId.ts",
    "content": "import { StageChannel, VoiceChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CompanionChannelsPluginType, TCompanionChannelOpts } from \"../types.js\";\n\nconst defaultCompanionChannelOpts: Partial<TCompanionChannelOpts> = {\n  enabled: true,\n};\n\nexport async function getCompanionChannelOptsForVoiceChannelId(\n  pluginData: GuildPluginData<CompanionChannelsPluginType>,\n  userId: string,\n  voiceChannel: VoiceChannel | StageChannel,\n): Promise<TCompanionChannelOpts[]> {\n  const config = await pluginData.config.getMatchingConfig({ userId, channelId: voiceChannel.id });\n  return Object.values(config.entries)\n    .filter(\n      (opts) =>\n        opts.voice_channel_ids.includes(voiceChannel.id) ||\n        (voiceChannel.parentId && opts.voice_channel_ids.includes(voiceChannel.parentId)),\n    )\n    .map((opts) => Object.assign({}, defaultCompanionChannelOpts, opts));\n}\n"
  },
  {
    "path": "backend/src/plugins/CompanionChannels/functions/handleCompanionPermissions.ts",
    "content": "import { PermissionsBitField, Snowflake, StageChannel, TextChannel, VoiceChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { MINUTES, isDiscordAPIError } from \"../../../utils.js\";\nimport { filterObject } from \"../../../utils/filterObject.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { CompanionChannelsPluginType, TCompanionChannelOpts } from \"../types.js\";\nimport { getCompanionChannelOptsForVoiceChannelId } from \"./getCompanionChannelOptsForVoiceChannelId.js\";\n\nconst ERROR_COOLDOWN_KEY = \"errorCooldown\";\nconst ERROR_COOLDOWN = 5 * MINUTES;\n\n// The real limit is 500, but to be on the safer side, this is lower\n// Temporary fix until we move to role-based companion channels\nconst MAX_OVERWRITES = 450;\n\nexport async function handleCompanionPermissions(\n  pluginData: GuildPluginData<CompanionChannelsPluginType>,\n  userId: string,\n  voiceChannel: VoiceChannel | StageChannel | null,\n  oldChannel?: VoiceChannel | StageChannel | null,\n) {\n  if (pluginData.state.errorCooldownManager.isOnCooldown(ERROR_COOLDOWN_KEY)) {\n    return;\n  }\n\n  const permsToDelete: Set<string> = new Set(); // channelId[]\n  const oldPerms: Map<string, number> = new Map(); // channelId => permissions\n  const permsToSet: Map<string, number> = new Map(); // channelId => permissions\n\n  const oldChannelOptsArr: TCompanionChannelOpts[] = oldChannel\n    ? await getCompanionChannelOptsForVoiceChannelId(pluginData, userId, oldChannel)\n    : [];\n  const newChannelOptsArr: TCompanionChannelOpts[] = voiceChannel\n    ? await getCompanionChannelOptsForVoiceChannelId(pluginData, userId, voiceChannel)\n    : [];\n\n  for (const oldChannelOpts of oldChannelOptsArr) {\n    for (const channelId of oldChannelOpts.text_channel_ids) {\n      oldPerms.set(channelId, oldChannelOpts.permissions);\n      permsToDelete.add(channelId);\n    }\n  }\n\n  for (const newChannelOpts of newChannelOptsArr) {\n    for (const channelId of newChannelOpts.text_channel_ids) {\n      if (oldPerms.get(channelId) !== newChannelOpts.permissions) {\n        // Update text channel perms if the channel we transitioned from didn't already have the same text channel perms\n        permsToSet.set(channelId, newChannelOpts.permissions);\n      }\n      if (permsToDelete.has(channelId)) {\n        permsToDelete.delete(channelId);\n      }\n    }\n  }\n\n  const logs = pluginData.getPlugin(LogsPlugin);\n\n  try {\n    for (const channelId of permsToDelete) {\n      const channel = pluginData.guild.channels.cache.get(channelId as Snowflake);\n      if (!channel || !(channel instanceof TextChannel)) continue;\n      pluginData.state.serverLogs.ignoreLog(LogType.CHANNEL_UPDATE, channelId, 3 * 1000);\n      await channel.permissionOverwrites\n        .resolve(userId as Snowflake)\n        ?.delete(`Companion Channel for ${oldChannel!.id} | User Left`);\n    }\n\n    for (const [channelId, permissions] of permsToSet) {\n      const channel = pluginData.guild.channels.cache.get(channelId as Snowflake);\n      if (!channel || !(channel instanceof TextChannel)) continue;\n      if (channel.permissionOverwrites.cache.size >= MAX_OVERWRITES) {\n        logs.logBotAlert({\n          body: `Could not apply companion channel permissions for <#${channel.id}>: too many permissions`,\n        });\n        continue;\n      }\n      pluginData.state.serverLogs.ignoreLog(LogType.CHANNEL_UPDATE, channelId, 3 * 1000);\n      const fullSerialized = new PermissionsBitField(BigInt(permissions)).serialize();\n      const onlyAllowed = filterObject(fullSerialized, (v) => v === true);\n      await channel.permissionOverwrites.create(userId, onlyAllowed, {\n        reason: `Companion Channel for ${voiceChannel!.id} | User Joined`,\n      });\n    }\n  } catch (e) {\n    if (isDiscordAPIError(e)) {\n      if (e.code === 50001) {\n        logs.logBotAlert({\n          body: `One of the companion channels can't be accessed. Pausing companion channels for 5 minutes or until the bot is reloaded on this server.`,\n        });\n        pluginData.state.errorCooldownManager.setCooldown(ERROR_COOLDOWN_KEY, ERROR_COOLDOWN);\n        return;\n      }\n\n      if (e.code === 50013) {\n        logs.logBotAlert({\n          body: `Missing permissions to handle companion channels. Pausing companion channels for 5 minutes or until the bot is reloaded on this server.`,\n        });\n        pluginData.state.errorCooldownManager.setCooldown(ERROR_COOLDOWN_KEY, ERROR_COOLDOWN);\n        return;\n      }\n    }\n\n    throw e;\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/CompanionChannels/types.ts",
    "content": "import { BasePluginType, CooldownManager, guildPluginEventListener } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { zBoundedCharacters, zSnowflake } from \"../../utils.js\";\n\nexport const zCompanionChannelOpts = z.strictObject({\n  voice_channel_ids: z.array(zSnowflake),\n  text_channel_ids: z.array(zSnowflake),\n  // See https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags\n  permissions: z.number(),\n  enabled: z.boolean().nullable().default(true),\n});\nexport type TCompanionChannelOpts = z.infer<typeof zCompanionChannelOpts>;\n\nexport const zCompanionChannelsConfig = z.strictObject({\n  entries: z.record(zBoundedCharacters(0, 100), zCompanionChannelOpts).default({}),\n});\n\nexport interface CompanionChannelsPluginType extends BasePluginType {\n  configSchema: typeof zCompanionChannelsConfig;\n  state: {\n    errorCooldownManager: CooldownManager;\n    serverLogs: GuildLogs;\n  };\n}\n\nexport const companionChannelsEvt = guildPluginEventListener<CompanionChannelsPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/ContextMenuPlugin.ts",
    "content": "import { PluginOverride, guildPlugin } from \"vety\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { CasesPlugin } from \"../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { ModActionsPlugin } from \"../ModActions/ModActionsPlugin.js\";\nimport { MutesPlugin } from \"../Mutes/MutesPlugin.js\";\nimport { UtilityPlugin } from \"../Utility/UtilityPlugin.js\";\nimport { BanCmd } from \"./commands/BanUserCtxCmd.js\";\nimport { CleanCmd } from \"./commands/CleanMessageCtxCmd.js\";\nimport { ModMenuCmd } from \"./commands/ModMenuUserCtxCmd.js\";\nimport { MuteCmd } from \"./commands/MuteUserCtxCmd.js\";\nimport { NoteCmd } from \"./commands/NoteUserCtxCmd.js\";\nimport { WarnCmd } from \"./commands/WarnUserCtxCmd.js\";\nimport { ContextMenuPluginType, zContextMenusConfig } from \"./types.js\";\n\nconst defaultOverrides: Array<PluginOverride<ContextMenuPluginType>> = [\n  {\n    level: \">=50\",\n    config: {\n      can_use: true,\n\n      can_open_mod_menu: true,\n    },\n  },\n];\n\nexport const ContextMenuPlugin = guildPlugin<ContextMenuPluginType>()({\n  name: \"context_menu\",\n\n  dependencies: () => [CasesPlugin, MutesPlugin, ModActionsPlugin, LogsPlugin, UtilityPlugin],\n  configSchema: zContextMenusConfig,\n  defaultOverrides,\n\n  contextMenuCommands: [ModMenuCmd, NoteCmd, WarnCmd, MuteCmd, BanCmd, CleanCmd],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.cases = GuildCases.getGuildInstance(guild.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/actions/ban.ts",
    "content": "import {\n  ActionRowBuilder,\n  ButtonInteraction,\n  ContextMenuCommandInteraction,\n  ModalBuilder,\n  ModalSubmitInteraction,\n  TextInputBuilder,\n  TextInputStyle,\n} from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { logger } from \"../../../logger.js\";\nimport { canActOn } from \"../../../pluginUtils.js\";\nimport { convertDelayStringToMS, renderUserUsername } from \"../../../utils.js\";\nimport { CaseArgs } from \"../../Cases/types.js\";\nimport { ModActionsPlugin } from \"../../ModActions/ModActionsPlugin.js\";\nimport { MODAL_TIMEOUT } from \"../commands/ModMenuUserCtxCmd.js\";\nimport { ContextMenuPluginType, ModMenuActionType } from \"../types.js\";\nimport { updateAction } from \"./update.js\";\n\nasync function banAction(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  duration: string | undefined,\n  reason: string | undefined,\n  evidence: string | undefined,\n  target: string,\n  interaction: ButtonInteraction | ContextMenuCommandInteraction,\n  submitInteraction: ModalSubmitInteraction,\n) {\n  const interactionToReply = interaction.isButton() ? interaction : submitInteraction;\n  const executingMember = await pluginData.guild.members.fetch(interaction.user.id);\n  const userCfg = await pluginData.config.getMatchingConfig({\n    channelId: interaction.channelId,\n    member: executingMember,\n  });\n\n  const modactions = pluginData.getPlugin(ModActionsPlugin);\n  if (!userCfg.can_use || !(await modactions.hasBanPermission(executingMember, interaction.channelId))) {\n    await interactionToReply.editReply({ content: \"Cannot ban: insufficient permissions\", embeds: [], components: [] });\n    return;\n  }\n\n  const targetMember = await pluginData.guild.members.fetch(target);\n  if (!canActOn(pluginData, executingMember, targetMember)) {\n    await interactionToReply.editReply({ content: \"Cannot ban: insufficient permissions\", embeds: [], components: [] });\n    return;\n  }\n\n  const caseArgs: Partial<CaseArgs> = {\n    modId: executingMember.id,\n  };\n\n  const durationMs = duration ? convertDelayStringToMS(duration)! : undefined;\n  const result = await modactions.banUserId(target, reason, reason, { caseArgs }, durationMs);\n  if (result.status === \"failed\") {\n    await interactionToReply.editReply({ content: \"Error: Failed to ban user\", embeds: [], components: [] });\n    return;\n  }\n\n  const userName = renderUserUsername(targetMember.user);\n  const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : \"\";\n  const banMessage = `Banned **${userName}** ${\n    durationMs ? `for ${humanizeDuration(durationMs)}` : \"indefinitely\"\n  } (Case #${result.case.case_number})${messageResultText}`;\n\n  if (evidence) {\n    await updateAction(pluginData, executingMember, result.case, evidence);\n  }\n\n  await interactionToReply.editReply({ content: banMessage, embeds: [], components: [] });\n}\n\nexport async function launchBanActionModal(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  interaction: ButtonInteraction | ContextMenuCommandInteraction,\n  target: string,\n) {\n  const modalId = `${ModMenuActionType.BAN}:${interaction.id}`;\n  const modal = new ModalBuilder().setCustomId(modalId).setTitle(\"Ban\");\n  const durationIn = new TextInputBuilder()\n    .setCustomId(\"duration\")\n    .setLabel(\"Duration (Optional)\")\n    .setRequired(false)\n    .setStyle(TextInputStyle.Short);\n  const reasonIn = new TextInputBuilder()\n    .setCustomId(\"reason\")\n    .setLabel(\"Reason (Optional)\")\n    .setRequired(false)\n    .setStyle(TextInputStyle.Paragraph);\n  const evidenceIn = new TextInputBuilder()\n    .setCustomId(\"evidence\")\n    .setLabel(\"Evidence (Optional)\")\n    .setRequired(false)\n    .setStyle(TextInputStyle.Paragraph);\n  const durationRow = new ActionRowBuilder<TextInputBuilder>().addComponents(durationIn);\n  const reasonRow = new ActionRowBuilder<TextInputBuilder>().addComponents(reasonIn);\n  const evidenceRow = new ActionRowBuilder<TextInputBuilder>().addComponents(evidenceIn);\n  modal.addComponents(durationRow, reasonRow, evidenceRow);\n\n  await interaction.showModal(modal);\n  await interaction\n    .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId })\n    .then(async (submitted) => {\n      if (interaction.isButton()) {\n        await submitted.deferUpdate().catch((err) => logger.error(`Ban interaction defer failed: ${err}`));\n      } else if (interaction.isContextMenuCommand()) {\n        await submitted.deferReply({ ephemeral: true });\n      }\n\n      const duration = submitted.fields.getTextInputValue(\"duration\");\n      const reason = submitted.fields.getTextInputValue(\"reason\");\n      const evidence = submitted.fields.getTextInputValue(\"evidence\");\n\n      await banAction(pluginData, duration, reason, evidence, target, interaction, submitted);\n    });\n}\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/actions/clean.ts",
    "content": "import {\n  ActionRowBuilder,\n  Message,\n  MessageContextMenuCommandInteraction,\n  ModalBuilder,\n  ModalSubmitInteraction,\n  TextInputBuilder,\n  TextInputStyle,\n} from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { logger } from \"../../../logger.js\";\nimport { UtilityPlugin } from \"../../../plugins/Utility/UtilityPlugin.js\";\nimport { MODAL_TIMEOUT } from \"../commands/ModMenuUserCtxCmd.js\";\nimport { ContextMenuPluginType, ModMenuActionType } from \"../types.js\";\n\nexport async function cleanAction(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  amount: number,\n  target: string,\n  targetMessage: Message,\n  targetChannelId: string,\n  interaction: ModalSubmitInteraction,\n) {\n  const executingMember = await pluginData.guild.members.fetch(interaction.user.id);\n  const userCfg = await pluginData.config.getMatchingConfig({\n    channelId: interaction.channelId,\n    member: executingMember,\n  });\n  const utility = pluginData.getPlugin(UtilityPlugin);\n\n  if (!userCfg.can_use || !(await utility.hasPermission(executingMember, targetChannelId, \"can_clean\"))) {\n    await interaction\n      .editReply({ content: \"Cannot clean: insufficient permissions\", embeds: [], components: [] })\n      .catch((err) => logger.error(`Clean interaction reply failed: ${err}`));\n    return;\n  }\n\n  const targetChannel = await pluginData.guild.channels.fetch(targetChannelId);\n  if (!targetChannel?.isTextBased()) {\n    await interaction\n      .editReply({ content: \"Cannot clean: target channel is not a text channel\", embeds: [], components: [] })\n      .catch((err) => logger.error(`Clean interaction reply failed: ${err}`));\n    return;\n  }\n\n  await interaction\n    .editReply({\n      content: `Cleaning ${amount} messages from ${target}...`,\n      embeds: [],\n      components: [],\n    })\n    .catch((err) => logger.error(`Clean interaction reply failed: ${err}`));\n\n  const fetchMessagesResult = await utility.fetchChannelMessagesToClean(targetChannel, {\n    count: amount,\n    beforeId: targetMessage.id,\n  });\n  if (\"error\" in fetchMessagesResult) {\n    interaction.editReply(fetchMessagesResult.error);\n    return;\n  }\n\n  if (fetchMessagesResult.messages.length > 0) {\n    await utility.cleanMessages(targetChannel, fetchMessagesResult.messages, interaction.user);\n    interaction.editReply(\n      `Cleaned ${fetchMessagesResult.messages.length} ${\n        fetchMessagesResult.messages.length === 1 ? \"message\" : \"messages\"\n      }`,\n    );\n  } else {\n    interaction.editReply(\"No messages to clean\");\n  }\n}\n\nexport async function launchCleanActionModal(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  interaction: MessageContextMenuCommandInteraction,\n  target: string,\n) {\n  const modalId = `${ModMenuActionType.CLEAN}:${interaction.id}`;\n  const modal = new ModalBuilder().setCustomId(modalId).setTitle(\"Clean\");\n  const amountIn = new TextInputBuilder().setCustomId(\"amount\").setLabel(\"Amount\").setStyle(TextInputStyle.Short);\n  const amountRow = new ActionRowBuilder<TextInputBuilder>().addComponents(amountIn);\n  modal.addComponents(amountRow);\n\n  await interaction.showModal(modal);\n  await interaction\n    .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId })\n    .then(async (submitted) => {\n      await submitted.deferReply({ ephemeral: true });\n\n      const amount = submitted.fields.getTextInputValue(\"amount\");\n      if (isNaN(Number(amount))) {\n        interaction.editReply({ content: `Error: Amount '${amount}' is invalid`, embeds: [], components: [] });\n        return;\n      }\n\n      await cleanAction(\n        pluginData,\n        Number(amount),\n        target,\n        interaction.targetMessage,\n        interaction.channelId,\n        submitted,\n      );\n    });\n}\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/actions/mute.ts",
    "content": "import {\n  ActionRowBuilder,\n  ButtonInteraction,\n  ContextMenuCommandInteraction,\n  ModalBuilder,\n  ModalSubmitInteraction,\n  TextInputBuilder,\n  TextInputStyle,\n} from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { ERRORS, RecoverablePluginError } from \"../../../RecoverablePluginError.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { logger } from \"../../../logger.js\";\nimport { canActOn } from \"../../../pluginUtils.js\";\nimport { convertDelayStringToMS } from \"../../../utils.js\";\nimport { CaseArgs } from \"../../Cases/types.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { ModActionsPlugin } from \"../../ModActions/ModActionsPlugin.js\";\nimport { MutesPlugin } from \"../../Mutes/MutesPlugin.js\";\nimport { MODAL_TIMEOUT } from \"../commands/ModMenuUserCtxCmd.js\";\nimport { ContextMenuPluginType, ModMenuActionType } from \"../types.js\";\nimport { updateAction } from \"./update.js\";\n\nasync function muteAction(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  duration: string | undefined,\n  reason: string | undefined,\n  evidence: string | undefined,\n  target: string,\n  interaction: ButtonInteraction | ContextMenuCommandInteraction,\n  submitInteraction: ModalSubmitInteraction,\n) {\n  const interactionToReply = interaction.isButton() ? interaction : submitInteraction;\n  const executingMember = await pluginData.guild.members.fetch(interaction.user.id);\n  const userCfg = await pluginData.config.getMatchingConfig({\n    channelId: interaction.channelId,\n    member: executingMember,\n  });\n\n  const modactions = pluginData.getPlugin(ModActionsPlugin);\n  if (!userCfg.can_use || !(await modactions.hasMutePermission(executingMember, interaction.channelId))) {\n    await interactionToReply.editReply({\n      content: \"Cannot mute: insufficient permissions\",\n      embeds: [],\n      components: [],\n    });\n    return;\n  }\n\n  const targetMember = await pluginData.guild.members.fetch(target);\n  if (!canActOn(pluginData, executingMember, targetMember)) {\n    await interactionToReply.editReply({\n      content: \"Cannot mute: insufficient permissions\",\n      embeds: [],\n      components: [],\n    });\n    return;\n  }\n\n  const caseArgs: Partial<CaseArgs> = {\n    modId: executingMember.id,\n  };\n  const mutes = pluginData.getPlugin(MutesPlugin);\n  const durationMs = duration ? convertDelayStringToMS(duration)! : undefined;\n\n  try {\n    const result = await mutes.muteUser(target, durationMs, reason, reason, { caseArgs });\n    const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : \"\";\n    const muteMessage = `Muted **${result.case!.user_name}** ${\n      durationMs ? `for ${humanizeDuration(durationMs)}` : \"indefinitely\"\n    } (Case #${result.case!.case_number})${messageResultText}`;\n\n    if (evidence) {\n      await updateAction(pluginData, executingMember, result.case!, evidence);\n    }\n\n    await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] });\n  } catch (e) {\n    await interactionToReply.editReply({\n      content: \"Plugin error, please check your BOT_ALERTs\",\n      embeds: [],\n      components: [],\n    });\n\n    if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Failed to mute <@!${target}> in ContextMenu action \\`mute\\` because a mute role has not been specified in server config`,\n      });\n    } else {\n      throw e;\n    }\n  }\n}\n\nexport async function launchMuteActionModal(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  interaction: ButtonInteraction | ContextMenuCommandInteraction,\n  target: string,\n) {\n  const modalId = `${ModMenuActionType.MUTE}:${interaction.id}`;\n  const modal = new ModalBuilder().setCustomId(modalId).setTitle(\"Mute\");\n  const durationIn = new TextInputBuilder()\n    .setCustomId(\"duration\")\n    .setLabel(\"Duration (Optional)\")\n    .setRequired(false)\n    .setStyle(TextInputStyle.Short);\n  const reasonIn = new TextInputBuilder()\n    .setCustomId(\"reason\")\n    .setLabel(\"Reason (Optional)\")\n    .setRequired(false)\n    .setStyle(TextInputStyle.Paragraph);\n  const evidenceIn = new TextInputBuilder()\n    .setCustomId(\"evidence\")\n    .setLabel(\"Evidence (Optional)\")\n    .setRequired(false)\n    .setStyle(TextInputStyle.Paragraph);\n  const durationRow = new ActionRowBuilder<TextInputBuilder>().addComponents(durationIn);\n  const reasonRow = new ActionRowBuilder<TextInputBuilder>().addComponents(reasonIn);\n  const evidenceRow = new ActionRowBuilder<TextInputBuilder>().addComponents(evidenceIn);\n  modal.addComponents(durationRow, reasonRow, evidenceRow);\n\n  await interaction.showModal(modal);\n  await interaction\n    .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId })\n    .then(async (submitted) => {\n      if (interaction.isButton()) {\n        await submitted.deferUpdate().catch((err) => logger.error(`Mute interaction defer failed: ${err}`));\n      } else if (interaction.isContextMenuCommand()) {\n        await submitted.deferReply({ ephemeral: true });\n      }\n\n      const duration = submitted.fields.getTextInputValue(\"duration\");\n      const reason = submitted.fields.getTextInputValue(\"reason\");\n      const evidence = submitted.fields.getTextInputValue(\"evidence\");\n\n      await muteAction(pluginData, duration, reason, evidence, target, interaction, submitted);\n    });\n}\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/actions/note.ts",
    "content": "import {\n  ActionRowBuilder,\n  ButtonInteraction,\n  ContextMenuCommandInteraction,\n  ModalBuilder,\n  ModalSubmitInteraction,\n  TextInputBuilder,\n  TextInputStyle,\n} from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { logger } from \"../../../logger.js\";\nimport { canActOn } from \"../../../pluginUtils.js\";\nimport { CasesPlugin } from \"../../../plugins/Cases/CasesPlugin.js\";\nimport { renderUserUsername } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { ModActionsPlugin } from \"../../ModActions/ModActionsPlugin.js\";\nimport { MODAL_TIMEOUT } from \"../commands/ModMenuUserCtxCmd.js\";\nimport { ContextMenuPluginType, ModMenuActionType } from \"../types.js\";\n\nasync function noteAction(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  reason: string,\n  target: string,\n  interaction: ButtonInteraction | ContextMenuCommandInteraction,\n  submitInteraction: ModalSubmitInteraction,\n) {\n  const interactionToReply = interaction.isButton() ? interaction : submitInteraction;\n  const executingMember = await pluginData.guild.members.fetch(interaction.user.id);\n  const userCfg = await pluginData.config.getMatchingConfig({\n    channelId: interaction.channelId,\n    member: executingMember,\n  });\n\n  const modactions = pluginData.getPlugin(ModActionsPlugin);\n  if (!userCfg.can_use || !(await modactions.hasNotePermission(executingMember, interaction.channelId))) {\n    await interactionToReply.editReply({\n      content: \"Cannot note: insufficient permissions\",\n      embeds: [],\n      components: [],\n    });\n    return;\n  }\n\n  const targetMember = await pluginData.guild.members.fetch(target);\n  if (!canActOn(pluginData, executingMember, targetMember)) {\n    await interactionToReply.editReply({\n      content: \"Cannot note: insufficient permissions\",\n      embeds: [],\n      components: [],\n    });\n    return;\n  }\n\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const createdCase = await casesPlugin.createCase({\n    userId: target,\n    modId: executingMember.id,\n    type: CaseTypes.Note,\n    reason,\n  });\n\n  pluginData.getPlugin(LogsPlugin).logMemberNote({\n    mod: interaction.user,\n    user: targetMember.user,\n    caseNumber: createdCase.case_number,\n    reason,\n  });\n\n  const userName = renderUserUsername(targetMember.user);\n  await interactionToReply.editReply({\n    content: `Note added on **${userName}** (Case #${createdCase.case_number})`,\n    embeds: [],\n    components: [],\n  });\n}\n\nexport async function launchNoteActionModal(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  interaction: ButtonInteraction | ContextMenuCommandInteraction,\n  target: string,\n) {\n  const modalId = `${ModMenuActionType.NOTE}:${interaction.id}`;\n  const modal = new ModalBuilder().setCustomId(modalId).setTitle(\"Note\");\n  const reasonIn = new TextInputBuilder().setCustomId(\"reason\").setLabel(\"Note\").setStyle(TextInputStyle.Paragraph);\n  const reasonRow = new ActionRowBuilder<TextInputBuilder>().addComponents(reasonIn);\n  modal.addComponents(reasonRow);\n\n  await interaction.showModal(modal);\n  await interaction\n    .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId })\n    .then(async (submitted) => {\n      if (interaction.isButton()) {\n        await submitted.deferUpdate().catch((err) => logger.error(`Note interaction defer failed: ${err}`));\n      } else if (interaction.isContextMenuCommand()) {\n        await submitted.deferReply({ ephemeral: true });\n      }\n\n      const reason = submitted.fields.getTextInputValue(\"reason\");\n\n      await noteAction(pluginData, reason, target, interaction, submitted);\n    });\n}\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/actions/update.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { ContextMenuPluginType } from \"../types.js\";\n\nexport async function updateAction(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  executingMember: GuildMember,\n  theCase: Case,\n  value: string,\n) {\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  await casesPlugin.createCaseNote({\n    caseId: theCase.case_number,\n    modId: executingMember.id,\n    body: value,\n  });\n\n  void pluginData.getPlugin(LogsPlugin).logCaseUpdate({\n    mod: executingMember.user,\n    caseNumber: theCase.case_number,\n    caseType: CaseTypes[theCase.type],\n    note: value,\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/actions/warn.ts",
    "content": "import {\n  ActionRowBuilder,\n  ButtonInteraction,\n  ContextMenuCommandInteraction,\n  ModalBuilder,\n  ModalSubmitInteraction,\n  TextInputBuilder,\n  TextInputStyle,\n} from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { logger } from \"../../../logger.js\";\nimport { canActOn } from \"../../../pluginUtils.js\";\nimport { renderUserUsername } from \"../../../utils.js\";\nimport { CaseArgs } from \"../../Cases/types.js\";\nimport { ModActionsPlugin } from \"../../ModActions/ModActionsPlugin.js\";\nimport { MODAL_TIMEOUT } from \"../commands/ModMenuUserCtxCmd.js\";\nimport { ContextMenuPluginType, ModMenuActionType } from \"../types.js\";\nimport { updateAction } from \"./update.js\";\n\nasync function warnAction(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  reason: string,\n  evidence: string | undefined,\n  target: string,\n  interaction: ButtonInteraction | ContextMenuCommandInteraction,\n  submitInteraction: ModalSubmitInteraction,\n) {\n  const interactionToReply = interaction.isButton() ? interaction : submitInteraction;\n  const executingMember = await pluginData.guild.members.fetch(interaction.user.id);\n  const userCfg = await pluginData.config.getMatchingConfig({\n    channelId: interaction.channelId,\n    member: executingMember,\n  });\n\n  const modactions = pluginData.getPlugin(ModActionsPlugin);\n  if (!userCfg.can_use || !(await modactions.hasWarnPermission(executingMember, interaction.channelId))) {\n    await interactionToReply.editReply({\n      content: \"Cannot warn: insufficient permissions\",\n      embeds: [],\n      components: [],\n    });\n    return;\n  }\n\n  const targetMember = await pluginData.guild.members.fetch(target);\n  if (!canActOn(pluginData, executingMember, targetMember)) {\n    await interactionToReply.editReply({\n      content: \"Cannot warn: insufficient permissions\",\n      embeds: [],\n      components: [],\n    });\n    return;\n  }\n\n  const caseArgs: Partial<CaseArgs> = {\n    modId: executingMember.id,\n  };\n\n  const result = await modactions.warnMember(targetMember, reason, reason, { caseArgs });\n  if (result.status === \"failed\") {\n    await interactionToReply.editReply({ content: \"Error: Failed to warn user\", embeds: [], components: [] });\n    return;\n  }\n\n  const userName = renderUserUsername(targetMember.user);\n  const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : \"\";\n  const muteMessage = `Warned **${userName}** (Case #${result.case.case_number})${messageResultText}`;\n\n  if (evidence) {\n    await updateAction(pluginData, executingMember, result.case, evidence);\n  }\n\n  await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] });\n}\n\nexport async function launchWarnActionModal(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  interaction: ButtonInteraction | ContextMenuCommandInteraction,\n  target: string,\n) {\n  const modalId = `${ModMenuActionType.WARN}:${interaction.id}`;\n  const modal = new ModalBuilder().setCustomId(modalId).setTitle(\"Warn\");\n  const reasonIn = new TextInputBuilder().setCustomId(\"reason\").setLabel(\"Reason\").setStyle(TextInputStyle.Paragraph);\n  const evidenceIn = new TextInputBuilder()\n    .setCustomId(\"evidence\")\n    .setLabel(\"Evidence (Optional)\")\n    .setRequired(false)\n    .setStyle(TextInputStyle.Paragraph);\n  const reasonRow = new ActionRowBuilder<TextInputBuilder>().addComponents(reasonIn);\n  const evidenceRow = new ActionRowBuilder<TextInputBuilder>().addComponents(evidenceIn);\n  modal.addComponents(reasonRow, evidenceRow);\n\n  await interaction.showModal(modal);\n  await interaction\n    .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId })\n    .then(async (submitted) => {\n      if (interaction.isButton()) {\n        await submitted.deferUpdate().catch((err) => logger.error(`Warn interaction defer failed: ${err}`));\n      } else if (interaction.isContextMenuCommand()) {\n        await submitted.deferReply({ ephemeral: true });\n      }\n\n      const reason = submitted.fields.getTextInputValue(\"reason\");\n      const evidence = submitted.fields.getTextInputValue(\"evidence\");\n\n      await warnAction(pluginData, reason, evidence, target, interaction, submitted);\n    });\n}\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts",
    "content": "import { PermissionFlagsBits } from \"discord.js\";\nimport { guildPluginUserContextMenuCommand } from \"vety\";\nimport { launchBanActionModal } from \"../actions/ban.js\";\n\nexport const BanCmd = guildPluginUserContextMenuCommand({\n  name: \"Ban\",\n  defaultMemberPermissions: PermissionFlagsBits.BanMembers.toString(),\n  async run({ pluginData, interaction }) {\n    await launchBanActionModal(pluginData, interaction, interaction.targetId);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts",
    "content": "import { PermissionFlagsBits } from \"discord.js\";\nimport { guildPluginMessageContextMenuCommand } from \"vety\";\nimport { launchCleanActionModal } from \"../actions/clean.js\";\n\nexport const CleanCmd = guildPluginMessageContextMenuCommand({\n  name: \"Clean\",\n  defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(),\n  async run({ pluginData, interaction }) {\n    await launchCleanActionModal(pluginData, interaction, interaction.targetId);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts",
    "content": "import {\n  APIEmbed,\n  ActionRowBuilder,\n  ButtonBuilder,\n  ButtonInteraction,\n  ButtonStyle,\n  ContextMenuCommandInteraction,\n  GuildMember,\n  PermissionFlagsBits,\n  User,\n} from \"discord.js\";\nimport { GuildPluginData, guildPluginUserContextMenuCommand } from \"vety\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { logger } from \"../../../logger.js\";\nimport { SECONDS, UnknownUser, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from \"../../../utils.js\";\nimport { asyncMap } from \"../../../utils/async.js\";\nimport { getChunkedEmbedFields } from \"../../../utils/getChunkedEmbedFields.js\";\nimport { getGuildPrefix } from \"../../../utils/getGuildPrefix.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { ModActionsPlugin } from \"../../ModActions/ModActionsPlugin.js\";\nimport { getUserInfoEmbed } from \"../../Utility/functions/getUserInfoEmbed.js\";\nimport { launchBanActionModal } from \"../actions/ban.js\";\nimport { launchMuteActionModal } from \"../actions/mute.js\";\nimport { launchNoteActionModal } from \"../actions/note.js\";\nimport { launchWarnActionModal } from \"../actions/warn.js\";\nimport {\n  ContextMenuPluginType,\n  LoadModMenuPageFn,\n  ModMenuActionOpts,\n  ModMenuActionType,\n  ModMenuNavigationType,\n} from \"../types.js\";\n\nexport const MODAL_TIMEOUT = 60 * SECONDS;\nconst MOD_MENU_TIMEOUT = 60 * SECONDS;\nconst CASES_PER_PAGE = 10;\n\nexport const ModMenuCmd = guildPluginUserContextMenuCommand({\n  name: \"Mod Menu\",\n  defaultMemberPermissions: PermissionFlagsBits.ViewAuditLog.toString(),\n  async run({ pluginData, interaction }) {\n    await interaction.deferReply({ ephemeral: true });\n\n    // Run permission checks for executing user.\n    const executingMember = await pluginData.guild.members.fetch(interaction.user.id);\n    const userCfg = await pluginData.config.getMatchingConfig({\n      channelId: interaction.channelId,\n      member: executingMember,\n    });\n    if (!userCfg.can_use || !userCfg.can_open_mod_menu) {\n      await interaction.followUp({ content: \"Error: Insufficient Permissions\" });\n      return;\n    }\n\n    const user = await resolveUser(pluginData.client, interaction.targetId, \"ContextMenus:ModMenuCmd\");\n    if (!user.id) {\n      await interaction.followUp(\"Error: User not found\");\n      return;\n    }\n\n    // Load cases and display mod menu\n    const cases: Case[] = await pluginData.state.cases.with(\"notes\").getByUserId(user.id);\n    const userName =\n      user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUserUsername(user);\n    const casesPlugin = pluginData.getPlugin(CasesPlugin);\n    const totalCases = cases.length;\n    const totalPages: number = Math.max(Math.ceil(totalCases / CASES_PER_PAGE), 1);\n    const prefix = getGuildPrefix(pluginData);\n    const infoEmbed = await getUserInfoEmbed(pluginData, user.id, false);\n    displayModMenu(\n      pluginData,\n      interaction,\n      totalPages,\n      async (page) => {\n        const pageCases: Case[] = await pluginData.state.cases\n          .with(\"notes\")\n          .getRecentByUserId(user.id, CASES_PER_PAGE, (page - 1) * CASES_PER_PAGE);\n        const lines = await asyncMap(pageCases, (c) => casesPlugin.getCaseSummary(c, true, interaction.targetId));\n\n        const firstCaseNum = (page - 1) * CASES_PER_PAGE + 1;\n        const lastCaseNum = Math.min(page * CASES_PER_PAGE, totalCases);\n        const title =\n          lines.length == 0\n            ? `${userName}`\n            : `Most recent cases for ${userName} | ${firstCaseNum}-${lastCaseNum} of ${totalCases}`;\n\n        const embed = {\n          author: {\n            name: title,\n            icon_url: user instanceof User ? user.displayAvatarURL() : undefined,\n          },\n          fields: [\n            ...getChunkedEmbedFields(\n              emptyEmbedValue,\n              lines.length == 0 ? `No cases found for **${userName}**` : lines.join(\"\\n\"),\n            ),\n            {\n              name: emptyEmbedValue,\n              value: trimLines(\n                lines.length == 0 ? \"\" : `Use \\`${prefix}case <num>\\` to see more information about an individual case`,\n              ),\n            },\n          ],\n          footer: { text: `Page ${page}/${totalPages}` },\n        } satisfies APIEmbed;\n\n        return embed;\n      },\n      infoEmbed,\n      executingMember,\n    );\n  },\n});\n\nasync function displayModMenu(\n  pluginData: GuildPluginData<ContextMenuPluginType>,\n  interaction: ContextMenuCommandInteraction,\n  totalPages: number,\n  loadPage: LoadModMenuPageFn,\n  infoEmbed: APIEmbed | null,\n  executingMember: GuildMember,\n) {\n  if (interaction.deferred == false) {\n    await interaction.deferReply().catch((err) => logger.error(`Mod menu interaction defer failed: ${err}`));\n  }\n\n  const firstButton = new ButtonBuilder()\n    .setStyle(ButtonStyle.Secondary)\n    .setEmoji(\"⏪\")\n    .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.FIRST }))\n    .setDisabled(true);\n  const prevButton = new ButtonBuilder()\n    .setStyle(ButtonStyle.Secondary)\n    .setEmoji(\"⬅\")\n    .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.PREV }))\n    .setDisabled(true);\n  const infoButton = new ButtonBuilder()\n    .setStyle(ButtonStyle.Primary)\n    .setLabel(\"Info\")\n    .setEmoji(\"ℹ\")\n    .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO }))\n    .setDisabled(infoEmbed != null ? false : true);\n  const nextButton = new ButtonBuilder()\n    .setStyle(ButtonStyle.Secondary)\n    .setEmoji(\"➡\")\n    .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.NEXT }))\n    .setDisabled(totalPages > 1 ? false : true);\n  const lastButton = new ButtonBuilder()\n    .setStyle(ButtonStyle.Secondary)\n    .setEmoji(\"⏩\")\n    .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.LAST }))\n    .setDisabled(totalPages > 1 ? false : true);\n  const navigationButtons = [firstButton, prevButton, infoButton, nextButton, lastButton] satisfies ButtonBuilder[];\n\n  const modactions = pluginData.getPlugin(ModActionsPlugin);\n  const moderationButtons = [\n    new ButtonBuilder()\n      .setStyle(ButtonStyle.Primary)\n      .setLabel(\"Note\")\n      .setEmoji(\"📝\")\n      .setDisabled(!(await modactions.hasNotePermission(executingMember, interaction.channelId)))\n      .setCustomId(serializeCustomId({ action: ModMenuActionType.NOTE, target: interaction.targetId })),\n    new ButtonBuilder()\n      .setStyle(ButtonStyle.Primary)\n      .setLabel(\"Warn\")\n      .setEmoji(\"⚠️\")\n      .setDisabled(!(await modactions.hasWarnPermission(executingMember, interaction.channelId)))\n      .setCustomId(serializeCustomId({ action: ModMenuActionType.WARN, target: interaction.targetId })),\n    new ButtonBuilder()\n      .setStyle(ButtonStyle.Primary)\n      .setLabel(\"Mute\")\n      .setEmoji(\"🔇\")\n      .setDisabled(!(await modactions.hasMutePermission(executingMember, interaction.channelId)))\n      .setCustomId(serializeCustomId({ action: ModMenuActionType.MUTE, target: interaction.targetId })),\n    new ButtonBuilder()\n      .setStyle(ButtonStyle.Primary)\n      .setLabel(\"Ban\")\n      .setEmoji(\"🚫\")\n      .setDisabled(!(await modactions.hasBanPermission(executingMember, interaction.channelId)))\n      .setCustomId(serializeCustomId({ action: ModMenuActionType.BAN, target: interaction.targetId })),\n  ] satisfies ButtonBuilder[];\n\n  const navigationRow = new ActionRowBuilder<ButtonBuilder>().addComponents(navigationButtons);\n  const moderationRow = new ActionRowBuilder<ButtonBuilder>().addComponents(moderationButtons);\n\n  let page = 1;\n  await interaction\n    .editReply({\n      embeds: [await loadPage(page)],\n      components: [navigationRow, moderationRow],\n    })\n    .then(async (currentPage) => {\n      const collector = await currentPage.createMessageComponentCollector({\n        time: MOD_MENU_TIMEOUT,\n      });\n\n      collector.on(\"collect\", async (i) => {\n        const opts = deserializeCustomId(i.customId);\n        if (opts.action == ModMenuActionType.PAGE) {\n          await i.deferUpdate().catch((err) => logger.error(`Mod menu defer failed: ${err}`));\n        }\n\n        // Update displayed embed if any navigation buttons were used\n        if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.INFO && infoEmbed != null) {\n          infoButton\n            .setLabel(\"Cases\")\n            .setEmoji(\"📋\")\n            .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.CASES }));\n          firstButton.setDisabled(true);\n          prevButton.setDisabled(true);\n          nextButton.setDisabled(true);\n          lastButton.setDisabled(true);\n\n          await i\n            .editReply({\n              embeds: [infoEmbed],\n              components: [navigationRow, moderationRow],\n            })\n            .catch((err) => logger.error(`Mod menu info view failed: ${err}`));\n        } else if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.CASES) {\n          infoButton\n            .setLabel(\"Info\")\n            .setEmoji(\"ℹ\")\n            .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO }));\n          updateNavButtonState(firstButton, prevButton, nextButton, lastButton, page, totalPages);\n\n          await i\n            .editReply({\n              embeds: [await loadPage(page)],\n              components: [navigationRow, moderationRow],\n            })\n            .catch((err) => logger.error(`Mod menu cases view failed: ${err}`));\n        } else if (opts.action == ModMenuActionType.PAGE) {\n          let pageDelta = 0;\n          switch (opts.target) {\n            case ModMenuNavigationType.PREV:\n              pageDelta = -1;\n              break;\n            case ModMenuNavigationType.NEXT:\n              pageDelta = 1;\n              break;\n          }\n\n          let newPage = 1;\n          if (opts.target == ModMenuNavigationType.PREV || opts.target == ModMenuNavigationType.NEXT) {\n            newPage = Math.max(Math.min(page + pageDelta, totalPages), 1);\n          } else if (opts.target == ModMenuNavigationType.FIRST) {\n            newPage = 1;\n          } else if (opts.target == ModMenuNavigationType.LAST) {\n            newPage = totalPages;\n          }\n\n          if (newPage != page) {\n            updateNavButtonState(firstButton, prevButton, nextButton, lastButton, newPage, totalPages);\n\n            await i\n              .editReply({\n                embeds: [await loadPage(newPage)],\n                components: [navigationRow, moderationRow],\n              })\n              .catch((err) => logger.error(`Mod menu navigation failed: ${err}`));\n\n            page = newPage;\n          }\n        } else if (opts.action == ModMenuActionType.NOTE) {\n          await launchNoteActionModal(pluginData, i as ButtonInteraction, opts.target);\n        } else if (opts.action == ModMenuActionType.WARN) {\n          await launchWarnActionModal(pluginData, i as ButtonInteraction, opts.target);\n        } else if (opts.action == ModMenuActionType.MUTE) {\n          await launchMuteActionModal(pluginData, i as ButtonInteraction, opts.target);\n        } else if (opts.action == ModMenuActionType.BAN) {\n          await launchBanActionModal(pluginData, i as ButtonInteraction, opts.target);\n        }\n\n        collector.resetTimer();\n      });\n\n      // Remove components on timeout.\n      collector.on(\"end\", async (_, reason) => {\n        if (reason !== \"messageDelete\") {\n          await interaction\n            .editReply({\n              components: [],\n            })\n            .catch((err) => logger.error(`Mod menu timeout failed: ${err}`));\n        }\n      });\n    })\n    .catch((err) => logger.error(`Mod menu setup failed: ${err}`));\n}\n\nfunction serializeCustomId(opts: ModMenuActionOpts) {\n  return `${opts.action}:${opts.target}`;\n}\n\nfunction deserializeCustomId(customId: string): ModMenuActionOpts {\n  const opts: ModMenuActionOpts = {\n    action: customId.split(\":\")[0] as ModMenuActionType,\n    target: customId.split(\":\")[1],\n  };\n\n  return opts;\n}\n\nfunction updateNavButtonState(\n  firstButton: ButtonBuilder,\n  prevButton: ButtonBuilder,\n  nextButton: ButtonBuilder,\n  lastButton: ButtonBuilder,\n  currentPage: number,\n  totalPages: number,\n) {\n  if (currentPage > 1) {\n    firstButton.setDisabled(false);\n    prevButton.setDisabled(false);\n  } else {\n    firstButton.setDisabled(true);\n    prevButton.setDisabled(true);\n  }\n\n  if (currentPage == totalPages) {\n    nextButton.setDisabled(true);\n    lastButton.setDisabled(true);\n  } else {\n    nextButton.setDisabled(false);\n    lastButton.setDisabled(false);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts",
    "content": "import { PermissionFlagsBits } from \"discord.js\";\nimport { guildPluginUserContextMenuCommand } from \"vety\";\nimport { launchMuteActionModal } from \"../actions/mute.js\";\n\nexport const MuteCmd = guildPluginUserContextMenuCommand({\n  name: \"Mute\",\n  defaultMemberPermissions: PermissionFlagsBits.ModerateMembers.toString(),\n  async run({ pluginData, interaction }) {\n    await launchMuteActionModal(pluginData, interaction, interaction.targetId);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts",
    "content": "import { PermissionFlagsBits } from \"discord.js\";\nimport { guildPluginUserContextMenuCommand } from \"vety\";\nimport { launchNoteActionModal } from \"../actions/note.js\";\n\nexport const NoteCmd = guildPluginUserContextMenuCommand({\n  name: \"Note\",\n  defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(),\n  async run({ pluginData, interaction }) {\n    await launchNoteActionModal(pluginData, interaction, interaction.targetId);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts",
    "content": "import { PermissionFlagsBits } from \"discord.js\";\nimport { guildPluginUserContextMenuCommand } from \"vety\";\nimport { launchWarnActionModal } from \"../actions/warn.js\";\n\nexport const WarnCmd = guildPluginUserContextMenuCommand({\n  name: \"Warn\",\n  defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(),\n  async run({ pluginData, interaction }) {\n    await launchWarnActionModal(pluginData, interaction, interaction.targetId);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zContextMenusConfig } from \"./types.js\";\n\nexport const contextMenuPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zContextMenusConfig,\n\n  prettyName: \"Context menu\",\n};\n"
  },
  {
    "path": "backend/src/plugins/ContextMenus/types.ts",
    "content": "import { APIEmbed, Awaitable } from \"discord.js\";\nimport { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\n\nexport const zContextMenusConfig = z.strictObject({\n  can_use: z.boolean().default(false),\n  can_open_mod_menu: z.boolean().default(false),\n});\n\nexport interface ContextMenuPluginType extends BasePluginType {\n  configSchema: typeof zContextMenusConfig;\n  state: {\n    cases: GuildCases;\n  };\n}\n\nexport const enum ModMenuActionType {\n  PAGE = \"page\",\n  NOTE = \"note\",\n  WARN = \"warn\",\n  CLEAN = \"clean\",\n  MUTE = \"mute\",\n  BAN = \"ban\",\n}\n\nexport const enum ModMenuNavigationType {\n  FIRST = \"first\",\n  PREV = \"prev\",\n  NEXT = \"next\",\n  LAST = \"last\",\n  INFO = \"info\",\n  CASES = \"cases\",\n}\n\nexport interface ModMenuActionOpts {\n  action: ModMenuActionType;\n  target: string;\n}\n\nexport type LoadModMenuPageFn = (page: number) => Awaitable<APIEmbed>;\n"
  },
  {
    "path": "backend/src/plugins/Counters/CountersPlugin.ts",
    "content": "import { EventEmitter } from \"events\";\nimport { PluginOverride, guildPlugin } from \"vety\";\nimport { GuildCounters } from \"../../data/GuildCounters.js\";\nimport {\n  CounterTrigger,\n  buildCounterConditionString,\n  getReverseCounterComparisonOp,\n  parseCounterConditionString,\n} from \"../../data/entities/CounterTrigger.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { MINUTES, convertDelayStringToMS } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { AddCounterCmd } from \"./commands/AddCounterCmd.js\";\nimport { CountersListCmd } from \"./commands/CountersListCmd.js\";\nimport { ResetAllCounterValuesCmd } from \"./commands/ResetAllCounterValuesCmd.js\";\nimport { ResetCounterCmd } from \"./commands/ResetCounterCmd.js\";\nimport { SetCounterCmd } from \"./commands/SetCounterCmd.js\";\nimport { ViewCounterCmd } from \"./commands/ViewCounterCmd.js\";\nimport { changeCounterValue } from \"./functions/changeCounterValue.js\";\nimport { counterExists } from \"./functions/counterExists.js\";\nimport { decayCounter } from \"./functions/decayCounter.js\";\nimport { getPrettyNameForCounter } from \"./functions/getPrettyNameForCounter.js\";\nimport { getPrettyNameForCounterTrigger } from \"./functions/getPrettyNameForCounterTrigger.js\";\nimport { offCounterEvent } from \"./functions/offCounterEvent.js\";\nimport { onCounterEvent } from \"./functions/onCounterEvent.js\";\nimport { setCounterValue } from \"./functions/setCounterValue.js\";\nimport { CountersPluginType, zCountersConfig } from \"./types.js\";\n\nconst DECAY_APPLY_INTERVAL = 5 * MINUTES;\n\nconst defaultOverrides: Array<PluginOverride<CountersPluginType>> = [\n  {\n    level: \">=50\",\n    config: {\n      can_view: true,\n    },\n  },\n  {\n    level: \">=100\",\n    config: {\n      can_edit: true,\n    },\n  },\n];\n\n/**\n * The Counters plugin keeps track of simple integer values that are tied to a user, channel, both, or neither — \"counters\".\n * These values can be changed using the functions in the plugin's public interface.\n * These values can also be set to automatically decay over time.\n *\n * Triggers can be registered that check for a specific condition, e.g. \"when this counter is over 100\".\n * Triggers are checked against every time a counter's value changes, and will emit an event when triggered.\n * A single trigger can only trigger once per user/channel/in general, depending on how specific the counter is (e.g. a per-user trigger can only trigger once per user).\n * After being triggered, a trigger is \"reset\" if the counter value no longer matches the trigger (e.g. drops to 100 or below in the above example). After this, that trigger can be triggered again.\n */\nexport const CountersPlugin = guildPlugin<CountersPluginType>()({\n  name: \"counters\",\n\n  configSchema: zCountersConfig,\n  defaultOverrides,\n\n  public(pluginData) {\n    return {\n      counterExists: makePublicFn(pluginData, counterExists),\n      changeCounterValue: makePublicFn(pluginData, changeCounterValue),\n      setCounterValue: makePublicFn(pluginData, setCounterValue),\n      getPrettyNameForCounter: makePublicFn(pluginData, getPrettyNameForCounter),\n      getPrettyNameForCounterTrigger: makePublicFn(pluginData, getPrettyNameForCounterTrigger),\n      onCounterEvent: makePublicFn(pluginData, onCounterEvent),\n      offCounterEvent: makePublicFn(pluginData, offCounterEvent),\n    };\n  },\n\n  // prettier-ignore\n  messageCommands: [\n    CountersListCmd,\n    ViewCounterCmd,\n    AddCounterCmd,\n    SetCounterCmd,\n    ResetCounterCmd,\n    ResetAllCounterValuesCmd,\n  ],\n\n  async beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.counters = new GuildCounters(guild.id);\n    state.events = new EventEmitter() as any;\n    state.counterTriggersByCounterId = new Map();\n\n    const activeTriggerIds: number[] = [];\n\n    // Initialize and store the IDs of each of the counters internally\n    state.counterIds = {};\n    const config = pluginData.config.get();\n    for (const [counterName, counter] of Object.entries(config.counters)) {\n      const dbCounter = await state.counters.findOrCreateCounter(counterName, counter.per_channel, counter.per_user);\n      state.counterIds[counterName] = dbCounter.id;\n\n      const thisCounterTriggers: CounterTrigger[] = [];\n      state.counterTriggersByCounterId.set(dbCounter.id, thisCounterTriggers);\n\n      // Initialize triggers\n      for (const [triggerName, trigger] of Object.entries(counter.triggers)) {\n        const parsedCondition = parseCounterConditionString(trigger.condition)!;\n        const rawReverseCondition =\n          trigger.reverse_condition ||\n          buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]);\n        const parsedReverseCondition = parseCounterConditionString(rawReverseCondition)!;\n        const counterTrigger = await state.counters.initCounterTrigger(\n          dbCounter.id,\n          triggerName,\n          parsedCondition[0],\n          parsedCondition[1],\n          parsedReverseCondition[0],\n          parsedReverseCondition[1],\n        );\n        activeTriggerIds.push(counterTrigger.id);\n        thisCounterTriggers.push(counterTrigger);\n      }\n    }\n\n    // Mark old/unused counters to be deleted later\n    await state.counters.markUnusedCountersToBeDeleted([...Object.values(state.counterIds)]);\n\n    // Mark old/unused triggers to be deleted later\n    await state.counters.markUnusedTriggersToBeDeleted(activeTriggerIds);\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  async afterLoad(pluginData) {\n    const { state } = pluginData;\n\n    const config = pluginData.config.get();\n\n    // Start decay timers\n    state.decayTimers = [];\n    for (const [counterName, counter] of Object.entries(config.counters)) {\n      if (!counter.decay) {\n        continue;\n      }\n\n      const decay = counter.decay;\n      const decayPeriodMs = convertDelayStringToMS(decay.every)!;\n      if (decayPeriodMs === 0) {\n        continue;\n      }\n\n      state.decayTimers.push(\n        setInterval(() => {\n          decayCounter(pluginData, counterName, decayPeriodMs, decay.amount);\n        }, DECAY_APPLY_INTERVAL),\n      );\n    }\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    if (state.decayTimers) {\n      for (const interval of state.decayTimers) {\n        clearInterval(interval);\n      }\n    }\n\n    (state.events as any).removeAllListeners();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Counters/commands/AddCounterCmd.ts",
    "content": "import { Snowflake, TextChannel } from \"discord.js\";\nimport { guildPluginMessageCommand } from \"vety\";\nimport { waitForReply } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { UnknownUser, resolveUser } from \"../../../utils.js\";\nimport { changeCounterValue } from \"../functions/changeCounterValue.js\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport const AddCounterCmd = guildPluginMessageCommand<CountersPluginType>()({\n  trigger: [\"counters add\", \"counter add\", \"addcounter\"],\n  permission: \"can_edit\",\n\n  signature: [\n    {\n      counterName: ct.string(),\n      amount: ct.number(),\n    },\n    {\n      counterName: ct.string(),\n      user: ct.resolvedUser(),\n      amount: ct.number(),\n    },\n    {\n      counterName: ct.string(),\n      channel: ct.textChannel(),\n      amount: ct.number(),\n    },\n    {\n      counterName: ct.string(),\n      channel: ct.textChannel(),\n      user: ct.resolvedUser(),\n      amount: ct.number(),\n    },\n    {\n      counterName: ct.string(),\n      user: ct.resolvedUser(),\n      channel: ct.textChannel(),\n      amount: ct.number(),\n    },\n  ],\n\n  async run({ pluginData, message, args }) {\n    const config = await pluginData.config.getForMessage(message);\n    const counter = config.counters[args.counterName];\n    const counterId = pluginData.state.counterIds[args.counterName];\n    if (!counter || !counterId) {\n      void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`);\n      return;\n    }\n\n    if (counter.can_edit === false) {\n      void pluginData.state.common.sendErrorMessage(message, `Missing permissions to edit this counter's value`);\n      return;\n    }\n\n    if (args.channel && !counter.per_channel) {\n      void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`);\n      return;\n    }\n\n    if (args.user && !counter.per_user) {\n      void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`);\n      return;\n    }\n\n    let channel = args.channel;\n    if (!channel && counter.per_channel) {\n      message.channel.send(`Which channel's counter value would you like to add to?`);\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake);\n      if (!potentialChannel || !(potentialChannel instanceof TextChannel)) {\n        void pluginData.state.common.sendErrorMessage(message, \"Channel is not a text channel, cancelling\");\n        return;\n      }\n\n      channel = potentialChannel;\n    }\n\n    let user = args.user;\n    if (!user && counter.per_user) {\n      message.channel.send(`Which user's counter value would you like to add to?`);\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialUser = await resolveUser(pluginData.client, reply.content, \"Counters:AddCounterCmd\");\n      if (!potentialUser || potentialUser instanceof UnknownUser) {\n        void pluginData.state.common.sendErrorMessage(message, \"Unknown user, cancelling\");\n        return;\n      }\n\n      user = potentialUser;\n    }\n\n    let amount = args.amount;\n    if (!amount) {\n      message.channel.send(\"How much would you like to add to the counter's value?\");\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialAmount = parseInt(reply.content, 10);\n      if (!potentialAmount) {\n        void pluginData.state.common.sendErrorMessage(message, \"Not a number, cancelling\");\n        return;\n      }\n\n      amount = potentialAmount;\n    }\n\n    await changeCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, amount);\n    const newValue = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null);\n\n    if (channel && user) {\n      message.channel.send(\n        `Added ${amount} to **${args.counterName}** for <@!${user.id}> in <#${channel.id}>. The value is now ${newValue}.`,\n      );\n    } else if (channel) {\n      message.channel.send(\n        `Added ${amount} to **${args.counterName}** in <#${channel.id}>. The value is now ${newValue}.`,\n      );\n    } else if (user) {\n      message.channel.send(\n        `Added ${amount} to **${args.counterName}** for <@!${user.id}>. The value is now ${newValue}.`,\n      );\n    } else {\n      message.channel.send(`Added ${amount} to **${args.counterName}**. The value is now ${newValue}.`);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Counters/commands/CountersListCmd.ts",
    "content": "import { guildPluginMessageCommand } from \"vety\";\nimport { trimMultilineString, ucfirst } from \"../../../utils.js\";\nimport { getGuildPrefix } from \"../../../utils/getGuildPrefix.js\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport const CountersListCmd = guildPluginMessageCommand<CountersPluginType>()({\n  trigger: [\"counters list\", \"counter list\", \"counters\"],\n  permission: \"can_view\",\n\n  signature: {},\n\n  async run({ pluginData, message }) {\n    const config = await pluginData.config.getForMessage(message);\n\n    const countersToShow = Object.entries(config.counters).filter(([, c]) => c.can_view !== false);\n    if (!countersToShow.length) {\n      void pluginData.state.common.sendErrorMessage(message, \"No counters are configured for this server\");\n      return;\n    }\n\n    const counterLines = countersToShow.map(([counterName, counter]) => {\n      const title = counter.pretty_name ? `**${counter.pretty_name}** (\\`${counterName}\\`)` : `\\`${counterName}\\``;\n\n      const types: string[] = [];\n      if (counter.per_user) types.push(\"per user\");\n      if (counter.per_channel) types.push(\"per channel\");\n      const typeInfo = types.length ? types.join(\", \") : \"global\";\n\n      const decayInfo = counter.decay ? `decays ${counter.decay.amount} every ${counter.decay.every}` : null;\n\n      const info = [typeInfo, decayInfo].filter(Boolean);\n      return `${title}\\n${ucfirst(info.join(\"; \"))}`;\n    });\n\n    const hintLines = [`Use \\`${getGuildPrefix(pluginData)}counters view <name>\\` to view a counter's value`];\n    if (config.can_edit) {\n      hintLines.push(`Use \\`${getGuildPrefix(pluginData)}counters set <name> <value>\\` to change a counter's value`);\n    }\n    if (config.can_reset_all) {\n      hintLines.push(`Use \\`${getGuildPrefix(pluginData)}counters reset_all <name>\\` to reset a counter entirely`);\n    }\n\n    message.channel.send(\n      trimMultilineString(`\n      ${counterLines.join(\"\\n\\n\")}\n\n      ${hintLines.join(\"\\n\")}\n    `),\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts",
    "content": "import { guildPluginMessageCommand } from \"vety\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { confirm, noop, trimMultilineString } from \"../../../utils.js\";\nimport { resetAllCounterValues } from \"../functions/resetAllCounterValues.js\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport const ResetAllCounterValuesCmd = guildPluginMessageCommand<CountersPluginType>()({\n  trigger: [\"counters reset_all\"],\n  permission: \"can_reset_all\",\n\n  signature: {\n    counterName: ct.string(),\n  },\n\n  async run({ pluginData, message, args }) {\n    const config = await pluginData.config.getForMessage(message);\n    const counter = config.counters[args.counterName];\n    const counterId = pluginData.state.counterIds[args.counterName];\n    if (!counter || !counterId) {\n      void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`);\n      return;\n    }\n\n    if (counter.can_reset_all === false) {\n      void pluginData.state.common.sendErrorMessage(\n        message,\n        `Missing permissions to reset all of this counter's values`,\n      );\n      return;\n    }\n\n    const confirmed = await confirm(message, message.author.id, {\n      content: trimMultilineString(`\n        Do you want to reset **ALL** values for counter **${args.counterName}**?\n        This will reset the counter for **all** users and channels.\n        **Note:** This will *not* trigger any triggers or counter triggers.\n      `),\n    });\n    if (!confirmed) {\n      void pluginData.state.common.sendErrorMessage(message, \"Cancelled\");\n      return;\n    }\n\n    const loadingMessage = await message.channel\n      .send(`Resetting counter **${args.counterName}**. This might take a while. Please don't reload the config.`)\n      .catch(() => null);\n\n    await resetAllCounterValues(pluginData, args.counterName);\n\n    loadingMessage?.delete().catch(noop);\n    void pluginData.state.common.sendSuccessMessage(\n      message,\n      `All counter values for **${args.counterName}** have been reset`,\n    );\n\n    pluginData.getVetyInstance().reloadGuild(pluginData.guild.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Counters/commands/ResetCounterCmd.ts",
    "content": "import { Snowflake, TextChannel } from \"discord.js\";\nimport { guildPluginMessageCommand } from \"vety\";\nimport { waitForReply } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { UnknownUser, resolveUser } from \"../../../utils.js\";\nimport { setCounterValue } from \"../functions/setCounterValue.js\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport const ResetCounterCmd = guildPluginMessageCommand<CountersPluginType>()({\n  trigger: [\"counters reset\", \"counter reset\", \"resetcounter\"],\n  permission: \"can_edit\",\n\n  signature: [\n    {\n      counterName: ct.string(),\n    },\n    {\n      counterName: ct.string(),\n      user: ct.resolvedUser(),\n    },\n    {\n      counterName: ct.string(),\n      channel: ct.textChannel(),\n    },\n    {\n      counterName: ct.string(),\n      channel: ct.textChannel(),\n      user: ct.resolvedUser(),\n    },\n    {\n      counterName: ct.string(),\n      user: ct.resolvedUser(),\n      channel: ct.textChannel(),\n    },\n  ],\n\n  async run({ pluginData, message, args }) {\n    const config = await pluginData.config.getForMessage(message);\n    const counter = config.counters[args.counterName];\n    const counterId = pluginData.state.counterIds[args.counterName];\n    if (!counter || !counterId) {\n      void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`);\n      return;\n    }\n\n    if (counter.can_edit === false) {\n      void pluginData.state.common.sendErrorMessage(message, `Missing permissions to reset this counter's value`);\n      return;\n    }\n\n    if (args.channel && !counter.per_channel) {\n      void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`);\n      return;\n    }\n\n    if (args.user && !counter.per_user) {\n      void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`);\n      return;\n    }\n\n    let channel = args.channel;\n    if (!channel && counter.per_channel) {\n      message.channel.send(`Which channel's counter value would you like to reset?`);\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake);\n      if (!potentialChannel || !(potentialChannel instanceof TextChannel)) {\n        void pluginData.state.common.sendErrorMessage(message, \"Channel is not a text channel, cancelling\");\n        return;\n      }\n\n      channel = potentialChannel;\n    }\n\n    let user = args.user;\n    if (!user && counter.per_user) {\n      message.channel.send(`Which user's counter value would you like to reset?`);\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialUser = await resolveUser(pluginData.client, reply.content, \"Counters:ResetCounterCmd\");\n      if (!potentialUser || potentialUser instanceof UnknownUser) {\n        void pluginData.state.common.sendErrorMessage(message, \"Unknown user, cancelling\");\n        return;\n      }\n\n      user = potentialUser;\n    }\n\n    await setCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, counter.initial_value);\n\n    if (channel && user) {\n      message.channel.send(`Reset **${args.counterName}** for <@!${user.id}> in <#${channel.id}>`);\n    } else if (channel) {\n      message.channel.send(`Reset **${args.counterName}** in <#${channel.id}>`);\n    } else if (user) {\n      message.channel.send(`Reset **${args.counterName}** for <@!${user.id}>`);\n    } else {\n      message.channel.send(`Reset **${args.counterName}**`);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Counters/commands/SetCounterCmd.ts",
    "content": "import { Snowflake, TextChannel } from \"discord.js\";\nimport { guildPluginMessageCommand } from \"vety\";\nimport { waitForReply } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { UnknownUser, resolveUser } from \"../../../utils.js\";\nimport { setCounterValue } from \"../functions/setCounterValue.js\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport const SetCounterCmd = guildPluginMessageCommand<CountersPluginType>()({\n  trigger: [\"counters set\", \"counter set\", \"setcounter\"],\n  permission: \"can_edit\",\n\n  signature: [\n    {\n      counterName: ct.string(),\n      value: ct.number(),\n    },\n    {\n      counterName: ct.string(),\n      user: ct.resolvedUser(),\n      value: ct.number(),\n    },\n    {\n      counterName: ct.string(),\n      channel: ct.textChannel(),\n      value: ct.number(),\n    },\n    {\n      counterName: ct.string(),\n      channel: ct.textChannel(),\n      user: ct.resolvedUser(),\n      value: ct.number(),\n    },\n    {\n      counterName: ct.string(),\n      user: ct.resolvedUser(),\n      channel: ct.textChannel(),\n      value: ct.number(),\n    },\n  ],\n\n  async run({ pluginData, message, args }) {\n    const config = await pluginData.config.getForMessage(message);\n    const counter = config.counters[args.counterName];\n    const counterId = pluginData.state.counterIds[args.counterName];\n    if (!counter || !counterId) {\n      void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`);\n      return;\n    }\n\n    if (counter.can_edit === false) {\n      void pluginData.state.common.sendErrorMessage(message, `Missing permissions to edit this counter's value`);\n      return;\n    }\n\n    if (args.channel && !counter.per_channel) {\n      void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`);\n      return;\n    }\n\n    if (args.user && !counter.per_user) {\n      void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`);\n      return;\n    }\n\n    let channel = args.channel;\n    if (!channel && counter.per_channel) {\n      message.channel.send(`Which channel's counter value would you like to change?`);\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake);\n      if (!potentialChannel || !(potentialChannel instanceof TextChannel)) {\n        void pluginData.state.common.sendErrorMessage(message, \"Channel is not a text channel, cancelling\");\n        return;\n      }\n\n      channel = potentialChannel;\n    }\n\n    let user = args.user;\n    if (!user && counter.per_user) {\n      message.channel.send(`Which user's counter value would you like to change?`);\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialUser = await resolveUser(pluginData.client, reply.content, \"Counters:SetCounterCmd\");\n      if (!potentialUser || potentialUser instanceof UnknownUser) {\n        void pluginData.state.common.sendErrorMessage(message, \"Unknown user, cancelling\");\n        return;\n      }\n\n      user = potentialUser;\n    }\n\n    let value = args.value;\n    if (!value) {\n      message.channel.send(\"What would you like to set the counter's value to?\");\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialValue = parseInt(reply.content, 10);\n      if (Number.isNaN(potentialValue)) {\n        void pluginData.state.common.sendErrorMessage(message, \"Not a number, cancelling\");\n        return;\n      }\n\n      value = potentialValue;\n    }\n\n    if (value < 0) {\n      void pluginData.state.common.sendErrorMessage(message, \"Cannot set counter value below 0\");\n      return;\n    }\n\n    await setCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, value);\n\n    if (channel && user) {\n      message.channel.send(`Set **${args.counterName}** for <@!${user.id}> in <#${channel.id}> to ${value}`);\n    } else if (channel) {\n      message.channel.send(`Set **${args.counterName}** in <#${channel.id}> to ${value}`);\n    } else if (user) {\n      message.channel.send(`Set **${args.counterName}** for <@!${user.id}> to ${value}`);\n    } else {\n      message.channel.send(`Set **${args.counterName}** to ${value}`);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Counters/commands/ViewCounterCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { guildPluginMessageCommand } from \"vety\";\nimport { waitForReply } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { resolveUser, UnknownUser } from \"../../../utils.js\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport const ViewCounterCmd = guildPluginMessageCommand<CountersPluginType>()({\n  trigger: [\"counters view\", \"counter view\", \"viewcounter\", \"counter\"],\n  permission: \"can_view\",\n\n  signature: [\n    {\n      counterName: ct.string(),\n    },\n    {\n      counterName: ct.string(),\n      user: ct.resolvedUser(),\n    },\n    {\n      counterName: ct.string(),\n      channel: ct.guildTextBasedChannel(),\n    },\n    {\n      counterName: ct.string(),\n      channel: ct.guildTextBasedChannel(),\n      user: ct.resolvedUser(),\n    },\n    {\n      counterName: ct.string(),\n      user: ct.resolvedUser(),\n      channel: ct.guildTextBasedChannel(),\n    },\n  ],\n\n  async run({ pluginData, message, args }) {\n    const config = await pluginData.config.getForMessage(message);\n    const counter = config.counters[args.counterName];\n    const counterId = pluginData.state.counterIds[args.counterName];\n    if (!counter || !counterId) {\n      void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`);\n      return;\n    }\n\n    if (counter.can_view === false) {\n      void pluginData.state.common.sendErrorMessage(message, `Missing permissions to view this counter's value`);\n      return;\n    }\n\n    if (args.channel && !counter.per_channel) {\n      void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`);\n      return;\n    }\n\n    if (args.user && !counter.per_user) {\n      void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`);\n      return;\n    }\n\n    let channel = args.channel;\n    if (!channel && counter.per_channel) {\n      message.channel.send(`Which channel's counter value would you like to view?`);\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake);\n      if (!potentialChannel?.isTextBased()) {\n        void pluginData.state.common.sendErrorMessage(message, \"Channel is not a text channel, cancelling\");\n        return;\n      }\n\n      channel = potentialChannel;\n    }\n\n    let user = args.user;\n    if (!user && counter.per_user) {\n      message.channel.send(`Which user's counter value would you like to view?`);\n      const reply = await waitForReply(pluginData.client, message.channel, message.author.id);\n      if (!reply || !reply.content) {\n        void pluginData.state.common.sendErrorMessage(message, \"Cancelling\");\n        return;\n      }\n\n      const potentialUser = await resolveUser(pluginData.client, reply.content, \"Counters:ViewCounterCmd\");\n      if (!potentialUser || potentialUser instanceof UnknownUser) {\n        void pluginData.state.common.sendErrorMessage(message, \"Unknown user, cancelling\");\n        return;\n      }\n\n      user = potentialUser;\n    }\n\n    const value = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null);\n    const finalValue = value ?? counter.initial_value;\n\n    if (channel && user) {\n      message.channel.send(`**${args.counterName}** for <@!${user.id}> in <#${channel.id}> is ${finalValue}`);\n    } else if (channel) {\n      message.channel.send(`**${args.counterName}** in <#${channel.id}> is ${finalValue}`);\n    } else if (user) {\n      message.channel.send(`**${args.counterName}** for <@!${user.id}> is ${finalValue}`);\n    } else {\n      message.channel.send(`**${args.counterName}** is ${finalValue}`);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Counters/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zCountersConfig } from \"./types.js\";\n\nexport const countersPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zCountersConfig,\n\n  prettyName: \"Counters\",\n  description:\n    \"Keep track of per-user, per-channel, or global numbers and trigger specific actions based on this number\",\n  configurationGuide: \"See <a href='/docs/setup-guides/counters'>Counters setup guide</a>\",\n};\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/changeCounterValue.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { counterIdLock } from \"../../../utils/lockNameHelpers.js\";\nimport { CountersPluginType } from \"../types.js\";\nimport { checkCounterTrigger } from \"./checkCounterTrigger.js\";\nimport { checkReverseCounterTrigger } from \"./checkReverseCounterTrigger.js\";\n\nexport async function changeCounterValue(\n  pluginData: GuildPluginData<CountersPluginType>,\n  counterName: string,\n  channelId: string | null,\n  userId: string | null,\n  change: number,\n) {\n  const config = pluginData.config.get();\n  const counter = config.counters[counterName];\n  if (!counter) {\n    throw new Error(`Unknown counter: ${counterName}`);\n  }\n\n  if (counter.per_channel && !channelId) {\n    throw new Error(`Counter is per channel but no channel ID was supplied`);\n  }\n\n  if (counter.per_user && !userId) {\n    throw new Error(`Counter is per user but no user ID was supplied`);\n  }\n\n  channelId = counter.per_channel ? channelId : null;\n  userId = counter.per_user ? userId : null;\n\n  const counterId = pluginData.state.counterIds[counterName];\n  const lock = await pluginData.locks.acquire(counterIdLock(counterId));\n\n  await pluginData.state.counters.changeCounterValue(counterId, channelId, userId, change, counter.initial_value);\n\n  // Check for trigger matches, if any, when the counter value changes\n  const triggers = pluginData.state.counterTriggersByCounterId.get(counterId);\n  if (triggers) {\n    const triggersArr = Array.from(triggers.values());\n    await Promise.all(\n      triggersArr.map((trigger) => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)),\n    );\n    await Promise.all(\n      triggersArr.map((trigger) => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)),\n    );\n  }\n\n  lock.unlock();\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CounterTrigger } from \"../../../data/entities/CounterTrigger.js\";\nimport { CountersPluginType } from \"../types.js\";\nimport { emitCounterEvent } from \"./emitCounterEvent.js\";\n\nexport async function checkAllValuesForReverseTrigger(\n  pluginData: GuildPluginData<CountersPluginType>,\n  counterName: string,\n  counterTrigger: CounterTrigger,\n) {\n  const triggeredContexts = await pluginData.state.counters.checkAllValuesForReverseTrigger(counterTrigger);\n  for (const context of triggeredContexts) {\n    emitCounterEvent(pluginData, \"reverseTrigger\", counterName, counterTrigger.name, context.channelId, context.userId);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CounterTrigger } from \"../../../data/entities/CounterTrigger.js\";\nimport { CountersPluginType } from \"../types.js\";\nimport { emitCounterEvent } from \"./emitCounterEvent.js\";\n\nexport async function checkAllValuesForTrigger(\n  pluginData: GuildPluginData<CountersPluginType>,\n  counterName: string,\n  counterTrigger: CounterTrigger,\n) {\n  const triggeredContexts = await pluginData.state.counters.checkAllValuesForTrigger(counterTrigger);\n  for (const context of triggeredContexts) {\n    emitCounterEvent(pluginData, \"trigger\", counterName, counterTrigger.name, context.channelId, context.userId);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/checkCounterTrigger.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CounterTrigger } from \"../../../data/entities/CounterTrigger.js\";\nimport { CountersPluginType } from \"../types.js\";\nimport { emitCounterEvent } from \"./emitCounterEvent.js\";\n\nexport async function checkCounterTrigger(\n  pluginData: GuildPluginData<CountersPluginType>,\n  counterName: string,\n  counterTrigger: CounterTrigger,\n  channelId: string | null,\n  userId: string | null,\n) {\n  const triggered = await pluginData.state.counters.checkForTrigger(counterTrigger, channelId, userId);\n  if (triggered) {\n    await emitCounterEvent(pluginData, \"trigger\", counterName, counterTrigger.name, channelId, userId);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CounterTrigger } from \"../../../data/entities/CounterTrigger.js\";\nimport { CountersPluginType } from \"../types.js\";\nimport { emitCounterEvent } from \"./emitCounterEvent.js\";\n\nexport async function checkReverseCounterTrigger(\n  pluginData: GuildPluginData<CountersPluginType>,\n  counterName: string,\n  counterTrigger: CounterTrigger,\n  channelId: string | null,\n  userId: string | null,\n) {\n  const triggered = await pluginData.state.counters.checkForReverseTrigger(counterTrigger, channelId, userId);\n  if (triggered) {\n    await emitCounterEvent(pluginData, \"reverseTrigger\", counterName, counterTrigger.name, channelId, userId);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/counterExists.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport function counterExists(pluginData: GuildPluginData<CountersPluginType>, counterName: string) {\n  const config = pluginData.config.get();\n  return config.counters[counterName] != null;\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/decayCounter.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { counterIdLock } from \"../../../utils/lockNameHelpers.js\";\nimport { CountersPluginType } from \"../types.js\";\nimport { checkAllValuesForReverseTrigger } from \"./checkAllValuesForReverseTrigger.js\";\nimport { checkAllValuesForTrigger } from \"./checkAllValuesForTrigger.js\";\n\nexport async function decayCounter(\n  pluginData: GuildPluginData<CountersPluginType>,\n  counterName: string,\n  decayPeriodMS: number,\n  decayAmount: number,\n) {\n  const config = pluginData.config.get();\n  const counter = config.counters[counterName];\n  if (!counter) {\n    throw new Error(`Unknown counter: ${counterName}`);\n  }\n\n  const counterId = pluginData.state.counterIds[counterName];\n  const lock = await pluginData.locks.acquire(counterIdLock(counterId));\n\n  await pluginData.state.counters.decay(counterId, decayPeriodMS, decayAmount);\n\n  // Check for trigger matches, if any, when the counter value changes\n  const triggers = pluginData.state.counterTriggersByCounterId.get(counterId);\n  if (triggers) {\n    const triggersArr = Array.from(triggers.values());\n    await Promise.all(triggersArr.map((trigger) => checkAllValuesForTrigger(pluginData, counterName, trigger)));\n    await Promise.all(triggersArr.map((trigger) => checkAllValuesForReverseTrigger(pluginData, counterName, trigger)));\n  }\n\n  lock.unlock();\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/emitCounterEvent.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CounterEvents, CountersPluginType } from \"../types.js\";\n\nexport function emitCounterEvent<TEvent extends keyof CounterEvents>(\n  pluginData: GuildPluginData<CountersPluginType>,\n  event: TEvent,\n  ...rest: Parameters<CounterEvents[TEvent]>\n) {\n  return pluginData.state.events.emit(event, ...rest);\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/getPrettyNameForCounter.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport function getPrettyNameForCounter(pluginData: GuildPluginData<CountersPluginType>, counterName: string) {\n  const config = pluginData.config.get();\n  const counter = config.counters[counterName];\n  return counter ? counter.pretty_name || counterName : \"Unknown Counter\";\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport function getPrettyNameForCounterTrigger(\n  pluginData: GuildPluginData<CountersPluginType>,\n  counterName: string,\n  triggerName: string,\n) {\n  const config = pluginData.config.get();\n  const counter = config.counters[counterName];\n  if (!counter) {\n    return \"Unknown Counter Trigger\";\n  }\n\n  const trigger = counter.triggers[triggerName];\n  return trigger ? trigger.pretty_name || triggerName : \"Unknown Counter Trigger\";\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/offCounterEvent.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CounterEventEmitter, CountersPluginType } from \"../types.js\";\n\nexport function offCounterEvent(\n  pluginData: GuildPluginData<CountersPluginType>,\n  ...rest: Parameters<CounterEventEmitter[\"off\"]>\n) {\n  return pluginData.state.events.off(...rest);\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/onCounterEvent.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { CounterEvents, CountersPluginType } from \"../types.js\";\n\nexport function onCounterEvent<TEvent extends keyof CounterEvents>(\n  pluginData: GuildPluginData<CountersPluginType>,\n  event: TEvent,\n  listener: CounterEvents[TEvent],\n) {\n  return pluginData.state.events.on(event, listener);\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/resetAllCounterValues.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { counterIdLock } from \"../../../utils/lockNameHelpers.js\";\nimport { CountersPluginType } from \"../types.js\";\n\nexport async function resetAllCounterValues(pluginData: GuildPluginData<CountersPluginType>, counterName: string) {\n  const config = pluginData.config.get();\n  const counter = config.counters[counterName];\n  if (!counter) {\n    throw new Error(`Unknown counter: ${counterName}`);\n  }\n\n  const counterId = pluginData.state.counterIds[counterName];\n  const lock = await pluginData.locks.acquire(counterIdLock(counterId));\n\n  await pluginData.state.counters.resetAllCounterValues(counterId);\n\n  lock.unlock();\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/functions/setCounterValue.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { counterIdLock } from \"../../../utils/lockNameHelpers.js\";\nimport { CountersPluginType } from \"../types.js\";\nimport { checkCounterTrigger } from \"./checkCounterTrigger.js\";\nimport { checkReverseCounterTrigger } from \"./checkReverseCounterTrigger.js\";\n\nexport async function setCounterValue(\n  pluginData: GuildPluginData<CountersPluginType>,\n  counterName: string,\n  channelId: string | null,\n  userId: string | null,\n  value: number,\n) {\n  const config = pluginData.config.get();\n  const counter = config.counters[counterName];\n  if (!counter) {\n    throw new Error(`Unknown counter: ${counterName}`);\n  }\n\n  if (counter.per_channel && !channelId) {\n    throw new Error(`Counter is per channel but no channel ID was supplied`);\n  }\n\n  if (counter.per_user && !userId) {\n    throw new Error(`Counter is per user but no user ID was supplied`);\n  }\n\n  channelId = counter.per_channel ? channelId : null;\n  userId = counter.per_user ? userId : null;\n\n  const counterId = pluginData.state.counterIds[counterName];\n  const lock = await pluginData.locks.acquire(counterIdLock(counterId));\n\n  await pluginData.state.counters.setCounterValue(counterId, channelId, userId, value);\n\n  // Check for trigger matches, if any, when the counter value changes\n  const triggers = pluginData.state.counterTriggersByCounterId.get(counterId);\n  if (triggers) {\n    const triggersArr = Array.from(triggers.values());\n    await Promise.all(\n      triggersArr.map((trigger) => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)),\n    );\n    await Promise.all(\n      triggersArr.map((trigger) => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)),\n    );\n  }\n\n  lock.unlock();\n}\n"
  },
  {
    "path": "backend/src/plugins/Counters/types.ts",
    "content": "import { EventEmitter } from \"events\";\nimport { BasePluginType, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildCounters, MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from \"../../data/GuildCounters.js\";\nimport {\n  CounterTrigger,\n  buildCounterConditionString,\n  getReverseCounterComparisonOp,\n  parseCounterConditionString,\n} from \"../../data/entities/CounterTrigger.js\";\nimport { zBoundedCharacters, zBoundedRecord, zDelayString } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport Timeout = NodeJS.Timeout;\n\nconst MAX_COUNTERS = 5;\nconst MAX_TRIGGERS_PER_COUNTER = 5;\n\nexport const zTrigger = z.strictObject({\n  // Dummy type because name gets replaced by the property key in transform()\n  pretty_name: zBoundedCharacters(0, 100).nullable().default(null),\n  condition: zBoundedCharacters(1, 64).refine((str) => parseCounterConditionString(str) !== null, {\n    message: \"Invalid counter trigger condition\",\n  }),\n  reverse_condition: zBoundedCharacters(1, 64)\n    .refine((str) => parseCounterConditionString(str) !== null, {\n      message: \"Invalid counter trigger reverse condition\",\n    })\n    .optional(),\n});\n\nconst zTriggerFromString = zBoundedCharacters(0, 100).transform((val, ctx) => {\n  const parsedCondition = parseCounterConditionString(val);\n  if (!parsedCondition) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: \"Invalid counter trigger condition\",\n    });\n    return z.NEVER;\n  }\n  return {\n    pretty_name: null,\n    condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]),\n    reverse_condition: buildCounterConditionString(\n      getReverseCounterComparisonOp(parsedCondition[0]),\n      parsedCondition[1],\n    ),\n  };\n});\n\nconst zTriggerInput = z.union([zTrigger, zTriggerFromString]);\n\nexport const zCounter = z.strictObject({\n  pretty_name: zBoundedCharacters(0, 100).nullable().default(null),\n  per_channel: z.boolean().default(false),\n  per_user: z.boolean().default(false),\n  initial_value: z.number().min(MIN_COUNTER_VALUE).max(MAX_COUNTER_VALUE).default(0),\n  triggers: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zTriggerInput), 1, MAX_TRIGGERS_PER_COUNTER),\n  decay: z\n    .strictObject({\n      amount: z.number(),\n      every: zDelayString,\n    })\n    .nullable()\n    .default(null),\n  can_view: z.boolean().nullable().default(null),\n  can_edit: z.boolean().nullable().default(null),\n  can_reset_all: z.boolean().nullable().default(null),\n});\n\nexport const zCountersConfig = z.strictObject({\n  counters: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCounter), 0, MAX_COUNTERS).default({}),\n  can_view: z.boolean().default(false),\n  can_edit: z.boolean().default(false),\n  can_reset_all: z.boolean().default(false),\n});\n\nexport interface CounterEvents {\n  trigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void;\n  reverseTrigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void;\n}\n\nexport interface CounterEventEmitter extends EventEmitter {\n  on<U extends keyof CounterEvents>(event: U, listener: CounterEvents[U]): this;\n  emit<U extends keyof CounterEvents>(event: U, ...args: Parameters<CounterEvents[U]>): boolean;\n}\n\nexport interface CountersPluginType extends BasePluginType {\n  configSchema: typeof zCountersConfig;\n  state: {\n    counters: GuildCounters;\n    counterIds: Record<string, number>;\n    decayTimers: Timeout[];\n    events: CounterEventEmitter;\n    counterTriggersByCounterId: Map<number, CounterTrigger[]>;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/ActionError.ts",
    "content": "export class ActionError extends Error {}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/CustomEventsPlugin.ts",
    "content": "import { GuildChannel, GuildMember, User } from \"discord.js\";\nimport { guildPlugin, guildPluginMessageCommand, parseSignature } from \"vety\";\nimport { TSignature } from \"knub-command-manager\";\nimport { commandTypes } from \"../../commandTypes.js\";\nimport { TemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from \"../../templateFormatter.js\";\nimport { UnknownUser } from \"../../utils.js\";\nimport { isScalar } from \"../../utils/isScalar.js\";\nimport {\n  channelToTemplateSafeChannel,\n  memberToTemplateSafeMember,\n  messageToTemplateSafeMessage,\n  userToTemplateSafeUser,\n} from \"../../utils/templateSafeObjects.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { runEvent } from \"./functions/runEvent.js\";\nimport { CustomEventsPluginType, zCustomEventsConfig } from \"./types.js\";\n\nexport const CustomEventsPlugin = guildPlugin<CustomEventsPluginType>()({\n  name: \"custom_events\",\n\n  dependencies: () => [LogsPlugin],\n  configSchema: zCustomEventsConfig,\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const config = pluginData.config.get();\n    for (const [key, event] of Object.entries(config.events)) {\n      if (event.trigger.type === \"command\") {\n        const signature: TSignature<any> = event.trigger.params\n          ? parseSignature(event.trigger.params, commandTypes)\n          : {};\n        const eventCommand = guildPluginMessageCommand<CustomEventsPluginType>()({\n          trigger: event.trigger.name,\n          permission: `events.${key}.trigger.can_use`,\n          signature,\n          run({ message, args }) {\n            const safeArgs = new TemplateSafeValueContainer();\n            for (const [argKey, argValue] of Object.entries(args as Record<string, unknown>)) {\n              if (argValue instanceof User || argValue instanceof UnknownUser) {\n                safeArgs[argKey] = userToTemplateSafeUser(argValue);\n              } else if (argValue instanceof GuildMember) {\n                safeArgs[argKey] = memberToTemplateSafeMember(argValue);\n              } else if (argValue instanceof GuildChannel && argValue.isTextBased()) {\n                safeArgs[argKey] = channelToTemplateSafeChannel(argValue);\n              } else if (isScalar(argValue)) {\n                safeArgs[argKey] = argValue;\n              }\n            }\n\n            const values = createTypedTemplateSafeValueContainer({\n              ...safeArgs,\n              msg: messageToTemplateSafeMessage(message),\n            });\n\n            runEvent(pluginData, event, { msg: message, args }, values);\n          },\n        });\n        pluginData.messageCommands.add(eventCommand);\n      }\n    }\n  },\n\n  beforeUnload() {\n    // TODO: Run clearTriggers() once we actually have something there\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/actions/addRoleAction.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { z } from \"zod\";\nimport { canActOn } from \"../../../pluginUtils.js\";\nimport { renderTemplate, TemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveMember, zBoundedCharacters, zSnowflake } from \"../../../utils.js\";\nimport { ActionError } from \"../ActionError.js\";\nimport { catchTemplateError } from \"../catchTemplateError.js\";\nimport { CustomEventsPluginType, TCustomEvent } from \"../types.js\";\n\nexport const zAddRoleAction = z.strictObject({\n  type: z.literal(\"add_role\"),\n  target: zBoundedCharacters(0, 100),\n  role: z.union([zSnowflake, z.array(zSnowflake)]),\n});\nexport type TAddRoleAction = z.infer<typeof zAddRoleAction>;\n\nexport async function addRoleAction(\n  pluginData: GuildPluginData<CustomEventsPluginType>,\n  action: TAddRoleAction,\n  values: TemplateSafeValueContainer,\n  event: TCustomEvent,\n  eventData: any,\n) {\n  const targetId = await catchTemplateError(\n    () => renderTemplate(action.target, values, false),\n    \"Invalid target format\",\n  );\n  const target = await resolveMember(pluginData.client, pluginData.guild, targetId);\n  if (!target) throw new ActionError(`Unknown target member: ${targetId}`);\n\n  if (event.trigger.type === \"command\" && !canActOn(pluginData, eventData.msg.member, target)) {\n    throw new ActionError(\"Missing permissions\");\n  }\n  const rolesToAdd = (Array.isArray(action.role) ? action.role : [action.role]).filter(\n    (id) => !target.roles.cache.has(id),\n  );\n  if (rolesToAdd.length === 0) {\n    throw new ActionError(\"Target already has the role(s) specified\");\n  }\n  await target.roles.add(rolesToAdd);\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/actions/createCaseAction.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { z } from \"zod\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { renderTemplate, TemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { zBoundedCharacters } from \"../../../utils.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { ActionError } from \"../ActionError.js\";\nimport { catchTemplateError } from \"../catchTemplateError.js\";\nimport { CustomEventsPluginType, TCustomEvent } from \"../types.js\";\n\nexport const zCreateCaseAction = z.strictObject({\n  type: z.literal(\"create_case\"),\n  case_type: zBoundedCharacters(0, 32),\n  mod: zBoundedCharacters(0, 100),\n  target: zBoundedCharacters(0, 100),\n  reason: zBoundedCharacters(0, 4000),\n});\nexport type TCreateCaseAction = z.infer<typeof zCreateCaseAction>;\n\nexport async function createCaseAction(\n  pluginData: GuildPluginData<CustomEventsPluginType>,\n  action: TCreateCaseAction,\n  values: TemplateSafeValueContainer,\n  event: TCustomEvent,\n  eventData: any, // eslint-disable-line @typescript-eslint/no-unused-vars\n) {\n  const modId = await catchTemplateError(() => renderTemplate(action.mod, values, false), \"Invalid mod format\");\n  const targetId = await catchTemplateError(\n    () => renderTemplate(action.target, values, false),\n    \"Invalid target format\",\n  );\n  const reason = await catchTemplateError(() => renderTemplate(action.reason, values, false), \"Invalid reason format\");\n\n  if (CaseTypes[action.case_type] == null) {\n    throw new ActionError(`Invalid case type: ${action.type}`);\n  }\n\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  await casesPlugin!.createCase({\n    userId: targetId,\n    modId,\n    type: CaseTypes[action.case_type],\n    reason: `__[${event.name}]__ ${reason}`,\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { z } from \"zod\";\nimport { TemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from \"../../../utils.js\";\nimport { ActionError } from \"../ActionError.js\";\nimport { CustomEventsPluginType, TCustomEvent } from \"../types.js\";\n\nexport const zMakeRoleMentionableAction = z.strictObject({\n  type: z.literal(\"make_role_mentionable\"),\n  role: zBoundedCharacters(0, 100),\n  timeout: zDelayString,\n});\nexport type TMakeRoleMentionableAction = z.infer<typeof zMakeRoleMentionableAction>;\n\nexport async function makeRoleMentionableAction(\n  pluginData: GuildPluginData<CustomEventsPluginType>,\n  action: TMakeRoleMentionableAction,\n  values: TemplateSafeValueContainer,\n  event: TCustomEvent,\n  eventData: any, // eslint-disable-line @typescript-eslint/no-unused-vars\n) {\n  const role = pluginData.guild.roles.cache.get(action.role as Snowflake);\n  if (!role) {\n    throw new ActionError(`Unknown role: ${role}`);\n  }\n\n  await role.setMentionable(true, `Custom event: ${event.name}`);\n\n  const timeout = convertDelayStringToMS(action.timeout)!;\n  setTimeout(() => {\n    role.setMentionable(false, `Custom event: ${event.name}`).catch(noop);\n  }, timeout);\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { z } from \"zod\";\nimport { TemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { zSnowflake } from \"../../../utils.js\";\nimport { ActionError } from \"../ActionError.js\";\nimport { CustomEventsPluginType, TCustomEvent } from \"../types.js\";\n\nexport const zMakeRoleUnmentionableAction = z.strictObject({\n  type: z.literal(\"make_role_unmentionable\"),\n  role: zSnowflake,\n});\nexport type TMakeRoleUnmentionableAction = z.infer<typeof zMakeRoleUnmentionableAction>;\n\nexport async function makeRoleUnmentionableAction(\n  pluginData: GuildPluginData<CustomEventsPluginType>,\n  action: TMakeRoleUnmentionableAction,\n  values: TemplateSafeValueContainer,\n  event: TCustomEvent,\n  eventData: any, // eslint-disable-line @typescript-eslint/no-unused-vars\n) {\n  const role = pluginData.guild.roles.cache.get(action.role as Snowflake);\n  if (!role) {\n    throw new ActionError(`Unknown role: ${role}`);\n  }\n\n  await role.setMentionable(false, `Custom event: ${event.name}`);\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/actions/messageAction.ts",
    "content": "import { Snowflake, TextChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { z } from \"zod\";\nimport { TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport { zBoundedCharacters } from \"../../../utils.js\";\nimport { ActionError } from \"../ActionError.js\";\nimport { catchTemplateError } from \"../catchTemplateError.js\";\nimport { CustomEventsPluginType } from \"../types.js\";\n\nexport const zMessageAction = z.strictObject({\n  type: z.literal(\"message\"),\n  channel: zBoundedCharacters(0, 100),\n  content: zBoundedCharacters(0, 4000),\n});\nexport type TMessageAction = z.infer<typeof zMessageAction>;\n\nexport async function messageAction(\n  pluginData: GuildPluginData<CustomEventsPluginType>,\n  action: TMessageAction,\n  values: TemplateSafeValueContainer,\n) {\n  const targetChannelId = await catchTemplateError(\n    () => renderTemplate(action.channel, values, false),\n    \"Invalid channel format\",\n  );\n  const targetChannel = pluginData.guild.channels.cache.get(targetChannelId as Snowflake);\n  if (!targetChannel) throw new ActionError(\"Unknown target channel\");\n  if (!(targetChannel instanceof TextChannel)) throw new ActionError(\"Target channel is not a text channel\");\n\n  await targetChannel.send({ content: action.content });\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts",
    "content": "import { Snowflake, VoiceChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { z } from \"zod\";\nimport { canActOn } from \"../../../pluginUtils.js\";\nimport { TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport { resolveMember, zBoundedCharacters } from \"../../../utils.js\";\nimport { ActionError } from \"../ActionError.js\";\nimport { catchTemplateError } from \"../catchTemplateError.js\";\nimport { CustomEventsPluginType, TCustomEvent } from \"../types.js\";\n\nexport const zMoveToVoiceChannelAction = z.strictObject({\n  type: z.literal(\"move_to_vc\"),\n  target: zBoundedCharacters(0, 100),\n  channel: zBoundedCharacters(0, 100),\n});\nexport type TMoveToVoiceChannelAction = z.infer<typeof zMoveToVoiceChannelAction>;\n\nexport async function moveToVoiceChannelAction(\n  pluginData: GuildPluginData<CustomEventsPluginType>,\n  action: TMoveToVoiceChannelAction,\n  values: TemplateSafeValueContainer,\n  event: TCustomEvent,\n  eventData: any,\n) {\n  const targetId = await catchTemplateError(\n    () => renderTemplate(action.target, values, false),\n    \"Invalid target format\",\n  );\n  const target = await resolveMember(pluginData.client, pluginData.guild, targetId);\n  if (!target) throw new ActionError(\"Unknown target member\");\n\n  if (event.trigger.type === \"command\" && !canActOn(pluginData, eventData.msg.member, target)) {\n    throw new ActionError(\"Missing permissions\");\n  }\n\n  const targetChannelId = await catchTemplateError(\n    () => renderTemplate(action.channel, values, false),\n    \"Invalid channel format\",\n  );\n  const targetChannel = pluginData.guild.channels.cache.get(targetChannelId as Snowflake);\n  if (!targetChannel) throw new ActionError(\"Unknown target channel\");\n  if (!(targetChannel instanceof VoiceChannel)) throw new ActionError(\"Target channel is not a voice channel\");\n\n  if (!target.voice.channelId) return;\n  await target.edit({\n    channel: targetChannel.id,\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts",
    "content": "import { PermissionsBitField, PermissionsString, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { z } from \"zod\";\nimport { TemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { zBoundedCharacters, zSnowflake } from \"../../../utils.js\";\nimport { ActionError } from \"../ActionError.js\";\nimport { CustomEventsPluginType, TCustomEvent } from \"../types.js\";\n\nexport const zSetChannelPermissionOverridesAction = z.strictObject({\n  type: z.literal(\"set_channel_permission_overrides\"),\n  channel: zBoundedCharacters(0, 100),\n  overrides: z\n    .array(\n      z.strictObject({\n        type: z.union([z.literal(\"member\"), z.literal(\"role\")]),\n        id: zSnowflake,\n        allow: z.number(),\n        deny: z.number(),\n      }),\n    )\n    .max(15),\n});\nexport type TSetChannelPermissionOverridesAction = z.infer<typeof zSetChannelPermissionOverridesAction>;\n\nexport async function setChannelPermissionOverridesAction(\n  pluginData: GuildPluginData<CustomEventsPluginType>,\n  action: TSetChannelPermissionOverridesAction,\n  values: TemplateSafeValueContainer, // eslint-disable-line @typescript-eslint/no-unused-vars\n  event: TCustomEvent, // eslint-disable-line @typescript-eslint/no-unused-vars\n  eventData: any, // eslint-disable-line @typescript-eslint/no-unused-vars\n) {\n  const channel = pluginData.guild.channels.cache.get(action.channel as Snowflake);\n  if (!channel || channel.isThread() || !(\"guild\" in channel)) {\n    throw new ActionError(`Unknown channel: ${action.channel}`);\n  }\n\n  for (const override of action.overrides) {\n    const allow = new PermissionsBitField(BigInt(override.allow)).serialize();\n    const deny = new PermissionsBitField(BigInt(override.deny)).serialize();\n    const perms: Partial<Record<PermissionsString, boolean | null>> = {};\n    for (const key in allow) {\n      if (allow[key]) {\n        perms[key] = true;\n      } else if (deny[key]) {\n        perms[key] = false;\n      }\n    }\n    channel.permissionOverwrites.create(override.id as Snowflake, perms);\n\n    /*\n    await channel.permissionOverwrites overwritePermissions(\n      [{ id: override.id, allow: BigInt(override.allow), deny: BigInt(override.deny), type: override.type }],\n      `Custom event: ${event.name}`,\n    );\n    */\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/catchTemplateError.ts",
    "content": "import { TemplateParseError } from \"../../templateFormatter.js\";\nimport { ActionError } from \"./ActionError.js\";\n\nexport function catchTemplateError(fn: () => Promise<string>, errorText: string): Promise<string> {\n  try {\n    return fn();\n  } catch (err) {\n    if (err instanceof TemplateParseError) {\n      throw new ActionError(`${errorText}: ${err.message}`);\n    }\n    throw err;\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zCustomEventsConfig } from \"./types.js\";\n\nexport const customEventsPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Custom events\",\n  type: \"internal\",\n  configSchema: zCustomEventsConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/functions/runEvent.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { TemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { ActionError } from \"../ActionError.js\";\nimport { addRoleAction } from \"../actions/addRoleAction.js\";\nimport { createCaseAction } from \"../actions/createCaseAction.js\";\nimport { makeRoleMentionableAction } from \"../actions/makeRoleMentionableAction.js\";\nimport { makeRoleUnmentionableAction } from \"../actions/makeRoleUnmentionableAction.js\";\nimport { messageAction } from \"../actions/messageAction.js\";\nimport { moveToVoiceChannelAction } from \"../actions/moveToVoiceChannelAction.js\";\nimport { setChannelPermissionOverridesAction } from \"../actions/setChannelPermissionOverrides.js\";\nimport { CustomEventsPluginType, TCustomEvent } from \"../types.js\";\n\nexport async function runEvent(\n  pluginData: GuildPluginData<CustomEventsPluginType>,\n  event: TCustomEvent,\n  eventData: any,\n  values: TemplateSafeValueContainer,\n) {\n  try {\n    for (const action of event.actions) {\n      if (action.type === \"add_role\") {\n        await addRoleAction(pluginData, action, values, event, eventData);\n      } else if (action.type === \"create_case\") {\n        await createCaseAction(pluginData, action, values, event, eventData);\n      } else if (action.type === \"move_to_vc\") {\n        await moveToVoiceChannelAction(pluginData, action, values, event, eventData);\n      } else if (action.type === \"message\") {\n        await messageAction(pluginData, action, values);\n      } else if (action.type === \"make_role_mentionable\") {\n        await makeRoleMentionableAction(pluginData, action, values, event, eventData);\n      } else if (action.type === \"make_role_unmentionable\") {\n        await makeRoleUnmentionableAction(pluginData, action, values, event, eventData);\n      } else if (action.type === \"set_channel_permission_overrides\") {\n        await setChannelPermissionOverridesAction(pluginData, action, values, event, eventData);\n      }\n    }\n  } catch (e) {\n    if (e instanceof ActionError) {\n      if (event.trigger.type === \"command\") {\n        void pluginData.state.common.sendErrorMessage(eventData.msg, e.message);\n      } else {\n        // TODO: Where to log action errors from other kinds of triggers?\n      }\n\n      return;\n    }\n\n    throw e;\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/CustomEvents/types.ts",
    "content": "import { BasePluginType, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { zBoundedCharacters, zBoundedRecord } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { zAddRoleAction } from \"./actions/addRoleAction.js\";\nimport { zCreateCaseAction } from \"./actions/createCaseAction.js\";\nimport { zMakeRoleMentionableAction } from \"./actions/makeRoleMentionableAction.js\";\nimport { zMakeRoleUnmentionableAction } from \"./actions/makeRoleUnmentionableAction.js\";\nimport { zMessageAction } from \"./actions/messageAction.js\";\nimport { zMoveToVoiceChannelAction } from \"./actions/moveToVoiceChannelAction.js\";\nimport { zSetChannelPermissionOverridesAction } from \"./actions/setChannelPermissionOverrides.js\";\n\nconst zCommandTrigger = z.strictObject({\n  type: z.literal(\"command\"),\n  name: zBoundedCharacters(0, 100),\n  params: zBoundedCharacters(0, 255),\n  can_use: z.boolean(),\n});\n\nconst zAnyTrigger = zCommandTrigger; // TODO: Make into a union once we have more triggers\n\nconst zAnyAction = z.union([\n  zAddRoleAction,\n  zCreateCaseAction,\n  zMoveToVoiceChannelAction,\n  zMessageAction,\n  zMakeRoleMentionableAction,\n  zMakeRoleUnmentionableAction,\n  zSetChannelPermissionOverridesAction,\n]);\n\nexport const zCustomEvent = z.strictObject({\n  name: zBoundedCharacters(0, 100),\n  trigger: zAnyTrigger,\n  actions: z.array(zAnyAction).max(10),\n});\nexport type TCustomEvent = z.infer<typeof zCustomEvent>;\n\nexport const zCustomEventsConfig = z.strictObject({\n  events: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCustomEvent), 0, 100).default({}),\n});\n\nexport interface CustomEventsPluginType extends BasePluginType {\n  configSchema: typeof zCustomEventsConfig;\n  state: {\n    clearTriggers: () => void;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts",
    "content": "import { Guild } from \"discord.js\";\nimport { GlobalPluginData, globalPlugin, globalPluginEventListener } from \"vety\";\nimport { AllowedGuilds } from \"../../data/AllowedGuilds.js\";\nimport { Configs } from \"../../data/Configs.js\";\nimport { env } from \"../../env.js\";\nimport { GuildAccessMonitorPluginType, zGuildAccessMonitorConfig } from \"./types.js\";\n\nasync function checkGuild(pluginData: GlobalPluginData<GuildAccessMonitorPluginType>, guild: Guild) {\n  if (!(await pluginData.state.allowedGuilds.isAllowed(guild.id))) {\n    // tslint:disable-next-line:no-console\n    console.log(`Non-allowed server ${guild.name} (${guild.id}), leaving`);\n    // guild.leave();\n  }\n}\n\n/**\n * Global plugin to monitor if Zeppelin is invited to a non-whitelisted server, and leave it\n */\nexport const GuildAccessMonitorPlugin = globalPlugin<GuildAccessMonitorPluginType>()({\n  name: \"guild_access_monitor\",\n  configSchema: zGuildAccessMonitorConfig,\n\n  events: [\n    globalPluginEventListener<GuildAccessMonitorPluginType>()({\n      event: \"guildCreate\",\n      listener({ pluginData, args: { guild } }) {\n        checkGuild(pluginData, guild);\n      },\n    }),\n  ],\n\n  async beforeLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.allowedGuilds = new AllowedGuilds();\n\n    const defaultAllowedServers = env.DEFAULT_ALLOWED_SERVERS || [];\n    const configs = new Configs();\n    for (const serverId of defaultAllowedServers) {\n      if (!(await state.allowedGuilds.isAllowed(serverId))) {\n        // tslint:disable-next-line:no-console\n        console.log(`Adding allowed-by-default server ${serverId} to the allowed servers`);\n        await state.allowedGuilds.add(serverId);\n        await configs.saveNewRevision(`guild-${serverId}`, \"plugins: {}\", 0);\n      }\n    }\n  },\n\n  afterLoad(pluginData) {\n    for (const guild of pluginData.client.guilds.cache.values()) {\n      checkGuild(pluginData, guild);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/GuildAccessMonitor/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zGuildAccessMonitorConfig } from \"./types.js\";\n\nexport const guildAccessMonitorPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  configSchema: zGuildAccessMonitorConfig,\n\n  prettyName: \"Bot control\",\n  description: trimPluginDescription(`\n    Automatically leaves servers that are not on the list of allowed servers\n  `),\n};\n"
  },
  {
    "path": "backend/src/plugins/GuildAccessMonitor/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\nimport { AllowedGuilds } from \"../../data/AllowedGuilds.js\";\n\nexport const zGuildAccessMonitorConfig = z.strictObject({});\n\nexport interface GuildAccessMonitorPluginType extends BasePluginType {\n  configSchema: typeof zGuildAccessMonitorConfig;\n  state: {\n    allowedGuilds: AllowedGuilds;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts",
    "content": "import { globalPlugin } from \"vety\";\nimport { Configs } from \"../../data/Configs.js\";\nimport { reloadChangedGuilds } from \"./functions/reloadChangedGuilds.js\";\nimport { GuildConfigReloaderPluginType, zGuildConfigReloaderPluginConfig } from \"./types.js\";\n\nexport const GuildConfigReloaderPlugin = globalPlugin<GuildConfigReloaderPluginType>()({\n  name: \"guild_config_reloader\",\n\n  configSchema: zGuildConfigReloaderPluginConfig,\n\n  async beforeLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.guildConfigs = new Configs();\n    state.highestConfigId = await state.guildConfigs.getHighestId();\n  },\n\n  afterLoad(pluginData) {\n    reloadChangedGuilds(pluginData);\n  },\n\n  beforeUnload(pluginData) {\n    clearTimeout(pluginData.state.nextCheckTimeout);\n    pluginData.state.unloaded = true;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/GuildConfigReloader/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zGuildConfigReloaderPluginConfig } from \"./types.js\";\n\nexport const guildConfigReloaderPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Guild config reloader\",\n  type: \"internal\",\n  configSchema: zGuildConfigReloaderPluginConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/GuildConfigReloader/functions/reloadChangedGuilds.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GlobalPluginData } from \"vety\";\nimport { SECONDS } from \"../../../utils.js\";\nimport { GuildConfigReloaderPluginType } from \"../types.js\";\n\nconst CHECK_INTERVAL = 1 * SECONDS;\n\nexport async function reloadChangedGuilds(pluginData: GlobalPluginData<GuildConfigReloaderPluginType>) {\n  if (pluginData.state.unloaded) return;\n\n  const changedConfigs = await pluginData.state.guildConfigs.getActiveLargerThanId(pluginData.state.highestConfigId);\n  for (const item of changedConfigs) {\n    if (!item.key.startsWith(\"guild-\")) continue;\n\n    const guildId = item.key.slice(\"guild-\".length) as Snowflake;\n    // tslint:disable-next-line:no-console\n    console.log(`Config changed, reloading guild ${guildId}`);\n    await pluginData.getVetyInstance().reloadGuild(guildId);\n\n    if (item.id > pluginData.state.highestConfigId) {\n      pluginData.state.highestConfigId = item.id;\n    }\n  }\n\n  pluginData.state.nextCheckTimeout = setTimeout(() => reloadChangedGuilds(pluginData), CHECK_INTERVAL);\n}\n"
  },
  {
    "path": "backend/src/plugins/GuildConfigReloader/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\nimport { Configs } from \"../../data/Configs.js\";\nimport Timeout = NodeJS.Timeout;\n\nexport const zGuildConfigReloaderPluginConfig = z.strictObject({});\n\nexport interface GuildConfigReloaderPluginType extends BasePluginType {\n  configSchema: typeof zGuildConfigReloaderPluginConfig;\n  state: {\n    guildConfigs: Configs;\n    unloaded: boolean;\n    highestConfigId: number;\n    nextCheckTimeout: Timeout;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts",
    "content": "import { Guild } from \"discord.js\";\nimport { guildPlugin, guildPluginEventListener } from \"vety\";\nimport { AllowedGuilds } from \"../../data/AllowedGuilds.js\";\nimport { ApiPermissionAssignments } from \"../../data/ApiPermissionAssignments.js\";\nimport { MINUTES } from \"../../utils.js\";\nimport { GuildInfoSaverPluginType, zGuildInfoSaverConfig } from \"./types.js\";\n\nexport const GuildInfoSaverPlugin = guildPlugin<GuildInfoSaverPluginType>()({\n  name: \"guild_info_saver\",\n\n  configSchema: zGuildInfoSaverConfig,\n\n  events: [\n    guildPluginEventListener({\n      event: \"guildUpdate\",\n      listener({ args }) {\n        void updateGuildInfo(args.newGuild);\n      },\n    }),\n  ],\n\n  afterLoad(pluginData) {\n    void updateGuildInfo(pluginData.guild);\n    pluginData.state.updateInterval = setInterval(() => updateGuildInfo(pluginData.guild), 60 * MINUTES);\n  },\n\n  beforeUnload(pluginData) {\n    clearInterval(pluginData.state.updateInterval);\n  },\n});\n\nasync function updateGuildInfo(guild: Guild) {\n  if (!guild.name) {\n    return;\n  }\n\n  const allowedGuilds = new AllowedGuilds();\n  const existingData = (await allowedGuilds.find(guild.id))!;\n  allowedGuilds.updateInfo(guild.id, guild.name, guild.iconURL(), guild.ownerId);\n\n  if (existingData.owner_id !== guild.ownerId || existingData.created_at === existingData.updated_at) {\n    const apiPermissions = new ApiPermissionAssignments();\n    apiPermissions.applyOwnerChange(guild.id, guild.ownerId);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/GuildInfoSaver/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zGuildInfoSaverConfig } from \"./types.js\";\n\nexport const guildInfoSaverPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Guild info saver\",\n  type: \"internal\",\n  configSchema: zGuildInfoSaverConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/GuildInfoSaver/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\n\nexport const zGuildInfoSaverConfig = z.strictObject({});\n\nexport interface GuildInfoSaverPluginType extends BasePluginType {\n  configSchema: typeof zGuildInfoSaverConfig;\n  state: {\n    updateInterval: NodeJS.Timeout;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildMemberCache } from \"../../data/GuildMemberCache.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { SECONDS } from \"../../utils.js\";\nimport { cancelDeletionOnMemberJoin } from \"./events/cancelDeletionOnMemberJoin.js\";\nimport { removeMemberCacheOnMemberLeave } from \"./events/removeMemberCacheOnMemberLeave.js\";\nimport { updateMemberCacheOnMemberUpdate } from \"./events/updateMemberCacheOnMemberUpdate.js\";\nimport { updateMemberCacheOnMessage } from \"./events/updateMemberCacheOnMessage.js\";\nimport { updateMemberCacheOnRoleChange } from \"./events/updateMemberCacheOnRoleChange.js\";\nimport { updateMemberCacheOnVoiceStateUpdate } from \"./events/updateMemberCacheOnVoiceStateUpdate.js\";\nimport { getCachedMemberData } from \"./functions/getCachedMemberData.js\";\nimport { GuildMemberCachePluginType, zGuildMemberCacheConfig } from \"./types.js\";\n\nconst PENDING_SAVE_INTERVAL = 30 * SECONDS;\n\nexport const GuildMemberCachePlugin = guildPlugin<GuildMemberCachePluginType>()({\n  name: \"guild_member_cache\",\n\n  configSchema: zGuildMemberCacheConfig,\n\n  events: [\n    updateMemberCacheOnMemberUpdate,\n    updateMemberCacheOnMessage,\n    updateMemberCacheOnVoiceStateUpdate,\n    updateMemberCacheOnRoleChange,\n    removeMemberCacheOnMemberLeave,\n    cancelDeletionOnMemberJoin,\n  ],\n\n  public(pluginData) {\n    return {\n      getCachedMemberData: makePublicFn(pluginData, getCachedMemberData),\n    };\n  },\n\n  beforeLoad(pluginData) {\n    pluginData.state.memberCache = GuildMemberCache.getGuildInstance(pluginData.guild.id);\n    // This won't leak memory... too much #trust\n    pluginData.state.initialUpdatedMembers = new Set();\n  },\n\n  afterLoad(pluginData) {\n    pluginData.state.saveInterval = setInterval(\n      () => pluginData.state.memberCache.savePendingUpdates(),\n      PENDING_SAVE_INTERVAL,\n    );\n  },\n\n  async beforeUnload(pluginData) {\n    clearInterval(pluginData.state.saveInterval);\n    await pluginData.state.memberCache.savePendingUpdates();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zGuildMemberCacheConfig } from \"./types.js\";\n\nexport const guildMemberCachePluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Guild member cache\",\n  type: \"internal\",\n  configSchema: zGuildMemberCacheConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/events/cancelDeletionOnMemberJoin.ts",
    "content": "import { guildPluginEventListener } from \"vety\";\nimport { GuildMemberCachePluginType } from \"../types.js\";\n\nexport const cancelDeletionOnMemberJoin = guildPluginEventListener<GuildMemberCachePluginType>()({\n  event: \"guildMemberAdd\",\n  async listener({ pluginData, args: { member } }) {\n    pluginData.state.memberCache.unmarkMemberForDeletion(member.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/events/removeMemberCacheOnMemberLeave.ts",
    "content": "import { guildPluginEventListener } from \"vety\";\nimport { GuildMemberCachePluginType } from \"../types.js\";\n\nexport const removeMemberCacheOnMemberLeave = guildPluginEventListener<GuildMemberCachePluginType>()({\n  event: \"guildMemberRemove\",\n  async listener({ pluginData, args: { member } }) {\n    pluginData.state.memberCache.markMemberForDeletion(member.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMemberUpdate.ts",
    "content": "import { guildPluginEventListener } from \"vety\";\nimport { updateMemberCacheForMember } from \"../functions/updateMemberCacheForMember.js\";\nimport { GuildMemberCachePluginType } from \"../types.js\";\n\nexport const updateMemberCacheOnMemberUpdate = guildPluginEventListener<GuildMemberCachePluginType>()({\n  event: \"guildMemberUpdate\",\n  async listener({ pluginData, args: { newMember } }) {\n    updateMemberCacheForMember(pluginData, newMember.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMessage.ts",
    "content": "import { guildPluginEventListener } from \"vety\";\nimport { updateMemberCacheForMember } from \"../functions/updateMemberCacheForMember.js\";\nimport { GuildMemberCachePluginType } from \"../types.js\";\n\nexport const updateMemberCacheOnMessage = guildPluginEventListener<GuildMemberCachePluginType>()({\n  event: \"messageCreate\",\n  listener({ pluginData, args }) {\n    // Update each member once per guild load when we see a message from them\n    if (pluginData.state.initialUpdatedMembers.has(args.message.author.id)) {\n      return;\n    }\n    updateMemberCacheForMember(pluginData, args.message.author.id);\n    pluginData.state.initialUpdatedMembers.add(args.message.author.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnRoleChange.ts",
    "content": "import { AuditLogEvent } from \"discord.js\";\nimport { guildPluginEventListener } from \"vety\";\nimport { updateMemberCacheForMember } from \"../functions/updateMemberCacheForMember.js\";\nimport { GuildMemberCachePluginType } from \"../types.js\";\n\nexport const updateMemberCacheOnRoleChange = guildPluginEventListener<GuildMemberCachePluginType>()({\n  event: \"guildAuditLogEntryCreate\",\n  async listener({ pluginData, args: { auditLogEntry } }) {\n    if (auditLogEntry.action !== AuditLogEvent.MemberRoleUpdate) {\n      return;\n    }\n\n    updateMemberCacheForMember(pluginData, auditLogEntry.targetId!);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnVoiceStateUpdate.ts",
    "content": "import { guildPluginEventListener } from \"vety\";\nimport { updateMemberCacheForMember } from \"../functions/updateMemberCacheForMember.js\";\nimport { GuildMemberCachePluginType } from \"../types.js\";\n\nexport const updateMemberCacheOnVoiceStateUpdate = guildPluginEventListener<GuildMemberCachePluginType>()({\n  event: \"voiceStateUpdate\",\n  listener({ pluginData, args }) {\n    const memberId = args.newState.member?.id;\n    if (!memberId) {\n      return;\n    }\n    // Update each member once per guild load when we see a message from them\n    if (pluginData.state.initialUpdatedMembers.has(memberId)) {\n      return;\n    }\n    updateMemberCacheForMember(pluginData, memberId);\n    pluginData.state.initialUpdatedMembers.add(memberId);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/functions/getCachedMemberData.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { MemberCacheItem } from \"../../../data/entities/MemberCacheItem.js\";\nimport { GuildMemberCachePluginType } from \"../types.js\";\n\nexport function getCachedMemberData(\n  pluginData: GuildPluginData<GuildMemberCachePluginType>,\n  userId: string,\n): Promise<MemberCacheItem | null> {\n  return pluginData.state.memberCache.getCachedMemberData(userId);\n}\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/functions/updateMemberCacheForMember.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { GuildMemberCachePluginType } from \"../types.js\";\n\nexport async function updateMemberCacheForMember(\n  pluginData: GuildPluginData<GuildMemberCachePluginType>,\n  userId: string,\n) {\n  const upToDateMember = await pluginData.guild.members.fetch(userId);\n  const roles = Array.from(upToDateMember.roles.cache.keys())\n    // Filter out @everyone role\n    .filter((roleId) => roleId !== pluginData.guild.id);\n  pluginData.state.memberCache.setCachedMemberData(upToDateMember.id, {\n    username: upToDateMember.user.username,\n    nickname: upToDateMember.nickname,\n    roles,\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/GuildMemberCache/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildMemberCache } from \"../../data/GuildMemberCache.js\";\n\nexport const zGuildMemberCacheConfig = z.strictObject({});\n\nexport interface GuildMemberCachePluginType extends BasePluginType {\n  configSchema: typeof zGuildMemberCacheConfig;\n  state: {\n    memberCache: GuildMemberCache;\n    saveInterval: NodeJS.Timeout;\n    initialUpdatedMembers: Set<string>;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/InternalPoster/InternalPosterPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { Queue } from \"../../Queue.js\";\nimport { Webhooks } from \"../../data/Webhooks.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { editMessage } from \"./functions/editMessage.js\";\nimport { sendMessage } from \"./functions/sendMessage.js\";\nimport { InternalPosterPluginType, zInternalPosterConfig } from \"./types.js\";\n\nexport const InternalPosterPlugin = guildPlugin<InternalPosterPluginType>()({\n  name: \"internal_poster\",\n\n  configSchema: zInternalPosterConfig,\n\n  public(pluginData) {\n    return {\n      sendMessage: makePublicFn(pluginData, sendMessage),\n      editMessage: makePublicFn(pluginData, editMessage),\n    };\n  },\n\n  async beforeLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.webhooks = new Webhooks();\n    state.queue = new Queue();\n    state.missingPermissions = false;\n    state.webhookClientCache = new Map();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/InternalPoster/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zInternalPosterConfig } from \"./types.js\";\n\nexport const internalPosterPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Internal poster\",\n  type: \"internal\",\n  configSchema: zInternalPosterConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/InternalPoster/functions/editMessage.ts",
    "content": "import { Message, MessageEditOptions, WebhookClient, WebhookMessageEditOptions } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { isDiscordAPIError, noop } from \"../../../utils.js\";\nimport { InternalPosterPluginType } from \"../types.js\";\n\n/**\n * Sends a message using a webhook or direct API requests, preferring webhooks when possible.\n */\nexport async function editMessage(\n  pluginData: GuildPluginData<InternalPosterPluginType>,\n  message: Message,\n  content: MessageEditOptions & WebhookMessageEditOptions,\n): Promise<void> {\n  const channel = message.channel;\n  if (!channel.isTextBased()) {\n    return;\n  }\n\n  await pluginData.state.queue.add(async () => {\n    if (message.webhookId) {\n      const webhook = await pluginData.state.webhooks.find(message.webhookId);\n      if (!webhook) {\n        // Webhook message but we're missing the token -> can't edit\n        return;\n      }\n\n      const webhookClient = new WebhookClient({\n        id: webhook.id,\n        token: webhook.token,\n      });\n      await webhookClient.editMessage(message.id, content).catch(async (err) => {\n        // Unknown Webhook, remove from DB\n        if (isDiscordAPIError(err) && err.code === 10015) {\n          await pluginData.state.webhooks.delete(webhookClient.id);\n          return;\n        }\n\n        throw err;\n      });\n      return;\n    }\n\n    await message.edit(content).catch(noop);\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/InternalPoster/functions/getOrCreateWebhookClientForChannel.ts",
    "content": "import { WebhookClient } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { InternalPosterPluginType } from \"../types.js\";\nimport { getOrCreateWebhookForChannel, WebhookableChannel } from \"./getOrCreateWebhookForChannel.js\";\n\nexport async function getOrCreateWebhookClientForChannel(\n  pluginData: GuildPluginData<InternalPosterPluginType>,\n  channel: WebhookableChannel,\n): Promise<WebhookClient | null> {\n  if (!pluginData.state.webhookClientCache.has(channel.id)) {\n    const webhookInfo = await getOrCreateWebhookForChannel(pluginData, channel);\n    if (webhookInfo) {\n      const client = new WebhookClient({\n        id: webhookInfo[0],\n        token: webhookInfo[1],\n      });\n      pluginData.state.webhookClientCache.set(channel.id, client);\n    } else {\n      pluginData.state.webhookClientCache.set(channel.id, null);\n    }\n  }\n\n  return pluginData.state.webhookClientCache.get(channel.id) ?? null;\n}\n"
  },
  {
    "path": "backend/src/plugins/InternalPoster/functions/getOrCreateWebhookForChannel.ts",
    "content": "import { GuildBasedChannel, PermissionsBitField } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { isDiscordAPIError } from \"../../../utils.js\";\nimport { InternalPosterPluginType } from \"../types.js\";\n\ntype WebhookInfo = [id: string, token: string];\n\nexport type WebhookableChannel = Extract<GuildBasedChannel, { createWebhook: (...args: any[]) => any }>;\n\nexport function channelIsWebhookable(channel: GuildBasedChannel): channel is WebhookableChannel {\n  return \"createWebhook\" in channel;\n}\n\nexport async function getOrCreateWebhookForChannel(\n  pluginData: GuildPluginData<InternalPosterPluginType>,\n  channel: WebhookableChannel,\n): Promise<WebhookInfo | null> {\n  // Database cache\n  const fromDb = await pluginData.state.webhooks.findByChannelId(channel.id);\n  if (fromDb) {\n    return [fromDb.id, fromDb.token];\n  }\n\n  if (pluginData.state.missingPermissions) {\n    return null;\n  }\n\n  // Create new webhook\n  const member = pluginData.client.user && pluginData.guild.members.cache.get(pluginData.client.user.id);\n  if (!member || member.permissions.has(PermissionsBitField.Flags.ManageWebhooks)) {\n    try {\n      const webhook = await channel.createWebhook({ name: `Zephook ${channel.id}` });\n      await pluginData.state.webhooks.create({\n        id: webhook.id,\n        guild_id: pluginData.guild.id,\n        channel_id: channel.id,\n        token: webhook.token!,\n      });\n      return [webhook.id, webhook.token!];\n    } catch (err) {\n      // tslint:disable-next-line:no-console\n      console.warn(`Error when trying to create webhook for ${pluginData.guild.id}/${channel.id}: ${err.message}`);\n\n      if (isDiscordAPIError(err) && err.code === 50013) {\n        pluginData.state.missingPermissions = true;\n      }\n\n      return null;\n    }\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "backend/src/plugins/InternalPoster/functions/sendMessage.ts",
    "content": "import { GuildTextBasedChannel, MessageCreateOptions, WebhookClient } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { isDiscordAPIError } from \"../../../utils.js\";\nimport { InternalPosterPluginType } from \"../types.js\";\nimport { getOrCreateWebhookClientForChannel } from \"./getOrCreateWebhookClientForChannel.js\";\nimport { channelIsWebhookable } from \"./getOrCreateWebhookForChannel.js\";\n\nexport type InternalPosterMessageResult = {\n  id: string;\n  channelId: string;\n};\n\nasync function sendDirectly(\n  channel: GuildTextBasedChannel,\n  content: MessageCreateOptions,\n): Promise<InternalPosterMessageResult | null> {\n  return channel.send(content).then((message) => ({\n    id: message.id,\n    channelId: message.channelId,\n  }));\n}\n\n/**\n * Sends a message using a webhook or direct API requests, preferring webhooks when possible.\n */\nexport async function sendMessage(\n  pluginData: GuildPluginData<InternalPosterPluginType>,\n  channel: GuildTextBasedChannel,\n  content: MessageCreateOptions,\n): Promise<InternalPosterMessageResult | null> {\n  return pluginData.state.queue.add(async () => {\n    let webhookClient: WebhookClient | null = null;\n    let threadId: string | undefined;\n    if (channelIsWebhookable(channel)) {\n      webhookClient = await getOrCreateWebhookClientForChannel(pluginData, channel);\n    } else if (channel.isThread() && channelIsWebhookable(channel.parent!)) {\n      webhookClient = await getOrCreateWebhookClientForChannel(pluginData, channel.parent!);\n      threadId = channel.id;\n    }\n\n    if (!webhookClient) {\n      return sendDirectly(channel, content);\n    }\n\n    return webhookClient\n      .send({\n        threadId,\n        ...content,\n        ...(pluginData.client.user && {\n          username: pluginData.client.user.username,\n          avatarURL: pluginData.client.user.displayAvatarURL(),\n        }),\n      })\n      .then((apiMessage) => ({\n        id: apiMessage.id,\n        channelId: apiMessage.channel_id,\n      }))\n      .catch(async (err) => {\n        // Unknown Webhook\n        if (isDiscordAPIError(err) && err.code === 10015) {\n          await pluginData.state.webhooks.delete(webhookClient!.id);\n          pluginData.state.webhookClientCache.delete(channel.id);\n\n          // Fallback to regular message for this log message\n          return sendDirectly(channel, content);\n        }\n\n        throw err;\n      });\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/InternalPoster/types.ts",
    "content": "import { WebhookClient } from \"discord.js\";\nimport { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\nimport { Queue } from \"../../Queue.js\";\nimport { Webhooks } from \"../../data/Webhooks.js\";\n\nexport const zInternalPosterConfig = z.strictObject({}).default({});\n\nexport interface InternalPosterPluginType extends BasePluginType {\n  configSchema: typeof zInternalPosterConfig;\n  state: {\n    queue: Queue;\n    webhooks: Webhooks;\n    missingPermissions: boolean;\n    webhookClientCache: Map<string, WebhookClient | null>;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/LocateUserPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { onGuildEvent } from \"../../data/GuildEvents.js\";\nimport { GuildVCAlerts } from \"../../data/GuildVCAlerts.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { FollowCmd } from \"./commands/FollowCmd.js\";\nimport { DeleteFollowCmd, ListFollowCmd } from \"./commands/ListFollowCmd.js\";\nimport { WhereCmd } from \"./commands/WhereCmd.js\";\nimport { GuildBanRemoveAlertsEvt } from \"./events/BanRemoveAlertsEvt.js\";\nimport { VoiceStateUpdateAlertEvt } from \"./events/SendAlertsEvts.js\";\nimport { LocateUserPluginType, zLocateUserConfig } from \"./types.js\";\nimport { clearExpiredAlert } from \"./utils/clearExpiredAlert.js\";\nimport { fillActiveAlertsList } from \"./utils/fillAlertsList.js\";\n\nexport const LocateUserPlugin = guildPlugin<LocateUserPluginType>()({\n  name: \"locate_user\",\n\n  configSchema: zLocateUserConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_where: true,\n        can_alert: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    WhereCmd,\n    FollowCmd,\n    ListFollowCmd,\n    DeleteFollowCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    VoiceStateUpdateAlertEvt,\n    GuildBanRemoveAlertsEvt\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.alerts = GuildVCAlerts.getGuildInstance(guild.id);\n    state.usersWithAlerts = [];\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.unregisterGuildEventListener = onGuildEvent(guild.id, \"expiredVCAlert\", (alert) =>\n      clearExpiredAlert(pluginData, alert),\n    );\n    fillActiveAlertsList(pluginData);\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.unregisterGuildEventListener?.();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/commands/FollowCmd.ts",
    "content": "import moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { registerExpiringVCAlert } from \"../../../data/loops/expiringVCAlertsLoop.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { MINUTES, SECONDS } from \"../../../utils.js\";\nimport { locateUserCmd } from \"../types.js\";\n\nexport const FollowCmd = locateUserCmd({\n  trigger: [\"follow\", \"f\"],\n  description: \"Sets up an alert that notifies you any time `<member>` switches or joins voice channels\",\n  usage: \"!f 108552944961454080\",\n  permission: \"can_alert\",\n\n  signature: {\n    member: ct.resolvedMember(),\n    reminder: ct.string({ required: false, catchAll: true }),\n\n    duration: ct.delay({ option: true, shortcut: \"d\" }),\n    active: ct.bool({ option: true, shortcut: \"a\", isSwitch: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const time = args.duration || 10 * MINUTES;\n    const alertTime = moment.utc().add(time, \"millisecond\");\n    const body = args.reminder || \"None\";\n    const active = args.active || false;\n\n    if (time < 30 * SECONDS) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Sorry, but the minimum duration for an alert is 30 seconds!\");\n      return;\n    }\n\n    const alert = await pluginData.state.alerts.add(\n      msg.author.id,\n      args.member.id,\n      msg.channel.id,\n      alertTime.format(\"YYYY-MM-DD HH:mm:ss\"),\n      body,\n      active,\n    );\n    registerExpiringVCAlert(alert);\n\n    if (!pluginData.state.usersWithAlerts.includes(args.member.id)) {\n      pluginData.state.usersWithAlerts.push(args.member.id);\n    }\n\n    if (active) {\n      void pluginData.state.common.sendSuccessMessage(\n        msg,\n        `Every time <@${args.member.id}> joins or switches VC in the next ${humanizeDuration(\n          time,\n        )} i will notify and move you.\\nPlease make sure to be in a voice channel, otherwise i cannot move you!`,\n      );\n    } else {\n      void pluginData.state.common.sendSuccessMessage(\n        msg,\n        `Every time <@${args.member.id}> joins or switches VC in the next ${humanizeDuration(time)} i will notify you`,\n      );\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/commands/ListFollowCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { clearExpiringVCAlert } from \"../../../data/loops/expiringVCAlertsLoop.js\";\nimport { createChunkedMessage, sorter } from \"../../../utils.js\";\nimport { locateUserCmd } from \"../types.js\";\n\nexport const ListFollowCmd = locateUserCmd({\n  trigger: [\"follows\", \"fs\"],\n  description: \"Displays all of your active alerts ordered by expiration time\",\n  usage: \"!fs\",\n  permission: \"can_alert\",\n\n  async run({ message: msg, pluginData }) {\n    const alerts = await pluginData.state.alerts.getAlertsByRequestorId(msg.author.id);\n    if (alerts.length === 0) {\n      void pluginData.state.common.sendErrorMessage(msg, \"You have no active alerts!\");\n      return;\n    }\n\n    alerts.sort(sorter(\"expires_at\"));\n    const longestNum = (alerts.length + 1).toString().length;\n    const lines = Array.from(alerts.entries()).map(([i, alert]) => {\n      const num = i + 1;\n      const paddedNum = num.toString().padStart(longestNum, \" \");\n      return `\\`${paddedNum}.\\` \\`${alert.expires_at}\\` **Target:** <@!${alert.user_id}> **Reminder:** \\`${\n        alert.body\n      }\\` **Active:** ${alert.active.valueOf()}`;\n    });\n    await createChunkedMessage(msg.channel, lines.join(\"\\n\"));\n  },\n});\n\nexport const DeleteFollowCmd = locateUserCmd({\n  trigger: [\"follows delete\", \"fs d\"],\n  description:\n    \"Deletes the alert at the position <num>.\\nThe value needed for <num> can be found using `!follows` (`!fs`)\",\n  usage: \"!fs d <num>\",\n  permission: \"can_alert\",\n\n  signature: {\n    num: ct.number({ required: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const alerts = await pluginData.state.alerts.getAlertsByRequestorId(msg.author.id);\n    alerts.sort(sorter(\"expires_at\"));\n\n    if (args.num > alerts.length || args.num <= 0) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Unknown alert!\");\n      return;\n    }\n\n    const toDelete = alerts[args.num - 1];\n    clearExpiringVCAlert(toDelete);\n    await pluginData.state.alerts.delete(toDelete.id);\n\n    void pluginData.state.common.sendSuccessMessage(msg, \"Alert deleted\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/commands/WhereCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { locateUserCmd } from \"../types.js\";\nimport { sendWhere } from \"../utils/sendWhere.js\";\n\nexport const WhereCmd = locateUserCmd({\n  trigger: [\"where\", \"w\"],\n  description: \"Posts an instant invite to the voice channel that `<member>` is in\",\n  usage: \"!w 108552944961454080\",\n  permission: \"can_where\",\n\n  signature: {\n    member: ct.resolvedMember(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    sendWhere(pluginData, args.member, msg.channel, `<@${msg.author.id}> | `);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zLocateUserConfig } from \"./types.js\";\n\nexport const locateUserPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Locate user\",\n  type: \"stable\",\n  description: trimPluginDescription(`\n    This plugin allows users with access to the commands the following:\n    * Instantly receive an invite to the voice channel of a user\n    * Be notified as soon as a user switches or joins a voice channel\n  `),\n  configSchema: zLocateUserConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts",
    "content": "import { clearExpiringVCAlert } from \"../../../data/loops/expiringVCAlertsLoop.js\";\nimport { locateUserEvt } from \"../types.js\";\n\nexport const GuildBanRemoveAlertsEvt = locateUserEvt({\n  event: \"guildBanAdd\",\n\n  async listener(meta) {\n    const alerts = await meta.pluginData.state.alerts.getAlertsByUserId(meta.args.ban.user.id);\n    alerts.forEach((alert) => {\n      clearExpiringVCAlert(alert);\n      meta.pluginData.state.alerts.delete(alert.id);\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/events/SendAlertsEvts.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { locateUserEvt } from \"../types.js\";\nimport { sendAlerts } from \"../utils/sendAlerts.js\";\n\nexport const VoiceStateUpdateAlertEvt = locateUserEvt({\n  event: \"voiceStateUpdate\",\n\n  async listener(meta) {\n    const memberId = meta.args.oldState.member?.id ?? meta.args.newState.member?.id;\n    if (!memberId) {\n      return;\n    }\n\n    if (meta.args.newState.channel != null) {\n      if (meta.pluginData.state.usersWithAlerts.includes(memberId)) {\n        sendAlerts(meta.pluginData, memberId);\n      }\n    } else {\n      const triggeredAlerts = await meta.pluginData.state.alerts.getAlertsByUserId(memberId);\n      const voiceChannel = meta.args.oldState.channel!;\n\n      triggeredAlerts.forEach((alert) => {\n        const txtChannel = meta.pluginData.guild.channels.resolve(alert.channel_id as Snowflake);\n        if (txtChannel?.isTextBased()) {\n          txtChannel.send({\n            content: `🔴 <@!${alert.requestor_id}> the user <@!${alert.user_id}> disconnected out of \\`${voiceChannel.name}\\``,\n            allowedMentions: { users: [alert.requestor_id as Snowflake] },\n          });\n        }\n      });\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildVCAlerts } from \"../../data/GuildVCAlerts.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zLocateUserConfig = z.strictObject({\n  can_where: z.boolean().default(false),\n  can_alert: z.boolean().default(false),\n});\n\nexport interface LocateUserPluginType extends BasePluginType {\n  configSchema: typeof zLocateUserConfig;\n  state: {\n    alerts: GuildVCAlerts;\n    usersWithAlerts: string[];\n    unregisterGuildEventListener: () => void;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const locateUserCmd = guildPluginMessageCommand<LocateUserPluginType>();\nexport const locateUserEvt = guildPluginEventListener<LocateUserPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/utils/clearExpiredAlert.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { VCAlert } from \"../../../data/entities/VCAlert.js\";\nimport { LocateUserPluginType } from \"../types.js\";\nimport { removeUserIdFromActiveAlerts } from \"./removeUserIdFromActiveAlerts.js\";\n\nexport async function clearExpiredAlert(pluginData: GuildPluginData<LocateUserPluginType>, alert: VCAlert) {\n  await pluginData.state.alerts.delete(alert.id);\n  await removeUserIdFromActiveAlerts(pluginData, alert.user_id);\n}\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/utils/createOrReuseInvite.ts",
    "content": "import { VoiceChannel } from \"discord.js\";\n\nexport async function createOrReuseInvite(vc: VoiceChannel) {\n  const existingInvites = await vc.fetchInvites();\n\n  if (existingInvites.size !== 0) {\n    return existingInvites.first()!;\n  } else {\n    return vc.createInvite();\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/utils/fillAlertsList.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { LocateUserPluginType } from \"../types.js\";\n\nexport async function fillActiveAlertsList(pluginData: GuildPluginData<LocateUserPluginType>) {\n  const allAlerts = await pluginData.state.alerts.getAllGuildAlerts();\n\n  allAlerts.forEach((alert) => {\n    if (!pluginData.state.usersWithAlerts.includes(alert.user_id)) {\n      pluginData.state.usersWithAlerts.push(alert.user_id);\n    }\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/utils/moveMember.ts",
    "content": "import { GuildMember, GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LocateUserPluginType } from \"../types.js\";\n\nexport async function moveMember(\n  pluginData: GuildPluginData<LocateUserPluginType>,\n  toMoveID: string,\n  target: GuildMember,\n  errorChannel: GuildTextBasedChannel,\n) {\n  const modMember: GuildMember = await pluginData.guild.members.fetch(toMoveID as Snowflake);\n  if (modMember.voice.channelId != null) {\n    try {\n      await modMember.edit({\n        channel: target.voice.channelId,\n      });\n    } catch {\n      void pluginData.state.common.sendErrorMessage(errorChannel, \"Failed to move you. Are you in a voice channel?\");\n      return;\n    }\n  } else {\n    void pluginData.state.common.sendErrorMessage(errorChannel, \"Failed to move you. Are you in a voice channel?\");\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/utils/removeUserIdFromActiveAlerts.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { LocateUserPluginType } from \"../types.js\";\n\nexport async function removeUserIdFromActiveAlerts(pluginData: GuildPluginData<LocateUserPluginType>, userId: string) {\n  const index = pluginData.state.usersWithAlerts.indexOf(userId);\n  if (index > -1) {\n    pluginData.state.usersWithAlerts.splice(index, 1);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/utils/sendAlerts.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { resolveMember } from \"../../../utils.js\";\nimport { LocateUserPluginType } from \"../types.js\";\nimport { moveMember } from \"./moveMember.js\";\nimport { sendWhere } from \"./sendWhere.js\";\n\nexport async function sendAlerts(pluginData: GuildPluginData<LocateUserPluginType>, userId: string) {\n  const triggeredAlerts = await pluginData.state.alerts.getAlertsByUserId(userId);\n  const member = await resolveMember(pluginData.client, pluginData.guild, userId);\n  if (!member) return;\n\n  triggeredAlerts.forEach((alert) => {\n    const prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\\nReminder: \\`${alert.body}\\`\\n`;\n    const txtChannel = pluginData.guild.channels.resolve(alert.channel_id as Snowflake);\n    if (txtChannel?.isTextBased()) {\n      sendWhere(pluginData, member, txtChannel, prepend);\n      if (alert.active) {\n        moveMember(pluginData, alert.requestor_id, member, txtChannel);\n      }\n    }\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/LocateUser/utils/sendWhere.ts",
    "content": "import { GuildMember, GuildTextBasedChannel, Invite, VoiceChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { getInviteLink } from \"vety/helpers\";\nimport { LocateUserPluginType } from \"../types.js\";\nimport { createOrReuseInvite } from \"./createOrReuseInvite.js\";\n\nexport async function sendWhere(\n  pluginData: GuildPluginData<LocateUserPluginType>,\n  member: GuildMember,\n  channel: GuildTextBasedChannel,\n  prepend: string,\n) {\n  const voice = member.voice.channelId\n    ? (pluginData.guild.channels.resolve(member.voice.channelId) as VoiceChannel)\n    : null;\n\n  if (voice == null) {\n    channel.send(prepend + \"That user is not in a channel\");\n  } else {\n    let invite: Invite;\n    try {\n      invite = await createOrReuseInvite(voice);\n    } catch {\n      void pluginData.state.common.sendErrorMessage(channel, \"Cannot create an invite to that channel!\");\n      return;\n    }\n    channel.send({\n      content: prepend + `<@${member.id}> is in the following channel: \\`${voice.name}\\` ${getInviteLink(invite)}`,\n      allowedMentions: { parse: [\"users\"] },\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/LogsPlugin.ts",
    "content": "import { CooldownManager, guildPlugin } from \"vety\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { LogType } from \"../../data/LogType.js\";\nimport { logger } from \"../../logger.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { discardRegExpRunner, getRegExpRunner } from \"../../regExpRunners.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../templateFormatter.js\";\nimport { TimeAndDatePlugin } from \"../TimeAndDate/TimeAndDatePlugin.js\";\nimport { LogsChannelCreateEvt, LogsChannelDeleteEvt, LogsChannelUpdateEvt } from \"./events/LogsChannelModifyEvts.js\";\nimport {\n  LogsEmojiCreateEvt,\n  LogsEmojiDeleteEvt,\n  LogsEmojiUpdateEvt,\n  LogsStickerCreateEvt,\n  LogsStickerDeleteEvt,\n  LogsStickerUpdateEvt,\n} from \"./events/LogsEmojiAndStickerModifyEvts.js\";\nimport { LogsGuildMemberAddEvt } from \"./events/LogsGuildMemberAddEvt.js\";\nimport { LogsGuildMemberRemoveEvt } from \"./events/LogsGuildMemberRemoveEvt.js\";\nimport { LogsRoleCreateEvt, LogsRoleDeleteEvt, LogsRoleUpdateEvt } from \"./events/LogsRoleModifyEvts.js\";\nimport {\n  LogsStageInstanceCreateEvt,\n  LogsStageInstanceDeleteEvt,\n  LogsStageInstanceUpdateEvt,\n} from \"./events/LogsStageInstanceModifyEvts.js\";\nimport { LogsThreadCreateEvt, LogsThreadDeleteEvt, LogsThreadUpdateEvt } from \"./events/LogsThreadModifyEvts.js\";\nimport { LogsGuildMemberUpdateEvt } from \"./events/LogsUserUpdateEvts.js\";\nimport { LogsVoiceStateUpdateEvt } from \"./events/LogsVoiceChannelEvts.js\";\nimport { LogsPluginType, zLogsConfig } from \"./types.js\";\nimport { getLogMessage } from \"./util/getLogMessage.js\";\nimport { log } from \"./util/log.js\";\nimport { onMessageDelete } from \"./util/onMessageDelete.js\";\nimport { onMessageDeleteBulk } from \"./util/onMessageDeleteBulk.js\";\nimport { onMessageUpdate } from \"./util/onMessageUpdate.js\";\n\nimport { escapeCodeBlock } from \"discord.js\";\nimport { InternalPosterPlugin } from \"../InternalPoster/InternalPosterPlugin.js\";\nimport { LogsGuildMemberRoleChangeEvt } from \"./events/LogsGuildMemberRoleChangeEvt.js\";\nimport { logAutomodAction } from \"./logFunctions/logAutomodAction.js\";\nimport { logBotAlert } from \"./logFunctions/logBotAlert.js\";\nimport { logCaseCreate } from \"./logFunctions/logCaseCreate.js\";\nimport { logCaseDelete } from \"./logFunctions/logCaseDelete.js\";\nimport { logCaseUpdate } from \"./logFunctions/logCaseUpdate.js\";\nimport { logCensor } from \"./logFunctions/logCensor.js\";\nimport { logChannelCreate } from \"./logFunctions/logChannelCreate.js\";\nimport { logChannelDelete } from \"./logFunctions/logChannelDelete.js\";\nimport { logChannelUpdate } from \"./logFunctions/logChannelUpdate.js\";\nimport { logClean } from \"./logFunctions/logClean.js\";\nimport { logDmFailed } from \"./logFunctions/logDmFailed.js\";\nimport { logEmojiCreate } from \"./logFunctions/logEmojiCreate.js\";\nimport { logEmojiDelete } from \"./logFunctions/logEmojiDelete.js\";\nimport { logEmojiUpdate } from \"./logFunctions/logEmojiUpdate.js\";\nimport { logMassBan } from \"./logFunctions/logMassBan.js\";\nimport { logMassMute } from \"./logFunctions/logMassMute.js\";\nimport { logMassUnban } from \"./logFunctions/logMassUnban.js\";\nimport { logMemberBan } from \"./logFunctions/logMemberBan.js\";\nimport { logMemberForceban } from \"./logFunctions/logMemberForceban.js\";\nimport { logMemberJoin } from \"./logFunctions/logMemberJoin.js\";\nimport { logMemberJoinWithPriorRecords } from \"./logFunctions/logMemberJoinWithPriorRecords.js\";\nimport { logMemberKick } from \"./logFunctions/logMemberKick.js\";\nimport { logMemberLeave } from \"./logFunctions/logMemberLeave.js\";\nimport { logMemberMute } from \"./logFunctions/logMemberMute.js\";\nimport { logMemberMuteExpired } from \"./logFunctions/logMemberMuteExpired.js\";\nimport { logMemberMuteRejoin } from \"./logFunctions/logMemberMuteRejoin.js\";\nimport { logMemberNickChange } from \"./logFunctions/logMemberNickChange.js\";\nimport { logMemberNote } from \"./logFunctions/logMemberNote.js\";\nimport { logMemberRestore } from \"./logFunctions/logMemberRestore.js\";\nimport { logMemberRoleAdd } from \"./logFunctions/logMemberRoleAdd.js\";\nimport { logMemberRoleChanges } from \"./logFunctions/logMemberRoleChanges.js\";\nimport { logMemberRoleRemove } from \"./logFunctions/logMemberRoleRemove.js\";\nimport { logMemberTimedBan } from \"./logFunctions/logMemberTimedBan.js\";\nimport { logMemberTimedMute } from \"./logFunctions/logMemberTimedMute.js\";\nimport { logMemberTimedUnban } from \"./logFunctions/logMemberTimedUnban.js\";\nimport { logMemberTimedUnmute } from \"./logFunctions/logMemberTimedUnmute.js\";\nimport { logMemberUnban } from \"./logFunctions/logMemberUnban.js\";\nimport { logMemberUnmute } from \"./logFunctions/logMemberUnmute.js\";\nimport { logMemberWarn } from \"./logFunctions/logMemberWarn.js\";\nimport { logMessageDelete } from \"./logFunctions/logMessageDelete.js\";\nimport { logMessageDeleteAuto } from \"./logFunctions/logMessageDeleteAuto.js\";\nimport { logMessageDeleteBare } from \"./logFunctions/logMessageDeleteBare.js\";\nimport { logMessageDeleteBulk } from \"./logFunctions/logMessageDeleteBulk.js\";\nimport { logMessageEdit } from \"./logFunctions/logMessageEdit.js\";\nimport { logMessageSpamDetected } from \"./logFunctions/logMessageSpamDetected.js\";\nimport { logOtherSpamDetected } from \"./logFunctions/logOtherSpamDetected.js\";\nimport { logPostedScheduledMessage } from \"./logFunctions/logPostedScheduledMessage.js\";\nimport { logRepeatedMessage } from \"./logFunctions/logRepeatedMessage.js\";\nimport { logRoleCreate } from \"./logFunctions/logRoleCreate.js\";\nimport { logRoleDelete } from \"./logFunctions/logRoleDelete.js\";\nimport { logRoleUpdate } from \"./logFunctions/logRoleUpdate.js\";\nimport { logScheduledMessage } from \"./logFunctions/logScheduledMessage.js\";\nimport { logScheduledRepeatedMessage } from \"./logFunctions/logScheduledRepeatedMessage.js\";\nimport { logSetAntiraidAuto } from \"./logFunctions/logSetAntiraidAuto.js\";\nimport { logSetAntiraidUser } from \"./logFunctions/logSetAntiraidUser.js\";\nimport { logStageInstanceCreate } from \"./logFunctions/logStageInstanceCreate.js\";\nimport { logStageInstanceDelete } from \"./logFunctions/logStageInstanceDelete.js\";\nimport { logStageInstanceUpdate } from \"./logFunctions/logStageInstanceUpdate.js\";\nimport { logStickerCreate } from \"./logFunctions/logStickerCreate.js\";\nimport { logStickerDelete } from \"./logFunctions/logStickerDelete.js\";\nimport { logStickerUpdate } from \"./logFunctions/logStickerUpdate.js\";\nimport { logThreadCreate } from \"./logFunctions/logThreadCreate.js\";\nimport { logThreadDelete } from \"./logFunctions/logThreadDelete.js\";\nimport { logThreadUpdate } from \"./logFunctions/logThreadUpdate.js\";\nimport { logVoiceChannelForceDisconnect } from \"./logFunctions/logVoiceChannelForceDisconnect.js\";\nimport { logVoiceChannelForceMove } from \"./logFunctions/logVoiceChannelForceMove.js\";\nimport { logVoiceChannelJoin } from \"./logFunctions/logVoiceChannelJoin.js\";\nimport { logVoiceChannelLeave } from \"./logFunctions/logVoiceChannelLeave.js\";\nimport { logVoiceChannelMove } from \"./logFunctions/logVoiceChannelMove.js\";\n\n// The `any` cast here is to prevent TypeScript from locking up from the circular dependency\nfunction getCasesPlugin(): Promise<any> {\n  return import(\"../Cases/CasesPlugin.js\") as Promise<any>;\n}\n\nexport const LogsPlugin = guildPlugin<LogsPluginType>()({\n  name: \"logs\",\n\n  dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getCasesPlugin()).CasesPlugin],\n  configSchema: zLogsConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        // Legacy/deprecated, read comment on global ping_user option\n        ping_user: false,\n      },\n    },\n  ],\n\n  events: [\n    LogsGuildMemberAddEvt,\n    LogsGuildMemberRemoveEvt,\n    LogsGuildMemberUpdateEvt,\n    LogsChannelCreateEvt,\n    LogsChannelDeleteEvt,\n    LogsChannelUpdateEvt,\n    LogsRoleCreateEvt,\n    LogsRoleDeleteEvt,\n    LogsRoleUpdateEvt,\n    LogsVoiceStateUpdateEvt,\n    LogsStageInstanceCreateEvt,\n    LogsStageInstanceDeleteEvt,\n    LogsStageInstanceUpdateEvt,\n    LogsThreadCreateEvt,\n    LogsThreadDeleteEvt,\n    LogsThreadUpdateEvt,\n    LogsEmojiCreateEvt,\n    LogsEmojiDeleteEvt,\n    LogsEmojiUpdateEvt,\n    LogsStickerCreateEvt,\n    LogsStickerDeleteEvt,\n    LogsStickerUpdateEvt,\n    LogsGuildMemberRoleChangeEvt,\n  ],\n\n  public(pluginData) {\n    return {\n      getLogMessage: makePublicFn(pluginData, getLogMessage),\n      logAutomodAction: makePublicFn(pluginData, logAutomodAction),\n      logBotAlert: makePublicFn(pluginData, logBotAlert),\n      logCaseCreate: makePublicFn(pluginData, logCaseCreate),\n      logCaseDelete: makePublicFn(pluginData, logCaseDelete),\n      logCaseUpdate: makePublicFn(pluginData, logCaseUpdate),\n      logCensor: makePublicFn(pluginData, logCensor),\n      logChannelCreate: makePublicFn(pluginData, logChannelCreate),\n      logChannelDelete: makePublicFn(pluginData, logChannelDelete),\n      logChannelUpdate: makePublicFn(pluginData, logChannelUpdate),\n      logClean: makePublicFn(pluginData, logClean),\n      logEmojiCreate: makePublicFn(pluginData, logEmojiCreate),\n      logEmojiDelete: makePublicFn(pluginData, logEmojiDelete),\n      logEmojiUpdate: makePublicFn(pluginData, logEmojiUpdate),\n      logMassBan: makePublicFn(pluginData, logMassBan),\n      logMassMute: makePublicFn(pluginData, logMassMute),\n      logMassUnban: makePublicFn(pluginData, logMassUnban),\n      logMemberBan: makePublicFn(pluginData, logMemberBan),\n      logMemberForceban: makePublicFn(pluginData, logMemberForceban),\n      logMemberJoin: makePublicFn(pluginData, logMemberJoin),\n      logMemberJoinWithPriorRecords: makePublicFn(pluginData, logMemberJoinWithPriorRecords),\n      logMemberKick: makePublicFn(pluginData, logMemberKick),\n      logMemberLeave: makePublicFn(pluginData, logMemberLeave),\n      logMemberMute: makePublicFn(pluginData, logMemberMute),\n      logMemberMuteExpired: makePublicFn(pluginData, logMemberMuteExpired),\n      logMemberMuteRejoin: makePublicFn(pluginData, logMemberMuteRejoin),\n      logMemberNickChange: makePublicFn(pluginData, logMemberNickChange),\n      logMemberNote: makePublicFn(pluginData, logMemberNote),\n      logMemberRestore: makePublicFn(pluginData, logMemberRestore),\n      logMemberRoleAdd: makePublicFn(pluginData, logMemberRoleAdd),\n      logMemberRoleChanges: makePublicFn(pluginData, logMemberRoleChanges),\n      logMemberRoleRemove: makePublicFn(pluginData, logMemberRoleRemove),\n      logMemberTimedBan: makePublicFn(pluginData, logMemberTimedBan),\n      logMemberTimedMute: makePublicFn(pluginData, logMemberTimedMute),\n      logMemberTimedUnban: makePublicFn(pluginData, logMemberTimedUnban),\n      logMemberTimedUnmute: makePublicFn(pluginData, logMemberTimedUnmute),\n      logMemberUnban: makePublicFn(pluginData, logMemberUnban),\n      logMemberUnmute: makePublicFn(pluginData, logMemberUnmute),\n      logMemberWarn: makePublicFn(pluginData, logMemberWarn),\n      logMessageDelete: makePublicFn(pluginData, logMessageDelete),\n      logMessageDeleteAuto: makePublicFn(pluginData, logMessageDeleteAuto),\n      logMessageDeleteBare: makePublicFn(pluginData, logMessageDeleteBare),\n      logMessageDeleteBulk: makePublicFn(pluginData, logMessageDeleteBulk),\n      logMessageEdit: makePublicFn(pluginData, logMessageEdit),\n      logMessageSpamDetected: makePublicFn(pluginData, logMessageSpamDetected),\n      logOtherSpamDetected: makePublicFn(pluginData, logOtherSpamDetected),\n      logPostedScheduledMessage: makePublicFn(pluginData, logPostedScheduledMessage),\n      logRepeatedMessage: makePublicFn(pluginData, logRepeatedMessage),\n      logRoleCreate: makePublicFn(pluginData, logRoleCreate),\n      logRoleDelete: makePublicFn(pluginData, logRoleDelete),\n      logRoleUpdate: makePublicFn(pluginData, logRoleUpdate),\n      logScheduledMessage: makePublicFn(pluginData, logScheduledMessage),\n      logScheduledRepeatedMessage: makePublicFn(pluginData, logScheduledRepeatedMessage),\n      logSetAntiraidAuto: makePublicFn(pluginData, logSetAntiraidAuto),\n      logSetAntiraidUser: makePublicFn(pluginData, logSetAntiraidUser),\n      logStageInstanceCreate: makePublicFn(pluginData, logStageInstanceCreate),\n      logStageInstanceDelete: makePublicFn(pluginData, logStageInstanceDelete),\n      logStageInstanceUpdate: makePublicFn(pluginData, logStageInstanceUpdate),\n      logStickerCreate: makePublicFn(pluginData, logStickerCreate),\n      logStickerDelete: makePublicFn(pluginData, logStickerDelete),\n      logStickerUpdate: makePublicFn(pluginData, logStickerUpdate),\n      logThreadCreate: makePublicFn(pluginData, logThreadCreate),\n      logThreadDelete: makePublicFn(pluginData, logThreadDelete),\n      logThreadUpdate: makePublicFn(pluginData, logThreadUpdate),\n      logVoiceChannelForceDisconnect: makePublicFn(pluginData, logVoiceChannelForceDisconnect),\n      logVoiceChannelForceMove: makePublicFn(pluginData, logVoiceChannelForceMove),\n      logVoiceChannelJoin: makePublicFn(pluginData, logVoiceChannelJoin),\n      logVoiceChannelLeave: makePublicFn(pluginData, logVoiceChannelLeave),\n      logVoiceChannelMove: makePublicFn(pluginData, logVoiceChannelMove),\n      logDmFailed: makePublicFn(pluginData, logDmFailed),\n    };\n  },\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.guildLogs = new GuildLogs(guild.id);\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.archives = GuildArchives.getGuildInstance(guild.id);\n    state.cases = GuildCases.getGuildInstance(guild.id);\n\n    state.buffers = new Map();\n    state.channelCooldowns = new CooldownManager();\n\n    state.regexRunner = getRegExpRunner(`guild-${pluginData.guild.id}`);\n  },\n\n  afterLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.logListener = ({ type, data }) => log(pluginData, type, data);\n    state.guildLogs.on(\"log\", state.logListener);\n\n    state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg);\n    state.savedMessages.events.on(\"delete\", state.onMessageDeleteFn);\n\n    state.onMessageDeleteBulkFn = (msg) => onMessageDeleteBulk(pluginData, msg);\n    state.savedMessages.events.on(\"deleteBulk\", state.onMessageDeleteBulkFn);\n\n    state.onMessageUpdateFn = (newMsg, oldMsg) => onMessageUpdate(pluginData, newMsg, oldMsg);\n    state.savedMessages.events.on(\"update\", state.onMessageUpdateFn);\n\n    state.regexRunnerRepeatedTimeoutListener = (regexSource, timeoutMs, failedTimes) => {\n      logger.warn(`Disabled heavy regex temporarily: ${regexSource}`);\n      log(\n        pluginData,\n        LogType.BOT_ALERT,\n        createTypedTemplateSafeValueContainer({\n          body:\n            `\n            The following regex has taken longer than ${timeoutMs}ms for ${failedTimes} times and has been temporarily disabled:\n          `.trim() +\n            \"\\n```\" +\n            escapeCodeBlock(regexSource) +\n            \"```\",\n        }),\n      );\n    };\n    state.regexRunner.on(\"repeatedTimeout\", state.regexRunnerRepeatedTimeoutListener);\n  },\n\n  beforeUnload(pluginData) {\n    const { state, guild } = pluginData;\n\n    if (state.logListener) {\n      state.guildLogs.removeListener(\"log\", state.logListener);\n    }\n\n    if (state.onMessageDeleteFn) {\n      state.savedMessages.events.off(\"delete\", state.onMessageDeleteFn);\n    }\n    if (state.onMessageDeleteBulkFn) {\n      state.savedMessages.events.off(\"deleteBulk\", state.onMessageDeleteBulkFn);\n    }\n    if (state.onMessageUpdateFn) {\n      state.savedMessages.events.off(\"update\", state.onMessageUpdateFn);\n    }\n\n    if (state.regexRunnerRepeatedTimeoutListener) {\n      state.regexRunner.off(\"repeatedTimeout\", state.regexRunnerRepeatedTimeoutListener);\n    }\n    discardRegExpRunner(`guild-${guild.id}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zLogsConfig } from \"./types.js\";\n\nexport const logsPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Logs\",\n  configSchema: zLogsConfig,\n  type: \"stable\",\n};\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsChannelModifyEvts.ts",
    "content": "import { TextChannel, VoiceChannel } from \"discord.js\";\nimport { differenceToString, getScalarDifference } from \"../../../utils.js\";\nimport { filterObject } from \"../../../utils/filterObject.js\";\nimport { logChannelCreate } from \"../logFunctions/logChannelCreate.js\";\nimport { logChannelDelete } from \"../logFunctions/logChannelDelete.js\";\nimport { logChannelUpdate } from \"../logFunctions/logChannelUpdate.js\";\nimport { logsEvt } from \"../types.js\";\n\nexport const LogsChannelCreateEvt = logsEvt({\n  event: \"channelCreate\",\n\n  async listener(meta) {\n    logChannelCreate(meta.pluginData, {\n      channel: meta.args.channel,\n    });\n  },\n});\n\nexport const LogsChannelDeleteEvt = logsEvt({\n  event: \"channelDelete\",\n\n  async listener(meta) {\n    logChannelDelete(meta.pluginData, {\n      channel: meta.args.channel,\n    });\n  },\n});\n\nconst validChannelDiffProps: Set<keyof TextChannel | keyof VoiceChannel> = new Set([\n  \"name\",\n  \"parentId\",\n  \"nsfw\",\n  \"rateLimitPerUser\",\n  \"topic\",\n  \"bitrate\",\n]);\n\nexport const LogsChannelUpdateEvt = logsEvt({\n  event: \"channelUpdate\",\n\n  async listener(meta) {\n    if (meta.args.oldChannel?.partial) {\n      return;\n    }\n\n    const oldChannelDiffProps = filterObject(meta.args.oldChannel || {}, (v, k) => validChannelDiffProps.has(k));\n    const newChannelDiffProps = filterObject(meta.args.newChannel, (v, k) => validChannelDiffProps.has(k));\n    const diff = getScalarDifference(oldChannelDiffProps, newChannelDiffProps);\n    const differenceString = differenceToString(diff);\n\n    if (differenceString.trim() === \"\") {\n      return;\n    }\n\n    logChannelUpdate(meta.pluginData, {\n      oldChannel: meta.args.oldChannel,\n      newChannel: meta.args.newChannel,\n      differenceString,\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsEmojiAndStickerModifyEvts.ts",
    "content": "import { GuildEmoji, Sticker } from \"discord.js\";\nimport { differenceToString, getScalarDifference } from \"../../../utils.js\";\nimport { filterObject } from \"../../../utils/filterObject.js\";\nimport { logEmojiCreate } from \"../logFunctions/logEmojiCreate.js\";\nimport { logEmojiDelete } from \"../logFunctions/logEmojiDelete.js\";\nimport { logEmojiUpdate } from \"../logFunctions/logEmojiUpdate.js\";\nimport { logStickerCreate } from \"../logFunctions/logStickerCreate.js\";\nimport { logStickerDelete } from \"../logFunctions/logStickerDelete.js\";\nimport { logStickerUpdate } from \"../logFunctions/logStickerUpdate.js\";\nimport { logsEvt } from \"../types.js\";\n\nexport const LogsEmojiCreateEvt = logsEvt({\n  event: \"emojiCreate\",\n\n  async listener(meta) {\n    logEmojiCreate(meta.pluginData, {\n      emoji: meta.args.emoji,\n    });\n  },\n});\n\nexport const LogsEmojiDeleteEvt = logsEvt({\n  event: \"emojiDelete\",\n\n  async listener(meta) {\n    logEmojiDelete(meta.pluginData, {\n      emoji: meta.args.emoji,\n    });\n  },\n});\n\nconst validEmojiDiffProps: Set<keyof GuildEmoji> = new Set([\"name\"]);\n\nexport const LogsEmojiUpdateEvt = logsEvt({\n  event: \"emojiUpdate\",\n\n  async listener(meta) {\n    const oldEmojiDiffProps = filterObject(meta.args.oldEmoji || {}, (v, k) => validEmojiDiffProps.has(k));\n    const newEmojiDiffProps = filterObject(meta.args.newEmoji, (v, k) => validEmojiDiffProps.has(k));\n    const diff = getScalarDifference(oldEmojiDiffProps, newEmojiDiffProps);\n    const differenceString = differenceToString(diff);\n\n    if (differenceString === \"\") {\n      return;\n    }\n\n    logEmojiUpdate(meta.pluginData, {\n      oldEmoji: meta.args.oldEmoji,\n      newEmoji: meta.args.newEmoji,\n      differenceString,\n    });\n  },\n});\n\nexport const LogsStickerCreateEvt = logsEvt({\n  event: \"stickerCreate\",\n\n  async listener(meta) {\n    logStickerCreate(meta.pluginData, {\n      sticker: meta.args.sticker,\n    });\n  },\n});\n\nexport const LogsStickerDeleteEvt = logsEvt({\n  event: \"stickerDelete\",\n\n  async listener(meta) {\n    logStickerDelete(meta.pluginData, {\n      sticker: meta.args.sticker,\n    });\n  },\n});\n\nconst validStickerDiffProps: Set<keyof Sticker> = new Set([\"name\"]);\n\nexport const LogsStickerUpdateEvt = logsEvt({\n  event: \"stickerUpdate\",\n\n  async listener(meta) {\n    const oldStickerDiffProps = filterObject(meta.args.oldSticker || {}, (v, k) => validStickerDiffProps.has(k));\n    const newStickerDiffProps = filterObject(meta.args.newSticker, (v, k) => validStickerDiffProps.has(k));\n    const diff = getScalarDifference(oldStickerDiffProps, newStickerDiffProps);\n    const differenceString = differenceToString(diff);\n\n    if (differenceString === \"\") {\n      return;\n    }\n\n    logStickerUpdate(meta.pluginData, {\n      oldSticker: meta.args.oldSticker,\n      newSticker: meta.args.newSticker,\n      differenceString,\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsGuildBanEvts.ts",
    "content": "import { AuditLogEvent } from \"discord.js\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { findMatchingAuditLogEntry } from \"../../../utils/findMatchingAuditLogEntry.js\";\nimport { logMemberBan } from \"../logFunctions/logMemberBan.js\";\nimport { logMemberUnban } from \"../logFunctions/logMemberUnban.js\";\nimport { logsEvt } from \"../types.js\";\nimport { isLogIgnored } from \"../util/isLogIgnored.js\";\n\nexport const LogsGuildBanAddEvt = logsEvt({\n  event: \"guildBanAdd\",\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n    const user = meta.args.ban.user;\n\n    if (isLogIgnored(pluginData, LogType.MEMBER_BAN, user.id)) {\n      return;\n    }\n\n    const relevantAuditLogEntry = await findMatchingAuditLogEntry(\n      pluginData.guild,\n      AuditLogEvent.MemberBanAdd,\n      user.id,\n    );\n    const mod = relevantAuditLogEntry?.executor ?? null;\n    logMemberBan(meta.pluginData, {\n      mod,\n      user,\n      caseNumber: 0,\n      reason: \"\",\n    });\n  },\n});\n\nexport const LogsGuildBanRemoveEvt = logsEvt({\n  event: \"guildBanRemove\",\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n    const user = meta.args.ban.user;\n\n    if (isLogIgnored(pluginData, LogType.MEMBER_UNBAN, user.id)) {\n      return;\n    }\n\n    const relevantAuditLogEntry = await findMatchingAuditLogEntry(\n      pluginData.guild,\n      AuditLogEvent.MemberBanRemove,\n      user.id,\n    );\n    const mod = relevantAuditLogEntry?.executor ?? null;\n    logMemberUnban(pluginData, {\n      mod,\n      userId: user.id,\n      caseNumber: 0,\n      reason: \"\",\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsGuildMemberAddEvt.ts",
    "content": "import { logMemberJoin } from \"../logFunctions/logMemberJoin.js\";\nimport { logsEvt } from \"../types.js\";\n\nexport const LogsGuildMemberAddEvt = logsEvt({\n  event: \"guildMemberAdd\",\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n    const member = meta.args.member;\n\n    logMemberJoin(pluginData, {\n      member,\n    });\n\n    // TODO: Uncomment below once circular dependencies in Vety have been fixed\n\n    // const cases = (await pluginData.state.cases.with(\"notes\").getByUserId(member.id)).filter(c => !c.is_hidden);\n    // cases.sort((a, b) => (a.created_at > b.created_at ? -1 : 1));\n    //\n    // if (cases.length) {\n    //   const recentCaseLines: string[] = [];\n    //   const recentCases = cases.slice(0, 2);\n    //   const casesPlugin = pluginData.getPlugin(CasesPlugin);\n    //   for (const theCase of recentCases) {\n    //     recentCaseLines.push((await casesPlugin.getCaseSummary(theCase))!);\n    //   }\n    //\n    //   let recentCaseSummary = recentCaseLines.join(\"\\n\");\n    //   if (recentCases.length < cases.length) {\n    //     const remaining = cases.length - recentCases.length;\n    //     if (remaining === 1) {\n    //       recentCaseSummary += `\\n*+${remaining} case*`;\n    //     } else {\n    //       recentCaseSummary += `\\n*+${remaining} cases*`;\n    //     }\n    //   }\n    //\n    //   logMemberJoinWithPriorRecords(pluginData, {\n    //     member,\n    //     recentCaseSummary,\n    //   });\n    // }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsGuildMemberRemoveEvt.ts",
    "content": "import { logMemberLeave } from \"../logFunctions/logMemberLeave.js\";\nimport { logsEvt } from \"../types.js\";\n\nexport const LogsGuildMemberRemoveEvt = logsEvt({\n  event: \"guildMemberRemove\",\n\n  async listener(meta) {\n    logMemberLeave(meta.pluginData, {\n      member: meta.args.member,\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsGuildMemberRoleChangeEvt.ts",
    "content": "import { APIRole, AuditLogChange, AuditLogEvent } from \"discord.js\";\nimport { guildPluginEventListener } from \"vety\";\nimport { resolveRole } from \"../../../utils.js\";\nimport { logMemberRoleAdd } from \"../logFunctions/logMemberRoleAdd.js\";\nimport { logMemberRoleRemove } from \"../logFunctions/logMemberRoleRemove.js\";\nimport { LogsPluginType } from \"../types.js\";\n\ntype RoleAddChange = AuditLogChange & {\n  key: \"$add\";\n  new: Array<Pick<APIRole, \"id\" | \"name\">>;\n};\n\nfunction isRoleAddChange(change: AuditLogChange): change is RoleAddChange {\n  return change.key === \"$add\";\n}\n\ntype RoleRemoveChange = AuditLogChange & {\n  key: \"$remove\";\n  new: Array<Pick<APIRole, \"id\" | \"name\">>;\n};\n\nfunction isRoleRemoveChange(change: AuditLogChange): change is RoleRemoveChange {\n  return change.key === \"$remove\";\n}\n\nexport const LogsGuildMemberRoleChangeEvt = guildPluginEventListener<LogsPluginType>()({\n  event: \"guildAuditLogEntryCreate\",\n  async listener({ pluginData, args: { auditLogEntry } }) {\n    // Ignore the bot's own audit log events\n    if (auditLogEntry.executorId === pluginData.client.user?.id) {\n      return;\n    }\n    if (auditLogEntry.action !== AuditLogEvent.MemberRoleUpdate) {\n      return;\n    }\n\n    const member = await pluginData.guild.members.fetch(auditLogEntry.targetId!);\n    const mod = auditLogEntry.executorId ? await pluginData.client.users.fetch(auditLogEntry.executorId) : null;\n    for (const change of auditLogEntry.changes) {\n      if (isRoleAddChange(change)) {\n        const addedRoles = change.new.map((r) => resolveRole(pluginData.guild, r.id));\n        logMemberRoleAdd(pluginData, {\n          member,\n          mod,\n          roles: addedRoles,\n        });\n      }\n      if (isRoleRemoveChange(change)) {\n        const removedRoles = change.new.map((r) => resolveRole(pluginData.guild, r.id));\n        logMemberRoleRemove(pluginData, {\n          member,\n          mod,\n          roles: removedRoles,\n        });\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsRoleModifyEvts.ts",
    "content": "import { Role } from \"discord.js\";\nimport { differenceToString, getScalarDifference } from \"../../../utils.js\";\nimport { filterObject } from \"../../../utils/filterObject.js\";\nimport { logRoleCreate } from \"../logFunctions/logRoleCreate.js\";\nimport { logRoleDelete } from \"../logFunctions/logRoleDelete.js\";\nimport { logRoleUpdate } from \"../logFunctions/logRoleUpdate.js\";\nimport { logsEvt } from \"../types.js\";\n\nexport const LogsRoleCreateEvt = logsEvt({\n  event: \"roleCreate\",\n\n  async listener(meta) {\n    logRoleCreate(meta.pluginData, {\n      role: meta.args.role,\n    });\n  },\n});\n\nexport const LogsRoleDeleteEvt = logsEvt({\n  event: \"roleDelete\",\n\n  async listener(meta) {\n    logRoleDelete(meta.pluginData, {\n      role: meta.args.role,\n    });\n  },\n});\n\nconst validRoleDiffProps: Set<keyof Role> = new Set([\"name\", \"hoist\", \"color\", \"mentionable\"]);\n\nexport const LogsRoleUpdateEvt = logsEvt({\n  event: \"roleUpdate\",\n\n  async listener(meta) {\n    const oldRoleDiffProps = filterObject(meta.args.oldRole || {}, (v, k) => validRoleDiffProps.has(k));\n    const newRoleDiffProps = filterObject(meta.args.newRole, (v, k) => validRoleDiffProps.has(k));\n    const diff = getScalarDifference(oldRoleDiffProps, newRoleDiffProps);\n    const differenceString = differenceToString(diff);\n\n    logRoleUpdate(meta.pluginData, {\n      newRole: meta.args.newRole,\n      oldRole: meta.args.oldRole,\n      differenceString,\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsStageInstanceModifyEvts.ts",
    "content": "import { StageChannel, StageInstance } from \"discord.js\";\nimport { differenceToString, getScalarDifference } from \"../../../utils.js\";\nimport { filterObject } from \"../../../utils/filterObject.js\";\nimport { logStageInstanceCreate } from \"../logFunctions/logStageInstanceCreate.js\";\nimport { logStageInstanceDelete } from \"../logFunctions/logStageInstanceDelete.js\";\nimport { logStageInstanceUpdate } from \"../logFunctions/logStageInstanceUpdate.js\";\nimport { logsEvt } from \"../types.js\";\n\nexport const LogsStageInstanceCreateEvt = logsEvt({\n  event: \"stageInstanceCreate\",\n\n  async listener(meta) {\n    const stageChannel =\n      meta.args.stageInstance.channel ??\n      ((await meta.pluginData.guild.channels.fetch(meta.args.stageInstance.channelId)) as StageChannel);\n\n    logStageInstanceCreate(meta.pluginData, {\n      stageInstance: meta.args.stageInstance,\n      stageChannel,\n    });\n  },\n});\n\nexport const LogsStageInstanceDeleteEvt = logsEvt({\n  event: \"stageInstanceDelete\",\n\n  async listener(meta) {\n    const stageChannel =\n      meta.args.stageInstance.channel ??\n      ((await meta.pluginData.guild.channels.fetch(meta.args.stageInstance.channelId)) as StageChannel);\n\n    logStageInstanceDelete(meta.pluginData, {\n      stageInstance: meta.args.stageInstance,\n      stageChannel,\n    });\n  },\n});\n\nconst validStageInstanceDiffProps: Set<keyof StageInstance> = new Set([\n  \"topic\",\n  \"privacyLevel\",\n  \"discoverableDisabled\",\n]);\n\nexport const LogsStageInstanceUpdateEvt = logsEvt({\n  event: \"stageInstanceUpdate\",\n\n  async listener(meta) {\n    const stageChannel =\n      meta.args.newStageInstance.channel ??\n      ((await meta.pluginData.guild.channels.fetch(meta.args.newStageInstance.channelId)) as StageChannel);\n\n    const oldStageInstanceDiffProps = filterObject(meta.args.oldStageInstance || {}, (v, k) =>\n      validStageInstanceDiffProps.has(k),\n    );\n    const newStageInstanceDiffProps = filterObject(meta.args.newStageInstance, (v, k) =>\n      validStageInstanceDiffProps.has(k),\n    );\n    const diff = getScalarDifference(oldStageInstanceDiffProps, newStageInstanceDiffProps);\n    const differenceString = differenceToString(diff);\n\n    logStageInstanceUpdate(meta.pluginData, {\n      oldStageInstance: meta.args.oldStageInstance,\n      newStageInstance: meta.args.newStageInstance,\n      stageChannel,\n      differenceString,\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsThreadModifyEvts.ts",
    "content": "import { ThreadChannel } from \"discord.js\";\nimport { differenceToString, getScalarDifference } from \"../../../utils.js\";\nimport { filterObject } from \"../../../utils/filterObject.js\";\nimport { logThreadCreate } from \"../logFunctions/logThreadCreate.js\";\nimport { logThreadDelete } from \"../logFunctions/logThreadDelete.js\";\nimport { logThreadUpdate } from \"../logFunctions/logThreadUpdate.js\";\nimport { logsEvt } from \"../types.js\";\n\nexport const LogsThreadCreateEvt = logsEvt({\n  event: \"threadCreate\",\n\n  async listener(meta) {\n    logThreadCreate(meta.pluginData, {\n      thread: meta.args.thread,\n    });\n  },\n});\n\nexport const LogsThreadDeleteEvt = logsEvt({\n  event: \"threadDelete\",\n\n  async listener(meta) {\n    logThreadDelete(meta.pluginData, {\n      thread: meta.args.thread,\n    });\n  },\n});\n\nconst validThreadDiffProps: Set<keyof ThreadChannel> = new Set([\"name\", \"autoArchiveDuration\", \"rateLimitPerUser\"]);\n\nexport const LogsThreadUpdateEvt = logsEvt({\n  event: \"threadUpdate\",\n\n  async listener(meta) {\n    const oldThreadDiffProps = filterObject(meta.args.oldThread || {}, (v, k) => validThreadDiffProps.has(k));\n    const newThreadDiffProps = filterObject(meta.args.newThread, (v, k) => validThreadDiffProps.has(k));\n    const diff = getScalarDifference(oldThreadDiffProps, newThreadDiffProps);\n    const differenceString = differenceToString(diff);\n\n    logThreadUpdate(meta.pluginData, {\n      oldThread: meta.args.oldThread,\n      newThread: meta.args.newThread,\n      differenceString,\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts",
    "content": "import { logMemberNickChange } from \"../logFunctions/logMemberNickChange.js\";\nimport { logsEvt } from \"../types.js\";\n\nexport const LogsGuildMemberUpdateEvt = logsEvt({\n  event: \"guildMemberUpdate\",\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n    const oldMember = meta.args.oldMember;\n    const member = meta.args.newMember;\n\n    if (!oldMember || oldMember.partial) {\n      return;\n    }\n\n    if (member.nickname !== oldMember.nickname) {\n      logMemberNickChange(pluginData, {\n        member,\n        oldNick: oldMember.nickname != null ? oldMember.nickname : \"<none>\",\n        newNick: member.nickname != null ? member.nickname : \"<none>\",\n      });\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/events/LogsVoiceChannelEvts.ts",
    "content": "import { logVoiceChannelJoin } from \"../logFunctions/logVoiceChannelJoin.js\";\nimport { logVoiceChannelLeave } from \"../logFunctions/logVoiceChannelLeave.js\";\nimport { logVoiceChannelMove } from \"../logFunctions/logVoiceChannelMove.js\";\nimport { logsEvt } from \"../types.js\";\n\nexport const LogsVoiceStateUpdateEvt = logsEvt({\n  event: \"voiceStateUpdate\",\n\n  async listener(meta) {\n    const oldChannel = meta.args.oldState.channel;\n    const newChannel = meta.args.newState.channel;\n    const member = meta.args.newState.member ?? meta.args.oldState.member;\n\n    if (!member) {\n      return;\n    }\n\n    if (!newChannel && oldChannel) {\n      // Leave evt\n      logVoiceChannelLeave(meta.pluginData, {\n        member,\n        channel: oldChannel,\n      });\n    } else if (!oldChannel && newChannel) {\n      // Join Evt\n      logVoiceChannelJoin(meta.pluginData, {\n        member,\n        channel: newChannel,\n      });\n    } else if (oldChannel && newChannel) {\n      if (oldChannel.id === newChannel.id) return;\n      logVoiceChannelMove(meta.pluginData, {\n        member,\n        oldChannel,\n        newChannel,\n      });\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logAutomodAction.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogAutomodActionData {\n  rule: string;\n  prettyName: string | undefined;\n  user?: User | null;\n  users: User[];\n  actionsTaken: string;\n  matchSummary: string;\n}\n\nexport function logAutomodAction(pluginData: GuildPluginData<LogsPluginType>, data: LogAutomodActionData) {\n  return log(\n    pluginData,\n    LogType.AUTOMOD_ACTION,\n    createTypedTemplateSafeValueContainer({\n      rule: data.rule,\n      prettyName: data.prettyName,\n      user: data.user ? userToTemplateSafeUser(data.user) : null,\n      users: data.users.map((user) => userToTemplateSafeUser(user)),\n      actionsTaken: data.actionsTaken,\n      matchSummary: data.matchSummary ?? \"\",\n    }),\n    {\n      userId: data.user ? data.user.id : null,\n      bot: data.user ? data.user.bot : false,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logBotAlert.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogBotAlertData {\n  body: string;\n}\n\nexport function logBotAlert(pluginData: GuildPluginData<LogsPluginType>, data: LogBotAlertData) {\n  return log(\n    pluginData,\n    LogType.BOT_ALERT,\n    createTypedTemplateSafeValueContainer({\n      body: data.body,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logCaseCreate.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogCaseCreateData {\n  mod: User;\n  userId: string;\n  caseNum: number;\n  caseType: string;\n  reason: string;\n}\n\nexport function logCaseCreate(pluginData: GuildPluginData<LogsPluginType>, data: LogCaseCreateData) {\n  return log(\n    pluginData,\n    LogType.CASE_CREATE,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      userId: data.userId,\n      caseNum: data.caseNum,\n      caseType: data.caseType,\n      reason: data.reason,\n    }),\n    {\n      userId: data.userId,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logCaseDelete.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { caseToTemplateSafeCase, memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogCaseDeleteData {\n  mod: GuildMember;\n  case: Case;\n}\n\nexport function logCaseDelete(pluginData: GuildPluginData<LogsPluginType>, data: LogCaseDeleteData) {\n  return log(\n    pluginData,\n    LogType.CASE_DELETE,\n    createTypedTemplateSafeValueContainer({\n      mod: memberToTemplateSafeMember(data.mod),\n      case: caseToTemplateSafeCase(data.case),\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logCaseUpdate.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogCaseUpdateData {\n  mod: User;\n  caseNumber: number;\n  caseType: string;\n  note: string;\n}\n\nexport function logCaseUpdate(pluginData: GuildPluginData<LogsPluginType>, data: LogCaseUpdateData) {\n  return log(\n    pluginData,\n    LogType.CASE_UPDATE,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      caseNumber: data.caseNumber,\n      caseType: data.caseType,\n      note: data.note,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logCensor.ts",
    "content": "import { GuildTextBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { deactivateMentions, disableCodeBlocks } from \"vety/helpers\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport {\n  channelToTemplateSafeChannel,\n  savedMessageToTemplateSafeSavedMessage,\n  userToTemplateSafeUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogCensorData {\n  user: User | UnknownUser;\n  channel: GuildTextBasedChannel;\n  reason: string;\n  message: SavedMessage;\n}\n\nexport function logCensor(pluginData: GuildPluginData<LogsPluginType>, data: LogCensorData) {\n  return log(\n    pluginData,\n    LogType.CENSOR,\n    createTypedTemplateSafeValueContainer({\n      user: userToTemplateSafeUser(data.user),\n      channel: channelToTemplateSafeChannel(data.channel),\n      reason: data.reason,\n      message: savedMessageToTemplateSafeSavedMessage(data.message),\n      messageText: disableCodeBlocks(deactivateMentions(data.message.data.content)),\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user instanceof User ? data.user.bot : false,\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logChannelCreate.ts",
    "content": "import { GuildBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogChannelCreateData {\n  channel: GuildBasedChannel;\n}\n\nexport function logChannelCreate(pluginData: GuildPluginData<LogsPluginType>, data: LogChannelCreateData) {\n  return log(\n    pluginData,\n    LogType.CHANNEL_CREATE,\n    createTypedTemplateSafeValueContainer({\n      channel: channelToTemplateSafeChannel(data.channel),\n    }),\n    {\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logChannelDelete.ts",
    "content": "import { GuildBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogChannelDeleteData {\n  channel: GuildBasedChannel;\n}\n\nexport function logChannelDelete(pluginData: GuildPluginData<LogsPluginType>, data: LogChannelDeleteData) {\n  return log(\n    pluginData,\n    LogType.CHANNEL_DELETE,\n    createTypedTemplateSafeValueContainer({\n      channel: channelToTemplateSafeChannel(data.channel),\n    }),\n    {\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logChannelUpdate.ts",
    "content": "import { GuildBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogChannelUpdateData {\n  oldChannel: GuildBasedChannel;\n  newChannel: GuildBasedChannel;\n  differenceString: string;\n}\n\nexport function logChannelUpdate(pluginData: GuildPluginData<LogsPluginType>, data: LogChannelUpdateData) {\n  return log(\n    pluginData,\n    LogType.CHANNEL_UPDATE,\n    createTypedTemplateSafeValueContainer({\n      oldChannel: channelToTemplateSafeChannel(data.oldChannel),\n      newChannel: channelToTemplateSafeChannel(data.newChannel),\n      differenceString: data.differenceString,\n    }),\n    {\n      ...resolveChannelIds(data.newChannel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logClean.ts",
    "content": "import { GuildTextBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogCleanData {\n  mod: User;\n  channel: GuildTextBasedChannel;\n  count: number;\n  archiveUrl: string;\n}\n\nexport function logClean(pluginData: GuildPluginData<LogsPluginType>, data: LogCleanData) {\n  return log(\n    pluginData,\n    LogType.CLEAN,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      channel: channelToTemplateSafeChannel(data.channel),\n      count: data.count,\n      archiveUrl: data.archiveUrl,\n    }),\n    {\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logDmFailed.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogDmFailedData {\n  source: string;\n  user: User | UnknownUser;\n}\n\nexport function logDmFailed(pluginData: GuildPluginData<LogsPluginType>, data: LogDmFailedData) {\n  return log(\n    pluginData,\n    LogType.DM_FAILED,\n    createTypedTemplateSafeValueContainer({\n      source: data.source,\n      user: userToTemplateSafeUser(data.user),\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user instanceof User ? data.user.bot : false,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logEmojiCreate.ts",
    "content": "import { Emoji } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { emojiToTemplateSafeEmoji } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogEmojiCreateData {\n  emoji: Emoji;\n}\n\nexport function logEmojiCreate(pluginData: GuildPluginData<LogsPluginType>, data: LogEmojiCreateData) {\n  return log(\n    pluginData,\n    LogType.EMOJI_CREATE,\n    createTypedTemplateSafeValueContainer({\n      emoji: emojiToTemplateSafeEmoji(data.emoji),\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logEmojiDelete.ts",
    "content": "import { Emoji } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { emojiToTemplateSafeEmoji } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogEmojiDeleteData {\n  emoji: Emoji;\n}\n\nexport function logEmojiDelete(pluginData: GuildPluginData<LogsPluginType>, data: LogEmojiDeleteData) {\n  return log(\n    pluginData,\n    LogType.EMOJI_DELETE,\n    createTypedTemplateSafeValueContainer({\n      emoji: emojiToTemplateSafeEmoji(data.emoji),\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logEmojiUpdate.ts",
    "content": "import { Emoji } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { emojiToTemplateSafeEmoji } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogEmojiUpdateData {\n  oldEmoji: Emoji;\n  newEmoji: Emoji;\n  differenceString: string;\n}\n\nexport function logEmojiUpdate(pluginData: GuildPluginData<LogsPluginType>, data: LogEmojiUpdateData) {\n  return log(\n    pluginData,\n    LogType.EMOJI_UPDATE,\n    createTypedTemplateSafeValueContainer({\n      oldEmoji: emojiToTemplateSafeEmoji(data.oldEmoji),\n      newEmoji: emojiToTemplateSafeEmoji(data.newEmoji),\n      differenceString: data.differenceString,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMassBan.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMassBanData {\n  mod: User;\n  count: number;\n  reason: string;\n}\n\nexport function logMassBan(pluginData: GuildPluginData<LogsPluginType>, data: LogMassBanData) {\n  return log(\n    pluginData,\n    LogType.MASSBAN,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      count: data.count,\n      reason: data.reason,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMassMute.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMassMuteData {\n  mod: User;\n  count: number;\n}\n\nexport function logMassMute(pluginData: GuildPluginData<LogsPluginType>, data: LogMassMuteData) {\n  return log(\n    pluginData,\n    LogType.MASSMUTE,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      count: data.count,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMassUnban.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMassUnbanData {\n  mod: User;\n  count: number;\n  reason: string;\n}\n\nexport function logMassUnban(pluginData: GuildPluginData<LogsPluginType>, data: LogMassUnbanData) {\n  return log(\n    pluginData,\n    LogType.MASSUNBAN,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      count: data.count,\n      reason: data.reason,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberBan.ts",
    "content": "import { PartialUser, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberBanData {\n  mod: User | UnknownUser | PartialUser | null;\n  user: User | UnknownUser;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberBan(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberBanData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_BAN,\n    createTypedTemplateSafeValueContainer({\n      mod: data.mod ? userToTemplateSafeUser(data.mod) : null,\n      user: userToTemplateSafeUser(data.user),\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user instanceof User ? data.user.bot : false,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberForceban.ts",
    "content": "import { GuildMember, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberForcebanData {\n  mod: GuildMember;\n  userId: Snowflake;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberForceban(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberForcebanData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_FORCEBAN,\n    createTypedTemplateSafeValueContainer({\n      mod: memberToTemplateSafeMember(data.mod),\n      userId: data.userId,\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.userId,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberJoin.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberJoinData {\n  member: GuildMember;\n}\n\nexport function logMemberJoin(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberJoinData) {\n  const newThreshold = moment.utc().valueOf() - 1000 * 60 * 60;\n  const accountAge = humanizeDuration(moment.utc().valueOf() - data.member.user.createdTimestamp, {\n    largest: 2,\n    round: true,\n  });\n\n  return log(\n    pluginData,\n    LogType.MEMBER_JOIN,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n      new: data.member.user.createdTimestamp >= newThreshold ? \" :new:\" : \"\",\n      account_age: accountAge,\n      account_age_ts: Math.round(data.member.user.createdTimestamp / 1000).toString(),\n    }),\n    {\n      userId: data.member.id,\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberJoinWithPriorRecords.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberJoinWithPriorRecordsData {\n  member: GuildMember;\n  recentCaseSummary: string;\n}\n\nexport function logMemberJoinWithPriorRecords(\n  pluginData: GuildPluginData<LogsPluginType>,\n  data: LogMemberJoinWithPriorRecordsData,\n) {\n  return log(\n    pluginData,\n    LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n      recentCaseSummary: data.recentCaseSummary,\n    }),\n    {\n      userId: data.member.id,\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberKick.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberKickData {\n  mod: User | UnknownUser | null;\n  user: User;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberKick(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberKickData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_KICK,\n    createTypedTemplateSafeValueContainer({\n      mod: data.mod ? userToTemplateSafeUser(data.mod) : null,\n      user: userToTemplateSafeUser(data.user),\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberLeave.ts",
    "content": "import { GuildMember, PartialGuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberLeaveData {\n  member: GuildMember | PartialGuildMember;\n}\n\nexport function logMemberLeave(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberLeaveData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_LEAVE,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n    }),\n    {\n      userId: data.member.id,\n      bot: data.member.user?.bot ?? false,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberMute.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberMuteData {\n  mod: User | UnknownUser;\n  user: User | UnknownUser;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberMute(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberMuteData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_MUTE,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      user: userToTemplateSafeUser(data.user),\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user instanceof User ? data.user.bot : false,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberMuteExpired.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport {\n  memberToTemplateSafeMember,\n  TemplateSafeUnknownMember,\n  TemplateSafeUnknownUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberMuteExpiredData {\n  member: GuildMember | UnknownUser;\n}\n\nexport function logMemberMuteExpired(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberMuteExpiredData) {\n  const member =\n    data.member instanceof GuildMember\n      ? memberToTemplateSafeMember(data.member)\n      : new TemplateSafeUnknownMember({\n          ...data.member,\n          user: new TemplateSafeUnknownUser({ ...data.member }),\n        });\n\n  const roles = data.member instanceof GuildMember ? Array.from(data.member.roles.cache.keys()) : [];\n\n  const bot = data.member instanceof GuildMember ? data.member.user.bot : false;\n\n  return log(\n    pluginData,\n    LogType.MEMBER_MUTE_EXPIRED,\n    createTypedTemplateSafeValueContainer({\n      member,\n    }),\n    {\n      userId: data.member.id,\n      roles,\n      bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberMuteRejoin.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberMuteRejoinData {\n  member: GuildMember;\n}\n\nexport function logMemberMuteRejoin(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberMuteRejoinData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_MUTE_REJOIN,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n    }),\n    {\n      userId: data.member.id,\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberNickChange.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberNickChangeData {\n  member: GuildMember;\n  oldNick: string;\n  newNick: string;\n}\n\nexport function logMemberNickChange(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberNickChangeData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_NICK_CHANGE,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n      oldNick: data.oldNick,\n      newNick: data.newNick,\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberNote.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberNoteData {\n  mod: User;\n  user: User | UnknownUser;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberNote(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberNoteData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_NOTE,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      user: userToTemplateSafeUser(data.user),\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user instanceof User ? data.user.bot : false,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberRestore.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberRestoreData {\n  member: GuildMember;\n  restoredData: string;\n}\n\nexport function logMemberRestore(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberRestoreData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_RESTORE,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n      restoredData: data.restoredData,\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberRoleAdd.ts",
    "content": "import { GuildMember, Role, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownRole } from \"../../../utils.js\";\nimport { memberToTemplateSafeMember, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberRoleAddData {\n  mod: User | null;\n  member: GuildMember;\n  roles: Array<Role | UnknownRole>;\n}\n\nexport function logMemberRoleAdd(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberRoleAddData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_ROLE_ADD,\n    createTypedTemplateSafeValueContainer({\n      mod: data.mod ? userToTemplateSafeUser(data.mod) : null,\n      member: memberToTemplateSafeMember(data.member),\n      roles: data.roles.map((r) => r.name).join(\", \"),\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberRoleChanges.ts",
    "content": "import { GuildMember, Role, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { memberToTemplateSafeMember, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberRoleChangesData {\n  mod: User | UnknownUser | null;\n  member: GuildMember;\n  addedRoles: Role[];\n  removedRoles: Role[];\n}\n\n/**\n * @deprecated Use logMemberRoleAdd() and logMemberRoleRemove() instead\n */\nexport function logMemberRoleChanges(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberRoleChangesData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_ROLE_CHANGES,\n    createTypedTemplateSafeValueContainer({\n      mod: data.mod ? userToTemplateSafeUser(data.mod) : null,\n      member: memberToTemplateSafeMember(data.member),\n      addedRoles: data.addedRoles.map((r) => r.name).join(\", \"),\n      removedRoles: data.removedRoles.map((r) => r.name).join(\", \"),\n    }),\n    {\n      userId: data.member.id,\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberRoleRemove.ts",
    "content": "import { GuildMember, Role, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownRole } from \"../../../utils.js\";\nimport { memberToTemplateSafeMember, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberRoleRemoveData {\n  mod: User | null;\n  member: GuildMember;\n  roles: Array<Role | UnknownRole>;\n}\n\nexport function logMemberRoleRemove(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberRoleRemoveData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_ROLE_REMOVE,\n    createTypedTemplateSafeValueContainer({\n      mod: data.mod ? userToTemplateSafeUser(data.mod) : null,\n      member: memberToTemplateSafeMember(data.member),\n      roles: data.roles.map((r) => r.name).join(\", \"),\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberTimedBan.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberTimedBanData {\n  mod: User | UnknownUser;\n  user: User | UnknownUser;\n  banTime: string;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberTimedBan(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberTimedBanData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_TIMED_BAN,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      user: userToTemplateSafeUser(data.user),\n      banTime: data.banTime,\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user instanceof User ? data.user.bot : false,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberTimedMute.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberTimedMuteData {\n  mod: User | UnknownUser;\n  user: User | UnknownUser;\n  time: string;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberTimedMute(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberTimedMuteData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_TIMED_MUTE,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      user: userToTemplateSafeUser(data.user),\n      time: data.time,\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberTimedUnban.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberTimedUnbanData {\n  mod: User | UnknownUser;\n  userId: string;\n  banTime: string;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberTimedUnban(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberTimedUnbanData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_TIMED_UNBAN,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      userId: data.userId,\n      banTime: data.banTime,\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.userId,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberTimedUnmute.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberTimedUnmuteData {\n  mod: User;\n  user: User | UnknownUser;\n  time: string;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberTimedUnmute(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberTimedUnmuteData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_TIMED_UNMUTE,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      user: userToTemplateSafeUser(data.user),\n      time: data.time,\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user instanceof User ? data.user.bot : false,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberUnban.ts",
    "content": "import { PartialUser, Snowflake, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberUnbanData {\n  mod: User | UnknownUser | PartialUser | null;\n  userId: Snowflake;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberUnban(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberUnbanData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_UNBAN,\n    createTypedTemplateSafeValueContainer({\n      mod: data.mod ? userToTemplateSafeUser(data.mod) : null,\n      userId: data.userId,\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.userId,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberUnmute.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberUnmuteData {\n  mod: User;\n  user: User | UnknownUser;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberUnmute(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberUnmuteData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_UNMUTE,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      user: userToTemplateSafeUser(data.user),\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user instanceof User ? data.user.bot : false,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMemberWarn.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMemberWarnData {\n  mod: GuildMember;\n  member: GuildMember;\n  caseNumber: number;\n  reason: string;\n}\n\nexport function logMemberWarn(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberWarnData) {\n  return log(\n    pluginData,\n    LogType.MEMBER_WARN,\n    createTypedTemplateSafeValueContainer({\n      mod: memberToTemplateSafeMember(data.mod),\n      member: memberToTemplateSafeMember(data.member),\n      caseNumber: data.caseNumber,\n      reason: data.reason,\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMessageDelete.ts",
    "content": "import { GuildTextBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { ISavedMessageAttachmentData, SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser, useMediaUrls } from \"../../../utils.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport {\n  channelToTemplateSafeChannel,\n  savedMessageToTemplateSafeSavedMessage,\n  userToTemplateSafeUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\nimport { getMessageReplyLogInfo } from \"../util/getMessageReplyLogInfo.js\";\n\nexport interface LogMessageDeleteData {\n  user: User | UnknownUser;\n  channel: GuildTextBasedChannel;\n  message: SavedMessage;\n}\n\nexport async function logMessageDelete(pluginData: GuildPluginData<LogsPluginType>, data: LogMessageDeleteData) {\n  // Replace attachment URLs with media URLs\n  if (data.message.data.attachments) {\n    for (const attachment of data.message.data.attachments as ISavedMessageAttachmentData[]) {\n      attachment.url = useMediaUrls(attachment.url);\n    }\n  }\n\n  // See comment on FORMAT_NO_TIMESTAMP in types.ts\n  const config = pluginData.config.get();\n  const timestampFormat = config.timestamp_format ?? undefined;\n\n  const { replyInfo, reply } = await getMessageReplyLogInfo(pluginData, data.message);\n\n  return log(\n    pluginData,\n    LogType.MESSAGE_DELETE,\n    createTypedTemplateSafeValueContainer({\n      user: userToTemplateSafeUser(data.user),\n      channel: channelToTemplateSafeChannel(data.channel),\n      message: savedMessageToTemplateSafeSavedMessage(data.message),\n      messageDate: pluginData\n        .getPlugin(TimeAndDatePlugin)\n        .inGuildTz(moment.utc(data.message.data.timestamp, \"x\"))\n        .format(timestampFormat),\n      replyInfo,\n      reply,\n    }),\n    {\n      userId: data.user.id,\n      messageTextContent: data.message.data.content,\n      bot: data.user instanceof User ? data.user.bot : false,\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMessageDeleteAuto.ts",
    "content": "import { GuildBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { ISavedMessageAttachmentData, SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser, useMediaUrls } from \"../../../utils.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport {\n  channelToTemplateSafeChannel,\n  savedMessageToTemplateSafeSavedMessage,\n  userToTemplateSafeUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\nimport { getMessageReplyLogInfo } from \"../util/getMessageReplyLogInfo.js\";\n\nexport interface LogMessageDeleteAutoData {\n  message: SavedMessage;\n  user: User | UnknownUser;\n  channel: GuildBasedChannel;\n  messageDate: string;\n}\n\nexport async function logMessageDeleteAuto(pluginData: GuildPluginData<LogsPluginType>, data: LogMessageDeleteAutoData) {\n  if (data.message.data.attachments) {\n    for (const attachment of data.message.data.attachments as ISavedMessageAttachmentData[]) {\n      attachment.url = useMediaUrls(attachment.url);\n    }\n  }\n\n  const { replyInfo, reply } = await getMessageReplyLogInfo(pluginData, data.message);\n\n  return log(\n    pluginData,\n    LogType.MESSAGE_DELETE_AUTO,\n    createTypedTemplateSafeValueContainer({\n      message: savedMessageToTemplateSafeSavedMessage(data.message),\n      user: userToTemplateSafeUser(data.user),\n      channel: channelToTemplateSafeChannel(data.channel),\n      messageDate: data.messageDate,\n      replyInfo,\n      reply,\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user instanceof User ? data.user.bot : false,\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMessageDeleteBare.ts",
    "content": "import { GuildTextBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMessageDeleteBareData {\n  messageId: string;\n  channel: GuildTextBasedChannel;\n}\n\nexport function logMessageDeleteBare(pluginData: GuildPluginData<LogsPluginType>, data: LogMessageDeleteBareData) {\n  return log(\n    pluginData,\n    LogType.MESSAGE_DELETE_BARE,\n    createTypedTemplateSafeValueContainer({\n      messageId: data.messageId,\n      channel: channelToTemplateSafeChannel(data.channel),\n    }),\n    {\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMessageDeleteBulk.ts",
    "content": "import { GuildTextBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMessageDeleteBulkData {\n  count: number;\n  authorIds: string[];\n  channel: GuildTextBasedChannel;\n  archiveUrl: string;\n}\n\nexport function logMessageDeleteBulk(pluginData: GuildPluginData<LogsPluginType>, data: LogMessageDeleteBulkData) {\n  return log(\n    pluginData,\n    LogType.MESSAGE_DELETE_BULK,\n    createTypedTemplateSafeValueContainer({\n      count: data.count,\n      authorIds: data.authorIds,\n      channel: channelToTemplateSafeChannel(data.channel),\n      archiveUrl: data.archiveUrl,\n    }),\n    {\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMessageEdit.ts",
    "content": "import { GuildTextBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { UnknownUser } from \"../../../utils.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport {\n  channelToTemplateSafeChannel,\n  savedMessageToTemplateSafeSavedMessage,\n  userToTemplateSafeUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMessageEditData {\n  user: User | UnknownUser;\n  channel: GuildTextBasedChannel;\n  before: SavedMessage;\n  after: SavedMessage;\n}\n\nexport function logMessageEdit(pluginData: GuildPluginData<LogsPluginType>, data: LogMessageEditData) {\n  return log(\n    pluginData,\n    LogType.MESSAGE_EDIT,\n    createTypedTemplateSafeValueContainer({\n      user: userToTemplateSafeUser(data.user),\n      channel: channelToTemplateSafeChannel(data.channel),\n      before: savedMessageToTemplateSafeSavedMessage(data.before),\n      after: savedMessageToTemplateSafeSavedMessage(data.after),\n    }),\n    {\n      userId: data.user.id,\n      messageTextContent: data.after.data.content,\n      bot: data.user instanceof User ? data.user.bot : false,\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logMessageSpamDetected.ts",
    "content": "import { GuildMember, GuildTextBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogMessageSpamDetectedData {\n  member: GuildMember;\n  channel: GuildTextBasedChannel;\n  description: string;\n  limit: number;\n  interval: number;\n  archiveUrl: string;\n}\n\nexport function logMessageSpamDetected(pluginData: GuildPluginData<LogsPluginType>, data: LogMessageSpamDetectedData) {\n  return log(\n    pluginData,\n    LogType.MESSAGE_SPAM_DETECTED,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n      channel: channelToTemplateSafeChannel(data.channel),\n      description: data.description,\n      limit: data.limit,\n      interval: data.interval,\n      archiveUrl: data.archiveUrl,\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      bot: data.member.user.bot,\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logOtherSpamDetected.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogOtherSpamDetectedData {\n  member: GuildMember;\n  description: string;\n  limit: number;\n  interval: number;\n}\n\nexport function logOtherSpamDetected(pluginData: GuildPluginData<LogsPluginType>, data: LogOtherSpamDetectedData) {\n  return log(\n    pluginData,\n    LogType.OTHER_SPAM_DETECTED,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n      description: data.description,\n      limit: data.limit,\n      interval: data.interval,\n    }),\n    {\n      userId: data.member.id,\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts",
    "content": "import { GuildTextBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogPostedScheduledMessageData {\n  author: User;\n  channel: GuildTextBasedChannel;\n  messageId: string;\n}\n\nexport function logPostedScheduledMessage(\n  pluginData: GuildPluginData<LogsPluginType>,\n  data: LogPostedScheduledMessageData,\n) {\n  return log(\n    pluginData,\n    LogType.POSTED_SCHEDULED_MESSAGE,\n    createTypedTemplateSafeValueContainer({\n      author: userToTemplateSafeUser(data.author),\n      channel: channelToTemplateSafeChannel(data.channel),\n      messageId: data.messageId,\n    }),\n    {\n      userId: data.author.id,\n      bot: data.author.bot,\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logRepeatedMessage.ts",
    "content": "import { GuildTextBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogRepeatedMessageData {\n  author: User;\n  channel: GuildTextBasedChannel;\n  datetime: string;\n  date: string;\n  time: string;\n  repeatInterval: string;\n  repeatDetails: string;\n}\n\nexport function logRepeatedMessage(pluginData: GuildPluginData<LogsPluginType>, data: LogRepeatedMessageData) {\n  return log(\n    pluginData,\n    LogType.REPEATED_MESSAGE,\n    createTypedTemplateSafeValueContainer({\n      author: userToTemplateSafeUser(data.author),\n      channel: channelToTemplateSafeChannel(data.channel),\n      datetime: data.datetime,\n      date: data.date,\n      time: data.time,\n      repeatInterval: data.repeatInterval,\n      repeatDetails: data.repeatDetails,\n    }),\n    {\n      userId: data.author.id,\n      bot: data.author.bot,\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logRoleCreate.ts",
    "content": "import { Role } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { roleToTemplateSafeRole } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogRoleCreateData {\n  role: Role;\n}\n\nexport function logRoleCreate(pluginData: GuildPluginData<LogsPluginType>, data: LogRoleCreateData) {\n  return log(\n    pluginData,\n    LogType.ROLE_CREATE,\n    createTypedTemplateSafeValueContainer({\n      role: roleToTemplateSafeRole(data.role),\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logRoleDelete.ts",
    "content": "import { Role } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { roleToTemplateSafeRole } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogRoleDeleteData {\n  role: Role;\n}\n\nexport function logRoleDelete(pluginData: GuildPluginData<LogsPluginType>, data: LogRoleDeleteData) {\n  return log(\n    pluginData,\n    LogType.ROLE_DELETE,\n    createTypedTemplateSafeValueContainer({\n      role: roleToTemplateSafeRole(data.role),\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logRoleUpdate.ts",
    "content": "import { Role } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { roleToTemplateSafeRole } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogRoleUpdateData {\n  oldRole: Role;\n  newRole: Role;\n  differenceString: string;\n}\n\nexport function logRoleUpdate(pluginData: GuildPluginData<LogsPluginType>, data: LogRoleUpdateData) {\n  return log(\n    pluginData,\n    LogType.ROLE_UPDATE,\n    createTypedTemplateSafeValueContainer({\n      oldRole: roleToTemplateSafeRole(data.oldRole),\n      newRole: roleToTemplateSafeRole(data.newRole),\n      differenceString: data.differenceString,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logScheduledMessage.ts",
    "content": "import { GuildTextBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogScheduledMessageData {\n  author: User;\n  channel: GuildTextBasedChannel;\n  datetime: string;\n  date: string;\n  time: string;\n}\n\nexport function logScheduledMessage(pluginData: GuildPluginData<LogsPluginType>, data: LogScheduledMessageData) {\n  return log(\n    pluginData,\n    LogType.SCHEDULED_MESSAGE,\n    createTypedTemplateSafeValueContainer({\n      author: userToTemplateSafeUser(data.author),\n      channel: channelToTemplateSafeChannel(data.channel),\n      datetime: data.datetime,\n      date: data.date,\n      time: data.time,\n    }),\n    {\n      userId: data.author.id,\n      bot: data.author.bot,\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logScheduledRepeatedMessage.ts",
    "content": "import { GuildTextBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogScheduledRepeatedMessageData {\n  author: User;\n  channel: GuildTextBasedChannel;\n  datetime: string;\n  date: string;\n  time: string;\n  repeatInterval: string;\n  repeatDetails: string;\n}\n\nexport function logScheduledRepeatedMessage(\n  pluginData: GuildPluginData<LogsPluginType>,\n  data: LogScheduledRepeatedMessageData,\n) {\n  return log(\n    pluginData,\n    LogType.SCHEDULED_REPEATED_MESSAGE,\n    createTypedTemplateSafeValueContainer({\n      author: userToTemplateSafeUser(data.author),\n      channel: channelToTemplateSafeChannel(data.channel),\n      datetime: data.datetime,\n      date: data.date,\n      time: data.time,\n      repeatInterval: data.repeatInterval,\n      repeatDetails: data.repeatDetails,\n    }),\n    {\n      userId: data.author.id,\n      bot: data.author.bot,\n      ...resolveChannelIds(data.channel),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logSetAntiraidAuto.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogSetAntiraidAutoData {\n  level: string;\n}\n\nexport function logSetAntiraidAuto(pluginData: GuildPluginData<LogsPluginType>, data: LogSetAntiraidAutoData) {\n  return log(\n    pluginData,\n    LogType.SET_ANTIRAID_AUTO,\n    createTypedTemplateSafeValueContainer({\n      level: data.level,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logSetAntiraidUser.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogSetAntiraidUserData {\n  level: string;\n  user: User;\n}\n\nexport function logSetAntiraidUser(pluginData: GuildPluginData<LogsPluginType>, data: LogSetAntiraidUserData) {\n  return log(\n    pluginData,\n    LogType.SET_ANTIRAID_USER,\n    createTypedTemplateSafeValueContainer({\n      level: data.level,\n      user: userToTemplateSafeUser(data.user),\n    }),\n    {\n      userId: data.user.id,\n      bot: data.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logStageInstanceCreate.ts",
    "content": "import { StageChannel, StageInstance } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, stageToTemplateSafeStage } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogStageInstanceCreateData {\n  stageInstance: StageInstance;\n  stageChannel: StageChannel;\n}\n\nexport function logStageInstanceCreate(pluginData: GuildPluginData<LogsPluginType>, data: LogStageInstanceCreateData) {\n  return log(\n    pluginData,\n    LogType.STAGE_INSTANCE_CREATE,\n    createTypedTemplateSafeValueContainer({\n      stageInstance: stageToTemplateSafeStage(data.stageInstance),\n      stageChannel: channelToTemplateSafeChannel(data.stageChannel),\n    }),\n    {\n      ...resolveChannelIds(data.stageInstance.channel!),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logStageInstanceDelete.ts",
    "content": "import { StageChannel, StageInstance } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, stageToTemplateSafeStage } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogStageInstanceDeleteData {\n  stageInstance: StageInstance;\n  stageChannel: StageChannel;\n}\n\nexport function logStageInstanceDelete(pluginData: GuildPluginData<LogsPluginType>, data: LogStageInstanceDeleteData) {\n  return log(\n    pluginData,\n    LogType.STAGE_INSTANCE_DELETE,\n    createTypedTemplateSafeValueContainer({\n      stageInstance: stageToTemplateSafeStage(data.stageInstance),\n      stageChannel: channelToTemplateSafeChannel(data.stageChannel),\n    }),\n    {\n      ...resolveChannelIds(data.stageInstance.channel!),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logStageInstanceUpdate.ts",
    "content": "import { StageChannel, StageInstance } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, stageToTemplateSafeStage } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogStageInstanceUpdateData {\n  oldStageInstance: StageInstance | null;\n  newStageInstance: StageInstance;\n  stageChannel: StageChannel;\n  differenceString: string;\n}\n\nexport function logStageInstanceUpdate(pluginData: GuildPluginData<LogsPluginType>, data: LogStageInstanceUpdateData) {\n  return log(\n    pluginData,\n    LogType.STAGE_INSTANCE_UPDATE,\n    createTypedTemplateSafeValueContainer({\n      oldStageInstance: data.oldStageInstance ? stageToTemplateSafeStage(data.oldStageInstance) : null,\n      newStageInstance: stageToTemplateSafeStage(data.newStageInstance),\n      stageChannel: channelToTemplateSafeChannel(data.stageChannel),\n      differenceString: data.differenceString,\n    }),\n    {\n      ...resolveChannelIds(data.newStageInstance.channel!),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logStickerCreate.ts",
    "content": "import { Sticker } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { stickerToTemplateSafeSticker } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogStickerCreateData {\n  sticker: Sticker;\n}\n\nexport function logStickerCreate(pluginData: GuildPluginData<LogsPluginType>, data: LogStickerCreateData) {\n  return log(\n    pluginData,\n    LogType.STICKER_CREATE,\n    createTypedTemplateSafeValueContainer({\n      sticker: stickerToTemplateSafeSticker(data.sticker),\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logStickerDelete.ts",
    "content": "import { Sticker } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { stickerToTemplateSafeSticker } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogStickerDeleteData {\n  sticker: Sticker;\n}\n\nexport function logStickerDelete(pluginData: GuildPluginData<LogsPluginType>, data: LogStickerDeleteData) {\n  return log(\n    pluginData,\n    LogType.STICKER_DELETE,\n    createTypedTemplateSafeValueContainer({\n      sticker: stickerToTemplateSafeSticker(data.sticker),\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logStickerUpdate.ts",
    "content": "import { Sticker } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { stickerToTemplateSafeSticker } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogStickerUpdateData {\n  oldSticker: Sticker;\n  newSticker: Sticker;\n  differenceString: string;\n}\n\nexport function logStickerUpdate(pluginData: GuildPluginData<LogsPluginType>, data: LogStickerUpdateData) {\n  return log(\n    pluginData,\n    LogType.STICKER_UPDATE,\n    createTypedTemplateSafeValueContainer({\n      oldSticker: stickerToTemplateSafeSticker(data.oldSticker),\n      newSticker: stickerToTemplateSafeSticker(data.newSticker),\n      differenceString: data.differenceString,\n    }),\n    {},\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logThreadCreate.ts",
    "content": "import { AnyThreadChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogThreadCreateData {\n  thread: AnyThreadChannel;\n}\n\nexport function logThreadCreate(pluginData: GuildPluginData<LogsPluginType>, data: LogThreadCreateData) {\n  return log(\n    pluginData,\n    LogType.THREAD_CREATE,\n    createTypedTemplateSafeValueContainer({\n      thread: channelToTemplateSafeChannel(data.thread),\n    }),\n    {\n      ...resolveChannelIds(data.thread),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logThreadDelete.ts",
    "content": "import { AnyThreadChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogThreadDeleteData {\n  thread: AnyThreadChannel;\n}\n\nexport function logThreadDelete(pluginData: GuildPluginData<LogsPluginType>, data: LogThreadDeleteData) {\n  return log(\n    pluginData,\n    LogType.THREAD_DELETE,\n    createTypedTemplateSafeValueContainer({\n      thread: channelToTemplateSafeChannel(data.thread),\n    }),\n    {\n      ...resolveChannelIds(data.thread),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logThreadUpdate.ts",
    "content": "import { AnyThreadChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogThreadUpdateData {\n  oldThread: AnyThreadChannel;\n  newThread: AnyThreadChannel;\n  differenceString: string;\n}\n\nexport function logThreadUpdate(pluginData: GuildPluginData<LogsPluginType>, data: LogThreadUpdateData) {\n  return log(\n    pluginData,\n    LogType.THREAD_UPDATE,\n    createTypedTemplateSafeValueContainer({\n      oldThread: channelToTemplateSafeChannel(data.oldThread),\n      newThread: channelToTemplateSafeChannel(data.newThread),\n      differenceString: data.differenceString,\n    }),\n    {\n      ...resolveChannelIds(data.newThread),\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logVoiceChannelForceDisconnect.ts",
    "content": "import { GuildMember, User, VoiceBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport {\n  channelToTemplateSafeChannel,\n  memberToTemplateSafeMember,\n  userToTemplateSafeUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogVoiceChannelForceDisconnectData {\n  mod: User;\n  member: GuildMember;\n  oldChannel: VoiceBasedChannel;\n}\n\nexport function logVoiceChannelForceDisconnect(\n  pluginData: GuildPluginData<LogsPluginType>,\n  data: LogVoiceChannelForceDisconnectData,\n) {\n  return log(\n    pluginData,\n    LogType.VOICE_CHANNEL_FORCE_DISCONNECT,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      member: memberToTemplateSafeMember(data.member),\n      oldChannel: channelToTemplateSafeChannel(data.oldChannel),\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      ...resolveChannelIds(data.oldChannel),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logVoiceChannelForceMove.ts",
    "content": "import { GuildMember, User, VoiceBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport {\n  channelToTemplateSafeChannel,\n  memberToTemplateSafeMember,\n  userToTemplateSafeUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogVoiceChannelForceMoveData {\n  mod: User;\n  member: GuildMember;\n  oldChannel: VoiceBasedChannel;\n  newChannel: VoiceBasedChannel;\n}\n\nexport function logVoiceChannelForceMove(\n  pluginData: GuildPluginData<LogsPluginType>,\n  data: LogVoiceChannelForceMoveData,\n) {\n  return log(\n    pluginData,\n    LogType.VOICE_CHANNEL_FORCE_MOVE,\n    createTypedTemplateSafeValueContainer({\n      mod: userToTemplateSafeUser(data.mod),\n      member: memberToTemplateSafeMember(data.member),\n      oldChannel: channelToTemplateSafeChannel(data.oldChannel),\n      newChannel: channelToTemplateSafeChannel(data.newChannel),\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      ...resolveChannelIds(data.newChannel),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logVoiceChannelJoin.ts",
    "content": "import { GuildMember, VoiceBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogVoiceChannelJoinData {\n  member: GuildMember;\n  channel: VoiceBasedChannel;\n}\n\nexport function logVoiceChannelJoin(pluginData: GuildPluginData<LogsPluginType>, data: LogVoiceChannelJoinData) {\n  return log(\n    pluginData,\n    LogType.VOICE_CHANNEL_JOIN,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n      channel: channelToTemplateSafeChannel(data.channel),\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      ...resolveChannelIds(data.channel),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logVoiceChannelLeave.ts",
    "content": "import { GuildMember, VoiceBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogVoiceChannelLeaveData {\n  member: GuildMember;\n  channel: VoiceBasedChannel;\n}\n\nexport function logVoiceChannelLeave(pluginData: GuildPluginData<LogsPluginType>, data: LogVoiceChannelLeaveData) {\n  return log(\n    pluginData,\n    LogType.VOICE_CHANNEL_LEAVE,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n      channel: channelToTemplateSafeChannel(data.channel),\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      ...resolveChannelIds(data.channel),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/logFunctions/logVoiceChannelMove.ts",
    "content": "import { GuildMember, VoiceBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { createTypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { resolveChannelIds } from \"../../../utils/resolveChannelIds.js\";\nimport { channelToTemplateSafeChannel, memberToTemplateSafeMember } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { log } from \"../util/log.js\";\n\nexport interface LogVoiceChannelMoveData {\n  member: GuildMember;\n  oldChannel: VoiceBasedChannel;\n  newChannel: VoiceBasedChannel;\n}\n\nexport function logVoiceChannelMove(pluginData: GuildPluginData<LogsPluginType>, data: LogVoiceChannelMoveData) {\n  return log(\n    pluginData,\n    LogType.VOICE_CHANNEL_MOVE,\n    createTypedTemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(data.member),\n      oldChannel: channelToTemplateSafeChannel(data.oldChannel),\n      newChannel: channelToTemplateSafeChannel(data.newChannel),\n    }),\n    {\n      userId: data.member.id,\n      roles: Array.from(data.member.roles.cache.keys()),\n      ...resolveChannelIds(data.newChannel),\n      bot: data.member.user.bot,\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/types.ts",
    "content": "import { BasePluginType, CooldownManager, guildPluginEventListener } from \"vety\";\nimport { z } from \"zod\";\nimport { RegExpRunner } from \"../../RegExpRunner.js\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { LogType } from \"../../data/LogType.js\";\nimport { keys, zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from \"../../utils.js\";\nimport { MessageBuffer } from \"../../utils/MessageBuffer.js\";\nimport {\n  TemplateSafeCase,\n  TemplateSafeChannel,\n  TemplateSafeEmoji,\n  TemplateSafeMember,\n  TemplateSafeRole,\n  TemplateSafeSavedMessage,\n  TemplateSafeStage,\n  TemplateSafeSticker,\n  TemplateSafeUnknownMember,\n  TemplateSafeUnknownUser,\n  TemplateSafeUser,\n} from \"../../utils/templateSafeObjects.js\";\nimport { TemplateSafeValueContainer } from \"../../templateFormatter.js\";\nimport DefaultLogMessages from \"../../data/DefaultLogMessages.json\" with { type: \"json\" };\nimport { TemplateSafeValueContainer } from \"templateFormatter.js\";\n\nconst DEFAULT_BATCH_TIME = 1000;\nconst MIN_BATCH_TIME = 250;\nconst MAX_BATCH_TIME = 5000;\n\n// A bit of a workaround so we can pass LogType keys to z.enum()\nconst zMessageContentWithDefault = zMessageContent.default(\"\");\nconst logTypes = keys(LogType);\nconst logTypeProps = logTypes.reduce(\n  (map, type) => {\n    map[type] = zMessageContent.default(DefaultLogMessages[type] || \"\");\n    return map;\n  },\n  {} as Record<keyof typeof LogType, typeof zMessageContentWithDefault>,\n);\nconst zLogFormats = z.strictObject(logTypeProps);\n\nconst zLogChannel = z.strictObject({\n  include: z.array(zBoundedCharacters(1, 255)).default([]),\n  exclude: z.array(zBoundedCharacters(1, 255)).default([]),\n  batched: z.boolean().default(true),\n  batch_time: z.number().min(MIN_BATCH_TIME).max(MAX_BATCH_TIME).default(DEFAULT_BATCH_TIME),\n  excluded_users: z.array(zSnowflake).nullable().default(null),\n  excluded_message_regexes: z.array(zRegex(z.string())).nullable().default(null),\n  excluded_channels: z.array(zSnowflake).nullable().default(null),\n  excluded_categories: z.array(zSnowflake).nullable().default(null),\n  excluded_threads: z.array(zSnowflake).nullable().default(null),\n  exclude_bots: z.boolean().default(false),\n  excluded_roles: z.array(zSnowflake).nullable().default(null),\n  format: zLogFormats.partial().default({}),\n  timestamp_format: z.string().nullable().default(null),\n  include_embed_timestamp: z.boolean().nullable().default(null),\n});\nexport type TLogChannel = z.infer<typeof zLogChannel>;\n\nconst zLogChannelMap = z.record(zSnowflake, zLogChannel);\nexport type TLogChannelMap = z.infer<typeof zLogChannelMap>;\n\nexport const zLogsConfig = z.strictObject({\n  channels: zLogChannelMap.default({}),\n  format: zLogFormats.prefault({}),\n  // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true\n  ping_user: z.boolean().default(true),\n  allow_user_mentions: z.boolean().default(false),\n  timestamp_format: z.string().nullable().default(\"[<t:]X[>]\"),\n  include_embed_timestamp: z.boolean().default(true),\n});\n\n// Hacky way of allowing a \"\"\"null\"\"\" default value for config.format.timestamp due to legacy io-ts reasons\nexport const FORMAT_NO_TIMESTAMP = \"__NO_TIMESTAMP__\";\n\nexport interface LogsPluginType extends BasePluginType {\n  configSchema: typeof zLogsConfig;\n  state: {\n    guildLogs: GuildLogs;\n    savedMessages: GuildSavedMessages;\n    archives: GuildArchives;\n    cases: GuildCases;\n\n    regexRunner: RegExpRunner;\n    regexRunnerRepeatedTimeoutListener;\n\n    logListener;\n\n    buffers: Map<string, MessageBuffer>;\n    channelCooldowns: CooldownManager;\n\n    onMessageDeleteFn;\n    onMessageDeleteBulkFn;\n    onMessageUpdateFn;\n  };\n}\n\nexport const logsEvt = guildPluginEventListener<LogsPluginType>();\n\nexport const LogTypeData = z.object({\n  [LogType.MEMBER_WARN]: z.object({\n    mod: z.instanceof(TemplateSafeMember),\n    member: z.instanceof(TemplateSafeMember),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_MUTE]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    user: z.instanceof(TemplateSafeUser),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_UNMUTE]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    user: z.instanceof(TemplateSafeUser),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_MUTE_EXPIRED]: z.object({\n    member: z.instanceof(TemplateSafeMember).or(z.instanceof(TemplateSafeUnknownMember)),\n  }),\n\n  [LogType.MEMBER_KICK]: z.object({\n    mod: z.instanceof(TemplateSafeUser).or(z.null()),\n    user: z.instanceof(TemplateSafeUser),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_BAN]: z.object({\n    mod: z.instanceof(TemplateSafeUser).or(z.null()),\n    user: z.instanceof(TemplateSafeUser),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_UNBAN]: z.object({\n    mod: z.instanceof(TemplateSafeUser).or(z.null()),\n    userId: z.string(),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_FORCEBAN]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    userId: z.string(),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_JOIN]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n    new: z.string(),\n    account_age: z.string(),\n    account_age_ts: z.string(),\n  }),\n\n  [LogType.MEMBER_LEAVE]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n  }),\n\n  [LogType.MEMBER_ROLE_ADD]: z.object({\n    mod: z.instanceof(TemplateSafeUser).or(z.null()),\n    member: z.instanceof(TemplateSafeMember),\n    roles: z.string(),\n  }),\n\n  [LogType.MEMBER_ROLE_REMOVE]: z.object({\n    mod: z.instanceof(TemplateSafeUser).or(z.null()),\n    member: z.instanceof(TemplateSafeMember),\n    roles: z.string(),\n  }),\n\n  [LogType.MEMBER_NICK_CHANGE]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n    oldNick: z.string(),\n    newNick: z.string(),\n  }),\n\n  [LogType.MEMBER_RESTORE]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n    restoredData: z.string(),\n  }),\n\n  [LogType.CHANNEL_CREATE]: z.object({\n    channel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.CHANNEL_DELETE]: z.object({\n    channel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.CHANNEL_UPDATE]: z.object({\n    oldChannel: z.instanceof(TemplateSafeChannel),\n    newChannel: z.instanceof(TemplateSafeChannel),\n    differenceString: z.string(),\n  }),\n\n  [LogType.THREAD_CREATE]: z.object({\n    thread: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.THREAD_DELETE]: z.object({\n    thread: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.THREAD_UPDATE]: z.object({\n    oldThread: z.instanceof(TemplateSafeChannel),\n    newThread: z.instanceof(TemplateSafeChannel),\n    differenceString: z.string(),\n  }),\n\n  [LogType.ROLE_CREATE]: z.object({\n    role: z.instanceof(TemplateSafeRole),\n  }),\n\n  [LogType.ROLE_DELETE]: z.object({\n    role: z.instanceof(TemplateSafeRole),\n  }),\n\n  [LogType.ROLE_UPDATE]: z.object({\n    oldRole: z.instanceof(TemplateSafeRole),\n    newRole: z.instanceof(TemplateSafeRole),\n    differenceString: z.string(),\n  }),\n\n  [LogType.MESSAGE_EDIT]: z.object({\n    user: z.instanceof(TemplateSafeUser),\n    channel: z.instanceof(TemplateSafeChannel),\n    before: z.instanceof(TemplateSafeSavedMessage),\n    after: z.instanceof(TemplateSafeSavedMessage),\n  }),\n\n  [LogType.MESSAGE_DELETE]: z.object({\n    user: z.instanceof(TemplateSafeUser),\n    channel: z.instanceof(TemplateSafeChannel),\n    messageDate: z.string(),\n    message: z.instanceof(TemplateSafeSavedMessage),\n    replyInfo: z.string(),\n    reply: z.instanceof(TemplateSafeValueContainer).nullable(),\n  }),\n\n  [LogType.MESSAGE_DELETE_BULK]: z.object({\n    count: z.number(),\n    authorIds: z.array(z.string()),\n    channel: z.instanceof(TemplateSafeChannel),\n    archiveUrl: z.string(),\n  }),\n\n  [LogType.MESSAGE_DELETE_BARE]: z.object({\n    messageId: z.string(),\n    channel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.VOICE_CHANNEL_JOIN]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n    channel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.VOICE_CHANNEL_LEAVE]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n    channel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.VOICE_CHANNEL_MOVE]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n    oldChannel: z.instanceof(TemplateSafeChannel),\n    newChannel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.STAGE_INSTANCE_CREATE]: z.object({\n    stageInstance: z.instanceof(TemplateSafeStage),\n    stageChannel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.STAGE_INSTANCE_DELETE]: z.object({\n    stageInstance: z.instanceof(TemplateSafeStage),\n    stageChannel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.STAGE_INSTANCE_UPDATE]: z.object({\n    oldStageInstance: z.instanceof(TemplateSafeStage).or(z.null()),\n    newStageInstance: z.instanceof(TemplateSafeStage),\n    stageChannel: z.instanceof(TemplateSafeChannel),\n    differenceString: z.string(),\n  }),\n\n  [LogType.EMOJI_CREATE]: z.object({\n    emoji: z.instanceof(TemplateSafeEmoji),\n  }),\n\n  [LogType.EMOJI_DELETE]: z.object({\n    emoji: z.instanceof(TemplateSafeEmoji),\n  }),\n\n  [LogType.EMOJI_UPDATE]: z.object({\n    oldEmoji: z.instanceof(TemplateSafeEmoji),\n    newEmoji: z.instanceof(TemplateSafeEmoji),\n    differenceString: z.string(),\n  }),\n\n  [LogType.STICKER_CREATE]: z.object({\n    sticker: z.instanceof(TemplateSafeSticker),\n  }),\n\n  [LogType.STICKER_DELETE]: z.object({\n    sticker: z.instanceof(TemplateSafeSticker),\n  }),\n\n  [LogType.STICKER_UPDATE]: z.object({\n    oldSticker: z.instanceof(TemplateSafeSticker),\n    newSticker: z.instanceof(TemplateSafeSticker),\n    differenceString: z.string(),\n  }),\n\n  [LogType.MESSAGE_SPAM_DETECTED]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n    channel: z.instanceof(TemplateSafeChannel),\n    description: z.string(),\n    limit: z.number(),\n    interval: z.number(),\n    archiveUrl: z.string(),\n  }),\n\n  [LogType.CENSOR]: z.object({\n    user: z.instanceof(TemplateSafeUser),\n    channel: z.instanceof(TemplateSafeChannel),\n    reason: z.string(),\n    message: z.instanceof(TemplateSafeSavedMessage),\n    messageText: z.string(),\n  }),\n\n  [LogType.CLEAN]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    channel: z.instanceof(TemplateSafeChannel),\n    count: z.number(),\n    archiveUrl: z.string(),\n  }),\n\n  [LogType.CASE_CREATE]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    userId: z.string(),\n    caseNum: z.number(),\n    caseType: z.string(),\n    reason: z.string(),\n  }),\n\n  [LogType.MASSUNBAN]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    count: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MASSBAN]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    count: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MASSMUTE]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    count: z.number(),\n  }),\n\n  [LogType.MEMBER_TIMED_MUTE]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    user: z.instanceof(TemplateSafeUser),\n    time: z.string(),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_TIMED_UNMUTE]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    user: z.instanceof(TemplateSafeUser),\n    time: z.string(),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_TIMED_BAN]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    user: z.instanceof(TemplateSafeUser),\n    banTime: z.string(),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_TIMED_UNBAN]: z.object({\n    mod: z.instanceof(TemplateSafeUser).or(z.instanceof(TemplateSafeUnknownUser)),\n    userId: z.string(),\n    banTime: z.string(),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n    recentCaseSummary: z.string(),\n  }),\n\n  [LogType.OTHER_SPAM_DETECTED]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n    description: z.string(),\n    limit: z.number(),\n    interval: z.number(),\n  }),\n\n  [LogType.MEMBER_ROLE_CHANGES]: z.object({\n    mod: z.instanceof(TemplateSafeUser).or(z.instanceof(TemplateSafeUnknownUser)).or(z.null()),\n    member: z.instanceof(TemplateSafeMember),\n    addedRoles: z.string(),\n    removedRoles: z.string(),\n  }),\n\n  [LogType.VOICE_CHANNEL_FORCE_MOVE]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    member: z.instanceof(TemplateSafeMember),\n    oldChannel: z.instanceof(TemplateSafeChannel),\n    newChannel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.VOICE_CHANNEL_FORCE_DISCONNECT]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    member: z.instanceof(TemplateSafeMember),\n    oldChannel: z.instanceof(TemplateSafeChannel),\n  }),\n\n  [LogType.CASE_UPDATE]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    caseNumber: z.number(),\n    caseType: z.string(),\n    note: z.string(),\n  }),\n\n  [LogType.MEMBER_MUTE_REJOIN]: z.object({\n    member: z.instanceof(TemplateSafeMember),\n  }),\n\n  [LogType.SCHEDULED_MESSAGE]: z.object({\n    author: z.instanceof(TemplateSafeUser),\n    channel: z.instanceof(TemplateSafeChannel),\n    datetime: z.string(),\n    date: z.string(),\n    time: z.string(),\n  }),\n\n  [LogType.POSTED_SCHEDULED_MESSAGE]: z.object({\n    author: z.instanceof(TemplateSafeUser),\n    channel: z.instanceof(TemplateSafeChannel),\n    messageId: z.string(),\n  }),\n\n  [LogType.BOT_ALERT]: z.object({\n    body: z.string(),\n  }),\n\n  [LogType.AUTOMOD_ACTION]: z.object({\n    rule: z.string(),\n    user: z.instanceof(TemplateSafeUser).nullable(),\n    users: z.array(z.instanceof(TemplateSafeUser)),\n    actionsTaken: z.string(),\n    matchSummary: z.string(),\n  }),\n\n  [LogType.SCHEDULED_REPEATED_MESSAGE]: z.object({\n    author: z.instanceof(TemplateSafeUser),\n    channel: z.instanceof(TemplateSafeChannel),\n    datetime: z.string(),\n    date: z.string(),\n    time: z.string(),\n    repeatInterval: z.string(),\n    repeatDetails: z.string(),\n  }),\n\n  [LogType.REPEATED_MESSAGE]: z.object({\n    author: z.instanceof(TemplateSafeUser),\n    channel: z.instanceof(TemplateSafeChannel),\n    datetime: z.string(),\n    date: z.string(),\n    time: z.string(),\n    repeatInterval: z.string(),\n    repeatDetails: z.string(),\n  }),\n\n  [LogType.MESSAGE_DELETE_AUTO]: z.object({\n    message: z.instanceof(TemplateSafeSavedMessage),\n    user: z.instanceof(TemplateSafeUser),\n    channel: z.instanceof(TemplateSafeChannel),\n    messageDate: z.string(),\n    replyInfo: z.string(),\n    reply: z.instanceof(TemplateSafeValueContainer).nullable(),\n  }),\n\n  [LogType.SET_ANTIRAID_USER]: z.object({\n    level: z.string(),\n    user: z.instanceof(TemplateSafeUser),\n  }),\n\n  [LogType.SET_ANTIRAID_AUTO]: z.object({\n    level: z.string(),\n  }),\n\n  [LogType.MEMBER_NOTE]: z.object({\n    mod: z.instanceof(TemplateSafeUser),\n    user: z.instanceof(TemplateSafeUser),\n    caseNumber: z.number(),\n    reason: z.string(),\n  }),\n\n  [LogType.CASE_DELETE]: z.object({\n    mod: z.instanceof(TemplateSafeMember),\n    case: z.instanceof(TemplateSafeCase),\n  }),\n\n  [LogType.DM_FAILED]: z.object({\n    source: z.string(),\n    user: z.instanceof(TemplateSafeUser).or(z.instanceof(TemplateSafeUnknownUser)),\n  }),\n});\n\nexport type ILogTypeData = z.infer<typeof LogTypeData>;\n"
  },
  {
    "path": "backend/src/plugins/Logs/util/getLogMessage.ts",
    "content": "import { MessageCreateOptions } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { logger } from \"../../../logger.js\";\nimport {\n  renderTemplate,\n  TemplateParseError,\n  TemplateSafeValueContainer,\n  TypedTemplateSafeValueContainer,\n} from \"../../../templateFormatter.js\";\nimport {\n  messageSummary,\n  renderRecursively,\n  resolveMember,\n  validateAndParseMessageContent,\n  verboseChannelMention,\n  verboseUserMention,\n  verboseUserName,\n} from \"../../../utils.js\";\nimport {\n  getTemplateSafeMemberLevel,\n  memberToTemplateSafeMember,\n  TemplateSafeMember,\n  TemplateSafeUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { ILogTypeData, LogsPluginType, TLogChannel } from \"../types.js\";\n\nexport async function getLogMessage<TLogType extends keyof ILogTypeData>(\n  pluginData: GuildPluginData<LogsPluginType>,\n  type: TLogType,\n  data: TypedTemplateSafeValueContainer<ILogTypeData[TLogType]>,\n  opts?: Pick<TLogChannel, \"format\" | \"timestamp_format\" | \"include_embed_timestamp\">,\n): Promise<MessageCreateOptions | null> {\n  const config = pluginData.config.get();\n  const format = opts?.format?.[LogType[type]] || config.format[LogType[type]] || \"\";\n  if (format === \"\" || format == null) return null;\n\n  // See comment on FORMAT_NO_TIMESTAMP in types.ts\n  const timestampFormat = opts?.timestamp_format ?? config.timestamp_format;\n\n  const includeEmbedTimestamp = opts?.include_embed_timestamp ?? config.include_embed_timestamp;\n\n  const time = pluginData.getPlugin(TimeAndDatePlugin).inGuildTz();\n  const isoTimestamp = time.toISOString();\n  const timestamp = timestampFormat ? time.format(timestampFormat) : \"\";\n\n  const values = new TemplateSafeValueContainer({\n    ...data,\n    timestamp,\n    userMention: async (inputUserOrMember: unknown) => {\n      if (!inputUserOrMember) {\n        return \"\";\n      }\n\n      const inputArray = Array.isArray(inputUserOrMember) ? inputUserOrMember : [inputUserOrMember];\n      // TODO: Resolve IDs to users/members\n      const usersOrMembers = inputArray.filter(\n        (v) => v instanceof TemplateSafeUser || v instanceof TemplateSafeMember,\n      ) as Array<TemplateSafeUser | TemplateSafeMember>;\n\n      const mentions: string[] = [];\n      for (const userOrMember of usersOrMembers) {\n        let user;\n        let member: TemplateSafeMember | null = null;\n\n        if (userOrMember.user) {\n          member = userOrMember as TemplateSafeMember;\n          user = member.user;\n        } else {\n          user = userOrMember;\n          const apiMember = await resolveMember(pluginData.client, pluginData.guild, user.id);\n          if (apiMember) {\n            member = memberToTemplateSafeMember(apiMember);\n          }\n        }\n\n        const level = member ? getTemplateSafeMemberLevel(pluginData, member) : 0;\n        const memberConfig =\n          (await pluginData.config.getMatchingConfig({\n            level,\n            memberRoles: member ? member.roles.map((r) => r.id) : [],\n            userId: user.id,\n          })) || ({} as any);\n\n        // Revert to old behavior (verbose name w/o ping if allow_user_mentions is enabled (for whatever reason))\n        if (config.allow_user_mentions) {\n          mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user));\n        } else {\n          mentions.push(verboseUserMention(user));\n        }\n      }\n\n      return mentions.join(\", \");\n    },\n    channelMention: (channel) => {\n      if (!channel) return \"\";\n      return verboseChannelMention(channel);\n    },\n    messageSummary: (msg: SavedMessage) => {\n      if (!msg) return \"\";\n      return messageSummary(msg);\n    },\n  });\n\n  if (type === LogType.BOT_ALERT) {\n    const valuesWithoutTmplEval = { ...values };\n    values.tmplEval = (str) => {\n      return renderTemplate(str, valuesWithoutTmplEval);\n    };\n  }\n\n  const renderLogString = (str) => renderTemplate(str, values);\n\n  let formatted;\n  try {\n    formatted =\n      typeof format === \"string\" ? await renderLogString(format) : await renderRecursively(format, renderLogString);\n  } catch (e) {\n    if (e instanceof TemplateParseError) {\n      logger.error(`Error when parsing template:\\nError: ${e.message}\\nTemplate: ${format}`);\n      return null;\n    } else {\n      throw e;\n    }\n  }\n\n  if (typeof formatted === \"string\") {\n    formatted = formatted.trim();\n  } else if (formatted != null) {\n    formatted = validateAndParseMessageContent(formatted);\n\n    if (formatted.embeds && Array.isArray(formatted.embeds) && includeEmbedTimestamp) {\n      for (const embed of formatted.embeds) {\n        embed.timestamp = isoTimestamp;\n      }\n    }\n  }\n\n  return formatted;\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/util/getMessageReplyLogInfo.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { ISavedMessageAttachmentData, SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { messageLink, messageSummary, useMediaUrls } from \"../../../utils.js\";\nimport { TemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { savedMessageToTemplateSafeSavedMessage, TemplateSafeSavedMessage } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPluginType } from \"../types.js\";\n\nexport interface MessageReplyLogInfo {\n  replyInfo: string;\n  reply: TemplateSafeValueContainer | null;\n}\n\nexport async function getMessageReplyLogInfo(\n  pluginData: GuildPluginData<LogsPluginType>,\n  message: SavedMessage,\n): Promise<MessageReplyLogInfo> {\n  const reference = message.data.reference;\n  if (!reference?.messageId || !reference.channelId) {\n    return { replyInfo: \"\", reply: null };\n  }\n\n  const link = messageLink(reference.guildId ?? message.guild_id, reference.channelId, reference.messageId);\n  let replyInfo = `\\n**Replied To:** [Jump to message](${link})`;\n\n  const referencedMessage = await pluginData.state.savedMessages.find(reference.messageId, true);\n\n  let timestamp: string | null = null;\n  let summary: string | null = null;\n  let timestampMs: number | null = null;\n  let templateSafeMessage: TemplateSafeSavedMessage | null = null;\n\n  if (referencedMessage) {\n    if (referencedMessage.data.attachments) {\n      for (const attachment of referencedMessage.data.attachments as ISavedMessageAttachmentData[]) {\n        attachment.url = useMediaUrls(attachment.url);\n      }\n    }\n\n    timestampMs = referencedMessage.data.timestamp;\n    timestamp = `<t:${Math.floor(timestampMs / 1000)}>`;\n    replyInfo += ` (posted at ${timestamp})`;\n\n    summary = messageSummary(referencedMessage);\n    if (summary) {\n      replyInfo += `\\n${summary}`;\n    }\n\n    templateSafeMessage = savedMessageToTemplateSafeSavedMessage(referencedMessage);\n  }\n\n  const reply = new TemplateSafeValueContainer({\n    link,\n    timestamp,\n    timestampMs,\n    summary,\n    message: templateSafeMessage,\n  });\n\n  return { replyInfo, reply };\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/util/isLogIgnored.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { LogsPluginType } from \"../types.js\";\n\nexport function isLogIgnored(\n  pluginData: GuildPluginData<LogsPluginType>,\n  type: keyof typeof LogType,\n  ignoreId: string,\n) {\n  return pluginData.state.guildLogs.isLogIgnored(type, ignoreId);\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/util/log.ts",
    "content": "import { APIEmbed, MessageMentionTypes, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { allowTimeout } from \"../../../RegExpRunner.js\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { TypedTemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { MINUTES, inputPatternToRegExp, isDiscordAPIError } from \"../../../utils.js\";\nimport { MessageBuffer } from \"../../../utils/MessageBuffer.js\";\nimport { InternalPosterPlugin } from \"../../InternalPoster/InternalPosterPlugin.js\";\nimport { ILogTypeData, LogsPluginType, TLogChannel, TLogChannelMap } from \"../types.js\";\nimport { getLogMessage } from \"./getLogMessage.js\";\n\ninterface ExclusionData {\n  userId?: Snowflake | null;\n  bot?: boolean | null;\n  roles?: Snowflake[] | null;\n  channel?: Snowflake | null;\n  category?: Snowflake | null;\n  thread?: Snowflake | null;\n  messageTextContent?: string | null;\n}\n\nconst DEFAULT_BATCH_TIME = 1000;\nconst MIN_BATCH_TIME = 250;\nconst MAX_BATCH_TIME = 5000;\n\nasync function shouldExclude(\n  pluginData: GuildPluginData<LogsPluginType>,\n  opts: TLogChannel,\n  exclusionData: ExclusionData,\n): Promise<boolean> {\n  if (opts.excluded_users && exclusionData.userId && opts.excluded_users.includes(exclusionData.userId)) {\n    return true;\n  }\n\n  if (opts.exclude_bots && exclusionData.bot) {\n    return true;\n  }\n\n  if (opts.excluded_roles && exclusionData.roles) {\n    for (const role of exclusionData.roles) {\n      if (opts.excluded_roles.includes(role)) {\n        return true;\n      }\n    }\n  }\n\n  if (opts.excluded_channels && exclusionData.channel && opts.excluded_channels.includes(exclusionData.channel)) {\n    return true;\n  }\n\n  if (opts.excluded_categories && exclusionData.category && opts.excluded_categories.includes(exclusionData.category)) {\n    return true;\n  }\n\n  if (opts.excluded_threads && exclusionData.thread && opts.excluded_threads.includes(exclusionData.thread)) {\n    return true;\n  }\n\n  if (opts.excluded_message_regexes && exclusionData.messageTextContent) {\n    for (const pattern of opts.excluded_message_regexes) {\n      const regex = inputPatternToRegExp(pattern);\n      const matches = await pluginData.state.regexRunner\n        .exec(regex, exclusionData.messageTextContent)\n        .catch(allowTimeout);\n      if (matches) {\n        return true;\n      }\n    }\n  }\n\n  return false;\n}\n\nexport async function log<TLogType extends keyof ILogTypeData>(\n  pluginData: GuildPluginData<LogsPluginType>,\n  type: TLogType,\n  data: TypedTemplateSafeValueContainer<ILogTypeData[TLogType]>,\n  exclusionData: ExclusionData = {},\n) {\n  const logChannels: TLogChannelMap = pluginData.config.get().channels;\n  const typeStr = LogType[type];\n\n  for (const [channelId, opts] of Object.entries(logChannels)) {\n    const channel = pluginData.guild.channels.cache.get(channelId as Snowflake);\n    if (!channel?.isTextBased()) continue;\n    if (pluginData.state.channelCooldowns.isOnCooldown(channelId)) continue;\n    if (opts.include?.length && !opts.include.includes(typeStr)) continue;\n    if (opts.exclude && opts.exclude.includes(typeStr)) continue;\n    if (await shouldExclude(pluginData, opts, exclusionData)) continue;\n\n    const message = await getLogMessage(pluginData, type, data, {\n      format: opts.format,\n      include_embed_timestamp: opts.include_embed_timestamp,\n      timestamp_format: opts.timestamp_format,\n    });\n    if (!message) return;\n\n    // Initialize message buffer for this channel\n    if (!pluginData.state.buffers.has(channelId)) {\n      const batchTime = Math.min(Math.max(opts.batch_time ?? DEFAULT_BATCH_TIME, MIN_BATCH_TIME), MAX_BATCH_TIME);\n      const internalPosterPlugin = pluginData.getPlugin(InternalPosterPlugin);\n      pluginData.state.buffers.set(\n        channelId,\n        new MessageBuffer({\n          timeout: batchTime,\n          textSeparator: \"\\n\",\n          consume: (part) => {\n            const parse: MessageMentionTypes[] = pluginData.config.get().allow_user_mentions ? [\"users\"] : [];\n            internalPosterPlugin\n              .sendMessage(channel, {\n                ...part,\n                allowedMentions: { parse },\n              })\n              .catch((err) => {\n                if (isDiscordAPIError(err)) {\n                  // Missing Access / Missing Permissions\n                  // TODO: Show/log this somewhere\n                  if (err.code === 50001 || err.code === 50013) {\n                    pluginData.state.channelCooldowns.setCooldown(channelId, 2 * MINUTES);\n                    return;\n                  }\n                }\n\n                // tslint:disable-next-line:no-console\n                console.warn(\n                  `Error while sending ${typeStr} log to ${pluginData.guild.id}/${channelId}: ${err.message}`,\n                );\n              });\n          },\n        }),\n      );\n    }\n\n    // Add log message to buffer\n    const buffer = pluginData.state.buffers.get(channelId)!;\n    buffer.push({\n      content: typeof message === \"string\" ? message : message.content || \"\",\n      embeds: typeof message === \"string\" ? [] : ((message.embeds || []) as APIEmbed[]),\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/util/onMessageDelete.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { resolveUser } from \"../../../utils.js\";\nimport { logMessageDelete } from \"../logFunctions/logMessageDelete.js\";\nimport { logMessageDeleteBare } from \"../logFunctions/logMessageDeleteBare.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { isLogIgnored } from \"./isLogIgnored.js\";\n\nexport async function onMessageDelete(pluginData: GuildPluginData<LogsPluginType>, savedMessage: SavedMessage) {\n  const user = await resolveUser(pluginData.client, savedMessage.user_id, \"Logs:onMessageDelete\");\n  const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake);\n\n  if (!channel?.isTextBased()) {\n    return;\n  }\n\n  if (isLogIgnored(pluginData, LogType.MESSAGE_DELETE, savedMessage.id)) {\n    return;\n  }\n\n  if (user) {\n    logMessageDelete(pluginData, {\n      user,\n      channel,\n      message: savedMessage,\n    });\n  } else {\n    logMessageDeleteBare(pluginData, {\n      messageId: savedMessage.id,\n      channel,\n    });\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/util/onMessageDeleteBulk.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { getBaseUrl } from \"../../../pluginUtils.js\";\nimport { logMessageDeleteBulk } from \"../logFunctions/logMessageDeleteBulk.js\";\nimport { LogsPluginType } from \"../types.js\";\nimport { isLogIgnored } from \"./isLogIgnored.js\";\n\nexport async function onMessageDeleteBulk(pluginData: GuildPluginData<LogsPluginType>, savedMessages: SavedMessage[]) {\n  if (isLogIgnored(pluginData, LogType.MESSAGE_DELETE, savedMessages[0].id)) {\n    return;\n  }\n\n  const channel = pluginData.guild.channels.cache.get(savedMessages[0].channel_id as Snowflake);\n  if (!channel?.isTextBased()) {\n    return;\n  }\n\n  const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild);\n  const archiveUrl = pluginData.state.archives.getUrl(getBaseUrl(pluginData), archiveId);\n  const authorIds = Array.from(new Set(savedMessages.map((item) => `\\`${item.user_id}\\``)));\n\n  logMessageDeleteBulk(pluginData, {\n    count: savedMessages.length,\n    authorIds,\n    channel,\n    archiveUrl,\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Logs/util/onMessageUpdate.ts",
    "content": "import { EmbedData, GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { resolveUser } from \"../../../utils.js\";\nimport { logMessageEdit } from \"../logFunctions/logMessageEdit.js\";\nimport { LogsPluginType } from \"../types.js\";\n\nexport async function onMessageUpdate(\n  pluginData: GuildPluginData<LogsPluginType>,\n  savedMessage: SavedMessage,\n  oldSavedMessage: SavedMessage,\n) {\n  // To log a message update, either the message content or a rich embed has to change\n  let logUpdate = false;\n\n  const oldEmbedsToCompare = ((oldSavedMessage.data.embeds || []) as EmbedData[])\n    .map((e) => structuredClone(e))\n    .filter((e) => e.type === \"rich\");\n\n  const newEmbedsToCompare = ((savedMessage.data.embeds || []) as EmbedData[])\n    .map((e) => structuredClone(e))\n    .filter((e) => e.type === \"rich\");\n\n  for (const embed of [...oldEmbedsToCompare, ...newEmbedsToCompare]) {\n    if (embed.thumbnail) {\n      delete embed.thumbnail.width;\n      delete embed.thumbnail.height;\n    }\n\n    if (embed.image) {\n      delete embed.image.width;\n      delete embed.image.height;\n    }\n  }\n\n  if (\n    oldSavedMessage.data.content !== savedMessage.data.content ||\n    oldEmbedsToCompare.length !== newEmbedsToCompare.length ||\n    JSON.stringify(oldEmbedsToCompare) !== JSON.stringify(newEmbedsToCompare)\n  ) {\n    logUpdate = true;\n  }\n\n  if (!logUpdate) {\n    return;\n  }\n\n  const user = await resolveUser(pluginData.client, savedMessage.user_id, \"Logs:onMessageUpdate\");\n  const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake)! as GuildTextBasedChannel;\n\n  logMessageEdit(pluginData, {\n    user,\n    channel,\n    before: oldSavedMessage,\n    after: savedMessage,\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/MessageSaver/MessageSaverPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { SaveMessagesToDBCmd } from \"./commands/SaveMessagesToDB.js\";\nimport { SavePinsToDBCmd } from \"./commands/SavePinsToDB.js\";\nimport {\n  MessageCreateEvt,\n  MessageDeleteBulkEvt,\n  MessageDeleteEvt,\n  MessageUpdateEvt,\n} from \"./events/SaveMessagesEvts.js\";\nimport { MessageSaverPluginType, zMessageSaverConfig } from \"./types.js\";\n\nexport const MessageSaverPlugin = guildPlugin<MessageSaverPluginType>()({\n  name: \"message_saver\",\n\n  configSchema: zMessageSaverConfig,\n  defaultOverrides: [\n    {\n      level: \">=100\",\n      config: {\n        can_manage: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    SaveMessagesToDBCmd,\n    SavePinsToDBCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    MessageCreateEvt,\n    MessageUpdateEvt,\n    MessageDeleteEvt,\n    MessageDeleteBulkEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { saveMessagesToDB } from \"../saveMessagesToDB.js\";\nimport { messageSaverCmd } from \"../types.js\";\n\nexport const SaveMessagesToDBCmd = messageSaverCmd({\n  trigger: \"save_messages_to_db\",\n  permission: \"can_manage\",\n  source: \"guild\",\n\n  signature: {\n    channel: ct.textChannel(),\n    ids: ct.string({ catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    await msg.channel.send(\"Saving specified messages...\");\n    const { savedCount, failed } = await saveMessagesToDB(pluginData, args.channel, args.ids.trim().split(\" \"));\n\n    if (failed.length) {\n      void pluginData.state.common.sendSuccessMessage(\n        msg,\n        `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(\", \")}`,\n      );\n    } else {\n      void pluginData.state.common.sendSuccessMessage(msg, `Saved ${savedCount} messages!`);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { saveMessagesToDB } from \"../saveMessagesToDB.js\";\nimport { messageSaverCmd } from \"../types.js\";\n\nexport const SavePinsToDBCmd = messageSaverCmd({\n  trigger: \"save_pins_to_db\",\n  permission: \"can_manage\",\n  source: \"guild\",\n\n  signature: {\n    channel: ct.textChannel(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    await msg.channel.send(`Saving pins from <#${args.channel.id}>...`);\n\n    const pins = await args.channel.messages.fetchPinned();\n    const { savedCount, failed } = await saveMessagesToDB(pluginData, args.channel, [...pins.keys()]);\n\n    if (failed.length) {\n      void pluginData.state.common.sendSuccessMessage(\n        msg,\n        `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(\", \")}`,\n      );\n    } else {\n      void pluginData.state.common.sendSuccessMessage(msg, `Saved ${savedCount} messages!`);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/MessageSaver/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zMessageSaverConfig } from \"./types.js\";\n\nexport const messageSaverPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Message saver\",\n  type: \"internal\",\n  configSchema: zMessageSaverConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/MessageSaver/events/SaveMessagesEvts.ts",
    "content": "import { Message, MessageType } from \"discord.js\";\nimport { messageSaverEvt } from \"../types.js\";\n\nconst AFFECTED_MESSAGE_TYPES: MessageType[] = [MessageType.Default, MessageType.Reply, MessageType.ChatInputCommand];\n\nexport const MessageCreateEvt = messageSaverEvt({\n  event: \"messageCreate\",\n  allowBots: true,\n  allowSelf: true,\n\n  async listener(meta) {\n    // Don't save partial messages\n    if (meta.args.message.partial) {\n      return;\n    }\n\n    if (!AFFECTED_MESSAGE_TYPES.includes(meta.args.message.type)) {\n      return;\n    }\n\n    await meta.pluginData.state.savedMessages.createFromMsg(meta.args.message);\n  },\n});\n\nexport const MessageUpdateEvt = messageSaverEvt({\n  event: \"messageUpdate\",\n  allowBots: true,\n  allowSelf: true,\n\n  async listener(meta) {\n    if (meta.args.newMessage.partial) {\n      return;\n    }\n\n    if (!AFFECTED_MESSAGE_TYPES.includes(meta.args.newMessage.type)) {\n      return;\n    }\n\n    await meta.pluginData.state.savedMessages.saveEditFromMsg(meta.args.newMessage as Message);\n  },\n});\n\nexport const MessageDeleteEvt = messageSaverEvt({\n  event: \"messageDelete\",\n  allowBots: true,\n  allowSelf: true,\n\n  async listener(meta) {\n    if (!meta.args.message.partial && !AFFECTED_MESSAGE_TYPES.includes(meta.args.message.type)) {\n      return;\n    }\n\n    await meta.pluginData.state.savedMessages.markAsDeleted(meta.args.message.id);\n  },\n});\n\nexport const MessageDeleteBulkEvt = messageSaverEvt({\n  event: \"messageDeleteBulk\",\n  allowBots: true,\n  allowSelf: true,\n\n  async listener(meta) {\n    const affectedMessages = meta.args.messages.filter((m) => m.partial || AFFECTED_MESSAGE_TYPES.includes(m.type));\n    const ids = affectedMessages.map((m) => m.id);\n    await meta.pluginData.state.savedMessages.markBulkAsDeleted(ids);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/MessageSaver/saveMessagesToDB.ts",
    "content": "import { GuildTextBasedChannel, Message } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { MessageSaverPluginType } from \"./types.js\";\n\nexport async function saveMessagesToDB(\n  pluginData: GuildPluginData<MessageSaverPluginType>,\n  channel: GuildTextBasedChannel,\n  ids: string[],\n) {\n  const failed: string[] = [];\n  for (const id of ids) {\n    const savedMessage = await pluginData.state.savedMessages.find(id);\n    if (savedMessage) continue;\n\n    let thisMsg: Message;\n\n    try {\n      thisMsg = await channel.messages.fetch(id);\n\n      if (!thisMsg) {\n        failed.push(id);\n        continue;\n      }\n\n      await pluginData.state.savedMessages.createFromMsg(thisMsg, { is_permanent: true });\n    } catch {\n      failed.push(id);\n    }\n  }\n\n  return {\n    savedCount: ids.length - failed.length,\n    failed,\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/MessageSaver/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zMessageSaverConfig = z.strictObject({\n  can_manage: z.boolean().default(false),\n});\n\nexport interface MessageSaverPluginType extends BasePluginType {\n  configSchema: typeof zMessageSaverConfig;\n  state: {\n    savedMessages: GuildSavedMessages;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const messageSaverCmd = guildPluginMessageCommand<MessageSaverPluginType>();\nexport const messageSaverEvt = guildPluginEventListener<MessageSaverPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/ModActions/ModActionsPlugin.ts",
    "content": "import { Message } from \"discord.js\";\nimport { EventEmitter } from \"events\";\nimport { guildPlugin } from \"vety\";\nimport { Queue } from \"../../Queue.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { onGuildEvent } from \"../../data/GuildEvents.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildMutes } from \"../../data/GuildMutes.js\";\nimport { GuildTempbans } from \"../../data/GuildTempbans.js\";\nimport { makePublicFn, mapToPublicFn } from \"../../pluginUtils.js\";\nimport { MINUTES } from \"../../utils.js\";\nimport { CasesPlugin } from \"../Cases/CasesPlugin.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { MutesPlugin } from \"../Mutes/MutesPlugin.js\";\nimport { TimeAndDatePlugin } from \"../TimeAndDate/TimeAndDatePlugin.js\";\nimport { AddCaseMsgCmd } from \"./commands/addcase/AddCaseMsgCmd.js\";\nimport { AddCaseSlashCmd } from \"./commands/addcase/AddCaseSlashCmd.js\";\nimport { BanMsgCmd } from \"./commands/ban/BanMsgCmd.js\";\nimport { BanSlashCmd } from \"./commands/ban/BanSlashCmd.js\";\nimport { CaseMsgCmd } from \"./commands/case/CaseMsgCmd.js\";\nimport { CaseSlashCmd } from \"./commands/case/CaseSlashCmd.js\";\nimport { CasesModMsgCmd } from \"./commands/cases/CasesModMsgCmd.js\";\nimport { CasesSlashCmd } from \"./commands/cases/CasesSlashCmd.js\";\nimport { CasesUserMsgCmd } from \"./commands/cases/CasesUserMsgCmd.js\";\nimport { DeleteCaseMsgCmd } from \"./commands/deletecase/DeleteCaseMsgCmd.js\";\nimport { DeleteCaseSlashCmd } from \"./commands/deletecase/DeleteCaseSlashCmd.js\";\nimport { ForceBanMsgCmd } from \"./commands/forceban/ForceBanMsgCmd.js\";\nimport { ForceBanSlashCmd } from \"./commands/forceban/ForceBanSlashCmd.js\";\nimport { ForceMuteMsgCmd } from \"./commands/forcemute/ForceMuteMsgCmd.js\";\nimport { ForceMuteSlashCmd } from \"./commands/forcemute/ForceMuteSlashCmd.js\";\nimport { ForceUnmuteMsgCmd } from \"./commands/forceunmute/ForceUnmuteMsgCmd.js\";\nimport { ForceUnmuteSlashCmd } from \"./commands/forceunmute/ForceUnmuteSlashCmd.js\";\nimport { HideCaseMsgCmd } from \"./commands/hidecase/HideCaseMsgCmd.js\";\nimport { HideCaseSlashCmd } from \"./commands/hidecase/HideCaseSlashCmd.js\";\nimport { KickMsgCmd } from \"./commands/kick/KickMsgCmd.js\";\nimport { KickSlashCmd } from \"./commands/kick/KickSlashCmd.js\";\nimport { MassBanMsgCmd } from \"./commands/massban/MassBanMsgCmd.js\";\nimport { MassBanSlashCmd } from \"./commands/massban/MassBanSlashCmd.js\";\nimport { MassMuteMsgCmd } from \"./commands/massmute/MassMuteMsgCmd.js\";\nimport { MassMuteSlashSlashCmd } from \"./commands/massmute/MassMuteSlashCmd.js\";\nimport { MassUnbanMsgCmd } from \"./commands/massunban/MassUnbanMsgCmd.js\";\nimport { MassUnbanSlashCmd } from \"./commands/massunban/MassUnbanSlashCmd.js\";\nimport { MuteMsgCmd } from \"./commands/mute/MuteMsgCmd.js\";\nimport { MuteSlashCmd } from \"./commands/mute/MuteSlashCmd.js\";\nimport { NoteMsgCmd } from \"./commands/note/NoteMsgCmd.js\";\nimport { NoteSlashCmd } from \"./commands/note/NoteSlashCmd.js\";\nimport { UnbanMsgCmd } from \"./commands/unban/UnbanMsgCmd.js\";\nimport { UnbanSlashCmd } from \"./commands/unban/UnbanSlashCmd.js\";\nimport { UnhideCaseMsgCmd } from \"./commands/unhidecase/UnhideCaseMsgCmd.js\";\nimport { UnhideCaseSlashCmd } from \"./commands/unhidecase/UnhideCaseSlashCmd.js\";\nimport { UnmuteMsgCmd } from \"./commands/unmute/UnmuteMsgCmd.js\";\nimport { UnmuteSlashCmd } from \"./commands/unmute/UnmuteSlashCmd.js\";\nimport { UpdateMsgCmd } from \"./commands/update/UpdateMsgCmd.js\";\nimport { UpdateSlashCmd } from \"./commands/update/UpdateSlashCmd.js\";\nimport { WarnMsgCmd } from \"./commands/warn/WarnMsgCmd.js\";\nimport { WarnSlashCmd } from \"./commands/warn/WarnSlashCmd.js\";\nimport { AuditLogEvents } from \"./events/AuditLogEvents.js\";\nimport { CreateBanCaseOnManualBanEvt } from \"./events/CreateBanCaseOnManualBanEvt.js\";\nimport { CreateUnbanCaseOnManualUnbanEvt } from \"./events/CreateUnbanCaseOnManualUnbanEvt.js\";\nimport { PostAlertOnMemberJoinEvt } from \"./events/PostAlertOnMemberJoinEvt.js\";\nimport { banUserId } from \"./functions/banUserId.js\";\nimport { clearTempban } from \"./functions/clearTempban.js\";\nimport {\n  hasBanPermission,\n  hasMutePermission,\n  hasNotePermission,\n  hasWarnPermission,\n} from \"./functions/hasModActionPerm.js\";\nimport { kickMember } from \"./functions/kickMember.js\";\nimport { offModActionsEvent } from \"./functions/offModActionsEvent.js\";\nimport { onModActionsEvent } from \"./functions/onModActionsEvent.js\";\nimport { updateCase } from \"./functions/updateCase.js\";\nimport { warnMember } from \"./functions/warnMember.js\";\nimport { ModActionsPluginType, modActionsSlashGroup, zModActionsConfig } from \"./types.js\";\n\nexport const ModActionsPlugin = guildPlugin<ModActionsPluginType>()({\n  name: \"mod_actions\",\n\n  dependencies: () => [TimeAndDatePlugin, CasesPlugin, MutesPlugin, LogsPlugin],\n  configSchema: zModActionsConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_note: true,\n        can_warn: true,\n        can_mute: true,\n        can_kick: true,\n        can_ban: true,\n        can_unban: true,\n        can_view: true,\n        can_addcase: true,\n      },\n    },\n    {\n      level: \">=100\",\n      config: {\n        can_massunban: true,\n        can_massban: true,\n        can_massmute: true,\n        can_hidecase: true,\n        can_act_as_other: true,\n      },\n    },\n  ],\n\n  events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents],\n\n  slashCommands: [\n    modActionsSlashGroup({\n      name: \"mod\",\n      description: \"Moderation actions\",\n      defaultMemberPermissions: \"0\",\n      subcommands: [\n        AddCaseSlashCmd,\n        BanSlashCmd,\n        CaseSlashCmd,\n        CasesSlashCmd,\n        DeleteCaseSlashCmd,\n        ForceBanSlashCmd,\n        ForceMuteSlashCmd,\n        ForceUnmuteSlashCmd,\n        HideCaseSlashCmd,\n        KickSlashCmd,\n        MassBanSlashCmd,\n        MassMuteSlashSlashCmd,\n        MassUnbanSlashCmd,\n        MuteSlashCmd,\n        NoteSlashCmd,\n        UnbanSlashCmd,\n        UnhideCaseSlashCmd,\n        UnmuteSlashCmd,\n        UpdateSlashCmd,\n        WarnSlashCmd,\n      ],\n    }),\n  ],\n\n  messageCommands: [\n    UpdateMsgCmd,\n    NoteMsgCmd,\n    WarnMsgCmd,\n    MuteMsgCmd,\n    ForceMuteMsgCmd,\n    UnmuteMsgCmd,\n    ForceUnmuteMsgCmd,\n    KickMsgCmd,\n    BanMsgCmd,\n    UnbanMsgCmd,\n    ForceBanMsgCmd,\n    MassBanMsgCmd,\n    MassMuteMsgCmd,\n    MassUnbanMsgCmd,\n    AddCaseMsgCmd,\n    CaseMsgCmd,\n    CasesUserMsgCmd,\n    CasesModMsgCmd,\n    HideCaseMsgCmd,\n    UnhideCaseMsgCmd,\n    DeleteCaseMsgCmd,\n  ],\n\n  public(pluginData) {\n    return {\n      warnMember: makePublicFn(pluginData, warnMember),\n      kickMember: makePublicFn(pluginData, kickMember),\n      banUserId: makePublicFn(pluginData, banUserId),\n      updateCase: (msg: Message, caseNumber: number | null, note: string) =>\n        updateCase(pluginData, msg, msg.author, caseNumber ?? undefined, note, [...msg.attachments.values()]),\n      hasNotePermission: makePublicFn(pluginData, hasNotePermission),\n      hasWarnPermission: makePublicFn(pluginData, hasWarnPermission),\n      hasMutePermission: makePublicFn(pluginData, hasMutePermission),\n      hasBanPermission: makePublicFn(pluginData, hasBanPermission),\n      on: mapToPublicFn(onModActionsEvent),\n      off: mapToPublicFn(offModActionsEvent),\n      getEventEmitter: () => pluginData.state.events,\n    };\n  },\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.mutes = GuildMutes.getGuildInstance(guild.id);\n    state.cases = GuildCases.getGuildInstance(guild.id);\n    state.tempbans = GuildTempbans.getGuildInstance(guild.id);\n    state.serverLogs = new GuildLogs(guild.id);\n\n    state.unloaded = false;\n    state.ignoredEvents = [];\n    // Massbans can take a while depending on rate limits,\n    // so we're giving each massban 15 minutes to complete before launching the next massban\n    state.massbanQueue = new Queue(15 * MINUTES);\n\n    state.events = new EventEmitter();\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.unregisterGuildEventListener = onGuildEvent(guild.id, \"expiredTempban\", (tempban) =>\n      clearTempban(pluginData, tempban),\n    );\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.unloaded = true;\n    state.unregisterGuildEventListener?.();\n    state.events.removeAllListeners();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/addcase/AddCaseMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { hasPermission } from \"../../../../pluginUtils.js\";\nimport { resolveUser } from \"../../../../utils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualAddCaseCmd } from \"./actualAddCaseCmd.js\";\n\nconst opts = {\n  mod: ct.member({ option: true }),\n};\n\nexport const AddCaseMsgCmd = modActionsMsgCmd({\n  trigger: \"addcase\",\n  permission: \"can_addcase\",\n  description: \"Add an arbitrary case to the specified user without taking any action\",\n\n  signature: [\n    {\n      type: ct.string(),\n      user: ct.string(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:AddCaseCmd\");\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    const member = msg.member || (await msg.guild.members.fetch(msg.author.id));\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = member;\n    if (args.mod) {\n      if (!(await hasPermission(pluginData, \"can_act_as_other\", { message: msg }))) {\n        pluginData.state.common.sendErrorMessage(msg, \"You don't have permission to use -mod\");\n        return;\n      }\n\n      mod = args.mod;\n    }\n\n    // Verify the case type is valid\n    const type: string = args.type[0].toUpperCase() + args.type.slice(1).toLowerCase();\n    if (!CaseTypes[type]) {\n      pluginData.state.common.sendErrorMessage(msg, \"Cannot add case: invalid case type\");\n      return;\n    }\n\n    actualAddCaseCmd(\n      pluginData,\n      msg,\n      member,\n      mod,\n      [...msg.attachments.values()],\n      user,\n      type as keyof CaseTypes,\n      args.reason || \"\",\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/addcase/AddCaseSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { hasPermission } from \"../../../../pluginUtils.js\";\nimport { resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualAddCaseCmd } from \"./actualAddCaseCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to add this case as\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const AddCaseSlashCmd = modActionsSlashCmd({\n  name: \"addcase\",\n  configPermission: \"can_addcase\",\n  description: \"Add an arbitrary case to the specified user without taking any action\",\n  allowDms: false,\n\n  signature: [\n    slashOptions.string({\n      name: \"type\",\n      description: \"The type of case to add\",\n      required: true,\n      choices: Object.keys(CaseTypes)\n        .filter((key) => isNaN(Number(key)))\n        .map((key) => ({ name: key, value: key })),\n    }),\n    slashOptions.user({ name: \"user\", description: \"The user to add a case to\", required: true }),\n\n    ...opts,\n  ],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = interaction.member as GuildMember;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        pluginData.state.common.sendErrorMessage(interaction, \"You don't have permission to act as another moderator\");\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n    }\n\n    actualAddCaseCmd(\n      pluginData,\n      interaction,\n      interaction.member as GuildMember,\n      mod,\n      attachments,\n      options.user,\n      options.type as keyof CaseTypes,\n      options.reason || \"\",\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/addcase/actualAddCaseCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { Case } from \"../../../../data/entities/Case.js\";\nimport { canActOn } from \"../../../../pluginUtils.js\";\nimport { UnknownUser, renderUsername, resolveMember } from \"../../../../utils.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../../Logs/LogsPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport { formatReasonWithMessageLinkForAttachments } from \"../../functions/formatReasonForAttachments.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualAddCaseCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  author: GuildMember,\n  mod: GuildMember,\n  attachments: Array<Attachment>,\n  user: User | UnknownUser,\n  type: keyof CaseTypes,\n  reason: string,\n) {\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  // If the user exists as a guild member, make sure we can act on them first\n  const member = await resolveMember(pluginData.client, pluginData.guild, user.id);\n  if (member && !canActOn(pluginData, author, member)) {\n    pluginData.state.common.sendErrorMessage(context, \"Cannot add case on this user: insufficient permissions\");\n    return;\n  }\n\n  const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments);\n\n  // Create the case\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const theCase: Case = await casesPlugin.createCase({\n    userId: user.id,\n    modId: mod.id,\n    type: CaseTypes[type],\n    reason: formattedReason,\n    ppId: mod.id !== author.id ? author.id : undefined,\n  });\n\n  if (user) {\n    pluginData.state.common.sendSuccessMessage(\n      context,\n      `Case #${theCase.case_number} created for **${renderUsername(user)}**`,\n    );\n  } else {\n    pluginData.state.common.sendSuccessMessage(context, `Case #${theCase.case_number} created`);\n  }\n\n  // Log the action\n  pluginData.getPlugin(LogsPlugin).logCaseCreate({\n    mod: mod.user,\n    userId: user.id,\n    caseNum: theCase.case_number,\n    caseType: type.toUpperCase(),\n    reason: formattedReason,\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { hasPermission } from \"../../../../pluginUtils.js\";\nimport { UserNotificationMethod, resolveUser } from \"../../../../utils.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualBanCmd } from \"./actualBanCmd.js\";\n\nconst opts = {\n  mod: ct.member({ option: true }),\n  notify: ct.string({ option: true }),\n  \"notify-channel\": ct.textChannel({ option: true }),\n  \"delete-days\": ct.number({ option: true, shortcut: \"d\" }),\n};\n\nexport const BanMsgCmd = modActionsMsgCmd({\n  trigger: \"ban\",\n  permission: \"can_ban\",\n  description: \"Ban or Tempban the specified member\",\n\n  signature: [\n    {\n      user: ct.string(),\n      time: ct.delay(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n    {\n      user: ct.string(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:BanMsgCmd\");\n\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    const member = msg.member || (await msg.guild.members.fetch(msg.author.id));\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = member;\n    if (args.mod) {\n      if (!(await hasPermission(pluginData, \"can_act_as_other\", { message: msg }))) {\n        pluginData.state.common.sendErrorMessage(msg, \"You don't have permission to use -mod\");\n        return;\n      }\n\n      mod = args.mod;\n    }\n\n    let contactMethods: UserNotificationMethod[] | undefined;\n    try {\n      contactMethods = readContactMethodsFromArgs(args) ?? undefined;\n    } catch (e) {\n      pluginData.state.common.sendErrorMessage(msg, e.message);\n      return;\n    }\n\n    actualBanCmd(\n      pluginData,\n      msg,\n      user,\n      args[\"time\"] ? args[\"time\"] : null,\n      args.reason || \"\",\n      [...msg.attachments.values()],\n      member,\n      mod,\n      contactMethods,\n      args[\"delete-days\"] ?? undefined,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/ban/BanSlashCmd.ts",
    "content": "import { ChannelType, GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { hasPermission } from \"../../../../pluginUtils.js\";\nimport { UserNotificationMethod, convertDelayStringToMS, resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualBanCmd } from \"./actualBanCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"time\", description: \"The duration of the ban\", required: false }),\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to ban as\", required: false }),\n  slashOptions.string({\n    name: \"notify\",\n    description: \"How to notify\",\n    required: false,\n    choices: [\n      { name: \"DM\", value: \"dm\" },\n      { name: \"Channel\", value: \"channel\" },\n    ],\n  }),\n  slashOptions.channel({\n    name: \"notify-channel\",\n    description: \"The channel to notify in\",\n    channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread],\n    required: false,\n  }),\n  slashOptions.number({\n    name: \"delete-days\",\n    description: \"The number of days of messages to delete\",\n    required: false,\n  }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const BanSlashCmd = modActionsSlashCmd({\n  name: \"ban\",\n  configPermission: \"can_ban\",\n  description: \"Ban or Tempban the specified member\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to ban\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    let mod = interaction.member as GuildMember;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        pluginData.state.common.sendErrorMessage(interaction, \"You don't have permission to act as another moderator\");\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n    }\n\n    let contactMethods: UserNotificationMethod[] | undefined;\n    try {\n      contactMethods = readContactMethodsFromArgs(options) ?? undefined;\n    } catch (e) {\n      pluginData.state.common.sendErrorMessage(interaction, e.message);\n      return;\n    }\n\n    const convertedTime = options.time ? convertDelayStringToMS(options.time) : null;\n    if (options.time && !convertedTime) {\n      pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`);\n      return;\n    }\n\n    actualBanCmd(\n      pluginData,\n      interaction,\n      options.user,\n      convertedTime,\n      options.reason || \"\",\n      attachments,\n      interaction.member as GuildMember,\n      mod,\n      contactMethods,\n      options[\"delete-days\"] ?? undefined,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/ban/actualBanCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { getMemberLevel } from \"vety/helpers\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { clearExpiringTempban, registerExpiringTempban } from \"../../../../data/loops/expiringTempbansLoop.js\";\nimport { humanizeDuration } from \"../../../../humanizeDuration.js\";\nimport { canActOn, getContextChannel } from \"../../../../pluginUtils.js\";\nimport { UnknownUser, UserNotificationMethod, renderUsername, resolveMember } from \"../../../../utils.js\";\nimport { banLock } from \"../../../../utils/lockNameHelpers.js\";\nimport { waitForButtonConfirm } from \"../../../../utils/waitForInteraction.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../../Logs/LogsPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport { banUserId } from \"../../functions/banUserId.js\";\nimport {\n  formatReasonWithAttachments,\n  formatReasonWithMessageLinkForAttachments,\n} from \"../../functions/formatReasonForAttachments.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualBanCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  user: User | UnknownUser,\n  time: number | null,\n  reason: string,\n  attachments: Attachment[],\n  author: GuildMember,\n  mod: GuildMember,\n  contactMethods?: UserNotificationMethod[],\n  deleteDays?: number,\n) {\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id);\n  const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments);\n  const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments);\n\n  // acquire a lock because of the needed user-inputs below (if banned/not on server)\n  const lock = await pluginData.locks.acquire(banLock(user));\n  let forceban = false;\n  const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id);\n\n  if (!memberToBan) {\n    const banned = await isBanned(pluginData, user.id);\n\n    if (!banned) {\n      // Ask the mod if we should upgrade to a forceban as the user is not on the server\n      const reply = await waitForButtonConfirm(\n        context,\n        { content: \"User not on server, forceban instead?\" },\n        { confirmText: \"Yes\", cancelText: \"No\", restrictToId: author.id },\n      );\n\n      if (!reply) {\n        pluginData.state.common.sendErrorMessage(context, \"User not on server, ban cancelled by moderator\");\n        lock.unlock();\n        return;\n      } else {\n        forceban = true;\n      }\n    } else {\n      // Abort if trying to ban user indefinitely if they are already banned indefinitely\n      if (!existingTempban && !time) {\n        pluginData.state.common.sendErrorMessage(context, `User is already banned indefinitely.`);\n        return;\n      }\n\n      // Ask the mod if we should update the existing ban\n      const reply = await waitForButtonConfirm(\n        context,\n        { content: \"Failed to message the user. Log the warning anyway?\" },\n        { confirmText: \"Yes\", cancelText: \"No\", restrictToId: author.id },\n      );\n\n      if (!reply) {\n        pluginData.state.common.sendErrorMessage(context, \"User already banned, update cancelled by moderator\");\n        lock.unlock();\n        return;\n      }\n\n      // Update or add new tempban / remove old tempban\n      if (time && time > 0) {\n        if (existingTempban) {\n          await pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id);\n        } else {\n          await pluginData.state.tempbans.addTempban(user.id, time, mod.id);\n        }\n        const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!;\n        registerExpiringTempban(tempban);\n      } else if (existingTempban) {\n        clearExpiringTempban(existingTempban);\n        pluginData.state.tempbans.clear(user.id);\n      }\n\n      // Create a new case for the updated ban since we never stored the old case id and log the action\n      const casesPlugin = pluginData.getPlugin(CasesPlugin);\n      const createdCase = await casesPlugin.createCase({\n        modId: mod.id,\n        type: CaseTypes.Ban,\n        userId: user.id,\n        reason: formattedReason,\n        noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : \"indefinite\"}`],\n      });\n      if (time) {\n        pluginData.getPlugin(LogsPlugin).logMemberTimedBan({\n          mod: mod.user,\n          user,\n          caseNumber: createdCase.case_number,\n          reason: formattedReason,\n          banTime: humanizeDuration(time),\n        });\n      } else {\n        pluginData.getPlugin(LogsPlugin).logMemberBan({\n          mod: mod.user,\n          user,\n          caseNumber: createdCase.case_number,\n          reason: formattedReason,\n        });\n      }\n\n      pluginData.state.common.sendSuccessMessage(\n        context,\n        `Ban updated to ${time ? \"expire in \" + humanizeDuration(time) + \" from now\" : \"indefinite\"}`,\n      );\n      lock.unlock();\n      return;\n    }\n  }\n\n  // Make sure we're allowed to ban this member if they are on the server\n  if (!forceban && !canActOn(pluginData, author, memberToBan!)) {\n    const ourLevel = getMemberLevel(pluginData, author);\n    const targetLevel = getMemberLevel(pluginData, memberToBan!);\n    pluginData.state.common.sendErrorMessage(\n      context,\n      `Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`,\n    );\n    lock.unlock();\n    return;\n  }\n\n  const matchingConfig = await pluginData.config.getMatchingConfig({\n    member: author,\n    channel: await getContextChannel(context),\n  });\n  const deleteMessageDays = deleteDays ?? matchingConfig.ban_delete_message_days;\n  const banResult = await banUserId(\n    pluginData,\n    user.id,\n    formattedReason,\n    formattedReasonWithAttachments,\n    {\n      contactMethods,\n      caseArgs: {\n        modId: mod.id,\n        ppId: mod.id !== author.id ? author.id : undefined,\n      },\n      deleteMessageDays,\n      modId: mod.id,\n    },\n    time ?? undefined,\n  );\n\n  if (banResult.status === \"failed\") {\n    pluginData.state.common.sendErrorMessage(context, `Failed to ban member: ${banResult.error}`);\n    lock.unlock();\n    return;\n  }\n\n  let forTime = \"\";\n  if (time && time > 0) {\n    forTime = `for ${humanizeDuration(time)} `;\n  }\n\n  // Confirm the action to the moderator\n  let response = \"\";\n  if (!forceban) {\n    response = `Banned **${renderUsername(user)}** ${forTime}(Case #${banResult.case.case_number})`;\n    if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`;\n  } else {\n    response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`;\n  }\n\n  lock.unlock();\n  pluginData.state.common.sendSuccessMessage(context, response);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/case/CaseMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualCaseCmd } from \"./actualCaseCmd.js\";\n\nexport const CaseMsgCmd = modActionsMsgCmd({\n  trigger: \"case\",\n  permission: \"can_view\",\n  description: \"Show information about a specific case\",\n\n  signature: [\n    {\n      caseNumber: ct.number(),\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    actualCaseCmd(pluginData, msg, msg.author.id, args.caseNumber);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/case/CaseSlashCmd.ts",
    "content": "import { slashOptions } from \"vety\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { actualCaseCmd } from \"./actualCaseCmd.js\";\n\nconst opts = [\n  slashOptions.boolean({ name: \"show\", description: \"To make the result visible to everyone\", required: false }),\n];\n\nexport const CaseSlashCmd = modActionsSlashCmd({\n  name: \"case\",\n  configPermission: \"can_view\",\n  description: \"Show information about a specific case\",\n  allowDms: false,\n\n  signature: [\n    slashOptions.number({ name: \"case-number\", description: \"The number of the case to show\", required: true }),\n\n    ...opts,\n  ],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: options.show !== true });\n    actualCaseCmd(pluginData, interaction, interaction.user.id, options[\"case-number\"], options.show);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/case/actualCaseCmd.ts",
    "content": "import { ChatInputCommandInteraction, Message } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { sendContextResponse } from \"../../../../pluginUtils.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualCaseCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  authorId: string,\n  caseNumber: number,\n  show?: boolean | null,\n) {\n  const theCase = await pluginData.state.cases.findByCaseNumber(caseNumber);\n\n  if (!theCase) {\n    void pluginData.state.common.sendErrorMessage(context, \"Case not found\", undefined, undefined, show !== true);\n    return;\n  }\n\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const content = await casesPlugin.getCaseEmbed(theCase.id, authorId);\n\n  void sendContextResponse(context, content, show !== true);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/cases/CasesModMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualCasesCmd } from \"./actualCasesCmd.js\";\n\nconst opts = {\n  mod: ct.userId({ option: true }),\n  expand: ct.bool({ option: true, isSwitch: true, shortcut: \"e\" }),\n  hidden: ct.bool({ option: true, isSwitch: true, shortcut: \"h\" }),\n  reverseFilters: ct.switchOption({ def: false, shortcut: \"r\" }),\n  notes: ct.switchOption({ def: false, shortcut: \"n\" }),\n  warns: ct.switchOption({ def: false, shortcut: \"w\" }),\n  mutes: ct.switchOption({ def: false, shortcut: \"m\" }),\n  unmutes: ct.switchOption({ def: false, shortcut: \"um\" }),\n  kicks: ct.switchOption({ def: false, shortcut: \"k\" }),\n  bans: ct.switchOption({ def: false, shortcut: \"b\" }),\n  unbans: ct.switchOption({ def: false, shortcut: \"ub\" }),\n  show: ct.switchOption({ def: false, shortcut: \"sh\" }),\n};\n\nexport const CasesModMsgCmd = modActionsMsgCmd({\n  trigger: [\"cases\", \"modlogs\", \"infractions\"],\n  permission: \"can_view\",\n  description: \"Show the most recent 5 cases by the specified -mod\",\n\n  signature: [\n    {\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const member = await resolveMessageMember(msg);\n    return actualCasesCmd(\n      pluginData,\n      msg,\n      args.mod,\n      null,\n      member,\n      args.notes,\n      args.warns,\n      args.mutes,\n      args.unmutes,\n      args.kicks,\n      args.bans,\n      args.unbans,\n      args.reverseFilters,\n      args.hidden,\n      args.expand,\n      args.show,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/cases/CasesSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { actualCasesCmd } from \"./actualCasesCmd.js\";\n\nconst opts = [\n  slashOptions.user({ name: \"user\", description: \"The user to show cases for\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The mod to filter cases by\", required: false }),\n  slashOptions.boolean({ name: \"expand\", description: \"Show each case individually\", required: false }),\n  slashOptions.boolean({ name: \"hidden\", description: \"Whether or not to show hidden cases\", required: false }),\n  slashOptions.boolean({\n    name: \"reverse-filters\",\n    description: \"To treat case type filters as exclusive instead of inclusive\",\n    required: false,\n  }),\n  slashOptions.boolean({ name: \"notes\", description: \"To filter notes\", required: false }),\n  slashOptions.boolean({ name: \"warns\", description: \"To filter warns\", required: false }),\n  slashOptions.boolean({ name: \"mutes\", description: \"To filter mutes\", required: false }),\n  slashOptions.boolean({ name: \"unmutes\", description: \"To filter unmutes\", required: false }),\n  slashOptions.boolean({ name: \"kicks\", description: \"To filter kicks\", required: false }),\n  slashOptions.boolean({ name: \"bans\", description: \"To filter bans\", required: false }),\n  slashOptions.boolean({ name: \"unbans\", description: \"To filter unbans\", required: false }),\n  slashOptions.boolean({ name: \"show\", description: \"To make the result visible to everyone\", required: false }),\n];\n\nexport const CasesSlashCmd = modActionsSlashCmd({\n  name: \"cases\",\n  configPermission: \"can_view\",\n  description: \"Show a list of cases the specified user has or the specified mod made\",\n  allowDms: false,\n\n  signature: [...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: options.show !== true });\n\n    return actualCasesCmd(\n      pluginData,\n      interaction,\n      options.mod?.id ?? null,\n      options.user,\n      interaction.member as GuildMember,\n      options.notes,\n      options.warns,\n      options.mutes,\n      options.unmutes,\n      options.kicks,\n      options.bans,\n      options.unbans,\n      options[\"reverse-filters\"],\n      options.hidden,\n      options.expand,\n      options.show,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/cases/CasesUserMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { resolveMember, resolveUser, UnknownUser } from \"../../../../utils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualCasesCmd } from \"./actualCasesCmd.js\";\n\nconst opts = {\n  mod: ct.userId({ option: true }),\n  expand: ct.bool({ option: true, isSwitch: true, shortcut: \"e\" }),\n  hidden: ct.bool({ option: true, isSwitch: true, shortcut: \"h\" }),\n  reverseFilters: ct.switchOption({ def: false, shortcut: \"r\" }),\n  notes: ct.switchOption({ def: false, shortcut: \"n\" }),\n  warns: ct.switchOption({ def: false, shortcut: \"w\" }),\n  mutes: ct.switchOption({ def: false, shortcut: \"m\" }),\n  unmutes: ct.switchOption({ def: false, shortcut: \"um\" }),\n  kicks: ct.switchOption({ def: false, shortcut: \"k\" }),\n  bans: ct.switchOption({ def: false, shortcut: \"b\" }),\n  unbans: ct.switchOption({ def: false, shortcut: \"ub\" }),\n  show: ct.switchOption({ def: false, shortcut: \"sh\" }),\n};\n\nexport const CasesUserMsgCmd = modActionsMsgCmd({\n  trigger: [\"cases\", \"modlogs\", \"infractions\"],\n  permission: \"can_view\",\n  description: \"Show a list of cases the specified user has\",\n\n  signature: [\n    {\n      user: ct.string(),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user =\n      (await resolveMember(pluginData.client, pluginData.guild, args.user)) ||\n      (await resolveUser(pluginData.client, args.user, \"ModActions:CasesUserMsgCmd\"));\n\n    if (user instanceof UnknownUser) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    const member = await resolveMessageMember(msg);\n\n    return actualCasesCmd(\n      pluginData,\n      msg,\n      args.mod,\n      user,\n      member,\n      args.notes,\n      args.warns,\n      args.mutes,\n      args.unmutes,\n      args.kicks,\n      args.bans,\n      args.unbans,\n      args.reverseFilters,\n      args.hidden,\n      args.expand,\n      args.show,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/cases/actualCasesCmd.ts",
    "content": "import { APIEmbed, ChatInputCommandInteraction, GuildMember, Message, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { FindOptionsWhere, In } from \"typeorm\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { Case } from \"../../../../data/entities/Case.js\";\nimport { sendContextResponse } from \"../../../../pluginUtils.js\";\nimport {\n  UnknownUser,\n  chunkArray,\n  emptyEmbedValue,\n  renderUsername,\n  resolveMember,\n  resolveUser,\n  trimLines,\n} from \"../../../../utils.js\";\nimport { asyncMap } from \"../../../../utils/async.js\";\nimport { createPaginatedMessage } from \"../../../../utils/createPaginatedMessage.js\";\nimport { getGuildPrefix } from \"../../../../utils/getGuildPrefix.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nconst casesPerPage = 5;\nconst maxExpandedCases = 8;\n\nasync function sendExpandedCases(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  casesCount: number,\n  cases: Case[],\n  show: boolean | null,\n) {\n  if (casesCount > maxExpandedCases) {\n    await sendContextResponse(context, {\n      content: \"Too many cases for expanded view. Please use compact view instead.\",\n      ephemeral: true,\n    });\n\n    return;\n  }\n\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n\n  for (const theCase of cases) {\n    const content = await casesPlugin.getCaseEmbed(theCase.id);\n    await sendContextResponse(context, content, !show);\n  }\n}\n\nasync function casesUserCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  author: User,\n  modId: string | null,\n  user: GuildMember | User | UnknownUser,\n  modName: string,\n  typesToShow: CaseTypes[],\n  hidden: boolean | null,\n  expand: boolean | null,\n  show: boolean | null,\n) {\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const casesFilters: Omit<FindOptionsWhere<Case>, \"guild_id\" | \"user_id\"> = { type: In(typesToShow) };\n\n  if (modId) {\n    casesFilters.mod_id = modId;\n  }\n\n  const cases = await pluginData.state.cases.with(\"notes\").getByUserId(user.id, casesFilters);\n  const normalCases = cases.filter((c) => !c.is_hidden);\n  const hiddenCases = cases.filter((c) => c.is_hidden);\n\n  const userName =\n    user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUsername(user);\n\n  if (cases.length === 0) {\n    await sendContextResponse(context, {\n      content: `No cases found for **${userName}**${modId ? ` by ${modName}` : \"\"}.`,\n      ephemeral: !show,\n    });\n\n    return;\n  }\n\n  const casesToDisplay = hidden ? cases : normalCases;\n\n  if (!casesToDisplay.length) {\n    await sendContextResponse(context, {\n      content: `No normal cases found for **${userName}**. Use \"-hidden\" to show ${cases.length} hidden cases.`,\n      ephemeral: !show,\n    });\n\n    return;\n  }\n\n  if (expand) {\n    await sendExpandedCases(pluginData, context, casesToDisplay.length, casesToDisplay, show);\n    return;\n  }\n\n  // Compact view (= regular message with a preview of each case)\n  const lines = await asyncMap(casesToDisplay, (c) => casesPlugin.getCaseSummary(c, true, author.id));\n  const prefix = getGuildPrefix(pluginData);\n  const linesPerChunk = 10;\n  const lineChunks = chunkArray(lines, linesPerChunk);\n\n  const footerField = {\n    name: emptyEmbedValue,\n    value: trimLines(`\n            Use \\`${prefix}case <num>\\` to see more information about an individual case\n          `),\n  };\n\n  for (const [i, linesInChunk] of lineChunks.entries()) {\n    const isLastChunk = i === lineChunks.length - 1;\n\n    if (isLastChunk && !hidden && hiddenCases.length) {\n      if (hiddenCases.length === 1) {\n        linesInChunk.push(`*+${hiddenCases.length} hidden case, use \"-hidden\" to show it*`);\n      } else {\n        linesInChunk.push(`*+${hiddenCases.length} hidden cases, use \"-hidden\" to show them*`);\n      }\n    }\n\n    const chunkStart = i * linesPerChunk + 1;\n    const chunkEnd = Math.min((i + 1) * linesPerChunk, lines.length);\n\n    const embed = {\n      author: {\n        name:\n          lineChunks.length === 1\n            ? `Cases for ${userName}${modId ? ` by ${modName}` : \"\"} (${lines.length} total)`\n            : `Cases ${chunkStart}–${chunkEnd} of ${lines.length} for ${userName}`,\n        icon_url: user instanceof UnknownUser ? undefined : user.displayAvatarURL(),\n      },\n      description: linesInChunk.join(\"\\n\"),\n      fields: [...(isLastChunk ? [footerField] : [])],\n    } satisfies APIEmbed;\n\n    await sendContextResponse(context, { embeds: [embed], ephemeral: !show });\n  }\n}\n\nasync function casesModCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  author: User,\n  modId: string | null,\n  mod: GuildMember | User | UnknownUser,\n  modName: string,\n  typesToShow: CaseTypes[],\n  hidden: boolean | null,\n  expand: boolean | null,\n  show: boolean | null,\n) {\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const casesFilters = { type: In(typesToShow), is_hidden: !!hidden };\n\n  const totalCases = await casesPlugin.getTotalCasesByMod(modId ?? author.id, casesFilters);\n\n  if (totalCases === 0) {\n    pluginData.state.common.sendErrorMessage(context, `No cases by **${modName}**`, undefined, undefined, !show);\n\n    return;\n  }\n\n  const totalPages = Math.max(Math.ceil(totalCases / casesPerPage), 1);\n  const prefix = getGuildPrefix(pluginData);\n\n  if (expand) {\n    // Expanded view (= individual case embeds)\n    const cases = totalCases > 8 ? [] : await casesPlugin.getRecentCasesByMod(modId ?? author.id, 8, 0, casesFilters);\n\n    await sendExpandedCases(pluginData, context, totalCases, cases, show);\n    return;\n  }\n\n  await createPaginatedMessage(\n    pluginData.client,\n    context,\n    totalPages,\n    async (page) => {\n      const cases = await casesPlugin.getRecentCasesByMod(\n        modId ?? author.id,\n        casesPerPage,\n        (page - 1) * casesPerPage,\n        casesFilters,\n      );\n\n      const lines = await asyncMap(cases, (c) => casesPlugin.getCaseSummary(c, true, author.id));\n      const firstCaseNum = (page - 1) * casesPerPage + 1;\n      const lastCaseNum = firstCaseNum - 1 + Math.min(cases.length, casesPerPage);\n      const title = `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${totalCases} by ${modName}`;\n\n      const embed = {\n        author: {\n          name: title,\n          icon_url: mod instanceof UnknownUser ? undefined : mod.displayAvatarURL(),\n        },\n        description: lines.join(\"\\n\"),\n        fields: [\n          {\n            name: emptyEmbedValue,\n            value: trimLines(`\n                Use \\`${prefix}case <num>\\` to see more information about an individual case\n                Use \\`${prefix}cases <user>\\` to see a specific user's cases\n              `),\n          },\n        ],\n      } satisfies APIEmbed;\n\n      return { embeds: [embed], ephemeral: !show };\n    },\n    {\n      limitToUserId: author.id,\n    },\n  );\n}\n\nexport async function actualCasesCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  modId: string | null,\n  user: GuildMember | User | UnknownUser | null,\n  author: GuildMember,\n  notes: boolean | null,\n  warns: boolean | null,\n  mutes: boolean | null,\n  unmutes: boolean | null,\n  kicks: boolean | null,\n  bans: boolean | null,\n  unbans: boolean | null,\n  reverseFilters: boolean | null,\n  hidden: boolean | null,\n  expand: boolean | null,\n  show: boolean | null,\n) {\n  const mod = modId\n    ? (await resolveMember(pluginData.client, pluginData.guild, modId)) || (await resolveUser(pluginData.client, modId, \"ModActions:actualCasesCmd\"))\n    : null;\n  const modName = modId ? (mod instanceof UnknownUser ? modId : renderUsername(mod!)) : renderUsername(author);\n\n  const allTypes = [\n    CaseTypes.Note,\n    CaseTypes.Warn,\n    CaseTypes.Mute,\n    CaseTypes.Unmute,\n    CaseTypes.Kick,\n    CaseTypes.Ban,\n    CaseTypes.Unban,\n  ];\n  let typesToShow: CaseTypes[] = [];\n\n  if (notes) typesToShow.push(CaseTypes.Note);\n  if (warns) typesToShow.push(CaseTypes.Warn);\n  if (mutes) typesToShow.push(CaseTypes.Mute);\n  if (unmutes) typesToShow.push(CaseTypes.Unmute);\n  if (kicks) typesToShow.push(CaseTypes.Kick);\n  if (bans) typesToShow.push(CaseTypes.Ban);\n  if (unbans) typesToShow.push(CaseTypes.Unban);\n\n  if (typesToShow.length === 0) {\n    typesToShow = allTypes;\n  } else {\n    if (reverseFilters) {\n      typesToShow = allTypes.filter((t) => !typesToShow.includes(t));\n    }\n  }\n\n  user\n    ? await casesUserCmd(\n        pluginData,\n        context,\n        author.user,\n        modId!,\n        user,\n        modName,\n        typesToShow,\n        hidden,\n        expand,\n        show === true,\n      )\n    : await casesModCmd(\n        pluginData,\n        context,\n        author.user,\n        modId!,\n        mod ?? author,\n        modName,\n        typesToShow,\n        hidden,\n        expand,\n        show === true,\n      );\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/constants.ts",
    "content": "export const NUMBER_ATTACHMENTS_CASE_CREATION = 1;\nexport const NUMBER_ATTACHMENTS_CASE_UPDATE = 3;\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/deletecase/DeleteCaseMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { trimLines } from \"../../../../utils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualDeleteCaseCmd } from \"./actualDeleteCaseCmd.js\";\n\nexport const DeleteCaseMsgCmd = modActionsMsgCmd({\n  trigger: [\"delete_case\", \"deletecase\"],\n  permission: \"can_deletecase\",\n  description: trimLines(`\n    Delete the specified case. This operation can *not* be reversed.\n    It is generally recommended to use \\`!hidecase\\` instead when possible.\n  `),\n\n  signature: {\n    caseNumber: ct.number({ rest: true }),\n\n    force: ct.switchOption({ def: false, shortcut: \"f\" }),\n  },\n\n  async run({ pluginData, message, args }) {\n    const member = await resolveMessageMember(message);\n    actualDeleteCaseCmd(pluginData, message, member, args.caseNumber, args.force);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/deletecase/DeleteCaseSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { actualDeleteCaseCmd } from \"./actualDeleteCaseCmd.js\";\n\nconst opts = [slashOptions.boolean({ name: \"force\", description: \"Whether or not to force delete\", required: false })];\n\nexport const DeleteCaseSlashCmd = modActionsSlashCmd({\n  name: \"deletecase\",\n  configPermission: \"can_deletecase\",\n  description: \"Delete the specified case. This operation can *not* be reversed.\",\n  allowDms: false,\n\n  signature: [\n    slashOptions.string({ name: \"case-number\", description: \"The number of the case to delete\", required: true }),\n\n    ...opts,\n  ],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n\n    actualDeleteCaseCmd(\n      pluginData,\n      interaction,\n      interaction.member as GuildMember,\n      options[\"case-number\"].split(/\\D+/).map(Number),\n      !!options.force,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/deletecase/actualDeleteCaseCmd.ts",
    "content": "import { ChatInputCommandInteraction, GuildMember, Message } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { Case } from \"../../../../data/entities/Case.js\";\nimport { getContextChannel } from \"../../../../pluginUtils.js\";\nimport { confirm, renderUsername } from \"../../../../utils.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../../Logs/LogsPlugin.js\";\nimport { TimeAndDatePlugin } from \"../../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualDeleteCaseCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  author: GuildMember,\n  caseNumbers: number[],\n  force: boolean,\n) {\n  const failed: number[] = [];\n  const validCases: Case[] = [];\n  let cancelled = 0;\n\n  for (const num of caseNumbers) {\n    const theCase = await pluginData.state.cases.findByCaseNumber(num);\n    if (!theCase) {\n      failed.push(num);\n      continue;\n    }\n\n    validCases.push(theCase);\n  }\n\n  if (failed.length === caseNumbers.length) {\n    pluginData.state.common.sendErrorMessage(context, \"None of the cases were found!\");\n    return;\n  }\n\n  for (const theCase of validCases) {\n    if (!force) {\n      const channel = await getContextChannel(context);\n      if (!channel) {\n        return;\n      }\n\n      const cases = pluginData.getPlugin(CasesPlugin);\n      const embedContent = await cases.getCaseEmbed(theCase);\n\n      const confirmed = await confirm(context, author.id, {\n        ...embedContent,\n        content: \"Delete the following case?\",\n      });\n\n      if (!confirmed) {\n        cancelled++;\n        continue;\n      }\n    }\n\n    const deletedByName = renderUsername(author);\n\n    const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n    const deletedAt = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat(\"pretty_datetime\"));\n\n    await pluginData.state.cases.softDelete(\n      theCase.id,\n      author.id,\n      deletedByName,\n      `Case deleted by **${deletedByName}** (\\`${author.id}\\`) on ${deletedAt}`,\n    );\n\n    const logs = pluginData.getPlugin(LogsPlugin);\n    logs.logCaseDelete({\n      mod: author,\n      case: theCase,\n    });\n  }\n\n  const failedAddendum =\n    failed.length > 0\n      ? `\\nThe following cases were not found: ${failed.toString().replace(new RegExp(\",\", \"g\"), \", \")}`\n      : \"\";\n  const amt = validCases.length - cancelled;\n  if (amt === 0) {\n    pluginData.state.common.sendErrorMessage(context, \"All deletions were cancelled, no cases were deleted.\");\n    return;\n  }\n\n  pluginData.state.common.sendSuccessMessage(\n    context,\n    `${amt} case${amt === 1 ? \" was\" : \"s were\"} deleted!${failedAddendum}`,\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/forceban/ForceBanMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { canActOn, hasPermission, resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { resolveMember, resolveUser } from \"../../../../utils.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualForceBanCmd } from \"./actualForceBanCmd.js\";\n\nconst opts = {\n  mod: ct.member({ option: true }),\n};\n\nexport const ForceBanMsgCmd = modActionsMsgCmd({\n  trigger: \"forceban\",\n  permission: \"can_ban\",\n  description: \"Force-ban the specified user, even if they aren't on the server\",\n\n  signature: [\n    {\n      user: ct.string(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:ForceBanMsgCmd\");\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    // If the user exists as a guild member, make sure we can act on them first\n    const authorMember = await resolveMessageMember(msg);\n    const targetMember = await resolveMember(pluginData.client, pluginData.guild, user.id);\n    if (targetMember && !canActOn(pluginData, authorMember, targetMember)) {\n      pluginData.state.common.sendErrorMessage(msg, \"Cannot forceban this user: insufficient permissions\");\n      return;\n    }\n\n    // Make sure the user isn't already banned\n    const banned = await isBanned(pluginData, user.id);\n    if (banned) {\n      pluginData.state.common.sendErrorMessage(msg, `User is already banned`);\n      return;\n    }\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = authorMember;\n    if (args.mod) {\n      if (!(await hasPermission(pluginData, \"can_act_as_other\", { message: msg }))) {\n        pluginData.state.common.sendErrorMessage(msg, \"You don't have permission to use -mod\");\n        return;\n      }\n\n      mod = args.mod;\n    }\n\n    actualForceBanCmd(pluginData, msg, msg.author.id, user, args.reason, [...msg.attachments.values()], mod);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/forceban/ForceBanSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { hasPermission } from \"../../../../pluginUtils.js\";\nimport { convertDelayStringToMS, resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualForceBanCmd } from \"./actualForceBanCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to ban as\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const ForceBanSlashCmd = modActionsSlashCmd({\n  name: \"forceban\",\n  configPermission: \"can_ban\",\n  description: \"Force-ban the specified user, even if they aren't on the server\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to ban\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.reason || options.reason.trim() === \"\") && attachments.length < 1) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Text or attachment required\", undefined, undefined, true);\n\n      return;\n    }\n\n    let mod = interaction.member as GuildMember;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        pluginData.state.common.sendErrorMessage(interaction, \"You don't have permission to act as another moderator\");\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n    }\n\n    const convertedTime = options.time ? convertDelayStringToMS(options.time) : null;\n    if (options.time && !convertedTime) {\n      pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`);\n      return;\n    }\n\n    actualForceBanCmd(\n      pluginData,\n      interaction,\n      interaction.user.id,\n      options.user,\n      options.reason ?? \"\",\n      attachments,\n      mod,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/forceban/actualForceBanCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { LogType } from \"../../../../data/LogType.js\";\nimport { DAYS, MINUTES, UnknownUser } from \"../../../../utils.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../../Logs/LogsPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport {\n  formatReasonWithAttachments,\n  formatReasonWithMessageLinkForAttachments,\n} from \"../../functions/formatReasonForAttachments.js\";\nimport { ignoreEvent } from \"../../functions/ignoreEvent.js\";\nimport { IgnoredEventType, ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualForceBanCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  authorId: string,\n  user: User | UnknownUser,\n  reason: string,\n  attachments: Array<Attachment>,\n  mod: GuildMember,\n) {\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments);\n  const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments);\n\n  ignoreEvent(pluginData, IgnoredEventType.Ban, user.id);\n  pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id);\n\n  try {\n    // FIXME: Use banUserId()?\n    await pluginData.guild.bans.create(user.id as Snowflake, {\n      deleteMessageSeconds: (1 * DAYS) / MINUTES,\n      reason: formattedReasonWithAttachments ?? undefined,\n    });\n  } catch {\n    pluginData.state.common.sendErrorMessage(context, \"Failed to forceban member\");\n    return;\n  }\n\n  // Create a case\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const createdCase = await casesPlugin.createCase({\n    userId: user.id,\n    modId: mod.id,\n    type: CaseTypes.Ban,\n    reason: formattedReason,\n    ppId: mod.id !== authorId ? authorId : undefined,\n  });\n\n  // Confirm the action\n  pluginData.state.common.sendSuccessMessage(context, `Member forcebanned (Case #${createdCase.case_number})`);\n\n  // Log the action\n  pluginData.getPlugin(LogsPlugin).logMemberForceban({\n    mod,\n    userId: user.id,\n    caseNumber: createdCase.case_number,\n    reason: formattedReason,\n  });\n\n  pluginData.state.events.emit(\"ban\", user.id, formattedReason);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { canActOn, hasPermission, resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { resolveMember, resolveUser } from \"../../../../utils.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualMuteCmd } from \"../mute/actualMuteCmd.js\";\n\nconst opts = {\n  mod: ct.member({ option: true }),\n  notify: ct.string({ option: true }),\n  \"notify-channel\": ct.textChannel({ option: true }),\n};\n\nexport const ForceMuteMsgCmd = modActionsMsgCmd({\n  trigger: \"forcemute\",\n  permission: \"can_mute\",\n  description: \"Force-mute the specified user, even if they're not on the server\",\n\n  signature: [\n    {\n      user: ct.string(),\n      time: ct.delay(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n    {\n      user: ct.string(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:ForceMuteMsgCmd\");\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n    const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id);\n\n    // Make sure we're allowed to mute this user\n    if (memberToMute && !canActOn(pluginData, authorMember, memberToMute)) {\n      pluginData.state.common.sendErrorMessage(msg, \"Cannot mute: insufficient permissions\");\n      return;\n    }\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = authorMember;\n    let ppId: string | undefined;\n\n    if (args.mod) {\n      if (!(await hasPermission(pluginData, \"can_act_as_other\", { message: msg }))) {\n        pluginData.state.common.sendErrorMessage(msg, \"You don't have permission to use -mod\");\n        return;\n      }\n\n      mod = args.mod;\n      ppId = msg.author.id;\n    }\n\n    let contactMethods;\n    try {\n      contactMethods = readContactMethodsFromArgs(args);\n    } catch (e) {\n      pluginData.state.common.sendErrorMessage(msg, e.message);\n      return;\n    }\n\n    actualMuteCmd(\n      pluginData,\n      msg,\n      user,\n      [...msg.attachments.values()],\n      mod,\n      ppId,\n      \"time\" in args ? (args.time ?? undefined) : undefined,\n      args.reason,\n      contactMethods,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/forcemute/ForceMuteSlashCmd.ts",
    "content": "import { ChannelType, GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { hasPermission } from \"../../../../pluginUtils.js\";\nimport { UserNotificationMethod, convertDelayStringToMS, resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualMuteCmd } from \"../mute/actualMuteCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"time\", description: \"The duration of the mute\", required: false }),\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to mute as\", required: false }),\n  slashOptions.string({\n    name: \"notify\",\n    description: \"How to notify\",\n    required: false,\n    choices: [\n      { name: \"DM\", value: \"dm\" },\n      { name: \"Channel\", value: \"channel\" },\n    ],\n  }),\n  slashOptions.channel({\n    name: \"notify-channel\",\n    description: \"The channel to notify in\",\n    channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread],\n    required: false,\n  }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const ForceMuteSlashCmd = modActionsSlashCmd({\n  name: \"forcemute\",\n  configPermission: \"can_mute\",\n  description: \"Force-mute the specified user, even if they're not on the server\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to mute\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.reason || options.reason.trim() === \"\") && attachments.length < 1) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Text or attachment required\", undefined, undefined, true);\n\n      return;\n    }\n\n    let mod = interaction.member as GuildMember;\n    let ppId: string | undefined;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        pluginData.state.common.sendErrorMessage(interaction, \"You don't have permission to act as another moderator\");\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n      ppId = interaction.user.id;\n    }\n\n    const convertedTime = options.time ? (convertDelayStringToMS(options.time) ?? undefined) : undefined;\n    if (options.time && !convertedTime) {\n      pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`);\n      return;\n    }\n\n    let contactMethods: UserNotificationMethod[] | undefined;\n    try {\n      contactMethods = readContactMethodsFromArgs(options) ?? undefined;\n    } catch (e) {\n      pluginData.state.common.sendErrorMessage(interaction, e.message);\n      return;\n    }\n\n    actualMuteCmd(\n      pluginData,\n      interaction,\n      options.user,\n      attachments,\n      mod,\n      ppId,\n      convertedTime,\n      options.reason ?? \"\",\n      contactMethods,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { canActOn, hasPermission, resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { resolveMember, resolveUser } from \"../../../../utils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualUnmuteCmd } from \"../unmute/actualUnmuteCmd.js\";\n\nconst opts = {\n  mod: ct.member({ option: true }),\n};\n\nexport const ForceUnmuteMsgCmd = modActionsMsgCmd({\n  trigger: \"forceunmute\",\n  permission: \"can_mute\",\n  description: \"Force-unmute the specified user, even if they're not on the server\",\n\n  signature: [\n    {\n      user: ct.string(),\n      time: ct.delay(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n    {\n      user: ct.string(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:ForceUnmuteMsgCmd\");\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    // Check if they're muted in the first place\n    if (!(await pluginData.state.mutes.isMuted(user.id))) {\n      pluginData.state.common.sendErrorMessage(msg, \"Cannot unmute: member is not muted\");\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n    const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id);\n\n    // Make sure we're allowed to unmute this member\n    if (memberToUnmute && !canActOn(pluginData, authorMember, memberToUnmute)) {\n      pluginData.state.common.sendErrorMessage(msg, \"Cannot unmute: insufficient permissions\");\n      return;\n    }\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = authorMember;\n    let ppId: string | undefined;\n\n    if (args.mod) {\n      if (!(await hasPermission(pluginData, \"can_act_as_other\", { message: msg }))) {\n        pluginData.state.common.sendErrorMessage(msg, \"You don't have permission to use -mod\");\n        return;\n      }\n\n      mod = args.mod;\n      ppId = msg.author.id;\n    }\n\n    actualUnmuteCmd(\n      pluginData,\n      msg,\n      user,\n      [...msg.attachments.values()],\n      mod,\n      ppId,\n      \"time\" in args ? (args.time ?? undefined) : undefined,\n      args.reason,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { hasPermission } from \"../../../../pluginUtils.js\";\nimport { convertDelayStringToMS, resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualUnmuteCmd } from \"../unmute/actualUnmuteCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"time\", description: \"The duration of the unmute\", required: false }),\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to unmute as\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const ForceUnmuteSlashCmd = modActionsSlashCmd({\n  name: \"forceunmute\",\n  configPermission: \"can_mute\",\n  description: \"Force-unmute the specified user, even if they're not on the server\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to unmute\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.reason || options.reason.trim() === \"\") && attachments.length < 1) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Text or attachment required\", undefined, undefined, true);\n\n      return;\n    }\n\n    let mod = interaction.member as GuildMember;\n    let ppId: string | undefined;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        pluginData.state.common.sendErrorMessage(interaction, \"You don't have permission to act as another moderator\");\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n      ppId = interaction.user.id;\n    }\n\n    const convertedTime = options.time ? (convertDelayStringToMS(options.time) ?? undefined) : undefined;\n    if (options.time && !convertedTime) {\n      pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`);\n      return;\n    }\n\n    actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, options.reason ?? \"\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/hidecase/HideCaseMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualHideCaseCmd } from \"./actualHideCaseCmd.js\";\n\nexport const HideCaseMsgCmd = modActionsMsgCmd({\n  trigger: [\"hide\", \"hidecase\", \"hide_case\"],\n  permission: \"can_hidecase\",\n  description: \"Hide the specified case so it doesn't appear in !cases or !info\",\n\n  signature: [\n    {\n      caseNum: ct.number({ rest: true }),\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    actualHideCaseCmd(pluginData, msg, args.caseNum);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/hidecase/HideCaseSlashCmd.ts",
    "content": "import { slashOptions } from \"vety\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { actualHideCaseCmd } from \"./actualHideCaseCmd.js\";\n\nexport const HideCaseSlashCmd = modActionsSlashCmd({\n  name: \"hidecase\",\n  configPermission: \"can_hidecase\",\n  description: \"Hide the specified case so it doesn't appear in !cases or !info\",\n  allowDms: false,\n\n  signature: [\n    slashOptions.string({ name: \"case-number\", description: \"The number of the case to hide\", required: true }),\n  ],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    actualHideCaseCmd(pluginData, interaction, options[\"case-number\"].split(/\\D+/).map(Number));\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/hidecase/actualHideCaseCmd.ts",
    "content": "import { ChatInputCommandInteraction, Message } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualHideCaseCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  caseNumbers: number[],\n) {\n  const failed: number[] = [];\n\n  for (const num of caseNumbers) {\n    const theCase = await pluginData.state.cases.findByCaseNumber(num);\n    if (!theCase) {\n      failed.push(num);\n      continue;\n    }\n\n    await pluginData.state.cases.setHidden(theCase.id, true);\n  }\n\n  if (failed.length === caseNumbers.length) {\n    pluginData.state.common.sendErrorMessage(context, \"None of the cases were found!\");\n    return;\n  }\n  const failedAddendum =\n    failed.length > 0\n      ? `\\nThe following cases were not found: ${failed.toString().replace(new RegExp(\",\", \"g\"), \", \")}`\n      : \"\";\n\n  const amt = caseNumbers.length - failed.length;\n  pluginData.state.common.sendSuccessMessage(\n    context,\n    `${amt} case${amt === 1 ? \" is\" : \"s are\"} now hidden! Use \\`unhidecase\\` to unhide them.${failedAddendum}`,\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/kick/KickMsgCmd.ts",
    "content": "import { hasPermission } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { resolveUser } from \"../../../../utils.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualKickCmd } from \"./actualKickCmd.js\";\n\nconst opts = {\n  mod: ct.member({ option: true }),\n  notify: ct.string({ option: true }),\n  \"notify-channel\": ct.textChannel({ option: true }),\n  clean: ct.bool({ option: true, isSwitch: true }),\n};\n\nexport const KickMsgCmd = modActionsMsgCmd({\n  trigger: \"kick\",\n  permission: \"can_kick\",\n  description: \"Kick the specified member\",\n\n  signature: [\n    {\n      user: ct.string(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:KickMsgCmd\");\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = authorMember;\n    if (args.mod) {\n      if (!(await hasPermission(await pluginData.config.getForMessage(msg), \"can_act_as_other\"))) {\n        pluginData.state.common.sendErrorMessage(msg, \"You don't have permission to use -mod\");\n        return;\n      }\n\n      mod = args.mod;\n    }\n\n    let contactMethods;\n    try {\n      contactMethods = readContactMethodsFromArgs(args);\n    } catch (e) {\n      pluginData.state.common.sendErrorMessage(msg, e.message);\n      return;\n    }\n\n    actualKickCmd(\n      pluginData,\n      msg,\n      authorMember,\n      user,\n      args.reason,\n      [...msg.attachments.values()],\n      mod,\n      contactMethods,\n      args.clean,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/kick/KickSlashCmd.ts",
    "content": "import { ChannelType, GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { hasPermission } from \"../../../../pluginUtils.js\";\nimport { UserNotificationMethod, resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualKickCmd } from \"./actualKickCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to kick as\", required: false }),\n  slashOptions.string({\n    name: \"notify\",\n    description: \"How to notify\",\n    required: false,\n    choices: [\n      { name: \"DM\", value: \"dm\" },\n      { name: \"Channel\", value: \"channel\" },\n    ],\n  }),\n  slashOptions.channel({\n    name: \"notify-channel\",\n    description: \"The channel to notify in\",\n    channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread],\n    required: false,\n  }),\n  slashOptions.boolean({\n    name: \"clean\",\n    description: \"Whether or not to delete the member's last messages\",\n    required: false,\n  }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const KickSlashCmd = modActionsSlashCmd({\n  name: \"kick\",\n  configPermission: \"can_kick\",\n  description: \"Kick the specified member\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to kick\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.reason || options.reason.trim() === \"\") && attachments.length < 1) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Text or attachment required\", undefined, undefined, true);\n\n      return;\n    }\n\n    let mod = interaction.member as GuildMember;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        pluginData.state.common.sendErrorMessage(interaction, \"You don't have permission to act as another moderator\");\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n    }\n\n    let contactMethods: UserNotificationMethod[] | undefined;\n    try {\n      contactMethods = readContactMethodsFromArgs(options) ?? undefined;\n    } catch (e) {\n      pluginData.state.common.sendErrorMessage(interaction, e.message);\n      return;\n    }\n\n    actualKickCmd(\n      pluginData,\n      interaction,\n      interaction.member as GuildMember,\n      options.user,\n      options.reason || \"\",\n      attachments,\n      mod,\n      contactMethods,\n      options.clean,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/kick/actualKickCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../../data/LogType.js\";\nimport { canActOn } from \"../../../../pluginUtils.js\";\nimport {\n  DAYS,\n  SECONDS,\n  UnknownUser,\n  UserNotificationMethod,\n  renderUsername,\n  resolveMember,\n} from \"../../../../utils.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport {\n  formatReasonWithAttachments,\n  formatReasonWithMessageLinkForAttachments,\n} from \"../../functions/formatReasonForAttachments.js\";\nimport { ignoreEvent } from \"../../functions/ignoreEvent.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { kickMember } from \"../../functions/kickMember.js\";\nimport { IgnoredEventType, ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualKickCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  author: GuildMember,\n  user: User | UnknownUser,\n  reason: string,\n  attachments: Attachment[],\n  mod: GuildMember,\n  contactMethods?: UserNotificationMethod[],\n  clean?: boolean | null,\n) {\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id);\n\n  if (!memberToKick) {\n    const banned = await isBanned(pluginData, user.id);\n    if (banned) {\n      pluginData.state.common.sendErrorMessage(context, `User is banned`);\n    } else {\n      pluginData.state.common.sendErrorMessage(context, `User not found on the server`);\n    }\n\n    return;\n  }\n\n  // Make sure we're allowed to kick this member\n  if (!canActOn(pluginData, author, memberToKick)) {\n    pluginData.state.common.sendErrorMessage(context, \"Cannot kick: insufficient permissions\");\n    return;\n  }\n\n  const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments);\n  const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments);\n\n  const kickResult = await kickMember(pluginData, memberToKick, formattedReason, formattedReasonWithAttachments, {\n    contactMethods,\n    caseArgs: {\n      modId: mod.id,\n      ppId: mod.id !== author.id ? author.id : undefined,\n    },\n  });\n\n  if (clean) {\n    pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToKick.id);\n    ignoreEvent(pluginData, IgnoredEventType.Ban, memberToKick.id);\n\n    try {\n      await memberToKick.ban({ deleteMessageSeconds: (1 * DAYS) / SECONDS, reason: \"kick -clean\" });\n    } catch {\n      pluginData.state.common.sendErrorMessage(context, \"Failed to ban the user to clean messages (-clean)\");\n    }\n\n    pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, memberToKick.id);\n    ignoreEvent(pluginData, IgnoredEventType.Unban, memberToKick.id);\n\n    try {\n      await pluginData.guild.bans.remove(memberToKick.id, \"kick -clean\");\n    } catch {\n      pluginData.state.common.sendErrorMessage(context, \"Failed to unban the user after banning them (-clean)\");\n    }\n  }\n\n  if (kickResult.status === \"failed\") {\n    pluginData.state.common.sendErrorMessage(context, `Failed to kick user`);\n    return;\n  }\n\n  // Confirm the action to the moderator\n  let response = `Kicked **${renderUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`;\n\n  if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`;\n  pluginData.state.common.sendSuccessMessage(context, response);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/massban/MassBanMsgCmd.ts",
    "content": "import { waitForReply } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualMassBanCmd } from \"./actualMassBanCmd.js\";\n\nexport const MassBanMsgCmd = modActionsMsgCmd({\n  trigger: \"massban\",\n  permission: \"can_massban\",\n  description: \"Mass-ban a list of user IDs\",\n\n  signature: [\n    {\n      userIds: ct.string({ rest: true }),\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    // Ask for ban reason (cleaner this way instead of trying to cram it into the args)\n    msg.reply(\"Ban reason? `cancel` to cancel\");\n    const banReasonReply = await waitForReply(pluginData.client, msg.channel, msg.author.id);\n\n    if (!banReasonReply || !banReasonReply.content || banReasonReply.content.toLowerCase().trim() === \"cancel\") {\n      pluginData.state.common.sendErrorMessage(msg, \"Cancelled\");\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n    actualMassBanCmd(pluginData, msg, args.userIds, authorMember, banReasonReply.content, [\n      ...banReasonReply.attachments.values(),\n    ]);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/massban/MassBanSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualMassBanCmd } from \"./actualMassBanCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const MassBanSlashCmd = modActionsSlashCmd({\n  name: \"massban\",\n  configPermission: \"can_massban\",\n  description: \"Mass-ban a list of user IDs\",\n  allowDms: false,\n\n  signature: [\n    slashOptions.string({ name: \"user-ids\", description: \"The list of user IDs to ban\", required: true }),\n\n    ...opts,\n  ],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.reason || options.reason.trim() === \"\") && attachments.length < 1) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Text or attachment required\", undefined, undefined, true);\n\n      return;\n    }\n\n    actualMassBanCmd(\n      pluginData,\n      interaction,\n      options[\"user-ids\"].split(/\\D+/),\n      interaction.member as GuildMember,\n      options.reason || \"\",\n      attachments,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/massban/actualMassBanCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { LogType } from \"../../../../data/LogType.js\";\nimport { humanizeDurationShort } from \"../../../../humanizeDuration.js\";\nimport {\n  canActOn,\n  deleteContextResponse,\n  editContextResponse,\n  getConfigForContext,\n  isContextInteraction,\n  sendContextResponse,\n} from \"../../../../pluginUtils.js\";\nimport { DAYS, MINUTES, SECONDS, noop } from \"../../../../utils.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../../Logs/LogsPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport {\n  formatReasonWithAttachments,\n  formatReasonWithMessageLinkForAttachments,\n} from \"../../functions/formatReasonForAttachments.js\";\nimport { ignoreEvent } from \"../../functions/ignoreEvent.js\";\nimport { IgnoredEventType, ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualMassBanCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  userIds: string[],\n  author: GuildMember,\n  reason: string,\n  attachments: Attachment[],\n) {\n  // Limit to 100 users at once (arbitrary?)\n  if (userIds.length > 100) {\n    pluginData.state.common.sendErrorMessage(context, `Can only massban max 100 users at once`);\n    return;\n  }\n\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  const banReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments);\n  const banReasonWithAttachments = formatReasonWithAttachments(reason, attachments);\n\n  // Verify we can act on each of the users specified\n  for (const userId of userIds) {\n    const member = pluginData.guild.members.cache.get(userId as Snowflake); // TODO: Get members on demand?\n    if (member && !canActOn(pluginData, author, member)) {\n      pluginData.state.common.sendErrorMessage(context, \"Cannot massban one or more users: insufficient permissions\");\n      return;\n    }\n  }\n\n  // Show a loading indicator since this can take a while\n  const maxWaitTime = pluginData.state.massbanQueue.timeout * pluginData.state.massbanQueue.length;\n  const maxWaitTimeFormatted = humanizeDurationShort(maxWaitTime, { round: true });\n  const initialLoadingText =\n    pluginData.state.massbanQueue.length === 0\n      ? \"Banning...\"\n      : `Massban queued. Waiting for previous massban to finish (max wait ${maxWaitTimeFormatted}).`;\n  const loadingMsg = await sendContextResponse(context, initialLoadingText, true);\n\n  const waitTimeStart = performance.now();\n  const waitingInterval = setInterval(() => {\n    const waitTime = humanizeDurationShort(performance.now() - waitTimeStart, { round: true });\n    const waitMessageContent = `Massban queued. Still waiting for previous massban to finish (waited ${waitTime}).`;\n\n    editContextResponse(loadingMsg, waitMessageContent).catch(() => clearInterval(waitingInterval));\n  }, 1 * MINUTES);\n\n  pluginData.state.massbanQueue.add(async () => {\n    clearInterval(waitingInterval);\n\n    if (pluginData.state.unloaded) {\n      await deleteContextResponse(loadingMsg);\n      return;\n    }\n\n    editContextResponse(loadingMsg, \"Banning...\").catch(noop);\n\n    // Ban each user and count failed bans (if any)\n    const startTime = performance.now();\n    const failedBans: string[] = [];\n    const casesPlugin = pluginData.getPlugin(CasesPlugin);\n    const messageConfig = await getConfigForContext(pluginData.config, context);\n    const deleteDays = messageConfig.ban_delete_message_days;\n\n    for (const [i, userId] of userIds.entries()) {\n      if (pluginData.state.unloaded) {\n        break;\n      }\n\n      try {\n        // Ignore automatic ban cases and logs\n        // We create our own cases below and post a single \"mass banned\" log instead\n        ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 30 * MINUTES);\n        pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 30 * MINUTES);\n\n        await pluginData.guild.bans.create(userId as Snowflake, {\n          deleteMessageSeconds: (deleteDays * DAYS) / SECONDS,\n          reason: banReasonWithAttachments,\n        });\n\n        await casesPlugin.createCase({\n          userId,\n          modId: author.id,\n          type: CaseTypes.Ban,\n          reason: `Mass ban: ${banReason}`,\n          postInCaseLogOverride: false,\n        });\n\n        pluginData.state.events.emit(\"ban\", userId, banReason);\n      } catch {\n        failedBans.push(userId);\n      }\n\n      // Send a status update every 10 bans\n      if ((i + 1) % 10 === 0) {\n        const newLoadingMessageContent = `Banning... ${i + 1}/${userIds.length}`;\n\n        if (isContextInteraction(context)) {\n          void context.editReply(newLoadingMessageContent).catch(noop);\n        } else {\n          loadingMsg.edit(newLoadingMessageContent).catch(noop);\n        }\n      }\n    }\n\n    const totalTime = performance.now() - startTime;\n    const formattedTimeTaken = humanizeDurationShort(totalTime, { round: true });\n\n    if (!isContextInteraction(context)) {\n      // Clear loading indicator\n      loadingMsg.delete().catch(noop);\n    }\n\n    const successfulBanCount = userIds.length - failedBans.length;\n    if (successfulBanCount === 0) {\n      // All bans failed - don't create a log entry and notify the user\n      pluginData.state.common.sendErrorMessage(context, \"All bans failed. Make sure the IDs are valid.\");\n    } else {\n      // Some or all bans were successful. Create a log entry for the mass ban and notify the user.\n      pluginData.getPlugin(LogsPlugin).logMassBan({\n        mod: author.user,\n        count: successfulBanCount,\n        reason: banReason,\n      });\n\n      if (failedBans.length) {\n        pluginData.state.common.sendSuccessMessage(\n          context,\n          `Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${failedBans.length} failed: ${failedBans.join(\n            \" \",\n          )}`,\n        );\n      } else {\n        pluginData.state.common.sendSuccessMessage(\n          context,\n          `Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`,\n        );\n      }\n    }\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/massmute/MassMuteMsgCmd.ts",
    "content": "import { waitForReply } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualMassMuteCmd } from \"./actualMassMuteCmd.js\";\n\nexport const MassMuteMsgCmd = modActionsMsgCmd({\n  trigger: \"massmute\",\n  permission: \"can_massmute\",\n  description: \"Mass-mute a list of user IDs\",\n\n  signature: [\n    {\n      userIds: ct.string({ rest: true }),\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    // Ask for mute reason\n    msg.reply(\"Mute reason? `cancel` to cancel\");\n    const muteReasonReceived = await waitForReply(pluginData.client, msg.channel, msg.author.id);\n    if (\n      !muteReasonReceived ||\n      !muteReasonReceived.content ||\n      muteReasonReceived.content.toLowerCase().trim() === \"cancel\"\n    ) {\n      pluginData.state.common.sendErrorMessage(msg, \"Cancelled\");\n      return;\n    }\n\n    const member = await resolveMessageMember(msg);\n    actualMassMuteCmd(pluginData, msg, args.userIds, member, muteReasonReceived.content, [\n      ...muteReasonReceived.attachments.values(),\n    ]);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/massmute/MassMuteSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualMassMuteCmd } from \"./actualMassMuteCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const MassMuteSlashSlashCmd = modActionsSlashCmd({\n  name: \"massmute\",\n  configPermission: \"can_massmute\",\n  description: \"Mass-mute a list of user IDs\",\n  allowDms: false,\n\n  signature: [\n    slashOptions.string({ name: \"user-ids\", description: \"The list of user IDs to mute\", required: true }),\n\n    ...opts,\n  ],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.reason || options.reason.trim() === \"\") && attachments.length < 1) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Text or attachment required\", undefined, undefined, true);\n\n      return;\n    }\n\n    actualMassMuteCmd(\n      pluginData,\n      interaction,\n      options[\"user-ids\"].split(/\\D+/),\n      interaction.member as GuildMember,\n      options.reason || \"\",\n      attachments,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/massmute/actualMassMuteCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../../data/LogType.js\";\nimport { logger } from \"../../../../logger.js\";\nimport { canActOn, deleteContextResponse, isContextInteraction, sendContextResponse } from \"../../../../pluginUtils.js\";\nimport { noop } from \"../../../../utils.js\";\nimport { LogsPlugin } from \"../../../Logs/LogsPlugin.js\";\nimport { MutesPlugin } from \"../../../Mutes/MutesPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport {\n  formatReasonWithAttachments,\n  formatReasonWithMessageLinkForAttachments,\n} from \"../../functions/formatReasonForAttachments.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualMassMuteCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  userIds: string[],\n  author: GuildMember,\n  reason: string,\n  attachments: Attachment[],\n) {\n  // Limit to 100 users at once (arbitrary?)\n  if (userIds.length > 100) {\n    pluginData.state.common.sendErrorMessage(context, `Can only massmute max 100 users at once`);\n    return;\n  }\n\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  const muteReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments);\n  const muteReasonWithAttachments = formatReasonWithAttachments(reason, attachments);\n\n  // Verify we can act upon all users\n  for (const userId of userIds) {\n    const member = pluginData.guild.members.cache.get(userId as Snowflake);\n    if (member && !canActOn(pluginData, author, member)) {\n      pluginData.state.common.sendErrorMessage(context, \"Cannot massmute one or more users: insufficient permissions\");\n      return;\n    }\n  }\n\n  // Ignore automatic mute cases and logs for these users\n  // We'll create our own cases below and post a single \"mass muted\" log instead\n  userIds.forEach((userId) => {\n    // Use longer timeouts since this can take a while\n    pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_MUTE, userId, 120 * 1000);\n  });\n\n  // Show loading indicator\n  const loadingMsg = await sendContextResponse(context, \"Muting...\", true);\n\n  // Mute everyone and count fails\n  const modId = author.id;\n  const failedMutes: string[] = [];\n  const mutesPlugin = pluginData.getPlugin(MutesPlugin);\n  for (const userId of userIds) {\n    try {\n      await mutesPlugin.muteUser(userId, 0, `Mass mute: ${muteReason}`, `Mass mute: ${muteReasonWithAttachments}`, {\n        caseArgs: {\n          modId,\n        },\n      });\n    } catch (e) {\n      logger.info(e);\n      failedMutes.push(userId);\n    }\n  }\n\n  if (!isContextInteraction(context)) {\n    // Clear loading indicator\n    deleteContextResponse(loadingMsg).catch(noop);\n  }\n\n  const successfulMuteCount = userIds.length - failedMutes.length;\n  if (successfulMuteCount === 0) {\n    // All mutes failed\n    pluginData.state.common.sendErrorMessage(context, \"All mutes failed. Make sure the IDs are valid.\");\n  } else {\n    // Success on all or some mutes\n    pluginData.getPlugin(LogsPlugin).logMassMute({\n      mod: author.user,\n      count: successfulMuteCount,\n    });\n\n    if (failedMutes.length) {\n      pluginData.state.common.sendSuccessMessage(\n        context,\n        `Muted ${successfulMuteCount} users, ${failedMutes.length} failed: ${failedMutes.join(\" \")}`,\n      );\n    } else {\n      pluginData.state.common.sendSuccessMessage(context, `Muted ${successfulMuteCount} users successfully`);\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/massunban/MassUnbanMsgCmd.ts",
    "content": "import { waitForReply } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualMassUnbanCmd } from \"./actualMassUnbanCmd.js\";\n\nexport const MassUnbanMsgCmd = modActionsMsgCmd({\n  trigger: \"massunban\",\n  permission: \"can_massunban\",\n  description: \"Mass-unban a list of user IDs\",\n\n  signature: [\n    {\n      userIds: ct.string({ rest: true }),\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    // Ask for unban reason (cleaner this way instead of trying to cram it into the args)\n    msg.reply(\"Unban reason? `cancel` to cancel\");\n    const unbanReasonReply = await waitForReply(pluginData.client, msg.channel, msg.author.id);\n    if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === \"cancel\") {\n      pluginData.state.common.sendErrorMessage(msg, \"Cancelled\");\n      return;\n    }\n\n    const member = await resolveMessageMember(msg);\n    actualMassUnbanCmd(pluginData, msg, args.userIds, member, unbanReasonReply.content, [\n      ...unbanReasonReply.attachments.values(),\n    ]);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/massunban/MassUnbanSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualMassUnbanCmd } from \"./actualMassUnbanCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const MassUnbanSlashCmd = modActionsSlashCmd({\n  name: \"massunban\",\n  configPermission: \"can_massunban\",\n  description: \"Mass-unban a list of user IDs\",\n  allowDms: false,\n\n  signature: [\n    slashOptions.string({ name: \"user-ids\", description: \"The list of user IDs to unban\", required: true }),\n\n    ...opts,\n  ],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.reason || options.reason.trim() === \"\") && attachments.length < 1) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Text or attachment required\", undefined, undefined, true);\n\n      return;\n    }\n\n    actualMassUnbanCmd(\n      pluginData,\n      interaction,\n      options[\"user-ids\"].split(/[\\s,\\r\\n]+/),\n      interaction.member as GuildMember,\n      options.reason || \"\",\n      attachments,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/massunban/actualMassUnbanCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { LogType } from \"../../../../data/LogType.js\";\nimport { deleteContextResponse, isContextInteraction, sendContextResponse } from \"../../../../pluginUtils.js\";\nimport { MINUTES, noop } from \"../../../../utils.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../../Logs/LogsPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport { formatReasonWithMessageLinkForAttachments } from \"../../functions/formatReasonForAttachments.js\";\nimport { ignoreEvent } from \"../../functions/ignoreEvent.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { IgnoredEventType, ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualMassUnbanCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  userIds: string[],\n  author: GuildMember,\n  reason: string,\n  attachments: Attachment[],\n) {\n  // Limit to 100 users at once (arbitrary?)\n  if (userIds.length > 100) {\n    pluginData.state.common.sendErrorMessage(context, `Can only mass-unban max 100 users at once`);\n    return;\n  }\n\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  const unbanReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments);\n\n  // Ignore automatic unban cases and logs for these users\n  // We'll create our own cases below and post a single \"mass unbanned\" log instead\n  userIds.forEach((userId) => {\n    // Use longer timeouts since this can take a while\n    ignoreEvent(pluginData, IgnoredEventType.Unban, userId, 2 * MINUTES);\n    pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 2 * MINUTES);\n  });\n\n  // Show a loading indicator since this can take a while\n  const loadingMsg = await sendContextResponse(context, { content: \"Unbanning...\", ephemeral: true });\n\n  // Unban each user and count failed unbans (if any)\n  const failedUnbans: Array<{ userId: string; reason: UnbanFailReasons }> = [];\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  for (const userId of userIds) {\n    if (!(await isBanned(pluginData, userId))) {\n      failedUnbans.push({ userId, reason: UnbanFailReasons.NOT_BANNED });\n      continue;\n    }\n\n    try {\n      await pluginData.guild.bans.remove(userId as Snowflake, unbanReason ?? undefined);\n\n      await casesPlugin.createCase({\n        userId,\n        modId: author.id,\n        type: CaseTypes.Unban,\n        reason: `Mass unban: ${unbanReason}`,\n        postInCaseLogOverride: false,\n      });\n    } catch {\n      failedUnbans.push({ userId, reason: UnbanFailReasons.UNBAN_FAILED });\n    }\n  }\n\n  if (!isContextInteraction(context)) {\n    // Clear loading indicator\n    await deleteContextResponse(loadingMsg).catch(noop);\n  }\n\n  const successfulUnbanCount = userIds.length - failedUnbans.length;\n  if (successfulUnbanCount === 0) {\n    // All unbans failed - don't create a log entry and notify the user\n    pluginData.state.common.sendErrorMessage(context, \"All unbans failed. Make sure the IDs are valid and banned.\");\n  } else {\n    // Some or all unbans were successful. Create a log entry for the mass unban and notify the user.\n    pluginData.getPlugin(LogsPlugin).logMassUnban({\n      mod: author.user,\n      count: successfulUnbanCount,\n      reason: unbanReason,\n    });\n\n    if (failedUnbans.length) {\n      const notBanned = failedUnbans.filter((x) => x.reason === UnbanFailReasons.NOT_BANNED);\n      const unbanFailed = failedUnbans.filter((x) => x.reason === UnbanFailReasons.UNBAN_FAILED);\n\n      let failedMsg = \"\";\n      if (notBanned.length > 0) {\n        failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`;\n        notBanned.forEach((fail) => {\n          failedMsg += \" \" + fail.userId;\n        });\n      }\n      if (unbanFailed.length > 0) {\n        failedMsg += `\\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`;\n        unbanFailed.forEach((fail) => {\n          failedMsg += \" \" + fail.userId;\n        });\n      }\n\n      pluginData.state.common.sendSuccessMessage(\n        context,\n        `Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\\n${failedMsg}`,\n      );\n    } else {\n      pluginData.state.common.sendSuccessMessage(context, `Unbanned ${successfulUnbanCount} users successfully`);\n    }\n  }\n}\n\nenum UnbanFailReasons {\n  NOT_BANNED = \"Not banned\",\n  UNBAN_FAILED = \"Unban failed\",\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { canActOn, hasPermission, resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { resolveMember, resolveUser } from \"../../../../utils.js\";\nimport { waitForButtonConfirm } from \"../../../../utils/waitForInteraction.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualMuteCmd } from \"./actualMuteCmd.js\";\n\nconst opts = {\n  mod: ct.member({ option: true }),\n  notify: ct.string({ option: true }),\n  \"notify-channel\": ct.textChannel({ option: true }),\n};\n\nexport const MuteMsgCmd = modActionsMsgCmd({\n  trigger: \"mute\",\n  permission: \"can_mute\",\n  description: \"Mute the specified member\",\n\n  signature: [\n    {\n      user: ct.string(),\n      time: ct.delay(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n    {\n      user: ct.string(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:MuteMsgCmd\");\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n    const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id);\n\n    if (!memberToMute) {\n      const _isBanned = await isBanned(pluginData, user.id);\n      const prefix = pluginData.fullConfig.prefix;\n      if (_isBanned) {\n        pluginData.state.common.sendErrorMessage(\n          msg,\n          `User is banned. Use \\`${prefix}forcemute\\` if you want to mute them anyway.`,\n        );\n        return;\n      } else {\n        // Ask the mod if we should upgrade to a forcemute as the user is not on the server\n        const reply = await waitForButtonConfirm(\n          msg,\n          { content: \"User not found on the server, forcemute instead?\" },\n          { confirmText: \"Yes\", cancelText: \"No\", restrictToId: authorMember.id },\n        );\n\n        if (!reply) {\n          pluginData.state.common.sendErrorMessage(msg, \"User not on server, mute cancelled by moderator\");\n          return;\n        }\n      }\n    }\n\n    // Make sure we're allowed to mute this member\n    if (memberToMute && !canActOn(pluginData, authorMember, memberToMute)) {\n      pluginData.state.common.sendErrorMessage(msg, \"Cannot mute: insufficient permissions\");\n      return;\n    }\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = authorMember;\n    let ppId: string | undefined;\n\n    if (args.mod) {\n      if (!(await hasPermission(pluginData, \"can_act_as_other\", { message: msg }))) {\n        pluginData.state.common.sendErrorMessage(msg, \"You don't have permission to use -mod\");\n        return;\n      }\n\n      mod = args.mod;\n      ppId = msg.author.id;\n    }\n\n    let contactMethods;\n    try {\n      contactMethods = readContactMethodsFromArgs(args);\n    } catch (e) {\n      pluginData.state.common.sendErrorMessage(msg, e.message);\n      return;\n    }\n\n    actualMuteCmd(\n      pluginData,\n      msg,\n      user,\n      [...msg.attachments.values()],\n      mod,\n      ppId,\n      \"time\" in args ? (args.time ?? undefined) : undefined,\n      args.reason,\n      contactMethods,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/mute/MuteSlashCmd.ts",
    "content": "import { ChannelType, GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { canActOn, hasPermission } from \"../../../../pluginUtils.js\";\nimport { UserNotificationMethod, convertDelayStringToMS, resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { waitForButtonConfirm } from \"../../../../utils/waitForInteraction.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualMuteCmd } from \"./actualMuteCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"time\", description: \"The duration of the mute\", required: false }),\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to mute as\", required: false }),\n  slashOptions.string({\n    name: \"notify\",\n    description: \"How to notify\",\n    required: false,\n    choices: [\n      { name: \"DM\", value: \"dm\" },\n      { name: \"Channel\", value: \"channel\" },\n    ],\n  }),\n  slashOptions.channel({\n    name: \"notify-channel\",\n    description: \"The channel to notify in\",\n    channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread],\n    required: false,\n  }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const MuteSlashCmd = modActionsSlashCmd({\n  name: \"mute\",\n  configPermission: \"can_mute\",\n  description: \"Mute the specified member\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to mute\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n    const memberToMute = await resolveMember(pluginData.client, pluginData.guild, options.user.id);\n\n    if (!memberToMute) {\n      const _isBanned = await isBanned(pluginData, options.user.id);\n      const prefix = pluginData.fullConfig.prefix;\n      if (_isBanned) {\n        pluginData.state.common.sendErrorMessage(\n          interaction,\n          `User is banned. Use \\`${prefix}forcemute\\` if you want to mute them anyway.`,\n        );\n        return;\n      } else {\n        // Ask the mod if we should upgrade to a forcemute as the user is not on the server\n        const reply = await waitForButtonConfirm(\n          interaction,\n          { content: \"User not found on the server, forcemute instead?\" },\n          { confirmText: \"Yes\", cancelText: \"No\", restrictToId: interaction.user.id },\n        );\n\n        if (!reply) {\n          pluginData.state.common.sendErrorMessage(interaction, \"User not on server, mute cancelled by moderator\");\n          return;\n        }\n      }\n    }\n\n    // Make sure we're allowed to mute this member\n    if (memberToMute && !canActOn(pluginData, interaction.member as GuildMember, memberToMute)) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Cannot mute: insufficient permissions\");\n      return;\n    }\n\n    let mod = interaction.member as GuildMember;\n    let ppId: string | undefined;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        pluginData.state.common.sendErrorMessage(interaction, \"You don't have permission to act as another moderator\");\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n      ppId = interaction.user.id;\n    }\n\n    const convertedTime = options.time ? (convertDelayStringToMS(options.time) ?? undefined) : undefined;\n    if (options.time && !convertedTime) {\n      pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`);\n      return;\n    }\n\n    let contactMethods: UserNotificationMethod[] | undefined;\n    try {\n      contactMethods = readContactMethodsFromArgs(options) ?? undefined;\n    } catch (e) {\n      pluginData.state.common.sendErrorMessage(interaction, e.message);\n      return;\n    }\n\n    actualMuteCmd(\n      pluginData,\n      interaction,\n      options.user,\n      attachments,\n      mod,\n      ppId,\n      convertedTime,\n      options.reason,\n      contactMethods,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/mute/actualMuteCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { ERRORS, RecoverablePluginError } from \"../../../../RecoverablePluginError.js\";\nimport { humanizeDuration } from \"../../../../humanizeDuration.js\";\nimport { logger } from \"../../../../logger.js\";\nimport {\n  UnknownUser,\n  UserNotificationMethod,\n  asSingleLine,\n  isDiscordAPIError,\n  renderUsername,\n} from \"../../../../utils.js\";\nimport { MutesPlugin } from \"../../../Mutes/MutesPlugin.js\";\nimport { MuteResult } from \"../../../Mutes/types.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport {\n  formatReasonWithAttachments,\n  formatReasonWithMessageLinkForAttachments,\n} from \"../../functions/formatReasonForAttachments.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\n/**\n * The actual function run by both !mute and !forcemute.\n * The only difference between the two commands is in target member validation.\n */\nexport async function actualMuteCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  user: User | UnknownUser,\n  attachments: Attachment[],\n  mod: GuildMember,\n  ppId?: string,\n  time?: number,\n  reason?: string | null,\n  contactMethods?: UserNotificationMethod[],\n) {\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  const timeUntilUnmute = time && humanizeDuration(time);\n  const formattedReason =\n    reason || attachments.length > 0\n      ? await formatReasonWithMessageLinkForAttachments(pluginData, reason ?? \"\", context, attachments)\n      : undefined;\n  const formattedReasonWithAttachments =\n    reason || attachments.length > 0 ? formatReasonWithAttachments(reason ?? \"\", attachments) : undefined;\n\n  let muteResult: MuteResult;\n  const mutesPlugin = pluginData.getPlugin(MutesPlugin);\n\n  try {\n    muteResult = await mutesPlugin.muteUser(user.id, time, formattedReason, formattedReasonWithAttachments, {\n      contactMethods,\n      caseArgs: {\n        modId: mod.id,\n        ppId,\n      },\n    });\n  } catch (e) {\n    if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {\n      pluginData.state.common.sendErrorMessage(context, \"Could not mute the user: no mute role set in config\");\n    } else if (isDiscordAPIError(e) && e.code === 10007) {\n      pluginData.state.common.sendErrorMessage(context, \"Could not mute the user: unknown member\");\n    } else {\n      logger.error(`Failed to mute user ${user.id}: ${e.stack}`);\n      if (user.id == null) {\n        // FIXME: Debug\n        // tslint:disable-next-line:no-console\n        console.trace(\"[DEBUG] Null user.id for mute\");\n      }\n      pluginData.state.common.sendErrorMessage(context, \"Could not mute the user\");\n    }\n\n    return;\n  }\n\n  // Confirm the action to the moderator\n  let response: string;\n  if (time) {\n    if (muteResult.updatedExistingMute) {\n      response = asSingleLine(`\n        Updated **${renderUsername(user)}**'s\n        mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number})\n      `);\n    } else {\n      response = asSingleLine(`\n        Muted **${renderUsername(user)}**\n        for ${timeUntilUnmute} (Case #${muteResult.case.case_number})\n      `);\n    }\n  } else {\n    if (muteResult.updatedExistingMute) {\n      response = asSingleLine(`\n        Updated **${renderUsername(user)}**'s\n        mute to indefinite (Case #${muteResult.case.case_number})\n      `);\n    } else {\n      response = asSingleLine(`\n        Muted **${renderUsername(user)}**\n        indefinitely (Case #${muteResult.case.case_number})\n      `);\n    }\n  }\n\n  if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`;\n  pluginData.state.common.sendSuccessMessage(context, response);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { resolveUser } from \"../../../../utils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualNoteCmd } from \"./actualNoteCmd.js\";\n\nexport const NoteMsgCmd = modActionsMsgCmd({\n  trigger: \"note\",\n  permission: \"can_note\",\n  description: \"Add a note to the specified user\",\n\n  signature: {\n    user: ct.string(),\n    note: ct.string({ required: false, catchAll: true }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:NoteMsgCmd\");\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    if (!args.note && msg.attachments.size === 0) {\n      pluginData.state.common.sendErrorMessage(msg, \"Text or attachment required\");\n      return;\n    }\n\n    actualNoteCmd(pluginData, msg, msg.author, [...msg.attachments.values()], user, args.note || \"\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts",
    "content": "import { slashOptions } from \"vety\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualNoteCmd } from \"./actualNoteCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"note\", description: \"The note to add to the user\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the note\",\n  }),\n];\n\nexport const NoteSlashCmd = modActionsSlashCmd({\n  name: \"note\",\n  configPermission: \"can_note\",\n  description: \"Add a note to the specified user\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to add a note to\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.note || options.note.trim() === \"\") && attachments.length < 1) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Text or attachment required\", undefined, undefined, true);\n\n      return;\n    }\n\n    actualNoteCmd(pluginData, interaction, interaction.user, attachments, options.user, options.note || \"\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/note/actualNoteCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, Message, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { UnknownUser, renderUsername } from \"../../../../utils.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../../Logs/LogsPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport { formatReasonWithMessageLinkForAttachments } from \"../../functions/formatReasonForAttachments.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualNoteCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  author: User,\n  attachments: Array<Attachment>,\n  user: User | UnknownUser,\n  note: string,\n) {\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, note)) {\n    return;\n  }\n\n  const userName = renderUsername(user);\n  const reason = await formatReasonWithMessageLinkForAttachments(pluginData, note, context, attachments);\n\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const createdCase = await casesPlugin.createCase({\n    userId: user.id,\n    modId: author.id,\n    type: CaseTypes.Note,\n    reason,\n  });\n\n  pluginData.getPlugin(LogsPlugin).logMemberNote({\n    mod: author,\n    user,\n    caseNumber: createdCase.case_number,\n    reason,\n  });\n\n  pluginData.state.common.sendSuccessMessage(\n    context,\n    `Note added on **${userName}** (Case #${createdCase.case_number})`,\n    undefined,\n    undefined,\n    true,\n  );\n\n  pluginData.state.events.emit(\"note\", user.id, reason);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/unban/UnbanMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { hasPermission, resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { resolveUser } from \"../../../../utils.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualUnbanCmd } from \"./actualUnbanCmd.js\";\n\nconst opts = {\n  mod: ct.member({ option: true }),\n};\n\nexport const UnbanMsgCmd = modActionsMsgCmd({\n  trigger: \"unban\",\n  permission: \"can_unban\",\n  description: \"Unban the specified member\",\n\n  signature: [\n    {\n      user: ct.string(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:UnbanMsgCmd\");\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = authorMember;\n    if (args.mod) {\n      if (!(await hasPermission(pluginData, \"can_act_as_other\", { message: msg, channelId: msg.channel.id }))) {\n        pluginData.state.common.sendErrorMessage(msg, \"You don't have permission to use -mod\");\n        return;\n      }\n\n      mod = args.mod;\n    }\n\n    actualUnbanCmd(pluginData, msg, msg.author.id, user, args.reason, [...msg.attachments.values()], mod);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/unban/UnbanSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { hasPermission } from \"../../../../pluginUtils.js\";\nimport { resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualUnbanCmd } from \"./actualUnbanCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to unban as\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const UnbanSlashCmd = modActionsSlashCmd({\n  name: \"unban\",\n  configPermission: \"can_unban\",\n  description: \"Unban the specified member\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to unban\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    let mod = interaction.member as GuildMember;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        pluginData.state.common.sendErrorMessage(interaction, \"You don't have permission to act as another moderator\");\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n    }\n\n    actualUnbanCmd(pluginData, interaction, interaction.user.id, options.user, options.reason ?? \"\", attachments, mod);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/unban/actualUnbanCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { LogType } from \"../../../../data/LogType.js\";\nimport { clearExpiringTempban } from \"../../../../data/loops/expiringTempbansLoop.js\";\nimport { UnknownUser } from \"../../../../utils.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../../Logs/LogsPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport { formatReasonWithMessageLinkForAttachments } from \"../../functions/formatReasonForAttachments.js\";\nimport { ignoreEvent } from \"../../functions/ignoreEvent.js\";\nimport { IgnoredEventType, ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualUnbanCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  authorId: string,\n  user: User | UnknownUser,\n  reason: string,\n  attachments: Array<Attachment>,\n  mod: GuildMember,\n) {\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id);\n  const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments);\n\n  try {\n    ignoreEvent(pluginData, IgnoredEventType.Unban, user.id);\n    await pluginData.guild.bans.remove(user.id as Snowflake, formattedReason ?? undefined);\n  } catch {\n    pluginData.state.common.sendErrorMessage(context, \"Failed to unban member; are you sure they're banned?\");\n    return;\n  }\n\n  // Create a case\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const createdCase = await casesPlugin.createCase({\n    userId: user.id,\n    modId: mod.id,\n    type: CaseTypes.Unban,\n    reason: formattedReason,\n    ppId: mod.id !== authorId ? authorId : undefined,\n  });\n\n  // Delete the tempban, if one exists\n  const tempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id);\n  if (tempban) {\n    clearExpiringTempban(tempban);\n    await pluginData.state.tempbans.clear(user.id);\n  }\n\n  // Confirm the action\n  pluginData.state.common.sendSuccessMessage(context, `Member unbanned (Case #${createdCase.case_number})`);\n\n  // Log the action\n  pluginData.getPlugin(LogsPlugin).logMemberUnban({\n    mod: mod.user,\n    userId: user.id,\n    caseNumber: createdCase.case_number,\n    reason: formattedReason ?? \"\",\n  });\n\n  pluginData.state.events.emit(\"unban\", user.id);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualUnhideCaseCmd } from \"./actualUnhideCaseCmd.js\";\n\nexport const UnhideCaseMsgCmd = modActionsMsgCmd({\n  trigger: [\"unhide\", \"unhidecase\", \"unhide_case\"],\n  permission: \"can_hidecase\",\n  description: \"Un-hide the specified case, making it appear in !cases and !info again\",\n\n  signature: [\n    {\n      caseNum: ct.number({ rest: true }),\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    actualUnhideCaseCmd(pluginData, msg, args.caseNum);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseSlashCmd.ts",
    "content": "import { slashOptions } from \"vety\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { actualUnhideCaseCmd } from \"./actualUnhideCaseCmd.js\";\n\nexport const UnhideCaseSlashCmd = modActionsSlashCmd({\n  name: \"unhidecase\",\n  configPermission: \"can_hidecase\",\n  description: \"Un-hide the specified case\",\n  allowDms: false,\n\n  signature: [\n    slashOptions.string({ name: \"case-number\", description: \"The number of the case to unhide\", required: true }),\n  ],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    actualUnhideCaseCmd(pluginData, interaction, options[\"case-number\"].split(/\\D+/).map(Number));\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/unhidecase/actualUnhideCaseCmd.ts",
    "content": "import { ChatInputCommandInteraction, Message } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualUnhideCaseCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  caseNumbers: number[],\n) {\n  const failed: number[] = [];\n\n  for (const num of caseNumbers) {\n    const theCase = await pluginData.state.cases.findByCaseNumber(num);\n    if (!theCase) {\n      failed.push(num);\n      continue;\n    }\n\n    await pluginData.state.cases.setHidden(theCase.id, false);\n  }\n\n  if (failed.length === caseNumbers.length) {\n    pluginData.state.common.sendErrorMessage(context, \"None of the cases were found!\");\n    return;\n  }\n\n  const failedAddendum =\n    failed.length > 0\n      ? `\\nThe following cases were not found: ${failed.toString().replace(new RegExp(\",\", \"g\"), \", \")}`\n      : \"\";\n\n  const amt = caseNumbers.length - failed.length;\n  pluginData.state.common.sendSuccessMessage(\n    context,\n    `${amt} case${amt === 1 ? \" is\" : \"s are\"} no longer hidden!${failedAddendum}`,\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { canActOn, hasPermission, resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { resolveMember, resolveUser } from \"../../../../utils.js\";\nimport { waitForButtonConfirm } from \"../../../../utils/waitForInteraction.js\";\nimport { MutesPlugin } from \"../../../Mutes/MutesPlugin.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualUnmuteCmd } from \"./actualUnmuteCmd.js\";\n\nconst opts = {\n  mod: ct.member({ option: true }),\n};\n\nexport const UnmuteMsgCmd = modActionsMsgCmd({\n  trigger: \"unmute\",\n  permission: \"can_mute\",\n  description: \"Unmute the specified member\",\n\n  signature: [\n    {\n      user: ct.string(),\n      time: ct.delay(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n    {\n      user: ct.string(),\n      reason: ct.string({ required: false, catchAll: true }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:UnmuteMsgCmd\");\n    if (!user.id) {\n      pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n    const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id);\n    const mutesPlugin = pluginData.getPlugin(MutesPlugin);\n    const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute);\n\n    // Check if they're muted in the first place\n    if (\n      !(await pluginData.state.mutes.isMuted(user.id)) &&\n      !hasMuteRole &&\n      !memberToUnmute?.isCommunicationDisabled()\n    ) {\n      pluginData.state.common.sendErrorMessage(msg, \"Cannot unmute: member is not muted\");\n      return;\n    }\n\n    if (!memberToUnmute) {\n      const banned = await isBanned(pluginData, user.id);\n      const prefix = pluginData.fullConfig.prefix;\n      if (banned) {\n        pluginData.state.common.sendErrorMessage(\n          msg,\n          `User is banned. Use \\`${prefix}forceunmute\\` to unmute them anyway.`,\n        );\n        return;\n      } else {\n        // Ask the mod if we should upgrade to a forceunmute as the user is not on the server\n        const reply = await waitForButtonConfirm(\n          msg,\n          { content: \"User not on server, forceunmute instead?\" },\n          { confirmText: \"Yes\", cancelText: \"No\", restrictToId: authorMember.id },\n        );\n\n        if (!reply) {\n          pluginData.state.common.sendErrorMessage(msg, \"User not on server, unmute cancelled by moderator\");\n          return;\n        }\n      }\n    }\n\n    // Make sure we're allowed to unmute this member\n    if (memberToUnmute && !canActOn(pluginData, authorMember, memberToUnmute)) {\n      pluginData.state.common.sendErrorMessage(msg, \"Cannot unmute: insufficient permissions\");\n      return;\n    }\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = authorMember;\n    let ppId: string | undefined;\n\n    if (args.mod) {\n      if (!(await hasPermission(pluginData, \"can_act_as_other\", { message: msg }))) {\n        pluginData.state.common.sendErrorMessage(msg, \"You don't have permission to use -mod\");\n        return;\n      }\n\n      mod = args.mod;\n      ppId = msg.author.id;\n    }\n\n    actualUnmuteCmd(\n      pluginData,\n      msg,\n      user,\n      [...msg.attachments.values()],\n      mod,\n      ppId,\n      \"time\" in args ? (args.time ?? undefined) : undefined,\n      args.reason,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/unmute/UnmuteSlashCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { canActOn, hasPermission } from \"../../../../pluginUtils.js\";\nimport { convertDelayStringToMS, resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { waitForButtonConfirm } from \"../../../../utils/waitForInteraction.js\";\nimport { MutesPlugin } from \"../../../Mutes/MutesPlugin.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualUnmuteCmd } from \"./actualUnmuteCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"time\", description: \"The duration of the unmute\", required: false }),\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to unmute as\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const UnmuteSlashCmd = modActionsSlashCmd({\n  name: \"unmute\",\n  configPermission: \"can_mute\",\n  description: \"Unmute the specified member\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to unmute\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.reason || options.reason.trim() === \"\") && attachments.length < 1) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Text or attachment required\", undefined, undefined, true);\n\n      return;\n    }\n\n    const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, options.user.id);\n    const mutesPlugin = pluginData.getPlugin(MutesPlugin);\n    const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute);\n\n    // Check if they're muted in the first place\n    if (\n      !(await pluginData.state.mutes.isMuted(options.user.id)) &&\n      !hasMuteRole &&\n      !memberToUnmute?.isCommunicationDisabled()\n    ) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Cannot unmute: member is not muted\");\n      return;\n    }\n\n    if (!memberToUnmute) {\n      const banned = await isBanned(pluginData, options.user.id);\n      const prefix = pluginData.fullConfig.prefix;\n      if (banned) {\n        pluginData.state.common.sendErrorMessage(\n          interaction,\n          `User is banned. Use \\`${prefix}forceunmute\\` to unmute them anyway.`,\n        );\n        return;\n      } else {\n        // Ask the mod if we should upgrade to a forceunmute as the user is not on the server\n        const reply = await waitForButtonConfirm(\n          interaction,\n          { content: \"User not on server, forceunmute instead?\" },\n          { confirmText: \"Yes\", cancelText: \"No\", restrictToId: interaction.user.id },\n        );\n\n        if (!reply) {\n          pluginData.state.common.sendErrorMessage(interaction, \"User not on server, unmute cancelled by moderator\");\n          return;\n        }\n      }\n    }\n\n    // Make sure we're allowed to unmute this member\n    if (memberToUnmute && !canActOn(pluginData, interaction.member as GuildMember, memberToUnmute)) {\n      pluginData.state.common.sendErrorMessage(interaction, \"Cannot unmute: insufficient permissions\");\n      return;\n    }\n\n    let mod = interaction.member as GuildMember;\n    let ppId: string | undefined;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        pluginData.state.common.sendErrorMessage(interaction, \"You don't have permission to act as another moderator\");\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n      ppId = interaction.user.id;\n    }\n\n    const convertedTime = options.time ? (convertDelayStringToMS(options.time) ?? undefined) : undefined;\n    if (options.time && !convertedTime) {\n      pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`);\n      return;\n    }\n\n    actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, options.reason);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/unmute/actualUnmuteCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { humanizeDuration } from \"../../../../humanizeDuration.js\";\nimport { UnknownUser, asSingleLine, renderUsername } from \"../../../../utils.js\";\nimport { MutesPlugin } from \"../../../Mutes/MutesPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport { formatReasonWithMessageLinkForAttachments } from \"../../functions/formatReasonForAttachments.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualUnmuteCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  user: User | UnknownUser,\n  attachments: Array<Attachment>,\n  mod: GuildMember,\n  ppId?: string,\n  time?: number,\n  reason?: string | null,\n) {\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  const formattedReason =\n    reason || attachments.length > 0\n      ? await formatReasonWithMessageLinkForAttachments(pluginData, reason ?? \"\", context, attachments)\n      : undefined;\n\n  const mutesPlugin = pluginData.getPlugin(MutesPlugin);\n  const result = await mutesPlugin.unmuteUser(user.id, time, {\n    modId: mod.id,\n    ppId: ppId ?? undefined,\n    reason: formattedReason,\n  });\n\n  if (!result) {\n    pluginData.state.common.sendErrorMessage(context, \"User is not muted!\");\n    return;\n  }\n\n  // Confirm the action to the moderator\n  if (time) {\n    const timeUntilUnmute = time && humanizeDuration(time);\n    pluginData.state.common.sendSuccessMessage(\n      context,\n      asSingleLine(`\n        Unmuting **${renderUsername(user)}**\n        in ${timeUntilUnmute} (Case #${result.case.case_number})\n      `),\n    );\n  } else {\n    pluginData.state.common.sendSuccessMessage(\n      context,\n      asSingleLine(`\n        Unmuted **${renderUsername(user)}**\n        (Case #${result.case.case_number})\n      `),\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/update/UpdateMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { updateCase } from \"../../functions/updateCase.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\n\nexport const UpdateMsgCmd = modActionsMsgCmd({\n  trigger: [\"update\", \"reason\"],\n  permission: \"can_note\",\n  description:\n    \"Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it\",\n\n  signature: [\n    {\n      caseNumber: ct.number(),\n      note: ct.string({ required: false, catchAll: true }),\n    },\n    {\n      note: ct.string({ required: false, catchAll: true }),\n    },\n  ],\n\n  async run({ pluginData, message: msg, args }) {\n    await updateCase(pluginData, msg, msg.author, args.caseNumber, args.note, [...msg.attachments.values()]);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/update/UpdateSlashCmd.ts",
    "content": "import { slashOptions } from \"vety\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { updateCase } from \"../../functions/updateCase.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_UPDATE } from \"../constants.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"case-number\", description: \"The number of the case to update\", required: false }),\n  slashOptions.string({ name: \"reason\", description: \"The note to add to the case\", required: false }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_UPDATE, {\n    name: \"attachment\",\n    description: \"An attachment to add to the update\",\n  }),\n];\n\nexport const UpdateSlashCmd = modActionsSlashCmd({\n  name: \"update\",\n  configPermission: \"can_note\",\n  description: \"Update the specified case (or your latest case) by adding more notes to it\",\n  allowDms: false,\n\n  signature: [...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n\n    await updateCase(\n      pluginData,\n      interaction,\n      interaction.user,\n      options[\"case-number\"] ? Number(options[\"case-number\"]) : null,\n      options.reason ?? \"\",\n      retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_UPDATE, options, \"attachment\"),\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/warn/WarnMsgCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../../commandTypes.js\";\nimport { canActOn, hasPermission, resolveMessageMember } from \"../../../../pluginUtils.js\";\nimport { errorMessage, resolveMember, resolveUser } from \"../../../../utils.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsMsgCmd } from \"../../types.js\";\nimport { actualWarnCmd } from \"./actualWarnCmd.js\";\n\nexport const WarnMsgCmd = modActionsMsgCmd({\n  trigger: \"warn\",\n  permission: \"can_warn\",\n  description: \"Send a warning to the specified user\",\n\n  signature: {\n    user: ct.string(),\n    reason: ct.string({ catchAll: true }),\n\n    mod: ct.member({ option: true }),\n    notify: ct.string({ option: true }),\n    \"notify-channel\": ct.textChannel({ option: true }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const user = await resolveUser(pluginData.client, args.user, \"ModActions:WarnMsgCmd\");\n    if (!user.id) {\n      await pluginData.state.common.sendErrorMessage(msg, `User not found`);\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n    const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id);\n\n    if (!memberToWarn) {\n      const _isBanned = await isBanned(pluginData, user.id);\n      if (_isBanned) {\n        await pluginData.state.common.sendErrorMessage(msg, `User is banned`);\n      } else {\n        await pluginData.state.common.sendErrorMessage(msg, `User not found on the server`);\n      }\n\n      return;\n    }\n\n    // Make sure we're allowed to warn this member\n    if (!canActOn(pluginData, authorMember, memberToWarn)) {\n      await pluginData.state.common.sendErrorMessage(msg, \"Cannot warn: insufficient permissions\");\n      return;\n    }\n\n    // The moderator who did the action is the message author or, if used, the specified -mod\n    let mod = authorMember;\n    if (args.mod) {\n      if (!(await hasPermission(pluginData, \"can_act_as_other\", { message: msg }))) {\n        msg.channel.send(errorMessage(\"You don't have permission to use -mod\"));\n        return;\n      }\n\n      mod = args.mod;\n    }\n\n    let contactMethods;\n    try {\n      contactMethods = readContactMethodsFromArgs(args);\n    } catch (e) {\n      await pluginData.state.common.sendErrorMessage(msg, e.message);\n      return;\n    }\n\n    actualWarnCmd(\n      pluginData,\n      msg,\n      msg.author.id,\n      mod,\n      memberToWarn,\n      args.reason,\n      [...msg.attachments.values()],\n      contactMethods,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/warn/WarnSlashCmd.ts",
    "content": "import { ChannelType, GuildMember } from \"discord.js\";\nimport { slashOptions } from \"vety\";\nimport { canActOn, hasPermission } from \"../../../../pluginUtils.js\";\nimport { UserNotificationMethod, resolveMember } from \"../../../../utils.js\";\nimport { generateAttachmentSlashOptions, retrieveMultipleOptions } from \"../../../../utils/multipleSlashOptions.js\";\nimport { isBanned } from \"../../functions/isBanned.js\";\nimport { readContactMethodsFromArgs } from \"../../functions/readContactMethodsFromArgs.js\";\nimport { modActionsSlashCmd } from \"../../types.js\";\nimport { NUMBER_ATTACHMENTS_CASE_CREATION } from \"../constants.js\";\nimport { actualWarnCmd } from \"./actualWarnCmd.js\";\n\nconst opts = [\n  slashOptions.string({ name: \"reason\", description: \"The reason\", required: false }),\n  slashOptions.user({ name: \"mod\", description: \"The moderator to warn as\", required: false }),\n  slashOptions.string({\n    name: \"notify\",\n    description: \"How to notify\",\n    required: false,\n    choices: [\n      { name: \"DM\", value: \"dm\" },\n      { name: \"Channel\", value: \"channel\" },\n    ],\n  }),\n  slashOptions.channel({\n    name: \"notify-channel\",\n    description: \"The channel to notify in\",\n    channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread],\n    required: false,\n  }),\n  ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, {\n    name: \"attachment\",\n    description: \"An attachment to add to the reason\",\n  }),\n];\n\nexport const WarnSlashCmd = modActionsSlashCmd({\n  name: \"warn\",\n  configPermission: \"can_warn\",\n  description: \"Send a warning to the specified user\",\n  allowDms: false,\n\n  signature: [slashOptions.user({ name: \"user\", description: \"The user to warn\", required: true }), ...opts],\n\n  async run({ interaction, options, pluginData }) {\n    await interaction.deferReply({ ephemeral: true });\n    const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, \"attachment\");\n\n    if ((!options.reason || options.reason.trim() === \"\") && attachments.length < 1) {\n      await pluginData.state.common.sendErrorMessage(\n        interaction,\n        \"Text or attachment required\",\n        undefined,\n        undefined,\n        true,\n      );\n\n      return;\n    }\n\n    const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, options.user.id);\n\n    if (!memberToWarn) {\n      const _isBanned = await isBanned(pluginData, options.user.id);\n      if (_isBanned) {\n        await pluginData.state.common.sendErrorMessage(interaction, `User is banned`);\n      } else {\n        await pluginData.state.common.sendErrorMessage(interaction, `User not found on the server`);\n      }\n\n      return;\n    }\n\n    // Make sure we're allowed to warn this member\n    if (!canActOn(pluginData, interaction.member as GuildMember, memberToWarn)) {\n      await pluginData.state.common.sendErrorMessage(interaction, \"Cannot warn: insufficient permissions\");\n      return;\n    }\n\n    let mod = interaction.member as GuildMember;\n    const canActAsOther = await hasPermission(pluginData, \"can_act_as_other\", {\n      channel: interaction.channel,\n      member: interaction.member,\n    });\n\n    if (options.mod) {\n      if (!canActAsOther) {\n        await pluginData.state.common.sendErrorMessage(\n          interaction,\n          \"You don't have permission to act as another moderator\",\n        );\n        return;\n      }\n\n      mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!;\n    }\n\n    let contactMethods: UserNotificationMethod[] | undefined;\n    try {\n      contactMethods = readContactMethodsFromArgs(options) ?? undefined;\n    } catch (e) {\n      await pluginData.state.common.sendErrorMessage(interaction, e.message);\n      return;\n    }\n\n    actualWarnCmd(\n      pluginData,\n      interaction,\n      interaction.user.id,\n      mod,\n      memberToWarn,\n      options.reason ?? \"\",\n      attachments,\n      contactMethods,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/commands/warn/actualWarnCmd.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, GuildMember, Message } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../../data/CaseTypes.js\";\nimport { UserNotificationMethod, renderUsername } from \"../../../../utils.js\";\nimport { waitForButtonConfirm } from \"../../../../utils/waitForInteraction.js\";\nimport { CasesPlugin } from \"../../../Cases/CasesPlugin.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"../../functions/attachmentLinkReaction.js\";\nimport {\n  formatReasonWithAttachments,\n  formatReasonWithMessageLinkForAttachments,\n} from \"../../functions/formatReasonForAttachments.js\";\nimport { warnMember } from \"../../functions/warnMember.js\";\nimport { ModActionsPluginType } from \"../../types.js\";\n\nexport async function actualWarnCmd(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  authorId: string,\n  mod: GuildMember,\n  memberToWarn: GuildMember,\n  reason: string,\n  attachments: Attachment[],\n  contactMethods?: UserNotificationMethod[],\n) {\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) {\n    return;\n  }\n\n  const config = pluginData.config.get();\n  const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments);\n  const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments);\n\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn);\n  if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) {\n    const reply = await waitForButtonConfirm(\n      context,\n      { content: config.warn_notify_message.replace(\"{priorWarnings}\", `${priorWarnAmount}`) },\n      { confirmText: \"Yes\", cancelText: \"No\", restrictToId: authorId },\n    );\n    if (!reply) {\n      await pluginData.state.common.sendErrorMessage(context, \"Warn cancelled by moderator\");\n      return;\n    }\n  }\n\n  const warnResult = await warnMember(pluginData, memberToWarn, formattedReason, formattedReasonWithAttachments, {\n    contactMethods,\n    caseArgs: {\n      modId: mod.id,\n      ppId: mod.id !== authorId ? authorId : undefined,\n      reason: formattedReason,\n    },\n    retryPromptContext: context,\n  });\n\n  if (warnResult.status === \"failed\") {\n    const failReason = warnResult.error ? `: ${warnResult.error}` : \"\";\n\n    await pluginData.state.common.sendErrorMessage(context, `Failed to warn user${failReason}`);\n\n    return;\n  }\n\n  const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : \"\";\n\n  await pluginData.state.common.sendSuccessMessage(\n    context,\n    `Warned **${renderUsername(memberToWarn.user)}** (Case #${warnResult.case.case_number})${messageResultText}`,\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zModActionsConfig } from \"./types.js\";\n\nexport const modActionsPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Mod actions\",\n  type: \"stable\",\n  description: trimPluginDescription(`\n    This plugin contains the 'typical' mod actions such as warning, muting, kicking, banning, etc.\n  `),\n  configSchema: zModActionsConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/ModActions/events/AuditLogEvents.ts",
    "content": "import { AuditLogChange, AuditLogEvent } from \"discord.js\";\nimport moment from \"moment-timezone\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { resolveUser } from \"../../../utils.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { modActionsEvt } from \"../types.js\";\n\nexport const AuditLogEvents = modActionsEvt({\n  event: \"guildAuditLogEntryCreate\",\n  async listener({ pluginData, args: { auditLogEntry } }) {\n    // Ignore the bot's own audit log events\n    if (auditLogEntry.executorId === pluginData.client.user?.id) {\n      return;\n    }\n\n    const config = pluginData.config.get();\n    const casesPlugin = pluginData.getPlugin(CasesPlugin);\n\n    // Create mute/unmute cases for manual timeouts\n    if (auditLogEntry.action === AuditLogEvent.MemberUpdate && config.create_cases_for_manual_actions) {\n      const target = await resolveUser(pluginData.client, auditLogEntry.targetId!, \"ModActions:AuditLogEvents\");\n\n      // Only act based on the last changes in this log\n      let muteChange: AuditLogChange | null = null;\n      let unmuteChange: AuditLogChange | null = null;\n      for (const change of auditLogEntry.changes) {\n        if (change.key === \"communication_disabled_until\") {\n          if (change.new == null) {\n            unmuteChange = change;\n          } else {\n            muteChange = change;\n            unmuteChange = null;\n          }\n        }\n      }\n\n      if (muteChange) {\n        const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(target.id);\n        const existingCaseId = existingMute?.case_id;\n        if (existingCaseId) {\n          await casesPlugin.createCaseNote({\n            caseId: existingCaseId,\n            modId: auditLogEntry.executor?.id || \"0\",\n            body: auditLogEntry.reason || \"\",\n            noteDetails: [\n              `Timeout set to expire on <t:${Math.ceil(moment.utc(muteChange.new as string).valueOf() / 1_000)}>`,\n            ],\n          });\n        } else {\n          await casesPlugin.createCase({\n            userId: target.id,\n            modId: auditLogEntry.executor?.id || \"0\",\n            type: CaseTypes.Mute,\n            auditLogId: auditLogEntry.id,\n            reason: auditLogEntry.reason || \"\",\n            automatic: true,\n          });\n        }\n      }\n\n      if (unmuteChange) {\n        await casesPlugin.createCase({\n          userId: target.id,\n          modId: auditLogEntry.executor?.id || \"0\",\n          type: CaseTypes.Unmute,\n          auditLogId: auditLogEntry.id,\n          reason: auditLogEntry.reason || \"\",\n          automatic: true,\n        });\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts",
    "content": "import { AuditLogEvent, User } from \"discord.js\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { UnknownUser, resolveUser } from \"../../../utils.js\";\nimport { findMatchingAuditLogEntry } from \"../../../utils/findMatchingAuditLogEntry.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { clearIgnoredEvents } from \"../functions/clearIgnoredEvents.js\";\nimport { isEventIgnored } from \"../functions/isEventIgnored.js\";\nimport { IgnoredEventType, modActionsEvt } from \"../types.js\";\n\n/**\n * Create a BAN case automatically when a user is banned manually.\n * Attempts to find the ban's details in the audit log.\n */\nexport const CreateBanCaseOnManualBanEvt = modActionsEvt({\n  event: \"guildBanAdd\",\n  async listener({ pluginData, args: { ban } }) {\n    const user = ban.user;\n    if (isEventIgnored(pluginData, IgnoredEventType.Ban, user.id)) {\n      clearIgnoredEvents(pluginData, IgnoredEventType.Ban, user.id);\n      return;\n    }\n\n    const relevantAuditLogEntry = await findMatchingAuditLogEntry(\n      pluginData.guild,\n      AuditLogEvent.MemberBanAdd,\n      user.id,\n    );\n\n    const casesPlugin = pluginData.getPlugin(CasesPlugin);\n\n    let createdCase: Case | null = null;\n    let mod: User | UnknownUser | null = null;\n    let reason = \"\";\n\n    if (relevantAuditLogEntry) {\n      const modId = relevantAuditLogEntry.executor!.id;\n      const auditLogId = relevantAuditLogEntry.id;\n\n      mod = await resolveUser(pluginData.client, modId, \"ModActions:CreateBanCaseOnManualBanEvt\");\n\n      const config = mod instanceof UnknownUser ? pluginData.config.get() : await pluginData.config.getForUser(mod);\n\n      if (config.create_cases_for_manual_actions) {\n        reason = relevantAuditLogEntry.reason ?? \"\";\n        createdCase = await casesPlugin.createCase({\n          userId: user.id,\n          modId,\n          type: CaseTypes.Ban,\n          auditLogId,\n          reason: reason || undefined,\n          automatic: true,\n        });\n      }\n    } else {\n      const config = pluginData.config.get();\n      if (config.create_cases_for_manual_actions) {\n        createdCase = await casesPlugin.createCase({\n          userId: user.id,\n          modId: \"0\",\n          type: CaseTypes.Ban,\n        });\n      }\n    }\n\n    pluginData.getPlugin(LogsPlugin).logMemberBan({\n      mod: mod ? userToTemplateSafeUser(mod) : null,\n      user: userToTemplateSafeUser(user),\n      caseNumber: createdCase?.case_number ?? 0,\n      reason,\n    });\n\n    pluginData.state.events.emit(\"ban\", user.id, reason);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts",
    "content": "import { AuditLogEvent, User } from \"discord.js\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { logger } from \"../../../logger.js\";\nimport { UnknownUser, resolveUser } from \"../../../utils.js\";\nimport { findMatchingAuditLogEntry } from \"../../../utils/findMatchingAuditLogEntry.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { clearIgnoredEvents } from \"../functions/clearIgnoredEvents.js\";\nimport { isEventIgnored } from \"../functions/isEventIgnored.js\";\nimport { IgnoredEventType, modActionsEvt } from \"../types.js\";\n\n/**\n * Create a KICK case automatically when a user is kicked manually.\n * Attempts to find the kick's details in the audit log.\n */\nexport const CreateKickCaseOnManualKickEvt = modActionsEvt({\n  event: \"guildMemberRemove\",\n  async listener({ pluginData, args: { member } }) {\n    if (isEventIgnored(pluginData, IgnoredEventType.Kick, member.id)) {\n      clearIgnoredEvents(pluginData, IgnoredEventType.Kick, member.id);\n      return;\n    }\n\n    const kickAuditLogEntry = await findMatchingAuditLogEntry(pluginData.guild, AuditLogEvent.MemberKick, member.id);\n\n    let mod: User | UnknownUser | null = null;\n    let createdCase: Case | null = null;\n\n    // Since a member leaving and a member being kicked are both the same gateway event,\n    // we can only really interpret this event as a kick if there is a matching audit log entry.\n    if (kickAuditLogEntry) {\n      createdCase = (await pluginData.state.cases.findByAuditLogId(kickAuditLogEntry.id)) || null;\n      if (createdCase) {\n        logger.warn(\n          `Tried to create duplicate case for audit log entry ${kickAuditLogEntry.id}, existing case id ${createdCase.id}`,\n        );\n      } else {\n        mod = await resolveUser(pluginData.client, kickAuditLogEntry.executor!.id, \"ModActions:CreateKickCaseOnManualKickEvt\");\n\n        const config = mod instanceof UnknownUser ? pluginData.config.get() : await pluginData.config.getForUser(mod);\n\n        if (config.create_cases_for_manual_actions) {\n          const casesPlugin = pluginData.getPlugin(CasesPlugin);\n          createdCase = await casesPlugin.createCase({\n            userId: member.id,\n            modId: mod.id,\n            type: CaseTypes.Kick,\n            auditLogId: kickAuditLogEntry.id,\n            reason: kickAuditLogEntry.reason || undefined,\n            automatic: true,\n          });\n        }\n      }\n\n      pluginData.getPlugin(LogsPlugin).logMemberKick({\n        user: member.user!,\n        mod,\n        caseNumber: createdCase?.case_number ?? 0,\n        reason: kickAuditLogEntry.reason || \"\",\n      });\n\n      pluginData.state.events.emit(\"kick\", member.id, kickAuditLogEntry.reason || undefined);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts",
    "content": "import { AuditLogEvent, User } from \"discord.js\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { UnknownUser, resolveUser } from \"../../../utils.js\";\nimport { findMatchingAuditLogEntry } from \"../../../utils/findMatchingAuditLogEntry.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { clearIgnoredEvents } from \"../functions/clearIgnoredEvents.js\";\nimport { isEventIgnored } from \"../functions/isEventIgnored.js\";\nimport { IgnoredEventType, modActionsEvt } from \"../types.js\";\n\n/**\n * Create an UNBAN case automatically when a user is unbanned manually.\n * Attempts to find the unban's details in the audit log.\n */\nexport const CreateUnbanCaseOnManualUnbanEvt = modActionsEvt({\n  event: \"guildBanRemove\",\n  async listener({ pluginData, args: { ban } }) {\n    const user = ban.user;\n    if (isEventIgnored(pluginData, IgnoredEventType.Unban, user.id)) {\n      clearIgnoredEvents(pluginData, IgnoredEventType.Unban, user.id);\n      return;\n    }\n\n    const relevantAuditLogEntry = await findMatchingAuditLogEntry(\n      pluginData.guild,\n      AuditLogEvent.MemberBanRemove,\n      user.id,\n    );\n\n    const casesPlugin = pluginData.getPlugin(CasesPlugin);\n\n    let createdCase: Case | null = null;\n    let mod: User | UnknownUser | null = null;\n\n    if (relevantAuditLogEntry) {\n      const modId = relevantAuditLogEntry.executor!.id;\n      const auditLogId = relevantAuditLogEntry.id;\n\n      mod = await resolveUser(pluginData.client, modId, \"ModActions:CreateUnbanCaseOnManualUnbanEvt\");\n\n      const config = mod instanceof UnknownUser ? pluginData.config.get() : await pluginData.config.getForUser(mod);\n\n      if (config.create_cases_for_manual_actions) {\n        createdCase = await casesPlugin.createCase({\n          userId: user.id,\n          modId,\n          type: CaseTypes.Unban,\n          auditLogId,\n          automatic: true,\n        });\n      }\n    } else {\n      const config = pluginData.config.get();\n      if (config.create_cases_for_manual_actions) {\n        createdCase = await casesPlugin.createCase({\n          userId: user.id,\n          modId: \"0\",\n          type: CaseTypes.Unban,\n          automatic: true,\n        });\n      }\n    }\n\n    pluginData.getPlugin(LogsPlugin).logMemberUnban({\n      mod,\n      userId: user.id,\n      caseNumber: createdCase?.case_number ?? 0,\n      reason: \"\",\n    });\n\n    pluginData.state.events.emit(\"unban\", user.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts",
    "content": "import { PermissionsBitField, Snowflake, TextChannel } from \"discord.js\";\nimport { renderUsername, resolveMember } from \"../../../utils.js\";\nimport { hasDiscordPermissions } from \"../../../utils/hasDiscordPermissions.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { modActionsEvt } from \"../types.js\";\n\n/**\n * Show an alert if a member with prior notes joins the server\n */\nexport const PostAlertOnMemberJoinEvt = modActionsEvt({\n  event: \"guildMemberAdd\",\n  async listener({ pluginData, args: { member } }) {\n    const config = pluginData.config.get();\n\n    if (!config.alert_on_rejoin) return;\n\n    const alertChannelId = config.alert_channel;\n    if (!alertChannelId) return;\n\n    const actions = await pluginData.state.cases.getByUserId(member.id);\n    const logs = pluginData.getPlugin(LogsPlugin);\n\n    if (actions.length) {\n      const alertChannel = pluginData.guild.channels.cache.get(alertChannelId as Snowflake);\n      if (!alertChannel) {\n        logs.logBotAlert({\n          body: `Unknown \\`alert_channel\\` configured for \\`mod_actions\\`: \\`${alertChannelId}\\``,\n        });\n        return;\n      }\n\n      if (!(alertChannel instanceof TextChannel)) {\n        logs.logBotAlert({\n          body: `Non-text channel configured as \\`alert_channel\\` in \\`mod_actions\\`: \\`${alertChannelId}\\``,\n        });\n        return;\n      }\n\n      const botMember = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id);\n      const botPerms = alertChannel.permissionsFor(botMember ?? pluginData.client.user!.id);\n      if (!hasDiscordPermissions(botPerms, PermissionsBitField.Flags.SendMessages)) {\n        logs.logBotAlert({\n          body: `Missing \"Send Messages\" permissions for the \\`alert_channel\\` configured in \\`mod_actions\\`: \\`${alertChannelId}\\``,\n        });\n        return;\n      }\n\n      await alertChannel.send(\n        `<@!${member.id}> (${renderUsername(member)} \\`${member.id}\\`) joined with ${actions.length} prior record(s)`,\n      );\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/attachmentLinkReaction.ts",
    "content": "import { ChatInputCommandInteraction, Message, SendableChannels } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { ModActionsPluginType } from \"../types.js\";\n\nexport function shouldReactToAttachmentLink(pluginData: GuildPluginData<ModActionsPluginType>) {\n  const config = pluginData.config.get();\n  return !config.attachment_link_reaction || config.attachment_link_reaction !== \"none\";\n}\n\nexport function attachmentLinkShouldRestrict(pluginData: GuildPluginData<ModActionsPluginType>) {\n  return pluginData.config.get().attachment_link_reaction === \"restrict\";\n}\n\nexport function detectAttachmentLink(reason: string | null | undefined) {\n  return reason && /https:\\/\\/(cdn|media)\\.discordapp\\.(com|net)\\/(ephemeral-)?attachments/gu.test(reason);\n}\n\nfunction sendAttachmentLinkDetectionErrorMessage(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: SendableChannels | Message | ChatInputCommandInteraction,\n  restricted = false,\n) {\n  const emoji = pluginData.state.common.getErrorEmoji();\n\n  pluginData.state.common.sendErrorMessage(\n    context,\n    \"You manually added a Discord attachment link to the reason. This link will only work for a limited time.\\n\" +\n      \"You should instead **re-upload** the attachment with the command, in the same message.\\n\\n\" +\n      (restricted ? `${emoji} **Command canceled.** ${emoji}` : \"\").trim(),\n  );\n}\n\nexport async function handleAttachmentLinkDetectionAndGetRestriction(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: SendableChannels | Message | ChatInputCommandInteraction,\n  reason: string | null | undefined,\n) {\n  if (!shouldReactToAttachmentLink(pluginData) || !detectAttachmentLink(reason)) {\n    return false;\n  }\n\n  const restricted = attachmentLinkShouldRestrict(pluginData);\n\n  sendAttachmentLinkDetectionErrorMessage(pluginData, context, restricted);\n\n  return restricted;\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/banUserId.ts",
    "content": "import { DiscordAPIError, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { registerExpiringTempban } from \"../../../data/loops/expiringTempbansLoop.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { logger } from \"../../../logger.js\";\nimport { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport {\n  DAYS,\n  SECONDS,\n  UserNotificationResult,\n  createUserNotificationError,\n  notifyUser,\n  resolveMember,\n  resolveUser,\n  ucfirst,\n} from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { BanOptions, BanResult, IgnoredEventType, ModActionsPluginType } from \"../types.js\";\nimport { getDefaultContactMethods } from \"./getDefaultContactMethods.js\";\nimport { ignoreEvent } from \"./ignoreEvent.js\";\n\n/**\n * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case.\n */\nexport async function banUserId(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  userId: string,\n  reason?: string,\n  reasonWithAttachments?: string,\n  banOptions: BanOptions = {},\n  banTime?: number,\n): Promise<BanResult> {\n  const config = pluginData.config.get();\n  const user = await resolveUser(pluginData.client, userId, \"ModActions:banUserId\");\n  if (!user.id) {\n    return {\n      status: \"failed\",\n      error: \"Invalid user\",\n    };\n  }\n\n  // Attempt to message the user *before* banning them, as doing it after may not be possible\n  const member = await resolveMember(pluginData.client, pluginData.guild, userId);\n  let notifyResult: UserNotificationResult = { method: null, success: true };\n  if (reasonWithAttachments && member) {\n    const contactMethods = banOptions?.contactMethods\n      ? banOptions.contactMethods\n      : getDefaultContactMethods(pluginData, \"ban\");\n\n    if (contactMethods.length) {\n      if (!banTime && config.ban_message) {\n        let banMessage: string;\n        try {\n          banMessage = await renderTemplate(\n            config.ban_message,\n            new TemplateSafeValueContainer({\n              guildName: pluginData.guild.name,\n              reason: reasonWithAttachments,\n              moderator: banOptions.caseArgs?.modId\n                ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId, \"ModActions:banUserId\"))\n                : null,\n            }),\n          );\n        } catch (err) {\n          if (err instanceof TemplateParseError) {\n            return {\n              status: \"failed\",\n              error: `Invalid ban_message format: ${err.message}`,\n            };\n          }\n          throw err;\n        }\n\n        notifyResult = await notifyUser(member.user, banMessage, contactMethods);\n      } else if (banTime && config.tempban_message) {\n        let banMessage: string;\n        try {\n          banMessage = await renderTemplate(\n            config.tempban_message,\n            new TemplateSafeValueContainer({\n              guildName: pluginData.guild.name,\n              reason: reasonWithAttachments,\n              moderator: banOptions.caseArgs?.modId\n                ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId, \"ModActions:banUserId\"))\n                : null,\n              banTime: humanizeDuration(banTime),\n            }),\n          );\n        } catch (err) {\n          if (err instanceof TemplateParseError) {\n            return {\n              status: \"failed\",\n              error: `Invalid tempban_message format: ${err.message}`,\n            };\n          }\n          throw err;\n        }\n\n        notifyResult = await notifyUser(member.user, banMessage, contactMethods);\n      } else {\n        notifyResult = createUserNotificationError(\"No ban/tempban message specified in config\");\n      }\n    }\n  }\n\n  // (Try to) ban the user\n  pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId);\n  ignoreEvent(pluginData, IgnoredEventType.Ban, userId);\n  try {\n    const deleteMessageDays = Math.min(7, Math.max(0, banOptions.deleteMessageDays ?? 1));\n    await pluginData.guild.bans.create(userId as Snowflake, {\n      deleteMessageSeconds: (deleteMessageDays * DAYS) / SECONDS,\n      reason: reason ?? undefined,\n    });\n  } catch (e) {\n    let errorMessage;\n    if (e instanceof DiscordAPIError) {\n      errorMessage = `API error ${e.code}: ${e.message}`;\n    } else {\n      logger.warn(`Error applying ban to ${userId}: ${e}`);\n      errorMessage = \"Unknown error\";\n    }\n\n    return {\n      status: \"failed\",\n      error: errorMessage,\n    };\n  }\n\n  const tempbanLock = await pluginData.locks.acquire(`tempban-${user.id}`);\n  const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id);\n  if (banTime && banTime > 0) {\n    const selfId = pluginData.client.user!.id;\n    if (existingTempban) {\n      await pluginData.state.tempbans.updateExpiryTime(user.id, banTime, banOptions.modId ?? selfId);\n    } else {\n      await pluginData.state.tempbans.addTempban(user.id, banTime, banOptions.modId ?? selfId);\n    }\n    const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!;\n    registerExpiringTempban(tempban);\n  }\n  tempbanLock.unlock();\n\n  // Create a case for this action\n  const modId = banOptions.caseArgs?.modId || pluginData.client.user!.id;\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n\n  const noteDetails: string[] = [];\n  const timeUntilUnban = banTime ? humanizeDuration(banTime) : \"indefinite\";\n  const timeDetails = `Banned ${banTime ? `for ${timeUntilUnban}` : \"indefinitely\"}`;\n  if (notifyResult.text) noteDetails.push(ucfirst(notifyResult.text));\n  noteDetails.push(timeDetails);\n\n  const createdCase = await casesPlugin.createCase({\n    ...(banOptions.caseArgs || {}),\n    userId,\n    modId,\n    type: CaseTypes.Ban,\n    reason,\n    noteDetails,\n  });\n\n  // Log the action\n  const mod = await resolveUser(pluginData.client, modId, \"ModActions:banUserId\");\n\n  if (banTime) {\n    pluginData.getPlugin(LogsPlugin).logMemberTimedBan({\n      mod,\n      user,\n      caseNumber: createdCase.case_number,\n      reason: reason ?? \"\",\n      banTime: humanizeDuration(banTime),\n    });\n  } else {\n    pluginData.getPlugin(LogsPlugin).logMemberBan({\n      mod,\n      user,\n      caseNumber: createdCase.case_number,\n      reason: reason ?? \"\",\n    });\n  }\n\n  pluginData.state.events.emit(\"ban\", user.id, reason, banOptions.isAutomodAction);\n\n  return {\n    status: \"success\",\n    case: createdCase,\n    notifyResult,\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { IgnoredEventType, ModActionsPluginType } from \"../types.js\";\n\nexport function clearIgnoredEvents(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  type: IgnoredEventType,\n  userId: string,\n) {\n  pluginData.state.ignoredEvents.splice(\n    pluginData.state.ignoredEvents.findIndex((info) => type === info.type && userId === info.userId),\n    1,\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/clearTempban.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { Tempban } from \"../../../data/entities/Tempban.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { logger } from \"../../../logger.js\";\nimport { resolveUser } from \"../../../utils.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { IgnoredEventType, ModActionsPluginType } from \"../types.js\";\nimport { ignoreEvent } from \"./ignoreEvent.js\";\nimport { isBanned } from \"./isBanned.js\";\n\nexport async function clearTempban(pluginData: GuildPluginData<ModActionsPluginType>, tempban: Tempban) {\n  if (!(await isBanned(pluginData, tempban.user_id))) {\n    pluginData.state.tempbans.clear(tempban.user_id);\n    return;\n  }\n\n  pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id);\n  const reason = `Tempban timed out.\n    Tempbanned at: \\`${tempban.created_at} UTC\\``;\n\n  try {\n    ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id);\n    await pluginData.guild.bans.remove(tempban.user_id as Snowflake, reason ?? undefined);\n  } catch (e) {\n    pluginData.getPlugin(LogsPlugin).logBotAlert({\n      body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`,\n    });\n    logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`);\n    return;\n  }\n\n  // Create case and delete tempban\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const createdCase = await casesPlugin.createCase({\n    userId: tempban.user_id,\n    modId: tempban.mod_id,\n    type: CaseTypes.Unban,\n    reason,\n    ppId: undefined,\n  });\n  pluginData.state.tempbans.clear(tempban.user_id);\n\n  // Log the unban\n  const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at));\n  pluginData.getPlugin(LogsPlugin).logMemberTimedUnban({\n    mod: await resolveUser(pluginData.client, tempban.mod_id, \"ModActions:clearTempban\"),\n    userId: tempban.user_id,\n    caseNumber: createdCase.case_number,\n    reason,\n    banTime: humanizeDuration(banTime),\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/formatReasonForAttachments.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, Message } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { isContextMessage } from \"../../../pluginUtils.js\";\nimport { ModActionsPluginType } from \"../types.js\";\n\nexport async function formatReasonWithMessageLinkForAttachments(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  reason: string,\n  context: Message | ChatInputCommandInteraction,\n  attachments: Attachment[],\n) {\n  if (isContextMessage(context)) {\n    const allAttachments = [...new Set([...context.attachments.values(), ...attachments])];\n\n    return allAttachments.length > 0 ? ((reason || \"\") + \" \" + context.url).trim() : reason;\n  }\n\n  if (attachments.length < 1) {\n    return reason;\n  }\n\n  const attachmentsMessage = await pluginData.state.common.storeAttachmentsAsMessage(attachments, context.channel);\n\n  return ((reason || \"\") + \" \" + attachmentsMessage.url).trim();\n}\n\nexport function formatReasonWithAttachments(reason: string, attachments: Attachment[]) {\n  const attachmentUrls = attachments.map((a) => a.url);\n  return ((reason || \"\") + \" \" + attachmentUrls.join(\" \")).trim();\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/getDefaultContactMethods.ts",
    "content": "import { Snowflake, TextChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { UserNotificationMethod } from \"../../../utils.js\";\nimport { ModActionsPluginType } from \"../types.js\";\n\nexport function getDefaultContactMethods(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  type: \"warn\" | \"kick\" | \"ban\",\n): UserNotificationMethod[] {\n  const methods: UserNotificationMethod[] = [];\n  const config = pluginData.config.get();\n\n  if (config[`dm_on_${type}`]) {\n    methods.push({ type: \"dm\" });\n  }\n\n  if (config[`message_on_${type}`] && config.message_channel) {\n    const channel = pluginData.guild.channels.cache.get(config.message_channel as Snowflake);\n    if (channel instanceof TextChannel) {\n      methods.push({\n        type: \"channel\",\n        channel,\n      });\n    }\n  }\n\n  return methods;\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/hasModActionPerm.ts",
    "content": "import { GuildMember, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { ModActionsPluginType } from \"../types.js\";\n\nexport async function hasNotePermission(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  member: GuildMember,\n  channelId: Snowflake,\n) {\n  return (await pluginData.config.getMatchingConfig({ member, channelId })).can_note;\n}\n\nexport async function hasWarnPermission(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  member: GuildMember,\n  channelId: Snowflake,\n) {\n  return (await pluginData.config.getMatchingConfig({ member, channelId })).can_warn;\n}\n\nexport async function hasMutePermission(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  member: GuildMember,\n  channelId: Snowflake,\n) {\n  return (await pluginData.config.getMatchingConfig({ member, channelId })).can_mute;\n}\n\nexport async function hasBanPermission(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  member: GuildMember,\n  channelId: Snowflake,\n) {\n  return (await pluginData.config.getMatchingConfig({ member, channelId })).can_ban;\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/ignoreEvent.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SECONDS } from \"../../../utils.js\";\nimport { IgnoredEventType, ModActionsPluginType } from \"../types.js\";\nimport { clearIgnoredEvents } from \"./clearIgnoredEvents.js\";\n\nconst DEFAULT_TIMEOUT = 15 * SECONDS;\n\nexport function ignoreEvent(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  type: IgnoredEventType,\n  userId: string,\n  timeout = DEFAULT_TIMEOUT,\n) {\n  pluginData.state.ignoredEvents.push({ type, userId });\n\n  // Clear after expiry (15sec by default)\n  setTimeout(() => {\n    clearIgnoredEvents(pluginData, type, userId);\n  }, timeout);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/isBanned.ts",
    "content": "import { PermissionsBitField, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SECONDS, isDiscordAPIError, isDiscordHTTPError, sleep } from \"../../../utils.js\";\nimport { hasDiscordPermissions } from \"../../../utils/hasDiscordPermissions.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { ModActionsPluginType } from \"../types.js\";\n\nexport async function isBanned(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  userId: string,\n  timeout: number = 5 * SECONDS,\n): Promise<boolean> {\n  const botMember = pluginData.guild.members.cache.get(pluginData.client.user!.id);\n  if (botMember && !hasDiscordPermissions(botMember.permissions, PermissionsBitField.Flags.BanMembers)) {\n    pluginData.getPlugin(LogsPlugin).logBotAlert({\n      body: `Missing \"Ban Members\" permission to check for existing bans`,\n    });\n    return false;\n  }\n\n  try {\n    const potentialBan = await Promise.race([\n      pluginData.guild.bans.fetch({ user: userId as Snowflake }).catch(() => null),\n      sleep(timeout),\n    ]);\n    return potentialBan != null;\n  } catch (e) {\n    if (isDiscordAPIError(e) && e.code === 10026) {\n      // [10026]: Unknown Ban\n      return false;\n    }\n\n    if (isDiscordHTTPError(e) && e.code === 500) {\n      // Internal server error, ignore\n      return false;\n    }\n\n    if (isDiscordAPIError(e) && e.code === 50013) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Missing \"Ban Members\" permission to check for existing bans`,\n      });\n    }\n\n    throw e;\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/isEventIgnored.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { IgnoredEventType, ModActionsPluginType } from \"../types.js\";\n\nexport function isEventIgnored(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  type: IgnoredEventType,\n  userId: string,\n) {\n  return pluginData.state.ignoredEvents.some((info) => type === info.type && userId === info.userId);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/kickMember.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { renderTemplate, TemplateParseError, TemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport {\n  createUserNotificationError,\n  notifyUser,\n  resolveUser,\n  ucfirst,\n  UserNotificationResult,\n} from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { IgnoredEventType, KickOptions, KickResult, ModActionsPluginType } from \"../types.js\";\nimport { getDefaultContactMethods } from \"./getDefaultContactMethods.js\";\nimport { ignoreEvent } from \"./ignoreEvent.js\";\n\n/**\n * Kick the specified server member. Generates a case.\n */\nexport async function kickMember(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  member: GuildMember,\n  reason?: string,\n  reasonWithAttachments?: string,\n  kickOptions: KickOptions = {},\n): Promise<KickResult> {\n  const config = pluginData.config.get();\n\n  // Attempt to message the user *before* kicking them, as doing it after may not be possible\n  let notifyResult: UserNotificationResult = { method: null, success: true };\n  if (reasonWithAttachments && member) {\n    const contactMethods = kickOptions?.contactMethods\n      ? kickOptions.contactMethods\n      : getDefaultContactMethods(pluginData, \"kick\");\n\n    if (contactMethods.length) {\n      if (config.kick_message) {\n        let kickMessage: string;\n        try {\n          kickMessage = await renderTemplate(\n            config.kick_message,\n            new TemplateSafeValueContainer({\n              guildName: pluginData.guild.name,\n              reason: reasonWithAttachments,\n              moderator: kickOptions.caseArgs?.modId\n                ? userToTemplateSafeUser(await resolveUser(pluginData.client, kickOptions.caseArgs.modId, \"ModActions:kickMember\"))\n                : null,\n            }),\n          );\n        } catch (err) {\n          if (err instanceof TemplateParseError) {\n            return {\n              status: \"failed\",\n              error: `Invalid kick_message format: ${err.message}`,\n            };\n          }\n          throw err;\n        }\n\n        notifyResult = await notifyUser(member.user, kickMessage, contactMethods);\n      } else {\n        notifyResult = createUserNotificationError(\"No kick message specified in the config\");\n      }\n    }\n  }\n\n  // Kick the user\n  pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_KICK, member.id);\n  ignoreEvent(pluginData, IgnoredEventType.Kick, member.id);\n  try {\n    await member.kick(reason ?? undefined);\n  } catch (e) {\n    return {\n      status: \"failed\",\n      error: e.message,\n    };\n  }\n\n  const modId = kickOptions.caseArgs?.modId || pluginData.client.user!.id;\n\n  // Create a case for this action\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const createdCase = await casesPlugin.createCase({\n    ...(kickOptions.caseArgs || {}),\n    userId: member.id,\n    modId,\n    type: CaseTypes.Kick,\n    reason,\n    noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [],\n  });\n\n  // Log the action\n  const mod = await resolveUser(pluginData.client, modId, \"ModActions:kickMember\");\n  pluginData.getPlugin(LogsPlugin).logMemberKick({\n    mod,\n    user: member.user,\n    caseNumber: createdCase.case_number,\n    reason: reason ?? \"\",\n  });\n\n  pluginData.state.events.emit(\"kick\", member.id, reason, kickOptions.isAutomodAction);\n\n  return {\n    status: \"success\",\n    case: createdCase,\n    notifyResult,\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/offModActionsEvent.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { ModActionsEvents, ModActionsPluginType } from \"../types.js\";\n\nexport function offModActionsEvent<TEvent extends keyof ModActionsEvents>(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  event: TEvent,\n  listener: ModActionsEvents[TEvent],\n) {\n  return pluginData.state.events.off(event, listener);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/onModActionsEvent.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { ModActionsEvents, ModActionsPluginType } from \"../types.js\";\n\nexport function onModActionsEvent<TEvent extends keyof ModActionsEvents>(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  event: TEvent,\n  listener: ModActionsEvents[TEvent],\n) {\n  return pluginData.state.events.on(event, listener);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts",
    "content": "import { GuildTextBasedChannel } from \"discord.js\";\nimport { disableUserNotificationStrings, UserNotificationMethod } from \"../../../utils.js\";\n\nexport function readContactMethodsFromArgs(args: {\n  notify?: string | null;\n  \"notify-channel\"?: GuildTextBasedChannel | null;\n}): null | UserNotificationMethod[] {\n  if (args.notify) {\n    if (args.notify === \"dm\") {\n      return [{ type: \"dm\" }];\n    } else if (args.notify === \"channel\") {\n      if (!args[\"notify-channel\"]) {\n        throw new Error(\"No `-notify-channel` specified\");\n      }\n\n      return [{ type: \"channel\", channel: args[\"notify-channel\"] }];\n    } else if (disableUserNotificationStrings.includes(args.notify)) {\n      return [];\n    } else {\n      throw new Error(\"Unknown contact method\");\n    }\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/updateCase.ts",
    "content": "import { Attachment, ChatInputCommandInteraction, Message, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { ModActionsPluginType } from \"../types.js\";\nimport { handleAttachmentLinkDetectionAndGetRestriction } from \"./attachmentLinkReaction.js\";\nimport { formatReasonWithMessageLinkForAttachments } from \"./formatReasonForAttachments.js\";\n\nexport async function updateCase(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  context: Message | ChatInputCommandInteraction,\n  author: User,\n  caseNumber?: number | null,\n  note = \"\",\n  attachments: Attachment[] = [],\n) {\n  let theCase: Case | null;\n  if (caseNumber != null) {\n    theCase = await pluginData.state.cases.findByCaseNumber(caseNumber);\n  } else {\n    theCase = await pluginData.state.cases.findLatestByModId(author.id);\n  }\n\n  if (!theCase) {\n    pluginData.state.common.sendErrorMessage(context, \"Case not found\");\n    return;\n  }\n\n  if (note.length === 0 && attachments.length === 0) {\n    pluginData.state.common.sendErrorMessage(context, \"Text or attachment required\");\n    return;\n  }\n\n  if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, note)) {\n    return;\n  }\n\n  const formattedNote = await formatReasonWithMessageLinkForAttachments(pluginData, note, context, attachments);\n\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  await casesPlugin.createCaseNote({\n    caseId: theCase.id,\n    modId: author.id,\n    body: formattedNote,\n  });\n\n  pluginData.getPlugin(LogsPlugin).logCaseUpdate({\n    mod: author,\n    caseNumber: theCase.case_number,\n    caseType: CaseTypes[theCase.type],\n    note: formattedNote,\n  });\n\n  pluginData.state.common.sendSuccessMessage(context, `Case \\`#${theCase.case_number}\\` updated`);\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/functions/warnMember.ts",
    "content": "import { GuildMember, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport {\n  UserNotificationResult,\n  createUserNotificationError,\n  notifyUser,\n  resolveUser,\n  ucfirst,\n} from \"../../../utils.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { waitForButtonConfirm } from \"../../../utils/waitForInteraction.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { ModActionsPluginType, WarnOptions, WarnResult } from \"../types.js\";\nimport { getDefaultContactMethods } from \"./getDefaultContactMethods.js\";\n\nexport async function warnMember(\n  pluginData: GuildPluginData<ModActionsPluginType>,\n  member: GuildMember,\n  reason: string,\n  reasonWithAttachments: string,\n  warnOptions: WarnOptions = {},\n): Promise<WarnResult> {\n  const config = pluginData.config.get();\n\n  let notifyResult: UserNotificationResult;\n  if (config.warn_message) {\n    let warnMessage: string;\n    try {\n      warnMessage = await renderTemplate(\n        config.warn_message,\n        new TemplateSafeValueContainer({\n          guildName: pluginData.guild.name,\n          reason: reasonWithAttachments,\n          moderator: warnOptions.caseArgs?.modId\n            ? userToTemplateSafeUser(await resolveUser(pluginData.client, warnOptions.caseArgs.modId, \"ModActions:warnMember\"))\n            : null,\n        }),\n      );\n    } catch (err) {\n      if (err instanceof TemplateParseError) {\n        return {\n          status: \"failed\",\n          error: `Invalid warn_message format: ${err.message}`,\n        };\n      }\n      throw err;\n    }\n    const contactMethods = warnOptions?.contactMethods\n      ? warnOptions.contactMethods\n      : getDefaultContactMethods(pluginData, \"warn\");\n    notifyResult = await notifyUser(member.user, warnMessage, contactMethods);\n  } else {\n    notifyResult = createUserNotificationError(\"No warn message specified in config\");\n  }\n\n  if (!notifyResult.success) {\n    if (!warnOptions.retryPromptContext) {\n      return {\n        status: \"failed\",\n        error: \"Failed to message user\",\n      };\n    }\n\n    const reply = await waitForButtonConfirm(\n      warnOptions.retryPromptContext,\n      { content: \"Failed to message the user. Log the warning anyway?\" },\n      { confirmText: \"Yes\", cancelText: \"No\", restrictToId: warnOptions.caseArgs?.modId },\n    );\n\n    if (!reply) {\n      return {\n        status: \"failed\",\n        error: \"Failed to message user\",\n      };\n    }\n  }\n\n  const modId = warnOptions.caseArgs?.modId ?? pluginData.client.user!.id;\n\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const createdCase = await casesPlugin.createCase({\n    ...(warnOptions.caseArgs || {}),\n    userId: member.id,\n    modId,\n    type: CaseTypes.Warn,\n    reason,\n    noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [],\n  });\n\n  const mod = await pluginData.guild.members.fetch(modId as Snowflake);\n  pluginData.getPlugin(LogsPlugin).logMemberWarn({\n    mod,\n    member,\n    caseNumber: createdCase.case_number,\n    reason: reason ?? \"\",\n  });\n\n  pluginData.state.events.emit(\"warn\", member.id, reason, warnOptions.isAutomodAction);\n\n  return {\n    status: \"success\",\n    case: createdCase,\n    notifyResult,\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/ModActions/types.ts",
    "content": "import { ChatInputCommandInteraction, Message } from \"discord.js\";\nimport { EventEmitter } from \"events\";\nimport {\n  BasePluginType,\n  guildPluginEventListener,\n  guildPluginMessageCommand,\n  guildPluginSlashCommand,\n  guildPluginSlashGroup,\n  pluginUtils,\n} from \"vety\";\nimport { z } from \"zod\";\nimport { Queue } from \"../../Queue.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildMutes } from \"../../data/GuildMutes.js\";\nimport { GuildTempbans } from \"../../data/GuildTempbans.js\";\nimport { Case } from \"../../data/entities/Case.js\";\nimport { UserNotificationMethod, UserNotificationResult } from \"../../utils.js\";\nimport { CaseArgs } from \"../Cases/types.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport type AttachmentLinkReactionType = \"none\" | \"warn\" | \"restrict\" | null;\n\nexport const zModActionsConfig = z.strictObject({\n  dm_on_warn: z.boolean().default(true),\n  dm_on_kick: z.boolean().default(false),\n  dm_on_ban: z.boolean().default(false),\n  message_on_warn: z.boolean().default(false),\n  message_on_kick: z.boolean().default(false),\n  message_on_ban: z.boolean().default(false),\n  message_channel: z.nullable(z.string()).default(null),\n  warn_message: z.nullable(z.string()).default(\"You have received a warning on the {guildName} server: {reason}\"),\n  kick_message: z\n    .nullable(z.string())\n    .default(\"You have been kicked from the {guildName} server. Reason given: {reason}\"),\n  ban_message: z\n    .nullable(z.string())\n    .default(\"You have been banned from the {guildName} server. Reason given: {reason}\"),\n  tempban_message: z\n    .nullable(z.string())\n    .default(\"You have been banned from the {guildName} server for {banTime}. Reason given: {reason}\"),\n  alert_on_rejoin: z.boolean().default(false),\n  alert_channel: z.nullable(z.string()).default(null),\n  warn_notify_enabled: z.boolean().default(false),\n  warn_notify_threshold: z.number().default(5),\n  warn_notify_message: z\n    .string()\n    .default(\n      \"The user already has **{priorWarnings}** warnings!\\n Please check their prior cases and assess whether or not to warn anyways.\\n Proceed with the warning?\",\n    ),\n  ban_delete_message_days: z.number().default(1),\n  attachment_link_reaction: z\n    .nullable(z.union([z.literal(\"none\"), z.literal(\"warn\"), z.literal(\"restrict\")]))\n    .default(\"warn\"),\n  can_note: z.boolean().default(false),\n  can_warn: z.boolean().default(false),\n  can_mute: z.boolean().default(false),\n  can_kick: z.boolean().default(false),\n  can_ban: z.boolean().default(false),\n  can_unban: z.boolean().default(false),\n  can_view: z.boolean().default(false),\n  can_addcase: z.boolean().default(false),\n  can_massunban: z.boolean().default(false),\n  can_massban: z.boolean().default(false),\n  can_massmute: z.boolean().default(false),\n  can_hidecase: z.boolean().default(false),\n  can_deletecase: z.boolean().default(false),\n  can_act_as_other: z.boolean().default(false),\n  create_cases_for_manual_actions: z.boolean().default(true),\n});\n\nexport interface ModActionsEvents {\n  note: (userId: string, reason?: string) => void;\n  warn: (userId: string, reason?: string, isAutomodAction?: boolean) => void;\n  kick: (userId: string, reason?: string, isAutomodAction?: boolean) => void;\n  ban: (userId: string, reason?: string, isAutomodAction?: boolean) => void;\n  unban: (userId: string, reason?: string) => void;\n  // mute/unmute are in the Mutes plugin\n}\n\nexport interface ModActionsEventEmitter extends EventEmitter {\n  on<U extends keyof ModActionsEvents>(event: U, listener: ModActionsEvents[U]): this;\n  emit<U extends keyof ModActionsEvents>(event: U, ...args: Parameters<ModActionsEvents[U]>): boolean;\n}\n\nexport interface ModActionsPluginType extends BasePluginType {\n  configSchema: typeof zModActionsConfig;\n  state: {\n    mutes: GuildMutes;\n    cases: GuildCases;\n    tempbans: GuildTempbans;\n    serverLogs: GuildLogs;\n\n    unloaded: boolean;\n    unregisterGuildEventListener: () => void;\n    ignoredEvents: IIgnoredEvent[];\n    massbanQueue: Queue;\n\n    events: ModActionsEventEmitter;\n\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport enum IgnoredEventType {\n  Ban = 1,\n  Unban,\n  Kick,\n}\n\nexport interface IIgnoredEvent {\n  type: IgnoredEventType;\n  userId: string;\n}\n\nexport type WarnResult =\n  | {\n      status: \"failed\";\n      error: string;\n    }\n  | {\n      status: \"success\";\n      case: Case;\n      notifyResult: UserNotificationResult;\n    };\n\nexport type KickResult =\n  | {\n      status: \"failed\";\n      error: string;\n    }\n  | {\n      status: \"success\";\n      case: Case;\n      notifyResult: UserNotificationResult;\n    };\n\nexport type BanResult =\n  | {\n      status: \"failed\";\n      error: string;\n    }\n  | {\n      status: \"success\";\n      case: Case;\n      notifyResult: UserNotificationResult;\n    };\n\nexport type WarnMemberNotifyRetryCallback = () => boolean | Promise<boolean>;\n\nexport interface WarnOptions {\n  caseArgs?: Partial<CaseArgs> | null;\n  contactMethods?: UserNotificationMethod[] | null;\n  retryPromptContext?: Message | ChatInputCommandInteraction | null;\n  isAutomodAction?: boolean;\n}\n\nexport interface KickOptions {\n  caseArgs?: Partial<CaseArgs>;\n  contactMethods?: UserNotificationMethod[];\n  isAutomodAction?: boolean;\n}\n\nexport interface BanOptions {\n  caseArgs?: Partial<CaseArgs>;\n  contactMethods?: UserNotificationMethod[];\n  deleteMessageDays?: number;\n  modId?: string;\n  isAutomodAction?: boolean;\n}\n\nexport type ModActionType = \"note\" | \"warn\" | \"mute\" | \"unmute\" | \"kick\" | \"ban\" | \"unban\";\n\nexport const modActionsMsgCmd = guildPluginMessageCommand<ModActionsPluginType>();\nexport const modActionsSlashGroup = guildPluginSlashGroup<ModActionsPluginType>();\nexport const modActionsSlashCmd = guildPluginSlashCommand<ModActionsPluginType>();\nexport const modActionsEvt = guildPluginEventListener<ModActionsPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Mutes/MutesPlugin.ts",
    "content": "import { GuildMember, Snowflake } from \"discord.js\";\nimport { EventEmitter } from \"events\";\nimport { guildPlugin } from \"vety\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { onGuildEvent } from \"../../data/GuildEvents.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildMutes } from \"../../data/GuildMutes.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { CasesPlugin } from \"../Cases/CasesPlugin.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../RoleManager/RoleManagerPlugin.js\";\nimport { ClearBannedMutesCmd } from \"./commands/ClearBannedMutesCmd.js\";\nimport { ClearMutesCmd } from \"./commands/ClearMutesCmd.js\";\nimport { ClearMutesWithoutRoleCmd } from \"./commands/ClearMutesWithoutRoleCmd.js\";\nimport { MutesCmd } from \"./commands/MutesCmd.js\";\nimport { ClearActiveMuteOnMemberBanEvt } from \"./events/ClearActiveMuteOnMemberBanEvt.js\";\nimport { ReapplyActiveMuteOnJoinEvt } from \"./events/ReapplyActiveMuteOnJoinEvt.js\";\nimport { RegisterManualTimeoutsEvt } from \"./events/RegisterManualTimeoutsEvt.js\";\nimport { clearMute } from \"./functions/clearMute.js\";\nimport { muteUser } from \"./functions/muteUser.js\";\nimport { offMutesEvent } from \"./functions/offMutesEvent.js\";\nimport { onMutesEvent } from \"./functions/onMutesEvent.js\";\nimport { renewTimeoutMute } from \"./functions/renewTimeoutMute.js\";\nimport { unmuteUser } from \"./functions/unmuteUser.js\";\nimport { MutesPluginType, zMutesConfig } from \"./types.js\";\n\nexport const MutesPlugin = guildPlugin<MutesPluginType>()({\n  name: \"mutes\",\n\n  dependencies: () => [CasesPlugin, LogsPlugin, RoleManagerPlugin],\n  configSchema: zMutesConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_view_list: true,\n      },\n    },\n    {\n      level: \">=100\",\n      config: {\n        can_cleanup: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    MutesCmd,\n    ClearBannedMutesCmd,\n    ClearMutesWithoutRoleCmd,\n    ClearMutesCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    // ClearActiveMuteOnRoleRemovalEvt, // FIXME: Temporarily disabled for performance\n    ClearActiveMuteOnMemberBanEvt,\n    ReapplyActiveMuteOnJoinEvt,\n    RegisterManualTimeoutsEvt,\n  ],\n\n  public(pluginData) {\n    return {\n      muteUser: makePublicFn(pluginData, muteUser),\n      unmuteUser: makePublicFn(pluginData, unmuteUser),\n      hasMutedRole: (member: GuildMember) => {\n        const muteRole = pluginData.config.get().mute_role;\n        return muteRole ? member.roles.cache.has(muteRole as Snowflake) : false;\n      },\n      on: makePublicFn(pluginData, onMutesEvent),\n      off: makePublicFn(pluginData, offMutesEvent),\n      getEventEmitter: () => pluginData.state.events,\n    };\n  },\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.mutes = GuildMutes.getGuildInstance(guild.id);\n    state.cases = GuildCases.getGuildInstance(guild.id);\n    state.serverLogs = new GuildLogs(guild.id);\n    state.archives = GuildArchives.getGuildInstance(guild.id);\n\n    state.events = new EventEmitter();\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.unregisterExpiredRoleMuteListener = onGuildEvent(guild.id, \"expiredMute\", (mute) =>\n      clearMute(pluginData, mute),\n    );\n    state.unregisterTimeoutMuteToRenewListener = onGuildEvent(guild.id, \"timeoutMuteToRenew\", (mute) =>\n      renewTimeoutMute(pluginData, mute),\n    );\n\n    const muteRole = pluginData.config.get().mute_role;\n    if (muteRole) {\n      state.mutes.fillMissingMuteRole(muteRole);\n    }\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.unregisterExpiredRoleMuteListener?.();\n    state.unregisterTimeoutMuteToRenewListener?.();\n    state.events.removeAllListeners();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { mutesCmd } from \"../types.js\";\n\nexport const ClearBannedMutesCmd = mutesCmd({\n  trigger: \"clear_banned_mutes\",\n  permission: \"can_cleanup\",\n  description: \"Clear dangling mutes for members who have been banned\",\n\n  async run({ pluginData, message: msg }) {\n    await msg.channel.send(\"Clearing mutes from banned users...\");\n\n    const activeMutes = await pluginData.state.mutes.getActiveMutes();\n\n    const bans = await pluginData.guild.bans.fetch({ cache: true });\n    const bannedIds = bans.map((b) => b.user.id);\n\n    await msg.channel.send(`Found ${activeMutes.length} mutes and ${bannedIds.length} bans, cross-referencing...`);\n\n    let cleared = 0;\n    for (const mute of activeMutes) {\n      if (bannedIds.includes(mute.user_id as Snowflake)) {\n        await pluginData.state.mutes.clear(mute.user_id);\n        cleared++;\n      }\n    }\n\n    void pluginData.state.common.sendSuccessMessage(msg, `Cleared ${cleared} mutes from banned users!`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Mutes/commands/ClearMutesCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { mutesCmd } from \"../types.js\";\n\nexport const ClearMutesCmd = mutesCmd({\n  trigger: \"clear_mutes\",\n  permission: \"can_cleanup\",\n  description: \"Clear dangling mute records from the bot. Be careful not to clear valid mutes.\",\n\n  signature: {\n    userIds: ct.string({ rest: true }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const failed: string[] = [];\n    for (const id of args.userIds) {\n      const mute = await pluginData.state.mutes.findExistingMuteForUserId(id);\n      if (!mute) {\n        failed.push(id);\n        continue;\n      }\n      await pluginData.state.mutes.clear(id);\n    }\n\n    if (failed.length !== args.userIds.length) {\n      void pluginData.state.common.sendSuccessMessage(\n        msg,\n        `**${args.userIds.length - failed.length} active mute(s) cleared**`,\n      );\n    }\n\n    if (failed.length) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `**${failed.length}/${args.userIds.length} IDs failed**, they are not muted: ${failed.join(\" \")}`,\n      );\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { resolveMember } from \"../../../utils.js\";\nimport { mutesCmd } from \"../types.js\";\n\nexport const ClearMutesWithoutRoleCmd = mutesCmd({\n  trigger: \"clear_mutes_without_role\",\n  permission: \"can_cleanup\",\n  description: \"Clear dangling mutes for members whose mute role was removed by other means\",\n\n  async run({ pluginData, message: msg }) {\n    const activeMutes = await pluginData.state.mutes.getActiveMutes();\n    const muteRole = pluginData.config.get().mute_role;\n    if (!muteRole) return;\n\n    await msg.channel.send(\"Clearing mutes from members that don't have the mute role...\");\n\n    let cleared = 0;\n    for (const mute of activeMutes) {\n      const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id);\n      if (!member) continue;\n\n      if (!member.roles.cache.has(muteRole as Snowflake)) {\n        await pluginData.state.mutes.clear(mute.user_id);\n        cleared++;\n      }\n    }\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `Cleared ${cleared} mutes from members that don't have the mute role`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Mutes/commands/MutesCmd.ts",
    "content": "import {\n  ActionRowBuilder,\n  ButtonBuilder,\n  ButtonStyle,\n  GuildMember,\n  MessageComponentInteraction,\n  Snowflake,\n} from \"discord.js\";\nimport moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { humanizeDurationShort } from \"../../../humanizeDuration.js\";\nimport { getBaseUrl } from \"../../../pluginUtils.js\";\nimport { DBDateFormat, MINUTES, renderUsername, resolveMember } from \"../../../utils.js\";\nimport { IMuteWithDetails, mutesCmd } from \"../types.js\";\n\nexport const MutesCmd = mutesCmd({\n  trigger: \"mutes\",\n  permission: \"can_view_list\",\n\n  signature: {\n    age: ct.delay({\n      option: true,\n      shortcut: \"a\",\n    }),\n\n    left: ct.switchOption({ def: false, shortcut: \"l\" }),\n    manual: ct.switchOption({ def: false, shortcut: \"m\" }),\n    export: ct.switchOption({ def: false, shortcut: \"e\" }),\n  },\n\n  async run({ pluginData, message: msg, args }) {\n    const listMessagePromise = msg.channel.send(\"Loading mutes...\");\n    const mutesPerPage = 10;\n    let totalMutes = 0;\n    let hasFilters = false;\n\n    let stopCollectionFn = () => {\n      return;\n    };\n    let stopCollectionTimeout: NodeJS.Timeout;\n    const stopCollectionDebounce = 5 * MINUTES;\n\n    const bumpCollectionTimeout = () => {\n      clearTimeout(stopCollectionTimeout);\n      stopCollectionTimeout = setTimeout(stopCollectionFn, stopCollectionDebounce);\n    };\n\n    let lines: string[] = [];\n\n    // Active, logged mutes\n    const activeMutes = await pluginData.state.mutes.getActiveMutes();\n    activeMutes.sort((a, b) => {\n      if (a.expires_at == null && b.expires_at != null) return 1;\n      if (b.expires_at == null && a.expires_at != null) return -1;\n      if (a.expires_at == null && b.expires_at == null) {\n        return a.created_at > b.created_at ? -1 : 1;\n      }\n      return a.expires_at! > b.expires_at! ? 1 : -1;\n    });\n\n    if (args.manual) {\n      // Show only manual mutes (i.e. \"Muted\" role added without a logged mute)\n      const muteUserIds = new Set(activeMutes.map((m) => m.user_id));\n      const manuallyMutedMembers: GuildMember[] = [];\n      const muteRole = pluginData.config.get().mute_role;\n\n      if (muteRole) {\n        pluginData.guild.members.cache.forEach((member) => {\n          if (muteUserIds.has(member.id)) return;\n          if (member.roles.cache.has(muteRole as Snowflake)) manuallyMutedMembers.push(member);\n        });\n      }\n\n      totalMutes = manuallyMutedMembers.length;\n\n      lines = manuallyMutedMembers.map((member) => {\n        return `<@!${member.id}> (**${renderUsername(member)}**, \\`${member.id}\\`)   🔧 Manual mute`;\n      });\n    } else {\n      // Show filtered active mutes (but not manual mutes)\n      let filteredMutes: IMuteWithDetails[] = activeMutes;\n      let bannedIds: string[] | null = null;\n\n      // Filter: mute age\n      if (args.age) {\n        const cutoff = moment.utc().subtract(args.age, \"ms\").format(DBDateFormat);\n        filteredMutes = filteredMutes.filter((m) => m.created_at <= cutoff);\n        hasFilters = true;\n      }\n\n      // Fetch some extra details for each mute: the muted member, and whether they've been banned\n      for (const [index, mute] of filteredMutes.entries()) {\n        const muteWithDetails = { ...mute };\n\n        const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id);\n\n        if (!member) {\n          if (!bannedIds) {\n            const bans = await pluginData.guild.bans.fetch({ cache: true });\n            bannedIds = bans.map((u) => u.user.id);\n          }\n\n          muteWithDetails.banned = bannedIds.includes(mute.user_id);\n        } else {\n          muteWithDetails.member = member;\n        }\n\n        filteredMutes[index] = muteWithDetails;\n      }\n\n      // Filter: left the server\n      if (args.left != null) {\n        filteredMutes = filteredMutes.filter((m) => (args.left && !m.member) || (!args.left && m.member));\n        hasFilters = true;\n      }\n\n      totalMutes = filteredMutes.length;\n\n      // Create a message line for each mute\n      const caseIds = filteredMutes.map((m) => m.case_id).filter((v) => !!v);\n      const muteCases = caseIds.length ? await pluginData.state.cases.get(caseIds) : [];\n      const muteCasesById = muteCases.reduce((map, c) => map.set(c.id, c), new Map());\n\n      lines = filteredMutes.map((mute) => {\n        const user = pluginData.client.users.resolve(mute.user_id as Snowflake);\n        const username = user ? renderUsername(user) : \"Unknown#0000\";\n        const theCase = muteCasesById.get(mute.case_id);\n        const caseName = theCase ? `Case #${theCase.case_number}` : \"No case\";\n\n        let line = `<@!${mute.user_id}> (**${username}**, \\`${mute.user_id}\\`)   📋 ${caseName}`;\n\n        if (mute.expires_at) {\n          const timeUntilExpiry = moment.utc().diff(moment.utc(mute.expires_at, DBDateFormat));\n          const humanizedTime = humanizeDurationShort(timeUntilExpiry, { largest: 2, round: true });\n          line += `   ⏰ Expires in ${humanizedTime}`;\n        } else {\n          line += `   ⏰ Indefinite`;\n        }\n\n        const timeFromMute = moment.utc(mute.created_at, DBDateFormat).diff(moment.utc());\n        const humanizedTimeFromMute = humanizeDurationShort(timeFromMute, { largest: 2, round: true });\n        line += `   🕒 Muted ${humanizedTimeFromMute} ago`;\n\n        if (mute.banned) {\n          line += `   🔨 Banned`;\n        } else if (!mute.member) {\n          line += `   ❌ Left server`;\n        }\n\n        return line;\n      });\n    }\n\n    const listMessage = await listMessagePromise;\n\n    let currentPage = 1;\n    const totalPages = Math.ceil(lines.length / mutesPerPage);\n\n    const drawListPage = async (page) => {\n      page = Math.max(1, Math.min(totalPages, page));\n      currentPage = page;\n\n      const pageStart = (page - 1) * mutesPerPage;\n      const pageLines = lines.slice(pageStart, pageStart + mutesPerPage);\n\n      const pageRangeText = `${pageStart + 1}–${pageStart + pageLines.length} of ${totalMutes}`;\n\n      let message;\n      if (args.manual) {\n        message = `Showing manual mutes ${pageRangeText}:`;\n      } else if (hasFilters) {\n        message = `Showing filtered active mutes ${pageRangeText}:`;\n      } else {\n        message = `Showing active mutes ${pageRangeText}:`;\n      }\n\n      message += \"\\n\\n\" + pageLines.join(\"\\n\");\n\n      listMessage.edit(message);\n      bumpCollectionTimeout();\n    };\n\n    if (totalMutes === 0) {\n      if (args.manual) {\n        listMessage.edit(\"No manual mutes found!\");\n      } else if (hasFilters) {\n        listMessage.edit(\"No mutes found with the specified filters!\");\n      } else {\n        listMessage.edit(\"No active mutes!\");\n      }\n    } else if (args.export) {\n      const archiveId = await pluginData.state.archives.create(lines.join(\"\\n\"), moment.utc().add(1, \"hour\"));\n      const baseUrl = getBaseUrl(pluginData);\n      const url = await pluginData.state.archives.getUrl(baseUrl, archiveId);\n\n      await listMessage.edit(`Exported mutes: ${url}`);\n    } else {\n      drawListPage(1);\n\n      if (totalPages > 1) {\n        const idMod = `${listMessage.id}:muteList`;\n        const buttons = [\n          new ButtonBuilder().setStyle(ButtonStyle.Secondary).setEmoji(\"⬅\").setCustomId(`previousButton:${idMod}`),\n          new ButtonBuilder().setStyle(ButtonStyle.Secondary).setEmoji(\"➡\").setCustomId(`nextButton:${idMod}`),\n        ] satisfies ButtonBuilder[];\n\n        const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buttons);\n        await listMessage.edit({ components: [row] });\n\n        const collector = listMessage.createMessageComponentCollector({ time: stopCollectionDebounce });\n\n        collector.on(\"collect\", async (interaction: MessageComponentInteraction) => {\n          if (msg.author.id !== interaction.user.id) {\n            interaction\n              .reply({ content: `You are not permitted to use these buttons.`, ephemeral: true })\n              // tslint:disable-next-line no-console\n              .catch((err) => console.trace(err.message));\n          } else {\n            collector.resetTimer();\n            await interaction.deferUpdate();\n            if (interaction.customId === `previousButton:${idMod}` && currentPage > 1) {\n              await drawListPage(currentPage - 1);\n            } else if (interaction.customId === `nextButton:${idMod}` && currentPage < totalPages) {\n              await drawListPage(currentPage + 1);\n            }\n          }\n        });\n\n        stopCollectionFn = async () => {\n          collector.stop();\n          await listMessage.edit({ content: listMessage.content, components: [] });\n        };\n        bumpCollectionTimeout();\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Mutes/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zMutesConfig } from \"./types.js\";\n\nexport const mutesPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Mutes\",\n  type: \"stable\",\n  configSchema: zMutesConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/Mutes/events/ClearActiveMuteOnMemberBanEvt.ts",
    "content": "import { mutesEvt } from \"../types.js\";\n\n/**\n * Clear active mute from the member if the member is banned\n */\nexport const ClearActiveMuteOnMemberBanEvt = mutesEvt({\n  event: \"guildBanAdd\",\n  async listener({ pluginData, args: { ban } }) {\n    const mute = await pluginData.state.mutes.findExistingMuteForUserId(ban.user.id);\n    if (mute) {\n      pluginData.state.mutes.clear(ban.user.id);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Mutes/events/ClearActiveMuteOnRoleRemovalEvt.ts",
    "content": "import { memberHasMutedRole } from \"../functions/memberHasMutedRole.js\";\nimport { mutesEvt } from \"../types.js\";\n\n/**\n * Clear active mute if the mute role is removed manually\n */\nexport const ClearActiveMuteOnRoleRemovalEvt = mutesEvt({\n  event: \"guildMemberUpdate\",\n  async listener({ pluginData, args: { newMember: member } }) {\n    const muteRole = pluginData.config.get().mute_role;\n    if (!muteRole) return;\n\n    const mute = await pluginData.state.mutes.findExistingMuteForUserId(member.id);\n    if (!mute) return;\n\n    if (!memberHasMutedRole(pluginData, member)) {\n      await pluginData.state.mutes.clear(muteRole);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts",
    "content": "import moment from \"moment-timezone\";\nimport { MuteTypes } from \"../../../data/MuteTypes.js\";\nimport { noop } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { getTimeoutExpiryTime } from \"../functions/getTimeoutExpiryTime.js\";\nimport { mutesEvt } from \"../types.js\";\n\n/**\n * Reapply active mutes on join\n */\nexport const ReapplyActiveMuteOnJoinEvt = mutesEvt({\n  event: \"guildMemberAdd\",\n  async listener({ pluginData, args: { member } }) {\n    const mute = await pluginData.state.mutes.findExistingMuteForUserId(member.id);\n    const logs = pluginData.getPlugin(LogsPlugin);\n    if (!mute) {\n      return;\n    }\n\n    if (mute.type === MuteTypes.Role) {\n      const muteRoleId = pluginData.config.get().mute_role;\n      if (muteRoleId) {\n        pluginData.getPlugin(RoleManagerPlugin).addPriorityRole(member.id, muteRoleId);\n      }\n    } else {\n      if (!member.isCommunicationDisabled()) {\n        const expiresAt = mute.expires_at ? moment.utc(mute.expires_at).valueOf() : null;\n        const timeoutExpiresAt = getTimeoutExpiryTime(expiresAt);\n        if (member.moderatable) {\n          await member.disableCommunicationUntil(timeoutExpiresAt).catch(noop);\n        } else {\n          logs.logBotAlert({\n            body: `Cannot mute user, specified user is not moderatable`,\n          });\n        }\n      }\n    }\n\n    logs.logMemberMuteRejoin({\n      member,\n    });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Mutes/events/RegisterManualTimeoutsEvt.ts",
    "content": "import { AuditLogChange, AuditLogEvent } from \"discord.js\";\nimport moment from \"moment-timezone\";\nimport { MuteTypes } from \"../../../data/MuteTypes.js\";\nimport { resolveUser } from \"../../../utils.js\";\nimport { mutesEvt } from \"../types.js\";\n\nexport const RegisterManualTimeoutsEvt = mutesEvt({\n  event: \"guildAuditLogEntryCreate\",\n  async listener({ pluginData, args: { auditLogEntry } }) {\n    // Ignore the bot's own audit log events\n    if (auditLogEntry.executorId === pluginData.client.user?.id) {\n      return;\n    }\n    if (auditLogEntry.action !== AuditLogEvent.MemberUpdate) {\n      return;\n    }\n\n    const target = await resolveUser(pluginData.client, auditLogEntry.targetId!, \"Mutes:RegisterManualTimeoutsEvt\");\n\n    // Only act based on the last changes in this log\n    let lastTimeoutChange: AuditLogChange | null = null;\n    for (const change of auditLogEntry.changes) {\n      if (change.key === \"communication_disabled_until\") {\n        lastTimeoutChange = change;\n      }\n    }\n    if (!lastTimeoutChange) {\n      return;\n    }\n\n    const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(target.id);\n\n    if (lastTimeoutChange.new == null && existingMute) {\n      await pluginData.state.mutes.clear(target.id);\n      return;\n    }\n\n    if (lastTimeoutChange.new != null) {\n      const expiresAtTimestamp = moment.utc(lastTimeoutChange.new as string).valueOf();\n      if (existingMute) {\n        await pluginData.state.mutes.updateExpiresAt(target.id, expiresAtTimestamp);\n      } else {\n        await pluginData.state.mutes.addMute({\n          userId: target.id,\n          type: MuteTypes.Timeout,\n          expiresAt: expiresAtTimestamp,\n          timeoutExpiresAt: expiresAtTimestamp,\n        });\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Mutes/functions/clearMute.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { MuteTypes } from \"../../../data/MuteTypes.js\";\nimport { Mute } from \"../../../data/entities/Mute.js\";\nimport { clearExpiringMute } from \"../../../data/loops/expiringMutesLoop.js\";\nimport { resolveMember, verboseUserMention } from \"../../../utils.js\";\nimport { memberRolesLock } from \"../../../utils/lockNameHelpers.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { MutesPluginType } from \"../types.js\";\n\nexport async function clearMute(\n  pluginData: GuildPluginData<MutesPluginType>,\n  mute: Mute | null = null,\n  member: GuildMember | null = null,\n) {\n  if (mute) {\n    clearExpiringMute(mute);\n  }\n\n  if (!member && mute) {\n    member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true);\n  }\n\n  if (member) {\n    const lock = await pluginData.locks.acquire(memberRolesLock(member));\n    const roleManagerPlugin = pluginData.getPlugin(RoleManagerPlugin);\n\n    try {\n      const defaultMuteRole = pluginData.config.get().mute_role;\n      if (mute) {\n        const muteRoleId = mute.mute_role || defaultMuteRole;\n\n        if (mute.type === MuteTypes.Role) {\n          if (muteRoleId) {\n            roleManagerPlugin.removePriorityRole(member.id, muteRoleId);\n          }\n        } else {\n          await member.timeout(null);\n        }\n\n        if (mute.roles_to_restore) {\n          const guildRoles = pluginData.guild.roles.cache;\n          for (const roleIdToRestore of mute?.roles_to_restore ?? []) {\n            if (guildRoles.has(roleIdToRestore) && roleIdToRestore !== muteRoleId) {\n              roleManagerPlugin.addRole(member.id, roleIdToRestore);\n            }\n          }\n        }\n      } else {\n        // Unmuting someone without an active mute -> remove timeouts and/or mute role\n        const muteRole = defaultMuteRole;\n        if (muteRole && member.roles.cache.has(muteRole)) {\n          roleManagerPlugin.removePriorityRole(member.id, muteRole);\n        }\n        if (member.isCommunicationDisabled()) {\n          await member.timeout(null);\n        }\n      }\n      pluginData.getPlugin(LogsPlugin).logMemberMuteExpired({ member });\n    } catch {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Failed to clear mute from ${verboseUserMention(member.user)}`,\n      });\n    } finally {\n      lock.unlock();\n    }\n  }\n\n  if (mute) {\n    await pluginData.state.mutes.clear(mute.user_id);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Mutes/functions/getDefaultMuteType.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { MuteTypes } from \"../../../data/MuteTypes.js\";\nimport { MutesPluginType } from \"../types.js\";\n\nexport function getDefaultMuteType(pluginData: GuildPluginData<MutesPluginType>): MuteTypes {\n  const muteRole = pluginData.config.get().mute_role;\n  return muteRole ? MuteTypes.Role : MuteTypes.Timeout;\n}\n"
  },
  {
    "path": "backend/src/plugins/Mutes/functions/getTimeoutExpiryTime.ts",
    "content": "import { MAX_TIMEOUT_DURATION } from \"../../../data/Mutes.js\";\n\n/**\n * Since timeouts have a limited duration (max 28d) but we support mutes longer than that,\n * the timeouts are applied for a certain duration at first and then renewed as necessary.\n * This function returns the initial end time for a timeout.\n * @return - Timeout expiry timestamp\n */\nexport function getTimeoutExpiryTime(muteExpiresAt: number | null | undefined): number {\n  if (muteExpiresAt && muteExpiresAt - Date.now() <= MAX_TIMEOUT_DURATION) {\n    return muteExpiresAt;\n  }\n  return Date.now() + MAX_TIMEOUT_DURATION;\n}\n"
  },
  {
    "path": "backend/src/plugins/Mutes/functions/memberHasMutedRole.ts",
    "content": "import { GuildMember, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { MutesPluginType } from \"../types.js\";\n\nexport function memberHasMutedRole(pluginData: GuildPluginData<MutesPluginType>, member: GuildMember): boolean {\n  const muteRole = pluginData.config.get().mute_role;\n  return muteRole ? member.roles.cache.has(muteRole as Snowflake) : false;\n}\n"
  },
  {
    "path": "backend/src/plugins/Mutes/functions/muteUser.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { ERRORS, RecoverablePluginError } from \"../../../RecoverablePluginError.js\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { AddMuteParams } from \"../../../data/GuildMutes.js\";\nimport { MuteTypes } from \"../../../data/MuteTypes.js\";\nimport { Case } from \"../../../data/entities/Case.js\";\nimport { Mute } from \"../../../data/entities/Mute.js\";\nimport { registerExpiringMute } from \"../../../data/loops/expiringMutesLoop.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { LogsPlugin } from \"../../../plugins/Logs/LogsPlugin.js\";\nimport { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport {\n  UserNotificationMethod,\n  UserNotificationResult,\n  noop,\n  notifyUser,\n  resolveMember,\n  resolveUser,\n  ucfirst,\n} from \"../../../utils.js\";\nimport { muteLock } from \"../../../utils/lockNameHelpers.js\";\nimport { userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { MuteOptions, MutesPluginType } from \"../types.js\";\nimport { getDefaultMuteType } from \"./getDefaultMuteType.js\";\nimport { getTimeoutExpiryTime } from \"./getTimeoutExpiryTime.js\";\n\n/**\n * TODO: Clean up this function\n */\nexport async function muteUser(\n  pluginData: GuildPluginData<MutesPluginType>,\n  userId: string,\n  muteTime?: number,\n  reason?: string,\n  reasonWithAttachments?: string,\n  muteOptions: MuteOptions = {},\n  removeRolesOnMuteOverride: boolean | string[] | null = null,\n  restoreRolesOnMuteOverride: boolean | string[] | null = null,\n) {\n  const lock = await pluginData.locks.acquire(muteLock({ id: userId }));\n\n  const muteRole = pluginData.config.get().mute_role;\n  const muteType = getDefaultMuteType(pluginData);\n  const muteExpiresAt = muteTime ? Date.now() + muteTime : null;\n  const timeoutUntil = getTimeoutExpiryTime(muteExpiresAt);\n\n  // No mod specified -> mark Zeppelin as the mod\n  if (!muteOptions.caseArgs?.modId) {\n    muteOptions.caseArgs = muteOptions.caseArgs ?? {};\n    muteOptions.caseArgs.modId = pluginData.client.user!.id;\n  }\n\n  const user = await resolveUser(pluginData.client, userId, \"Mutes:muteUser\");\n  if (!user.id) {\n    lock.unlock();\n    throw new RecoverablePluginError(ERRORS.INVALID_USER);\n  }\n\n  const member = await resolveMember(pluginData.client, pluginData.guild, user.id, true); // Grab the fresh member so we don't have stale role info\n  const config = await pluginData.config.getMatchingConfig({ member, userId });\n\n  const logs = pluginData.getPlugin(LogsPlugin);\n\n  let rolesToRestore: string[] = [];\n  if (member) {\n    // remove and store any roles to be removed/restored\n    const currentUserRoles = [...member.roles.cache.keys()];\n    let newRoles: string[] = currentUserRoles;\n    const removeRoles = removeRolesOnMuteOverride ?? config.remove_roles_on_mute;\n    const restoreRoles = restoreRolesOnMuteOverride ?? config.restore_roles_on_mute;\n\n    // Remove roles\n    if (!Array.isArray(removeRoles)) {\n      if (removeRoles) {\n        // exclude managed roles from being removed\n        const managedRoles = pluginData.guild.roles.cache.filter((x) => x.managed).map((y) => y.id);\n        newRoles = currentUserRoles.filter((r) => managedRoles.includes(r));\n        await member.roles.set(newRoles as Snowflake[]);\n      }\n    } else {\n      newRoles = currentUserRoles.filter((x) => !(<string[]>removeRoles).includes(x));\n      await member.roles.set(newRoles as Snowflake[]);\n    }\n\n    // Set roles to be restored\n    if (!Array.isArray(restoreRoles)) {\n      if (restoreRoles) {\n        rolesToRestore = currentUserRoles;\n      }\n    } else {\n      rolesToRestore = currentUserRoles.filter((x) => (<string[]>restoreRoles).includes(x));\n    }\n\n    if (muteType === MuteTypes.Role) {\n      // Verify the configured mute role is valid\n      const actualMuteRole = pluginData.guild.roles.cache.get(muteRole!);\n      if (!actualMuteRole) {\n        lock.unlock();\n        logs.logBotAlert({\n          body: `Cannot mute users, specified mute role Id is invalid`,\n        });\n        throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID);\n      }\n\n      // Verify the mute role is not above Zep's roles\n      const zep = await pluginData.guild.members.fetchMe();\n      const zepRoles = pluginData.guild.roles.cache.filter((x) => zep.roles.cache.has(x.id));\n      if (zepRoles.size === 0 || !zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) {\n        lock.unlock();\n        logs.logBotAlert({\n          body: `Cannot mute user, specified mute role is above Zeppelin in the role hierarchy`,\n        });\n        throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild);\n      }\n\n      if (!currentUserRoles.includes(muteRole!)) {\n        pluginData.getPlugin(RoleManagerPlugin).addPriorityRole(member.id, muteRole!);\n      }\n    } else {\n      if (!member.manageable) {\n        lock.unlock();\n        logs.logBotAlert({\n          body: `Cannot mute user, specified user is above Zeppelin in the role hierarchy`,\n        });\n        throw new RecoverablePluginError(ERRORS.USER_ABOVE_ZEP, pluginData.guild);\n      }\n\n      if (!member.moderatable) {\n        // redundant safety, since canActOn already checks this\n        lock.unlock();\n        logs.logBotAlert({\n          body: `Cannot mute user, specified user is not moderatable`,\n        });\n        throw new RecoverablePluginError(ERRORS.USER_NOT_MODERATABLE, pluginData.guild);\n      }\n\n      await member.disableCommunicationUntil(timeoutUntil).catch(noop);\n    }\n\n    // If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role)\n    const cfg = pluginData.config.get();\n    const moveToVoiceChannel = cfg.kick_from_voice_channel ? null : cfg.move_to_voice_channel;\n    if (moveToVoiceChannel || cfg.kick_from_voice_channel) {\n      // TODO: Add back the voiceState check once we figure out how to get voice state for guild members that are loaded on-demand\n      try {\n        await member.edit({ channel: moveToVoiceChannel as Snowflake });\n      } catch {} // eslint-disable-line no-empty\n    }\n  }\n\n  // If the user is already muted, update the duration of their existing mute\n  const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(user.id);\n  let finalMute: Mute;\n  let notifyResult: UserNotificationResult = { method: null, success: true };\n\n  if (existingMute) {\n    if (existingMute.roles_to_restore?.length || rolesToRestore?.length) {\n      rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...rolesToRestore]));\n    }\n    await pluginData.state.mutes.updateExpiryTime(user.id, muteTime, rolesToRestore);\n    if (muteType === MuteTypes.Timeout) {\n      await pluginData.state.mutes.updateTimeoutExpiresAt(user.id, timeoutUntil);\n    }\n    finalMute = (await pluginData.state.mutes.findExistingMuteForUserId(user.id))!;\n  } else {\n    const muteParams: AddMuteParams = {\n      userId: user.id,\n      type: muteType,\n      expiresAt: muteExpiresAt,\n      rolesToRestore,\n    };\n    if (muteType === MuteTypes.Role) {\n      muteParams.muteRole = muteRole;\n    } else {\n      muteParams.timeoutExpiresAt = timeoutUntil;\n    }\n    finalMute = await pluginData.state.mutes.addMute(muteParams);\n  }\n\n  registerExpiringMute(finalMute);\n\n  const timeUntilUnmuteStr = muteTime ? humanizeDuration(muteTime) : \"indefinite\";\n  const template = existingMute\n    ? config.update_mute_message\n    : muteTime\n      ? config.timed_mute_message\n      : config.mute_message;\n\n  let muteMessage: string | null = null;\n  try {\n    muteMessage =\n      template &&\n      (await renderTemplate(\n        template,\n        new TemplateSafeValueContainer({\n          guildName: pluginData.guild.name,\n          reason: reasonWithAttachments || \"None\",\n          time: timeUntilUnmuteStr,\n          moderator: muteOptions.caseArgs?.modId\n            ? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId, \"Mutes:muteUser\"))\n            : null,\n        }),\n      ));\n  } catch (err) {\n    if (err instanceof TemplateParseError) {\n      logs.logBotAlert({\n        body: `Invalid mute message format. The mute was still applied: ${err.message}`,\n      });\n    } else {\n      lock.unlock();\n      throw err;\n    }\n  }\n\n  if (muteMessage && member) {\n    let contactMethods: UserNotificationMethod[] = [];\n\n    if (muteOptions?.contactMethods) {\n      contactMethods = muteOptions.contactMethods;\n    } else {\n      const useDm = existingMute ? config.dm_on_update : config.dm_on_mute;\n      if (useDm) {\n        contactMethods.push({ type: \"dm\" });\n      }\n\n      const useChannel = existingMute ? config.message_on_update : config.message_on_mute;\n      const channel = config.message_channel\n        ? pluginData.guild.channels.cache.get(config.message_channel as Snowflake)\n        : null;\n      if (useChannel && channel?.isTextBased()) {\n        contactMethods.push({ type: \"channel\", channel });\n      }\n    }\n\n    notifyResult = await notifyUser(member.user, muteMessage, contactMethods);\n  }\n\n  // Create/update a case\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  let theCase: Case | null =\n    existingMute && existingMute.case_id ? await pluginData.state.cases.find(existingMute.case_id) : null;\n\n  if (theCase) {\n    // Update old case\n    const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmuteStr : \"indefinite\"}`];\n    const reasons = reason ? [reason] : [\"\"]; // Empty string so that there is a case update even without reason\n\n    if (muteOptions.caseArgs?.extraNotes) {\n      reasons.push(...muteOptions.caseArgs.extraNotes);\n    }\n\n    for (const noteReason of reasons) {\n      await casesPlugin.createCaseNote({\n        caseId: existingMute!.case_id,\n        modId: muteOptions.caseArgs?.modId,\n        body: noteReason,\n        noteDetails,\n        postInCaseLogOverride: muteOptions.caseArgs?.postInCaseLogOverride,\n      });\n    }\n  } else {\n    // Create new case\n    const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmuteStr}` : \"indefinitely\"}`];\n    if (notifyResult.text) {\n      noteDetails.push(ucfirst(notifyResult.text));\n    }\n\n    theCase = await casesPlugin.createCase({\n      ...(muteOptions.caseArgs || {}),\n      userId,\n      modId: muteOptions.caseArgs?.modId,\n      type: CaseTypes.Mute,\n      reason,\n      noteDetails,\n    });\n    await pluginData.state.mutes.setCaseId(user.id, theCase.id);\n  }\n\n  // Log the action\n  const mod = await resolveUser(pluginData.client, muteOptions.caseArgs?.modId, \"Mutes:muteUser\");\n  if (muteTime) {\n    pluginData.getPlugin(LogsPlugin).logMemberTimedMute({\n      mod,\n      user,\n      time: timeUntilUnmuteStr,\n      caseNumber: theCase.case_number,\n      reason: reason ?? \"\",\n    });\n  } else {\n    pluginData.getPlugin(LogsPlugin).logMemberMute({\n      mod,\n      user,\n      caseNumber: theCase.case_number,\n      reason: reason ?? \"\",\n    });\n  }\n\n  lock.unlock();\n\n  pluginData.state.events.emit(\"mute\", user.id, reason, muteOptions.isAutomodAction);\n\n  return {\n    case: theCase,\n    notifyResult,\n    updatedExistingMute: !!existingMute,\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/Mutes/functions/offMutesEvent.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { MutesEvents, MutesPluginType } from \"../types.js\";\n\nexport function offMutesEvent<TEvent extends keyof MutesEvents>(\n  pluginData: GuildPluginData<MutesPluginType>,\n  event: TEvent,\n  listener: MutesEvents[TEvent],\n) {\n  return pluginData.state.events.off(event, listener);\n}\n"
  },
  {
    "path": "backend/src/plugins/Mutes/functions/onMutesEvent.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { MutesEvents, MutesPluginType } from \"../types.js\";\n\nexport function onMutesEvent<TEvent extends keyof MutesEvents>(\n  pluginData: GuildPluginData<MutesPluginType>,\n  event: TEvent,\n  listener: MutesEvents[TEvent],\n) {\n  return pluginData.state.events.on(event, listener);\n}\n"
  },
  {
    "path": "backend/src/plugins/Mutes/functions/renewTimeoutMute.ts",
    "content": "import { PermissionFlagsBits } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { MAX_TIMEOUT_DURATION } from \"../../../data/Mutes.js\";\nimport { Mute } from \"../../../data/entities/Mute.js\";\nimport { DBDateFormat, noop, resolveMember } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { MutesPluginType } from \"../types.js\";\n\nexport async function renewTimeoutMute(pluginData: GuildPluginData<MutesPluginType>, mute: Mute) {\n  const me =\n    pluginData.client.user && (await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user.id));\n  if (!me || !me.permissions.has(PermissionFlagsBits.ModerateMembers)) {\n    return;\n  }\n\n  const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true);\n  if (!member) {\n    return;\n  }\n\n  let newExpiryTime = moment.utc().add(MAX_TIMEOUT_DURATION).format(DBDateFormat);\n  if (mute.expires_at && newExpiryTime > mute.expires_at) {\n    newExpiryTime = mute.expires_at;\n  }\n\n  const expiryTimestamp = moment.utc(newExpiryTime).valueOf();\n  if (!member.moderatable) {\n    pluginData.getPlugin(LogsPlugin).logBotAlert({\n      body: `Cannot renew user's timeout, specified user is not moderatable`,\n    });\n    return;\n  }\n\n  await member.disableCommunicationUntil(expiryTimestamp).catch(noop);\n  await pluginData.state.mutes.updateTimeoutExpiresAt(mute.user_id, expiryTimestamp);\n}\n"
  },
  {
    "path": "backend/src/plugins/Mutes/functions/unmuteUser.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { AddMuteParams } from \"../../../data/GuildMutes.js\";\nimport { MuteTypes } from \"../../../data/MuteTypes.js\";\nimport { Mute } from \"../../../data/entities/Mute.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { noop, resolveMember, resolveUser } from \"../../../utils.js\";\nimport { CasesPlugin } from \"../../Cases/CasesPlugin.js\";\nimport { CaseArgs } from \"../../Cases/types.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { MutesPluginType, UnmuteResult } from \"../types.js\";\nimport { clearMute } from \"./clearMute.js\";\nimport { getDefaultMuteType } from \"./getDefaultMuteType.js\";\nimport { getTimeoutExpiryTime } from \"./getTimeoutExpiryTime.js\";\nimport { memberHasMutedRole } from \"./memberHasMutedRole.js\";\n\nexport async function unmuteUser(\n  pluginData: GuildPluginData<MutesPluginType>,\n  userId: string,\n  unmuteTime?: number,\n  caseArgs: Partial<CaseArgs> = {},\n): Promise<UnmuteResult | null> {\n  const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(userId);\n  const user = await resolveUser(pluginData.client, userId, \"Mutes:unmuteUser\");\n  const member = await resolveMember(pluginData.client, pluginData.guild, userId, true); // Grab the fresh member so we don't have stale role info\n  const modId = caseArgs.modId || pluginData.client.user!.id;\n\n  if (!existingMute && member && !memberHasMutedRole(pluginData, member) && !member?.isCommunicationDisabled()) {\n    return null;\n  }\n\n  if (unmuteTime) {\n    // Schedule timed unmute (= just update the mute's duration)\n    const muteExpiresAt = Date.now() + unmuteTime;\n    const timeoutExpiresAt = getTimeoutExpiryTime(muteExpiresAt);\n    let createdMute: Mute | null = null;\n\n    if (!existingMute) {\n      const defaultMuteType = getDefaultMuteType(pluginData);\n      const muteParams: AddMuteParams = {\n        userId,\n        type: defaultMuteType,\n        expiresAt: muteExpiresAt,\n      };\n      if (defaultMuteType === MuteTypes.Role) {\n        muteParams.muteRole = pluginData.config.get().mute_role;\n      } else {\n        muteParams.timeoutExpiresAt = timeoutExpiresAt;\n      }\n      createdMute = await pluginData.state.mutes.addMute(muteParams);\n    } else {\n      await pluginData.state.mutes.updateExpiryTime(userId, unmuteTime);\n    }\n\n    // Update timeout\n    if (member && (existingMute?.type === MuteTypes.Timeout || createdMute?.type === MuteTypes.Timeout)) {\n      if (!member.moderatable) return null;\n\n      await member.disableCommunicationUntil(timeoutExpiresAt).catch(noop);\n      await pluginData.state.mutes.updateTimeoutExpiresAt(userId, timeoutExpiresAt);\n    }\n  } else {\n    // Unmute immediately\n    clearMute(pluginData, existingMute);\n  }\n\n  const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime);\n\n  // Create a case\n  const noteDetails: string[] = [];\n  if (unmuteTime) {\n    noteDetails.push(`Scheduled unmute in ${timeUntilUnmute}`);\n  } else {\n    noteDetails.push(`Unmuted immediately`);\n  }\n  if (!existingMute) {\n    noteDetails.push(`Removed external mute`);\n  }\n\n  const casesPlugin = pluginData.getPlugin(CasesPlugin);\n  const createdCase = await casesPlugin.createCase({\n    ...caseArgs,\n    userId,\n    modId,\n    type: CaseTypes.Unmute,\n    noteDetails,\n  });\n\n  // Log the action\n  const mod = await pluginData.client.users.fetch(modId as Snowflake);\n  if (unmuteTime) {\n    pluginData.getPlugin(LogsPlugin).logMemberTimedUnmute({\n      mod,\n      user,\n      caseNumber: createdCase.case_number,\n      time: timeUntilUnmute,\n      reason: caseArgs.reason ?? \"\",\n    });\n  } else {\n    pluginData.getPlugin(LogsPlugin).logMemberUnmute({\n      mod,\n      user,\n      caseNumber: createdCase.case_number,\n      reason: caseArgs.reason ?? \"\",\n    });\n  }\n\n  if (!unmuteTime) {\n    // If the member was unmuted, not just scheduled to be unmuted, fire the unmute event as well\n    // Scheduled unmutes have their event fired in clearExpiredMutes()\n    pluginData.state.events.emit(\"unmute\", user.id, caseArgs.reason);\n  }\n\n  return {\n    case: createdCase,\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/Mutes/types.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { EventEmitter } from \"events\";\nimport { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildMutes } from \"../../data/GuildMutes.js\";\nimport { Case } from \"../../data/entities/Case.js\";\nimport { Mute } from \"../../data/entities/Mute.js\";\nimport { UserNotificationMethod, UserNotificationResult, zSnowflake } from \"../../utils.js\";\nimport { CaseArgs } from \"../Cases/types.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zMutesConfig = z.strictObject({\n  mute_role: zSnowflake.nullable().default(null),\n  move_to_voice_channel: zSnowflake.nullable().default(null),\n  kick_from_voice_channel: z.boolean().default(false),\n\n  dm_on_mute: z.boolean().default(false),\n  dm_on_update: z.boolean().default(false),\n  message_on_mute: z.boolean().default(false),\n  message_on_update: z.boolean().default(false),\n  message_channel: z.string().nullable().default(null),\n  mute_message: z.string().nullable().default(\"You have been muted on the {guildName} server. Reason given: {reason}\"),\n  timed_mute_message: z\n    .string()\n    .nullable()\n    .default(\"You have been muted on the {guildName} server for {time}. Reason given: {reason}\"),\n  update_mute_message: z.string().nullable().default(\"Your mute on the {guildName} server has been updated to {time}.\"),\n  remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),\n  restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),\n\n  can_view_list: z.boolean().default(false),\n  can_cleanup: z.boolean().default(false),\n});\n\nexport interface MutesEvents {\n  mute: (userId: string, reason?: string, isAutomodAction?: boolean) => void;\n  unmute: (userId: string, reason?: string) => void;\n}\n\nexport interface MutesEventEmitter extends EventEmitter {\n  on<U extends keyof MutesEvents>(event: U, listener: MutesEvents[U]): this;\n  emit<U extends keyof MutesEvents>(event: U, ...args: Parameters<MutesEvents[U]>): boolean;\n}\n\nexport interface MutesPluginType extends BasePluginType {\n  configSchema: typeof zMutesConfig;\n  state: {\n    mutes: GuildMutes;\n    cases: GuildCases;\n    serverLogs: GuildLogs;\n    archives: GuildArchives;\n\n    unregisterExpiredRoleMuteListener: () => void;\n    unregisterTimeoutMuteToRenewListener: () => void;\n\n    events: MutesEventEmitter;\n\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport interface IMuteWithDetails extends Mute {\n  member?: GuildMember;\n  banned?: boolean;\n}\n\nexport type MuteResult = {\n  case: Case;\n  notifyResult: UserNotificationResult;\n  updatedExistingMute: boolean;\n};\n\nexport type UnmuteResult = {\n  case: Case;\n};\n\nexport interface MuteOptions {\n  caseArgs?: Partial<CaseArgs>;\n  contactMethods?: UserNotificationMethod[];\n  isAutomodAction?: boolean;\n}\n\nexport const mutesCmd = guildPluginMessageCommand<MutesPluginType>();\nexport const mutesEvt = guildPluginEventListener<MutesPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/NameHistory/NameHistoryPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { Queue } from \"../../Queue.js\";\nimport { GuildNicknameHistory } from \"../../data/GuildNicknameHistory.js\";\nimport { UsernameHistory } from \"../../data/UsernameHistory.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { NamesCmd } from \"./commands/NamesCmd.js\";\nimport { NameHistoryPluginType, zNameHistoryConfig } from \"./types.js\";\n\nexport const NameHistoryPlugin = guildPlugin<NameHistoryPluginType>()({\n  name: \"name_history\",\n\n  configSchema: zNameHistoryConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_view: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    NamesCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    // FIXME: Temporary\n    // ChannelJoinEvt,\n    // MessageCreateEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.nicknameHistory = GuildNicknameHistory.getGuildInstance(guild.id);\n    state.usernameHistory = new UsernameHistory();\n    state.updateQueue = new Queue();\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/NameHistory/commands/NamesCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { createChunkedMessage, disableCodeBlocks } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { MAX_NICKNAME_ENTRIES_PER_USER } from \"../../../data/GuildNicknameHistory.js\";\nimport { MAX_USERNAME_ENTRIES_PER_USER } from \"../../../data/UsernameHistory.js\";\nimport { NICKNAME_RETENTION_PERIOD } from \"../../../data/cleanup/nicknames.js\";\nimport { DAYS, renderUsername } from \"../../../utils.js\";\nimport { nameHistoryCmd } from \"../types.js\";\n\nexport const NamesCmd = nameHistoryCmd({\n  trigger: \"names\",\n  permission: \"can_view\",\n\n  signature: {\n    userId: ct.userId(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const nicknames = await pluginData.state.nicknameHistory.getByUserId(args.userId);\n    const usernames = await pluginData.state.usernameHistory.getByUserId(args.userId);\n\n    if (nicknames.length === 0 && usernames.length === 0) {\n      void pluginData.state.common.sendErrorMessage(msg, \"No name history found\");\n      return;\n    }\n\n    const nicknameRows = nicknames.map(\n      (r) => `\\`[${r.timestamp}]\\` ${r.nickname ? `**${disableCodeBlocks(r.nickname)}**` : \"*None*\"}`,\n    );\n    const usernameRows = usernames.map((r) => `\\`[${r.timestamp}]\\` **${disableCodeBlocks(r.username)}**`);\n\n    const user = await pluginData.client.users.fetch(args.userId as Snowflake).catch(() => null);\n    const currentUsername = user ? renderUsername(user) : args.userId;\n\n    const nicknameDays = Math.round(NICKNAME_RETENTION_PERIOD / DAYS);\n    const usernameDays = Math.round(NICKNAME_RETENTION_PERIOD / DAYS);\n\n    let message = `Name history for **${currentUsername}**:`;\n    if (nicknameRows.length) {\n      message += `\\n\\n__Last ${MAX_NICKNAME_ENTRIES_PER_USER} nicknames within ${nicknameDays} days:__\\n${nicknameRows.join(\n        \"\\n\",\n      )}`;\n    }\n    if (usernameRows.length) {\n      message += `\\n\\n__Last ${MAX_USERNAME_ENTRIES_PER_USER} usernames within ${usernameDays} days:__\\n${usernameRows.join(\n        \"\\n\",\n      )}`;\n    }\n\n    createChunkedMessage(msg.channel, message);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/NameHistory/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zNameHistoryConfig } from \"./types.js\";\n\nexport const nameHistoryPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Name history\",\n  type: \"internal\",\n  configSchema: zNameHistoryConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/NameHistory/events/UpdateNameEvts.ts",
    "content": "import { nameHistoryEvt } from \"../types.js\";\nimport { updateNickname } from \"../updateNickname.js\";\n\nexport const ChannelJoinEvt = nameHistoryEvt({\n  event: \"voiceStateUpdate\",\n\n  async listener(meta) {\n    meta.pluginData.state.updateQueue.add(() =>\n      updateNickname(meta.pluginData, meta.args.newState.member ?? meta.args.oldState.member!),\n    );\n  },\n});\n\nexport const MessageCreateEvt = nameHistoryEvt({\n  event: \"messageCreate\",\n\n  async listener(meta) {\n    meta.pluginData.state.updateQueue.add(() => updateNickname(meta.pluginData, meta.args.message.member!));\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/NameHistory/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { Queue } from \"../../Queue.js\";\nimport { GuildNicknameHistory } from \"../../data/GuildNicknameHistory.js\";\nimport { UsernameHistory } from \"../../data/UsernameHistory.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zNameHistoryConfig = z.strictObject({\n  can_view: z.boolean().default(false),\n});\n\nexport interface NameHistoryPluginType extends BasePluginType {\n  configSchema: typeof zNameHistoryConfig;\n  state: {\n    nicknameHistory: GuildNicknameHistory;\n    usernameHistory: UsernameHistory;\n    updateQueue: Queue;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const nameHistoryCmd = guildPluginMessageCommand<NameHistoryPluginType>();\nexport const nameHistoryEvt = guildPluginEventListener<NameHistoryPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/NameHistory/updateNickname.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { NameHistoryPluginType } from \"./types.js\";\n\nexport async function updateNickname(pluginData: GuildPluginData<NameHistoryPluginType>, member: GuildMember) {\n  if (!member) return;\n  const latestEntry = await pluginData.state.nicknameHistory.getLastEntry(member.id);\n  if (!latestEntry || latestEntry.nickname !== member.nickname) {\n    if (!latestEntry && member.nickname == null) return; // No need to save \"no nickname\" if there's no previous data\n    await pluginData.state.nicknameHistory.addEntry(member.id, member.nickname);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Persist/PersistPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildPersistedData } from \"../../data/GuildPersistedData.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../RoleManager/RoleManagerPlugin.js\";\nimport { LoadDataEvt } from \"./events/LoadDataEvt.js\";\nimport { StoreDataEvt } from \"./events/StoreDataEvt.js\";\nimport { PersistPluginType, zPersistConfig } from \"./types.js\";\n\nexport const PersistPlugin = guildPlugin<PersistPluginType>()({\n  name: \"persist\",\n\n  dependencies: () => [LogsPlugin, RoleManagerPlugin],\n  configSchema: zPersistConfig,\n\n  // prettier-ignore\n  events: [\n    StoreDataEvt,\n    LoadDataEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.persistedData = GuildPersistedData.getGuildInstance(guild.id);\n    state.logs = new GuildLogs(guild.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Persist/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zPersistConfig } from \"./types.js\";\n\nexport const persistPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Persist\",\n  description: trimPluginDescription(`\n    Re-apply roles or nicknames for users when they rejoin the server.\n    Mute roles are re-applied automatically, this plugin is not required for that.\n  `),\n  configSchema: zPersistConfig,\n  type: \"stable\",\n};\n"
  },
  {
    "path": "backend/src/plugins/Persist/events/LoadDataEvt.ts",
    "content": "import { GuildMember, PermissionFlagsBits } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { intersection } from \"lodash-es\";\nimport { PersistedData } from \"../../../data/entities/PersistedData.js\";\nimport { SECONDS } from \"../../../utils.js\";\nimport { canAssignRole } from \"../../../utils/canAssignRole.js\";\nimport { getMissingPermissions } from \"../../../utils/getMissingPermissions.js\";\nimport { missingPermissionError } from \"../../../utils/missingPermissionError.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { PersistPluginType, persistEvt } from \"../types.js\";\n\nconst p = PermissionFlagsBits;\n\nasync function applyPersistedData(\n  pluginData: GuildPluginData<PersistPluginType>,\n  persistedData: PersistedData,\n  member: GuildMember,\n): Promise<string[]> {\n  const config = await pluginData.config.getForMember(member);\n  const guildRoles = Array.from(pluginData.guild.roles.cache.keys());\n  const restoredData: string[] = [];\n\n  const persistedRoles = config.persisted_roles;\n  if (persistedRoles.length) {\n    const roleManager = pluginData.getPlugin(RoleManagerPlugin);\n    const rolesToRestore = intersection(persistedRoles, persistedData.roles, guildRoles).filter(\n      (roleId) => !member.roles.cache.has(roleId),\n    );\n\n    if (rolesToRestore.length) {\n      restoredData.push(\"roles\");\n      for (const roleId of rolesToRestore) {\n        roleManager.addRole(member.id, roleId);\n      }\n    }\n  }\n\n  if (config.persist_nicknames && persistedData.nickname && member.nickname !== persistedData.nickname) {\n    restoredData.push(\"nickname\");\n    await member.edit({\n      nick: persistedData.nickname,\n    });\n  }\n\n  return restoredData;\n}\n\nexport const LoadDataEvt = persistEvt({\n  event: \"guildMemberAdd\",\n\n  async listener(meta) {\n    const member = meta.args.member;\n    const pluginData = meta.pluginData;\n\n    const persistedData = await pluginData.state.persistedData.find(member.id);\n    if (!persistedData) {\n      return;\n    }\n    await pluginData.state.persistedData.clear(member.id);\n\n    const config = await pluginData.config.getForMember(member);\n\n    // Check permissions\n    const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;\n    let requiredPermissions = 0n;\n    if (config.persist_nicknames) requiredPermissions |= p.ManageNicknames;\n    if (config.persisted_roles) requiredPermissions |= p.ManageRoles;\n    const missingPermissions = getMissingPermissions(me.permissions, requiredPermissions);\n    if (missingPermissions) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Missing permissions for persist plugin: ${missingPermissionError(missingPermissions)}`,\n      });\n      return;\n    }\n\n    const guildRoles = Array.from(pluginData.guild.roles.cache.keys());\n\n    // Check specific role permissions\n    if (config.persisted_roles) {\n      for (const roleId of config.persisted_roles) {\n        if (!canAssignRole(pluginData.guild, me, roleId) && guildRoles.includes(roleId)) {\n          pluginData.getPlugin(LogsPlugin).logBotAlert({\n            body: `Missing permissions to assign role \\`${roleId}\\` in persist plugin`,\n          });\n          return;\n        }\n      }\n    }\n\n    const restoredData = await applyPersistedData(pluginData, persistedData, member);\n    setTimeout(() => {\n      // Reapply persisted data after a while for better interop with other bots that restore roles\n      void applyPersistedData(pluginData, persistedData, member);\n    }, 5 * SECONDS);\n\n    if (restoredData.length) {\n      pluginData.getPlugin(LogsPlugin).logMemberRestore({\n        member,\n        restoredData: restoredData.join(\", \"),\n      });\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Persist/events/StoreDataEvt.ts",
    "content": "import { PersistedData } from \"../../../data/entities/PersistedData.js\";\nimport { persistEvt } from \"../types.js\";\n\nexport const StoreDataEvt = persistEvt({\n  event: \"guildMemberRemove\",\n\n  async listener({ pluginData, args: { member } }) {\n    const config = await pluginData.config.getForUser(member.user);\n    const persistData: Partial<PersistedData> = {};\n\n    // FIXME: New caching thing, or fix deadlocks with this plugin\n    if (member.partial) {\n      return;\n      // Djs hasn't cached member data => use db cache\n      /*\n      const data = await pluginData.getPlugin(GuildMemberCachePlugin).getCachedMemberData(member.id);\n      if (!data) {\n        return;\n      }\n\n      const rolesToPersist = config.persisted_roles.filter((roleId) => data.roles.includes(roleId));\n      if (rolesToPersist.length) {\n        persistData.roles = rolesToPersist;\n      }\n      if (config.persist_nicknames && data.nickname) {\n        persistData.nickname = data.nickname;\n      }*/\n    } else {\n      // Djs has cached member data => use that\n      const memberRoles = Array.from(member.roles.cache.keys());\n      const rolesToPersist = config.persisted_roles.filter((roleId) => memberRoles.includes(roleId));\n      if (rolesToPersist.length) {\n        persistData.roles = rolesToPersist;\n      }\n      if (config.persist_nicknames && member.nickname) {\n        persistData.nickname = member.nickname as any;\n      }\n    }\n\n    if (Object.keys(persistData).length) {\n      pluginData.state.persistedData.set(member.id, persistData);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Persist/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildPersistedData } from \"../../data/GuildPersistedData.js\";\nimport { zSnowflake } from \"../../utils.js\";\n\nexport const zPersistConfig = z.strictObject({\n  persisted_roles: z.array(zSnowflake).default([]),\n  persist_nicknames: z.boolean().default(false),\n  persist_voice_mutes: z.boolean().default(false),\n});\n\nexport interface PersistPluginType extends BasePluginType {\n  configSchema: typeof zPersistConfig;\n  state: {\n    persistedData: GuildPersistedData;\n    logs: GuildLogs;\n  };\n}\n\nexport const persistEvt = guildPluginEventListener<PersistPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Phisherman/PhishermanPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { PhishermanPluginType, zPhishermanConfig } from \"./types.js\";\n\nexport const PhishermanPlugin = guildPlugin<PhishermanPluginType>()({\n  name: \"phisherman\",\n  configSchema: zPhishermanConfig,\n});\n"
  },
  {
    "path": "backend/src/plugins/Phisherman/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zPhishermanConfig } from \"./types.js\";\n\nexport const phishermanPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Phisherman\",\n  type: \"legacy\",\n  description: trimPluginDescription(`\n    Match malicious links using Phisherman\n  `),\n  configurationGuide: trimPluginDescription(`\n    This plugin has been deprecated. Please use the \\`include_malicious\\` option for automod \\`match_links\\` trigger instead.\n  `),\n  configSchema: zPhishermanConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/Phisherman/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\n\nexport const zPhishermanConfig = z.strictObject({\n  api_key: z.string().max(255).nullable().default(null),\n});\n\nexport interface PhishermanPluginType extends BasePluginType {\n  configSchema: typeof zPhishermanConfig;\n  // eslint-disable-next-line @typescript-eslint/ban-types\n  state: {};\n}\n"
  },
  {
    "path": "backend/src/plugins/PingableRoles/PingableRolesPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildPingableRoles } from \"../../data/GuildPingableRoles.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { PingableRoleDisableCmd } from \"./commands/PingableRoleDisableCmd.js\";\nimport { PingableRoleEnableCmd } from \"./commands/PingableRoleEnableCmd.js\";\nimport { PingableRolesPluginType, zPingableRolesConfig } from \"./types.js\";\n\nexport const PingableRolesPlugin = guildPlugin<PingableRolesPluginType>()({\n  name: \"pingable_roles\",\n\n  configSchema: zPingableRolesConfig,\n  defaultOverrides: [\n    {\n      level: \">=100\",\n      config: {\n        can_manage: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    PingableRoleEnableCmd,\n    PingableRoleDisableCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    // FIXME: Temporarily disabled for performance. This is very buggy anyway, so consider removing in the future.\n    // TypingEnablePingableEvt,\n    // MessageCreateDisablePingableEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.pingableRoles = GuildPingableRoles.getGuildInstance(guild.id);\n    state.cache = new Map();\n    state.timeouts = new Map();\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { pingableRolesCmd } from \"../types.js\";\n\nexport const PingableRoleDisableCmd = pingableRolesCmd({\n  trigger: [\"pingable_role disable\", \"pingable_role d\"],\n  permission: \"can_manage\",\n\n  signature: {\n    channelId: ct.channelId(),\n    role: ct.role(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const pingableRole = await pluginData.state.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id);\n    if (!pingableRole) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `**${args.role.name}** is not set as pingable in <#${args.channelId}>`,\n      );\n      return;\n    }\n\n    await pluginData.state.pingableRoles.delete(args.channelId, args.role.id);\n    pluginData.state.cache.delete(args.channelId);\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `**${args.role.name}** is no longer set as pingable in <#${args.channelId}>`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { pingableRolesCmd } from \"../types.js\";\n\nexport const PingableRoleEnableCmd = pingableRolesCmd({\n  trigger: \"pingable_role\",\n  permission: \"can_manage\",\n\n  signature: {\n    channelId: ct.channelId(),\n    role: ct.role(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const existingPingableRole = await pluginData.state.pingableRoles.getByChannelAndRoleId(\n      args.channelId,\n      args.role.id,\n    );\n    if (existingPingableRole) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `**${args.role.name}** is already set as pingable in <#${args.channelId}>`,\n      );\n      return;\n    }\n\n    await pluginData.state.pingableRoles.add(args.channelId, args.role.id);\n    pluginData.state.cache.delete(args.channelId);\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `**${args.role.name}** has been set as pingable in <#${args.channelId}>`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/PingableRoles/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zPingableRolesConfig } from \"./types.js\";\n\nexport const pingableRolesPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Pingable roles\",\n  configSchema: zPingableRolesConfig,\n  type: \"stable\",\n};\n"
  },
  {
    "path": "backend/src/plugins/PingableRoles/events/ChangePingableEvts.ts",
    "content": "import { pingableRolesEvt } from \"../types.js\";\nimport { disablePingableRoles } from \"../utils/disablePingableRoles.js\";\nimport { enablePingableRoles } from \"../utils/enablePingableRoles.js\";\nimport { getPingableRolesForChannel } from \"../utils/getPingableRolesForChannel.js\";\n\nconst TIMEOUT = 10 * 1000;\n\nexport const TypingEnablePingableEvt = pingableRolesEvt({\n  event: \"typingStart\",\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n    const channel = meta.args.typing.channel;\n\n    const pingableRoles = await getPingableRolesForChannel(pluginData, channel.id);\n    if (pingableRoles.length === 0) return;\n\n    if (pluginData.state.timeouts.has(channel.id)) {\n      clearTimeout(pluginData.state.timeouts.get(channel.id));\n    }\n\n    enablePingableRoles(pluginData, pingableRoles);\n\n    const timeout = setTimeout(() => {\n      disablePingableRoles(pluginData, pingableRoles);\n    }, TIMEOUT);\n    pluginData.state.timeouts.set(channel.id, timeout);\n  },\n});\n\nexport const MessageCreateDisablePingableEvt = pingableRolesEvt({\n  event: \"messageCreate\",\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n    const msg = meta.args.message;\n\n    const pingableRoles = await getPingableRolesForChannel(pluginData, msg.channel.id);\n    if (pingableRoles.length === 0) return;\n\n    if (pluginData.state.timeouts.has(msg.channel.id)) {\n      clearTimeout(pluginData.state.timeouts.get(msg.channel.id));\n    }\n\n    disablePingableRoles(pluginData, pingableRoles);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/PingableRoles/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildPingableRoles } from \"../../data/GuildPingableRoles.js\";\nimport { PingableRole } from \"../../data/entities/PingableRole.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zPingableRolesConfig = z.strictObject({\n  can_manage: z.boolean().default(false),\n});\n\nexport interface PingableRolesPluginType extends BasePluginType {\n  configSchema: typeof zPingableRolesConfig;\n  state: {\n    pingableRoles: GuildPingableRoles;\n    cache: Map<string, PingableRole[]>;\n    timeouts: Map<string, any>;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const pingableRolesCmd = guildPluginMessageCommand<PingableRolesPluginType>();\nexport const pingableRolesEvt = guildPluginEventListener<PingableRolesPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/PingableRoles/utils/disablePingableRoles.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { PingableRole } from \"../../../data/entities/PingableRole.js\";\nimport { PingableRolesPluginType } from \"../types.js\";\n\nexport function disablePingableRoles(\n  pluginData: GuildPluginData<PingableRolesPluginType>,\n  pingableRoles: PingableRole[],\n) {\n  for (const pingableRole of pingableRoles) {\n    const role = pluginData.guild.roles.cache.get(pingableRole.role_id as Snowflake);\n    if (!role) continue;\n\n    role.setMentionable(false, \"Disable pingable role\");\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/PingableRoles/utils/enablePingableRoles.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { PingableRole } from \"../../../data/entities/PingableRole.js\";\nimport { PingableRolesPluginType } from \"../types.js\";\n\nexport function enablePingableRoles(\n  pluginData: GuildPluginData<PingableRolesPluginType>,\n  pingableRoles: PingableRole[],\n) {\n  for (const pingableRole of pingableRoles) {\n    const role = pluginData.guild.roles.cache.get(pingableRole.role_id as Snowflake);\n    if (!role) continue;\n\n    role.setMentionable(true, \"Enable pingable role\");\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/PingableRoles/utils/getPingableRolesForChannel.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { PingableRole } from \"../../../data/entities/PingableRole.js\";\nimport { PingableRolesPluginType } from \"../types.js\";\n\nexport async function getPingableRolesForChannel(\n  pluginData: GuildPluginData<PingableRolesPluginType>,\n  channelId: string,\n): Promise<PingableRole[]> {\n  if (!pluginData.state.cache.has(channelId)) {\n    pluginData.state.cache.set(channelId, await pluginData.state.pingableRoles.getForChannel(channelId));\n  }\n\n  return pluginData.state.cache.get(channelId)!;\n}\n"
  },
  {
    "path": "backend/src/plugins/Post/PostPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { onGuildEvent } from \"../../data/GuildEvents.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { GuildScheduledPosts } from \"../../data/GuildScheduledPosts.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { TimeAndDatePlugin } from \"../TimeAndDate/TimeAndDatePlugin.js\";\nimport { EditCmd } from \"./commands/EditCmd.js\";\nimport { EditEmbedCmd } from \"./commands/EditEmbedCmd.js\";\nimport { PostCmd } from \"./commands/PostCmd.js\";\nimport { PostEmbedCmd } from \"./commands/PostEmbedCmd.js\";\nimport { ScheduledPostsDeleteCmd } from \"./commands/ScheduledPostsDeleteCmd.js\";\nimport { ScheduledPostsListCmd } from \"./commands/ScheduledPostsListCmd.js\";\nimport { ScheduledPostsShowCmd } from \"./commands/ScheduledPostsShowCmd.js\";\nimport { PostPluginType, zPostConfig } from \"./types.js\";\nimport { postScheduledPost } from \"./util/postScheduledPost.js\";\n\nexport const PostPlugin = guildPlugin<PostPluginType>()({\n  name: \"post\",\n\n  dependencies: () => [TimeAndDatePlugin, LogsPlugin],\n  configSchema: zPostConfig,\n  defaultOverrides: [\n    {\n      level: \">=100\",\n      config: {\n        can_post: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n      PostCmd,\n      PostEmbedCmd,\n      EditCmd,\n      EditEmbedCmd,\n      ScheduledPostsShowCmd,\n      ScheduledPostsListCmd,\n      ScheduledPostsDeleteCmd,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.scheduledPosts = GuildScheduledPosts.getGuildInstance(guild.id);\n    state.logs = new GuildLogs(guild.id);\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.unregisterGuildEventListener = onGuildEvent(guild.id, \"scheduledPost\", (post) =>\n      postScheduledPost(pluginData, post),\n    );\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.unregisterGuildEventListener?.();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Post/commands/EditCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { postCmd } from \"../types.js\";\nimport { formatContent } from \"../util/formatContent.js\";\n\nexport const EditCmd = postCmd({\n  trigger: \"edit\",\n  permission: \"can_post\",\n\n  signature: {\n    message: ct.messageTarget(),\n    content: ct.string({ catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const targetMessage = await args.message.channel.messages.fetch(args.message.messageId);\n    if (!targetMessage) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Unknown message\");\n      return;\n    }\n\n    if (targetMessage.author.id !== pluginData.client.user!.id) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Message wasn't posted by me\");\n      return;\n    }\n\n    targetMessage.channel.messages.edit(targetMessage.id, {\n      content: formatContent(args.content),\n    });\n    void pluginData.state.common.sendSuccessMessage(msg, \"Message edited\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Post/commands/EditEmbedCmd.ts",
    "content": "import { APIEmbed } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isValidEmbed, trimLines } from \"../../../utils.js\";\nimport { parseColor } from \"../../../utils/parseColor.js\";\nimport { rgbToInt } from \"../../../utils/rgbToInt.js\";\nimport { postCmd } from \"../types.js\";\nimport { formatContent } from \"../util/formatContent.js\";\n\nexport const EditEmbedCmd = postCmd({\n  trigger: \"edit_embed\",\n  permission: \"can_post\",\n\n  signature: {\n    message: ct.messageTarget(),\n    maincontent: ct.string({ catchAll: true }),\n\n    title: ct.string({ option: true }),\n    content: ct.string({ option: true }),\n    color: ct.string({ option: true }),\n    raw: ct.bool({ option: true, isSwitch: true, shortcut: \"r\" }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const content = args.content || args.maincontent;\n\n    let color: number | null = null;\n    if (args.color) {\n      const colorRgb = parseColor(args.color);\n      if (colorRgb) {\n        color = rgbToInt(colorRgb);\n      } else {\n        void pluginData.state.common.sendErrorMessage(msg, \"Invalid color specified\");\n        return;\n      }\n    }\n\n    const targetMessage = await args.message.channel.messages.fetch(args.message.messageId);\n    if (!targetMessage) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Unknown message\");\n      return;\n    }\n\n    let embed: APIEmbed = targetMessage.embeds![0]?.toJSON() ?? { fields: [] };\n    if (args.title) embed.title = args.title;\n    if (color) embed.color = color;\n\n    if (content) {\n      if (args.raw) {\n        let parsed;\n        try {\n          parsed = JSON.parse(content);\n        } catch (e) {\n          void pluginData.state.common.sendErrorMessage(msg, `Syntax error in embed JSON: ${e.message}`);\n          return;\n        }\n\n        if (!isValidEmbed(parsed)) {\n          void pluginData.state.common.sendErrorMessage(msg, \"Embed is not valid\");\n          return;\n        }\n\n        embed = Object.assign({}, embed, parsed);\n      } else {\n        embed.description = formatContent(content);\n      }\n    }\n\n    args.message.channel.messages.edit(targetMessage.id, {\n      embeds: [embed],\n    });\n    await pluginData.state.common.sendSuccessMessage(msg, \"Embed edited\");\n\n    if (args.content) {\n      const prefix = pluginData.fullConfig.prefix || \"!\";\n      msg.channel.send(\n        trimLines(`\n        <@!${msg.author.id}> You can now specify an embed's content directly at the end of the command:\n        \\`${prefix}edit_embed -title \"Some title\" content goes here\\`\n        The \\`-content\\` option will soon be removed in favor of this.\n      `),\n      );\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Post/commands/PostCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { postCmd } from \"../types.js\";\nimport { actualPostCmd } from \"../util/actualPostCmd.js\";\n\nexport const PostCmd = postCmd({\n  trigger: \"post\",\n  permission: \"can_post\",\n\n  signature: {\n    channel: ct.textChannel(),\n    content: ct.string({ catchAll: true }),\n\n    \"enable-mentions\": ct.bool({ option: true, isSwitch: true }),\n    schedule: ct.string({ option: true }),\n    repeat: ct.delay({ option: true }),\n    \"repeat-until\": ct.string({ option: true }),\n    \"repeat-times\": ct.number({ option: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    actualPostCmd(pluginData, msg, args.channel, { content: args.content }, args);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Post/commands/PostEmbedCmd.ts",
    "content": "import { APIEmbed } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isValidEmbed, trimLines } from \"../../../utils.js\";\nimport { parseColor } from \"../../../utils/parseColor.js\";\nimport { rgbToInt } from \"../../../utils/rgbToInt.js\";\nimport { postCmd } from \"../types.js\";\nimport { actualPostCmd } from \"../util/actualPostCmd.js\";\nimport { formatContent } from \"../util/formatContent.js\";\n\nexport const PostEmbedCmd = postCmd({\n  trigger: \"post_embed\",\n  permission: \"can_post\",\n\n  signature: {\n    channel: ct.textChannel(),\n    maincontent: ct.string({ catchAll: true }),\n\n    title: ct.string({ option: true }),\n    content: ct.string({ option: true }),\n    color: ct.string({ option: true }),\n    raw: ct.bool({ option: true, isSwitch: true, shortcut: \"r\" }),\n\n    schedule: ct.string({ option: true }),\n    repeat: ct.delay({ option: true }),\n    \"repeat-until\": ct.string({ option: true }),\n    \"repeat-times\": ct.number({ option: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const content = args.content || args.maincontent;\n\n    if (!args.title && !content) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Title or content required\");\n      return;\n    }\n\n    let color: number | null = null;\n    if (args.color) {\n      const colorRgb = parseColor(args.color);\n      if (colorRgb) {\n        color = rgbToInt(colorRgb);\n      } else {\n        void pluginData.state.common.sendErrorMessage(msg, \"Invalid color specified\");\n        return;\n      }\n    }\n\n    let embed: APIEmbed = {};\n    if (args.title) embed.title = args.title;\n    if (color) embed.color = color;\n\n    if (content) {\n      if (args.raw) {\n        let parsed;\n        try {\n          parsed = JSON.parse(content);\n        } catch (e) {\n          void pluginData.state.common.sendErrorMessage(msg, `Syntax error in embed JSON: ${e.message}`);\n          return;\n        }\n\n        if (!isValidEmbed(parsed)) {\n          void pluginData.state.common.sendErrorMessage(msg, \"Embed is not valid\");\n          return;\n        }\n\n        embed = Object.assign({}, embed, parsed);\n      } else {\n        embed.description = formatContent(content);\n      }\n    }\n\n    if (args.content) {\n      const prefix = pluginData.fullConfig.prefix || \"!\";\n      msg.channel.send(\n        trimLines(`\n        <@!${msg.author.id}> You can now specify an embed's content directly at the end of the command:\n        \\`${prefix}edit_embed -title \"Some title\" content goes here\\`\n        The \\`-content\\` option will soon be removed in favor of this.\n      `),\n      );\n    }\n\n    actualPostCmd(pluginData, msg, args.channel, { embeds: [embed] }, args);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { clearUpcomingScheduledPost } from \"../../../data/loops/upcomingScheduledPostsLoop.js\";\nimport { sorter } from \"../../../utils.js\";\nimport { postCmd } from \"../types.js\";\n\nexport const ScheduledPostsDeleteCmd = postCmd({\n  trigger: [\"scheduled_posts delete\", \"scheduled_posts d\"],\n  permission: \"can_post\",\n\n  signature: {\n    num: ct.number(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const scheduledPosts = await pluginData.state.scheduledPosts.all();\n    scheduledPosts.sort(sorter(\"post_at\"));\n    const post = scheduledPosts[args.num - 1];\n    if (!post) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Scheduled post not found\");\n      return;\n    }\n\n    clearUpcomingScheduledPost(post);\n    await pluginData.state.scheduledPosts.delete(post.id);\n    void pluginData.state.common.sendSuccessMessage(msg, \"Scheduled post deleted!\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts",
    "content": "import { escapeCodeBlock } from \"discord.js\";\nimport moment from \"moment-timezone\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { createChunkedMessage, DBDateFormat, deactivateMentions, sorter, trimLines } from \"../../../utils.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { postCmd } from \"../types.js\";\n\nconst SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50;\n\nexport const ScheduledPostsListCmd = postCmd({\n  trigger: [\"scheduled_posts\", \"scheduled_posts list\"],\n  permission: \"can_post\",\n\n  async run({ message: msg, pluginData }) {\n    const scheduledPosts = await pluginData.state.scheduledPosts.all();\n    if (scheduledPosts.length === 0) {\n      msg.channel.send(\"No scheduled posts\");\n      return;\n    }\n\n    scheduledPosts.sort(sorter(\"post_at\"));\n\n    let i = 1;\n    const postLines = scheduledPosts.map((p) => {\n      let previewText = p.content.content || p.content.embeds?.[0]?.description || p.content.embeds?.[0]?.title || \"\";\n\n      const isTruncated = previewText.length > SCHEDULED_POST_PREVIEW_TEXT_LENGTH;\n\n      previewText = escapeCodeBlock(deactivateMentions(previewText))\n        .replace(/\\s+/g, \" \")\n        .slice(0, SCHEDULED_POST_PREVIEW_TEXT_LENGTH);\n\n      const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n      const prettyPostAt = timeAndDate\n        .inGuildTz(moment.utc(p.post_at!, DBDateFormat))\n        .format(timeAndDate.getDateFormat(\"pretty_datetime\"));\n      const parts = [`\\`#${i++}\\` \\`[${prettyPostAt}]\\` ${previewText}${isTruncated ? \"...\" : \"\"}`];\n      if (p.attachments.length) parts.push(\"*(with attachment)*\");\n      if (p.content.embeds?.length) parts.push(\"*(embed)*\");\n      if (p.repeat_until) {\n        parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`);\n      }\n      if (p.repeat_times) {\n        parts.push(\n          `*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${\n            p.repeat_times === 1 ? \"time\" : \"times\"\n          })*`,\n        );\n      }\n      parts.push(`*(${p.author_name})*`);\n\n      return parts.join(\" \");\n    });\n\n    const finalMessage = trimLines(`\n      ${postLines.join(\"\\n\")}\n\n      Use \\`scheduled_posts <num>\\` to view a scheduled post in full\n      Use \\`scheduled_posts delete <num>\\` to delete a scheduled post\n    `);\n    createChunkedMessage(msg.channel, finalMessage);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { sorter } from \"../../../utils.js\";\nimport { postCmd } from \"../types.js\";\nimport { postMessage } from \"../util/postMessage.js\";\n\nexport const ScheduledPostsShowCmd = postCmd({\n  trigger: [\"scheduled_posts\", \"scheduled_posts show\"],\n  permission: \"can_post\",\n\n  signature: {\n    num: ct.number(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const scheduledPosts = await pluginData.state.scheduledPosts.all();\n    scheduledPosts.sort(sorter(\"post_at\"));\n    const post = scheduledPosts[args.num - 1];\n    if (!post) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Scheduled post not found\");\n      return;\n    }\n\n    postMessage(pluginData, msg.channel, post.content, post.attachments, post.enable_mentions);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Post/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zPostConfig } from \"./types.js\";\n\nexport const postPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Post\",\n  configSchema: zPostConfig,\n  type: \"stable\",\n};\n"
  },
  {
    "path": "backend/src/plugins/Post/types.ts",
    "content": "import { BasePluginType, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { GuildScheduledPosts } from \"../../data/GuildScheduledPosts.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zPostConfig = z.strictObject({\n  can_post: z.boolean().default(false),\n});\n\nexport interface PostPluginType extends BasePluginType {\n  configSchema: typeof zPostConfig;\n  state: {\n    savedMessages: GuildSavedMessages;\n    scheduledPosts: GuildScheduledPosts;\n    logs: GuildLogs;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n\n    unregisterGuildEventListener: () => void;\n  };\n}\n\nexport const postCmd = guildPluginMessageCommand<PostPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Post/util/actualPostCmd.ts",
    "content": "import { GuildTextBasedChannel, Message } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { registerUpcomingScheduledPost } from \"../../../data/loops/upcomingScheduledPostsLoop.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { DBDateFormat, MINUTES, StrictMessageContent, errorMessage, renderUsername } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { PostPluginType } from \"../types.js\";\nimport { parseScheduleTime } from \"./parseScheduleTime.js\";\nimport { postMessage } from \"./postMessage.js\";\n\nconst MIN_REPEAT_TIME = 5 * MINUTES;\nconst MAX_REPEAT_TIME = Math.pow(2, 32);\nconst MAX_REPEAT_UNTIL = moment.utc().add(100, \"years\");\n\nexport async function actualPostCmd(\n  pluginData: GuildPluginData<PostPluginType>,\n  msg: Message,\n  targetChannel: GuildTextBasedChannel,\n  content: StrictMessageContent,\n  opts: {\n    \"enable-mentions\"?: boolean;\n    schedule?: string;\n    repeat?: number;\n    \"repeat-until\"?: string;\n    \"repeat-times\"?: number;\n  } = {},\n) {\n  if (!targetChannel.isSendable()) {\n    msg.reply(errorMessage(\"Specified channel is not a sendable channel\"));\n    return;\n  }\n\n  if (content == null && msg.attachments.size === 0) {\n    msg.reply(errorMessage(\"Message content or attachment required\"));\n    return;\n  }\n\n  if (opts.repeat) {\n    if (opts.repeat < MIN_REPEAT_TIME) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`,\n      );\n      return;\n    }\n    if (opts.repeat > MAX_REPEAT_TIME) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`,\n      );\n      return;\n    }\n  }\n\n  // If this is a scheduled or repeated post, figure out the next post date\n  let postAt;\n  if (opts.schedule) {\n    // Schedule the post to be posted later\n    postAt = await parseScheduleTime(pluginData, msg.author.id, opts.schedule);\n    if (!postAt) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Invalid schedule time\");\n      return;\n    }\n  } else if (opts.repeat) {\n    postAt = moment.utc().add(opts.repeat, \"ms\");\n  }\n\n  // For repeated posts, make sure repeat-until or repeat-times is specified\n  let repeatUntil: moment.Moment | null = null;\n  let repeatTimes: number | null = null;\n  let repeatDetailsStr: string | null = null;\n\n  if (opts[\"repeat-until\"]) {\n    repeatUntil = await parseScheduleTime(pluginData, msg.author.id, opts[\"repeat-until\"]);\n\n    // Invalid time\n    if (!repeatUntil) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Invalid time specified for -repeat-until\");\n      return;\n    }\n    if (repeatUntil.isBefore(moment.utc())) {\n      void pluginData.state.common.sendErrorMessage(msg, \"You can't set -repeat-until in the past\");\n      return;\n    }\n    if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        \"Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?\",\n      );\n      return;\n    }\n  } else if (opts[\"repeat-times\"]) {\n    repeatTimes = opts[\"repeat-times\"];\n    if (repeatTimes <= 0) {\n      void pluginData.state.common.sendErrorMessage(msg, \"-repeat-times must be 1 or more\");\n      return;\n    }\n  }\n\n  if (repeatUntil && repeatTimes) {\n    void pluginData.state.common.sendErrorMessage(\n      msg,\n      \"You can only use one of -repeat-until or -repeat-times at once\",\n    );\n    return;\n  }\n\n  if (opts.repeat && !repeatUntil && !repeatTimes) {\n    void pluginData.state.common.sendErrorMessage(\n      msg,\n      \"You must specify -repeat-until or -repeat-times for repeated messages\",\n    );\n    return;\n  }\n\n  if (opts.repeat) {\n    repeatDetailsStr = repeatUntil\n      ? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}`\n      : `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`;\n  }\n\n  const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n\n  // Save schedule/repeat information in DB\n  if (postAt) {\n    if (postAt < moment.utc()) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Post can't be scheduled to be posted in the past\");\n      return;\n    }\n\n    const post = await pluginData.state.scheduledPosts.create({\n      author_id: msg.author.id,\n      author_name: renderUsername(msg.author),\n      channel_id: targetChannel.id,\n      content,\n      attachments: [...msg.attachments.values()],\n      post_at: postAt.clone().tz(\"Etc/UTC\").format(DBDateFormat),\n      enable_mentions: opts[\"enable-mentions\"],\n      repeat_interval: opts.repeat,\n      repeat_until: repeatUntil ? repeatUntil.clone().tz(\"Etc/UTC\").format(DBDateFormat) : null,\n      repeat_times: repeatTimes ?? null,\n    });\n    registerUpcomingScheduledPost(post);\n\n    if (opts.repeat) {\n      pluginData.getPlugin(LogsPlugin).logScheduledRepeatedMessage({\n        author: msg.author,\n        channel: targetChannel,\n        datetime: postAt.format(timeAndDate.getDateFormat(\"pretty_datetime\")),\n        date: postAt.format(timeAndDate.getDateFormat(\"date\")),\n        time: postAt.format(timeAndDate.getDateFormat(\"time\")),\n        repeatInterval: humanizeDuration(opts.repeat),\n        repeatDetails: repeatDetailsStr!,\n      });\n    } else {\n      pluginData.getPlugin(LogsPlugin).logScheduledMessage({\n        author: msg.author,\n        channel: targetChannel,\n        datetime: postAt.format(timeAndDate.getDateFormat(\"pretty_datetime\")),\n        date: postAt.format(timeAndDate.getDateFormat(\"date\")),\n        time: postAt.format(timeAndDate.getDateFormat(\"time\")),\n      });\n    }\n  }\n\n  // When the message isn't scheduled for later, post it immediately\n  if (!opts.schedule) {\n    await postMessage(pluginData, targetChannel, content, [...msg.attachments.values()], opts[\"enable-mentions\"]);\n  }\n\n  if (opts.repeat) {\n    pluginData.getPlugin(LogsPlugin).logRepeatedMessage({\n      author: msg.author,\n      channel: targetChannel,\n      datetime: postAt.format(timeAndDate.getDateFormat(\"pretty_datetime\")),\n      date: postAt.format(timeAndDate.getDateFormat(\"date\")),\n      time: postAt.format(timeAndDate.getDateFormat(\"time\")),\n      repeatInterval: humanizeDuration(opts.repeat),\n      repeatDetails: repeatDetailsStr ?? \"\",\n    });\n  }\n\n  // Bot reply schenanigans\n  let successMessage = opts.schedule\n    ? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format(\n        timeAndDate.getDateFormat(\"pretty_datetime\"),\n      )}`\n    : `Message posted in <#${targetChannel.id}>`;\n\n  if (opts.repeat) {\n    successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`;\n\n    if (repeatUntil) {\n      successMessage += ` until ${repeatUntil.format(timeAndDate.getDateFormat(\"pretty_datetime\"))}`;\n    } else if (repeatTimes) {\n      successMessage += `, ${repeatTimes} times in total`;\n    }\n\n    successMessage += \".\";\n  }\n\n  if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) {\n    void pluginData.state.common.sendSuccessMessage(msg, successMessage);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Post/util/formatContent.ts",
    "content": "export function formatContent(str: string) {\n  return str.replace(/\\\\n/g, \"\\n\");\n}\n"
  },
  {
    "path": "backend/src/plugins/Post/util/parseScheduleTime.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport moment, { Moment } from \"moment-timezone\";\nimport { convertDelayStringToMS } from \"../../../utils.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\n\n// TODO: Extract out of the Post plugin, use everywhere with a date input\nexport async function parseScheduleTime(\n  pluginData: GuildPluginData<any>,\n  memberId: string,\n  str: string,\n): Promise<Moment | null> {\n  const tz = await pluginData.getPlugin(TimeAndDatePlugin).getMemberTz(memberId);\n\n  const dt1 = moment.tz(str, \"YYYY-MM-DD HH:mm:ss\", tz);\n  if (dt1 && dt1.isValid()) return dt1;\n\n  const dt2 = moment.tz(str, \"YYYY-MM-DD HH:mm\", tz);\n  if (dt2 && dt2.isValid()) return dt2;\n\n  const date = moment.tz(str, \"YYYY-MM-DD\", tz);\n  if (date && date.isValid()) return date;\n\n  const t1 = moment.tz(str, \"HH:mm:ss\", tz);\n  if (t1 && t1.isValid()) {\n    if (t1.isBefore(moment.utc())) t1.add(1, \"day\");\n    return t1;\n  }\n\n  const t2 = moment.tz(str, \"HH:mm\", tz);\n  if (t2 && t2.isValid()) {\n    if (t2.isBefore(moment.utc())) t2.add(1, \"day\");\n    return t2;\n  }\n\n  const delayStringMS = convertDelayStringToMS(str, \"m\");\n  if (delayStringMS) {\n    return moment.tz(tz).add(delayStringMS, \"ms\");\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "backend/src/plugins/Post/util/postMessage.ts",
    "content": "import { Attachment, GuildTextBasedChannel, Message, MessageCreateOptions } from \"discord.js\";\nimport fs from \"fs\";\nimport { GuildPluginData } from \"vety\";\nimport { downloadFile } from \"../../../utils.js\";\nimport { PostPluginType } from \"../types.js\";\nimport { formatContent } from \"./formatContent.js\";\n\nconst fsp = fs.promises;\n\nexport async function postMessage(\n  pluginData: GuildPluginData<PostPluginType>,\n  channel: GuildTextBasedChannel,\n  content: MessageCreateOptions,\n  attachments: Attachment[] = [],\n  enableMentions = false,\n): Promise<Message> {\n  if (typeof content === \"string\") {\n    content = { content };\n  }\n\n  if (content && content.content) {\n    content.content = formatContent(content.content);\n  }\n\n  let downloadedAttachment;\n  let file;\n  if (attachments.length) {\n    downloadedAttachment = await downloadFile(attachments[0].url);\n    file = {\n      name: attachments[0].name,\n      file: await fsp.readFile(downloadedAttachment.path),\n    };\n    content.files = [file.file];\n  }\n\n  if (enableMentions) {\n    content.allowedMentions = {\n      parse: [\"everyone\", \"roles\", \"users\"],\n    };\n  }\n\n  const createdMsg = await channel.send(content);\n  pluginData.state.savedMessages.setPermanent(createdMsg.id);\n\n  if (downloadedAttachment) {\n    downloadedAttachment.deleteFn();\n  }\n\n  return createdMsg;\n}\n"
  },
  {
    "path": "backend/src/plugins/Post/util/postScheduledPost.ts",
    "content": "import { Snowflake, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { ScheduledPost } from \"../../../data/entities/ScheduledPost.js\";\nimport { registerUpcomingScheduledPost } from \"../../../data/loops/upcomingScheduledPostsLoop.js\";\nimport { logger } from \"../../../logger.js\";\nimport { DBDateFormat, verboseChannelMention, verboseUserMention } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { PostPluginType } from \"../types.js\";\nimport { postMessage } from \"./postMessage.js\";\n\nexport async function postScheduledPost(pluginData: GuildPluginData<PostPluginType>, post: ScheduledPost) {\n  // First, update the scheduled post or delete it from the database *before* we try posting it.\n  // This ensures strange errors don't cause reposts.\n  let shouldClear = true;\n\n  if (post.repeat_interval) {\n    const nextPostAt = moment.utc().add(post.repeat_interval, \"ms\");\n\n    if (post.repeat_until) {\n      const repeatUntil = moment.utc(post.repeat_until, DBDateFormat);\n      if (nextPostAt.isSameOrBefore(repeatUntil)) {\n        await pluginData.state.scheduledPosts.update(post.id, {\n          post_at: nextPostAt.format(DBDateFormat),\n        });\n        shouldClear = false;\n      }\n    } else if (post.repeat_times) {\n      if (post.repeat_times > 1) {\n        await pluginData.state.scheduledPosts.update(post.id, {\n          post_at: nextPostAt.format(DBDateFormat),\n          repeat_times: post.repeat_times - 1,\n        });\n        shouldClear = false;\n      }\n    }\n  }\n\n  if (shouldClear) {\n    await pluginData.state.scheduledPosts.delete(post.id);\n  } else {\n    const upToDatePost = (await pluginData.state.scheduledPosts.find(post.id))!;\n    registerUpcomingScheduledPost(upToDatePost);\n  }\n\n  // Post the message\n  const channel = pluginData.guild.channels.cache.get(post.channel_id as Snowflake);\n  if (channel?.isTextBased() || channel?.isThread()) {\n    const [username, discriminator] = post.author_name.split(\"#\");\n    const author: User = (await pluginData.client.users.fetch(post.author_id as Snowflake)) || {\n      id: post.author_id,\n      username,\n      discriminator,\n    };\n\n    try {\n      const postedMessage = await postMessage(\n        pluginData,\n        channel,\n        post.content,\n        post.attachments,\n        post.enable_mentions,\n      );\n      pluginData.getPlugin(LogsPlugin).logPostedScheduledMessage({\n        author,\n        channel,\n        messageId: postedMessage.id,\n      });\n    } catch {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Failed to post scheduled message by ${verboseUserMention(author)} to ${verboseChannelMention(channel)}`,\n      });\n      logger.warn(\n        `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${pluginData.guild.name} (${pluginData.guild.id})`,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { Queue } from \"../../Queue.js\";\nimport { GuildReactionRoles } from \"../../data/GuildReactionRoles.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { ClearReactionRolesCmd } from \"./commands/ClearReactionRolesCmd.js\";\nimport { InitReactionRolesCmd } from \"./commands/InitReactionRolesCmd.js\";\nimport { RefreshReactionRolesCmd } from \"./commands/RefreshReactionRolesCmd.js\";\nimport { AddReactionRoleEvt } from \"./events/AddReactionRoleEvt.js\";\nimport { MessageDeletedEvt } from \"./events/MessageDeletedEvt.js\";\nimport { ReactionRolesPluginType, zReactionRolesConfig } from \"./types.js\";\n\nexport const ReactionRolesPlugin = guildPlugin<ReactionRolesPluginType>()({\n  name: \"reaction_roles\",\n\n  dependencies: () => [LogsPlugin],\n  configSchema: zReactionRolesConfig,\n  defaultOverrides: [\n    {\n      level: \">=100\",\n      config: {\n        can_manage: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    RefreshReactionRolesCmd,\n    ClearReactionRolesCmd,\n    InitReactionRolesCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    AddReactionRoleEvt,\n    MessageDeletedEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id);\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.reactionRemoveQueue = new Queue();\n    state.roleChangeQueue = new Queue();\n    state.pendingRoleChanges = new Map();\n    state.pendingRefreshes = new Set();\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const config = pluginData.config.get();\n    if (config.button_groups) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: \"The 'button_groups' option of the 'reaction_roles' plugin is deprecated and non-functional. Consider using the new 'role_buttons' plugin instead!\",\n      });\n    }\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    if (state.autoRefreshTimeout) {\n      clearTimeout(state.autoRefreshTimeout);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts",
    "content": "import { Message } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { isDiscordAPIError } from \"../../../utils.js\";\nimport { reactionRolesCmd } from \"../types.js\";\n\nexport const ClearReactionRolesCmd = reactionRolesCmd({\n  trigger: \"reaction_roles clear\",\n  permission: \"can_manage\",\n\n  signature: {\n    message: ct.messageTarget(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const existingReactionRoles = pluginData.state.reactionRoles.getForMessage(args.message.messageId);\n    if (!existingReactionRoles) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Message doesn't have reaction roles on it\");\n      return;\n    }\n\n    pluginData.state.reactionRoles.removeFromMessage(args.message.messageId);\n\n    let targetMessage: Message;\n    try {\n      targetMessage = await args.message.channel.messages.fetch(args.message.messageId);\n    } catch (err) {\n      if (isDiscordAPIError(err) && err.code === 50001) {\n        void pluginData.state.common.sendErrorMessage(msg, \"Missing access to the specified message\");\n        return;\n      }\n\n      throw err;\n    }\n\n    await targetMessage.reactions.removeAll();\n\n    void pluginData.state.common.sendSuccessMessage(msg, \"Reaction roles cleared\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { canUseEmoji, isDiscordAPIError, isValidEmoji, noop, trimPluginDescription } from \"../../../utils.js\";\nimport { canReadChannel } from \"../../../utils/canReadChannel.js\";\nimport { TReactionRolePair, reactionRolesCmd } from \"../types.js\";\nimport { applyReactionRoleReactionsToMessage } from \"../util/applyReactionRoleReactionsToMessage.js\";\n\nconst CLEAR_ROLES_EMOJI = \"❌\";\n\nexport const InitReactionRolesCmd = reactionRolesCmd({\n  trigger: \"reaction_roles\",\n  permission: \"can_manage\",\n  description: trimPluginDescription(`\n  This command allows you to add reaction roles to a given message.  \n  The basic usage is as follows:  \n    \n  !reaction_roles 800865377520582687  \n  👍 = 556110793058287637  \n  👎 = 558037973581430785  \n    \n  A reactionRolePair is any emoji the bot can use, an equal sign and the role id it should correspond to.  \n  Every pair needs to be in its own line for the command to work properly.  \n  If the message you specify is not found, use \\`!save_messages_to_db <channelId> <messageId>\\`  \n  to manually add it to the stored messages database permanently.\n  `),\n\n  signature: {\n    message: ct.messageTarget(),\n    reactionRolePairs: ct.string({ catchAll: true }),\n\n    exclusive: ct.bool({ option: true, isSwitch: true, shortcut: \"e\" }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const member = await resolveMessageMember(msg);\n    if (!canReadChannel(args.message.channel, member)) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        \"You can't add reaction roles to channels you can't see yourself\",\n      );\n      return;\n    }\n\n    let targetMessage;\n    try {\n      targetMessage = await args.message.channel.messages.fetch(args.message.messageId);\n    } catch (e) {\n      if (isDiscordAPIError(e)) {\n        void pluginData.state.common.sendErrorMessage(msg, `Error ${e.code} while getting message: ${e.message}`);\n        return;\n      }\n\n      throw e;\n    }\n\n    // Clear old reaction roles for the message from the DB\n    await pluginData.state.reactionRoles.removeFromMessage(targetMessage.id);\n\n    // Turn \"emoji = role\" pairs into an array of tuples of the form [emoji, roleId]\n    // Emoji is either a unicode emoji or the snowflake of a custom emoji\n    const emojiRolePairs: TReactionRolePair[] = args.reactionRolePairs\n      .trim()\n      .split(\"\\n\")\n      .map((v) => v.split(/[\\s=,]+/).map((v) => v.trim())) // tslint:disable-line\n      .map((pair): TReactionRolePair => {\n        const customEmojiMatch = pair[0].match(/^<a?:(.*?):(\\d+)>$/);\n        if (customEmojiMatch) {\n          return [customEmojiMatch[2], pair[1], customEmojiMatch[1]];\n        } else {\n          return pair as TReactionRolePair;\n        }\n      });\n\n    // Verify the specified emojis and roles are valid and usable\n    for (const pair of emojiRolePairs) {\n      if (pair[0] === CLEAR_ROLES_EMOJI) {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          `The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`,\n        );\n        return;\n      }\n\n      if (!isValidEmoji(pair[0])) {\n        void pluginData.state.common.sendErrorMessage(msg, `Invalid emoji: ${pair[0]}`);\n        return;\n      }\n\n      if (!canUseEmoji(pluginData.client, pair[0])) {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          \"I can only use regular emojis and custom emojis from servers I'm on\",\n        );\n        return;\n      }\n\n      if (!pluginData.guild.roles.cache.has(pair[1] as Snowflake)) {\n        void pluginData.state.common.sendErrorMessage(msg, `Unknown role ${pair[1]}`);\n        return;\n      }\n    }\n\n    const progressMessage = msg.channel.send(\"Adding reaction roles...\");\n\n    // Save the new reaction roles to the database\n    let pos = 0;\n    for (const pair of emojiRolePairs) {\n      await pluginData.state.reactionRoles.add(\n        args.message.channel.id,\n        targetMessage.id,\n        pair[0],\n        pair[1],\n        args.exclusive,\n        pos,\n      );\n      pos++;\n    }\n\n    // Apply the reactions themselves\n    const reactionRoles = await pluginData.state.reactionRoles.getForMessage(targetMessage.id);\n    const errors = await applyReactionRoleReactionsToMessage(\n      pluginData,\n      targetMessage.channel.id,\n      targetMessage.id,\n      reactionRoles,\n    );\n\n    if (errors?.length) {\n      void pluginData.state.common.sendErrorMessage(msg, `Errors while adding reaction roles:\\n${errors.join(\"\\n\")}`);\n    } else {\n      void pluginData.state.common.sendSuccessMessage(msg, \"Reaction roles added\");\n    }\n\n    (await progressMessage).delete().catch(noop);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { reactionRolesCmd } from \"../types.js\";\nimport { refreshReactionRoles } from \"../util/refreshReactionRoles.js\";\n\nexport const RefreshReactionRolesCmd = reactionRolesCmd({\n  trigger: \"reaction_roles refresh\",\n  permission: \"can_manage\",\n\n  signature: {\n    message: ct.messageTarget(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    if (pluginData.state.pendingRefreshes.has(`${args.message.channel.id}-${args.message.messageId}`)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Another refresh in progress\");\n      return;\n    }\n\n    await refreshReactionRoles(pluginData, args.message.channel.id, args.message.messageId);\n\n    void pluginData.state.common.sendSuccessMessage(msg, \"Reaction roles refreshed\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zReactionRolesConfig } from \"./types.js\";\n\nexport const reactionRolesPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Reaction roles\",\n  description: \"Consider using the [Role buttons](https://zeppelin.gg/docs/plugins/role_buttons) plugin instead.\",\n  type: \"legacy\",\n  configSchema: zReactionRolesConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts",
    "content": "import { Message } from \"discord.js\";\nimport { noop, resolveMember, sleep } from \"../../../utils.js\";\nimport { reactionRolesEvt } from \"../types.js\";\nimport { addMemberPendingRoleChange } from \"../util/addMemberPendingRoleChange.js\";\n\nconst CLEAR_ROLES_EMOJI = \"❌\";\n\nexport const AddReactionRoleEvt = reactionRolesEvt({\n  event: \"messageReactionAdd\",\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n    const msg = meta.args.reaction.message as Message;\n    const emoji = meta.args.reaction.emoji;\n    const userId = meta.args.user.id;\n\n    if (userId === pluginData.client.user!.id) {\n      // Don't act on own reactions\n      // FIXME: This may not be needed? Vety currently requires the *member* to be found for the user to be resolved as well. Need to look into it more.\n      return;\n    }\n\n    // Make sure this message has reaction roles on it\n    const reactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id);\n    if (reactionRoles.length === 0) return;\n\n    const member = await resolveMember(pluginData.client, pluginData.guild, userId);\n    if (!member) return;\n\n    if (emoji.name === CLEAR_ROLES_EMOJI) {\n      // User reacted with \"clear roles\" emoji -> clear their roles\n      const reactionRoleRoleIds = reactionRoles.map((rr) => rr.role_id);\n      for (const roleId of reactionRoleRoleIds) {\n        addMemberPendingRoleChange(pluginData, userId, \"-\", roleId);\n      }\n    } else {\n      // User reacted with a reaction role emoji -> add the role\n      const matchingReactionRole = await pluginData.state.reactionRoles.getByMessageAndEmoji(\n        msg.id,\n        emoji.id || emoji.name!,\n      );\n      if (!matchingReactionRole) return;\n\n      // If the reaction role is exclusive, remove any other roles in the message first\n      if (matchingReactionRole.is_exclusive) {\n        const messageReactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id);\n        for (const reactionRole of messageReactionRoles) {\n          addMemberPendingRoleChange(pluginData, userId, \"-\", reactionRole.role_id);\n        }\n      }\n\n      addMemberPendingRoleChange(pluginData, userId, \"+\", matchingReactionRole.role_id);\n    }\n\n    // Remove the reaction after a small delay\n    const config = await pluginData.config.getForMember(member);\n    if (config.remove_user_reactions) {\n      setTimeout(() => {\n        pluginData.state.reactionRemoveQueue.add(async () => {\n          const wait = sleep(1500);\n          await meta.args.reaction.users.remove(userId).catch(noop);\n          await wait;\n        });\n      }, 1500);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/events/MessageDeletedEvt.ts",
    "content": "import { reactionRolesEvt } from \"../types.js\";\n\nexport const MessageDeletedEvt = reactionRolesEvt({\n  event: \"messageDelete\",\n  allowBots: true,\n  allowSelf: true,\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n\n    await pluginData.state.reactionRoles.removeFromMessage(meta.args.message.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { Queue } from \"../../Queue.js\";\nimport { GuildReactionRoles } from \"../../data/GuildReactionRoles.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nconst MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API\n\nexport const zReactionRolesConfig = z.strictObject({\n  auto_refresh_interval: z.number().min(MIN_AUTO_REFRESH).default(MIN_AUTO_REFRESH),\n  remove_user_reactions: z.boolean().default(true),\n  can_manage: z.boolean().default(false),\n  button_groups: z.null().default(null),\n});\n\nexport type RoleChangeMode = \"+\" | \"-\";\n\nexport type PendingMemberRoleChanges = {\n  timeout: NodeJS.Timeout | null;\n  applyFn: () => void;\n  changes: Array<{\n    mode: RoleChangeMode;\n    roleId: string;\n  }>;\n};\n\nconst zReactionRolePair = z.union([z.tuple([z.string(), z.string(), z.string()]), z.tuple([z.string(), z.string()])]);\nexport type TReactionRolePair = z.infer<typeof zReactionRolePair>;\n\nexport interface ReactionRolesPluginType extends BasePluginType {\n  configSchema: typeof zReactionRolesConfig;\n  state: {\n    reactionRoles: GuildReactionRoles;\n    savedMessages: GuildSavedMessages;\n\n    reactionRemoveQueue: Queue;\n    roleChangeQueue: Queue;\n    pendingRoleChanges: Map<string, PendingMemberRoleChanges>;\n    pendingRefreshes: Set<string>;\n\n    autoRefreshTimeout: NodeJS.Timeout;\n\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const reactionRolesCmd = guildPluginMessageCommand<ReactionRolesPluginType>();\nexport const reactionRolesEvt = guildPluginEventListener<ReactionRolesPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { logger } from \"../../../logger.js\";\nimport { renderUsername, resolveMember } from \"../../../utils.js\";\nimport { memberRolesLock } from \"../../../utils/lockNameHelpers.js\";\nimport { PendingMemberRoleChanges, ReactionRolesPluginType, RoleChangeMode } from \"../types.js\";\n\nconst ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500;\n\nexport async function addMemberPendingRoleChange(\n  pluginData: GuildPluginData<ReactionRolesPluginType>,\n  memberId: string,\n  mode: RoleChangeMode,\n  roleId: string,\n) {\n  if (!pluginData.state.pendingRoleChanges.has(memberId)) {\n    const newPendingRoleChangeObj: PendingMemberRoleChanges = {\n      timeout: null,\n      changes: [],\n      applyFn: async () => {\n        pluginData.state.pendingRoleChanges.delete(memberId);\n\n        const lock = await pluginData.locks.acquire(memberRolesLock({ id: memberId }));\n\n        const member = await resolveMember(pluginData.client, pluginData.guild, memberId);\n        if (member) {\n          const newRoleIds = new Set(member.roles.cache.keys());\n          for (const change of newPendingRoleChangeObj.changes) {\n            if (change.mode === \"+\") newRoleIds.add(change.roleId as Snowflake);\n            else newRoleIds.delete(change.roleId as Snowflake);\n          }\n\n          try {\n            await member.roles.set(Array.from(newRoleIds.values()), \"Reaction roles\");\n          } catch (e) {\n            logger.warn(`Failed to apply role changes to ${renderUsername(member)} (${member.id}): ${e.message}`);\n          }\n        }\n        lock.unlock();\n      },\n    };\n\n    pluginData.state.pendingRoleChanges.set(memberId, newPendingRoleChangeObj);\n  }\n\n  const pendingRoleChangeObj = pluginData.state.pendingRoleChanges.get(memberId)!;\n  pendingRoleChangeObj.changes.push({ mode, roleId });\n\n  if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout);\n  pendingRoleChangeObj.timeout = setTimeout(\n    () => pluginData.state.roleChangeQueue.add(pendingRoleChangeObj.applyFn),\n    ROLE_CHANGE_BATCH_DEBOUNCE_TIME,\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { ReactionRole } from \"../../../data/entities/ReactionRole.js\";\nimport { isDiscordAPIError, isDiscordJsTypeError, sleep } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { ReactionRolesPluginType } from \"../types.js\";\n\nconst CLEAR_ROLES_EMOJI = \"❌\";\n\n/**\n * @return Errors encountered while applying reaction roles, if any\n */\nexport async function applyReactionRoleReactionsToMessage(\n  pluginData: GuildPluginData<ReactionRolesPluginType>,\n  channelId: string,\n  messageId: string,\n  reactionRoles: ReactionRole[],\n): Promise<string[] | undefined> {\n  const channel = pluginData.guild.channels.cache.get(channelId as Snowflake);\n  if (!channel?.isTextBased()) return;\n\n  const errors: string[] = [];\n  const logs = pluginData.getPlugin(LogsPlugin);\n\n  let targetMessage;\n  try {\n    targetMessage = await channel.messages.fetch({ message: messageId, force: true });\n  } catch (e) {\n    if (isDiscordAPIError(e)) {\n      if (e.code === 10008) {\n        // Unknown message, remove reaction roles from the message\n        logs.logBotAlert({\n          body: `Removed reaction roles from unknown message ${channelId}/${messageId} (${pluginData.guild.id})`,\n        });\n        await pluginData.state.reactionRoles.removeFromMessage(messageId);\n      } else {\n        logs.logBotAlert({\n          body: `Error ${e.code} when applying reaction roles to message ${channelId}/${messageId}: ${e.message}`,\n        });\n      }\n\n      errors.push(`Error ${e.code} while fetching reaction role message: ${e.message}`);\n      return errors;\n    } else {\n      throw e;\n    }\n  }\n\n  // Remove old reactions, if any\n  try {\n    await targetMessage.reactions.removeAll();\n  } catch (e) {\n    if (isDiscordAPIError(e)) {\n      errors.push(`Error ${e.code} while removing old reactions: ${e.message}`);\n      logs.logBotAlert({\n        body: `Error ${e.code} while removing old reaction role reactions from message ${channelId}/${messageId}: ${e.message}`,\n      });\n      return errors;\n    }\n\n    throw e;\n  }\n\n  await sleep(1500);\n\n  // Add reaction role reactions\n  const emojisToAdd = reactionRoles.map((rr) => rr.emoji);\n  emojisToAdd.push(CLEAR_ROLES_EMOJI);\n\n  for (const rawEmoji of emojisToAdd) {\n    try {\n      await targetMessage.react(rawEmoji);\n      await sleep(750); // Make sure we don't hit rate limits\n    } catch (e) {\n      if (isDiscordJsTypeError(e)) {\n        errors.push(e.message);\n        logs.logBotAlert({\n          body: `Error ${e.code} while applying reaction role reactions to ${channelId}/${messageId}: ${e.message}.`,\n        });\n      } else if (isDiscordAPIError(e)) {\n        if (e.code === 10014) {\n          pluginData.state.reactionRoles.removeFromMessage(messageId, rawEmoji);\n          errors.push(`Unknown emoji: ${rawEmoji}`);\n          logs.logBotAlert({\n            body: `Could not add unknown reaction role emoji ${rawEmoji} to message ${channelId}/${messageId}`,\n          });\n          continue;\n        } else if (e.code === 50013) {\n          errors.push(`Missing permissions to apply reactions`);\n          logs.logBotAlert({\n            body: `Error ${e.code} while applying reaction role reactions to ${channelId}/${messageId}: ${e.message}`,\n          });\n          break;\n        } else if (e.code === 30010) {\n          errors.push(`Maximum number of reactions reached (20)`);\n          logs.logBotAlert({\n            body: `Error ${e.code} while applying reaction role reactions to ${channelId}/${messageId}: ${e.message}`,\n          });\n          break;\n        }\n      }\n\n      throw e;\n    }\n  }\n\n  return errors;\n}\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { ReactionRolesPluginType } from \"../types.js\";\nimport { runAutoRefresh } from \"./runAutoRefresh.js\";\n\nexport async function autoRefreshLoop(pluginData: GuildPluginData<ReactionRolesPluginType>, interval: number) {\n  pluginData.state.autoRefreshTimeout = setTimeout(async () => {\n    await runAutoRefresh(pluginData);\n    autoRefreshLoop(pluginData, interval);\n  }, interval);\n}\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/util/refreshReactionRoles.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { ReactionRolesPluginType } from \"../types.js\";\nimport { applyReactionRoleReactionsToMessage } from \"./applyReactionRoleReactionsToMessage.js\";\n\nexport async function refreshReactionRoles(\n  pluginData: GuildPluginData<ReactionRolesPluginType>,\n  channelId: string,\n  messageId: string,\n) {\n  const pendingKey = `${channelId}-${messageId}`;\n  if (pluginData.state.pendingRefreshes.has(pendingKey)) return;\n  pluginData.state.pendingRefreshes.add(pendingKey);\n\n  try {\n    const reactionRoles = await pluginData.state.reactionRoles.getForMessage(messageId);\n    await applyReactionRoleReactionsToMessage(pluginData, channelId, messageId, reactionRoles);\n  } finally {\n    pluginData.state.pendingRefreshes.delete(pendingKey);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { ReactionRolesPluginType } from \"../types.js\";\nimport { refreshReactionRoles } from \"./refreshReactionRoles.js\";\n\nexport async function runAutoRefresh(pluginData: GuildPluginData<ReactionRolesPluginType>) {\n  // Refresh reaction roles on all reaction role messages\n  const reactionRoles = await pluginData.state.reactionRoles.all();\n  const idPairs = new Set(reactionRoles.map((r) => `${r.channel_id}-${r.message_id}`));\n  for (const pair of idPairs) {\n    const [channelId, messageId] = pair.split(\"-\");\n    await refreshReactionRoles(pluginData, channelId, messageId);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Reminders/RemindersPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { onGuildEvent } from \"../../data/GuildEvents.js\";\nimport { GuildReminders } from \"../../data/GuildReminders.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { TimeAndDatePlugin } from \"../TimeAndDate/TimeAndDatePlugin.js\";\nimport { RemindCmd } from \"./commands/RemindCmd.js\";\nimport { RemindersCmd } from \"./commands/RemindersCmd.js\";\nimport { RemindersDeleteCmd } from \"./commands/RemindersDeleteCmd.js\";\nimport { postReminder } from \"./functions/postReminder.js\";\nimport { RemindersPluginType, zRemindersConfig } from \"./types.js\";\n\nexport const RemindersPlugin = guildPlugin<RemindersPluginType>()({\n  name: \"reminders\",\n\n  dependencies: () => [TimeAndDatePlugin],\n  configSchema: zRemindersConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_use: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    RemindCmd,\n    RemindersCmd,\n    RemindersDeleteCmd,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.reminders = GuildReminders.getGuildInstance(guild.id);\n    state.tries = new Map();\n    state.unloaded = false;\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.unregisterGuildEventListener = onGuildEvent(guild.id, \"reminder\", (reminder) =>\n      postReminder(pluginData, reminder),\n    );\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.unregisterGuildEventListener?.();\n    state.unloaded = true;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Reminders/commands/RemindCmd.ts",
    "content": "import moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { registerUpcomingReminder } from \"../../../data/loops/upcomingRemindersLoop.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { convertDelayStringToMS, messageLink } from \"../../../utils.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { remindersCmd } from \"../types.js\";\n\nexport const RemindCmd = remindersCmd({\n  trigger: [\"remind\", \"remindme\", \"reminder\"],\n  usage: \"!remind 3h Remind me of this in 3 hours please\",\n  permission: \"can_use\",\n\n  signature: {\n    time: ct.string(),\n    reminder: ct.string({ required: false, catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n\n    const now = moment.utc();\n    const tz = await timeAndDate.getMemberTz(msg.author.id);\n\n    let reminderTime: moment.Moment;\n    if (args.time.match(/^\\d{4}-\\d{1,2}-\\d{1,2}$/)) {\n      // Date in YYYY-MM-DD format, remind at current time on that date\n      reminderTime = moment.tz(args.time, \"YYYY-M-D\", tz).set({\n        hour: now.hour(),\n        minute: now.minute(),\n        second: now.second(),\n      });\n    } else if (args.time.match(/^\\d{4}-\\d{1,2}-\\d{1,2}T\\d{2}:\\d{2}$/)) {\n      // Date and time in YYYY-MM-DD[T]HH:mm format\n      reminderTime = moment.tz(args.time, \"YYYY-M-D[T]HH:mm\", tz).second(0);\n    } else {\n      // \"Delay string\" i.e. e.g. \"2h30m\"\n      const ms = convertDelayStringToMS(args.time);\n      if (ms === null) {\n        void pluginData.state.common.sendErrorMessage(msg, \"Invalid reminder time\");\n        return;\n      }\n\n      reminderTime = moment.utc().add(ms, \"millisecond\");\n    }\n\n    if (!reminderTime.isValid() || reminderTime.isBefore(now)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Invalid reminder time\");\n      return;\n    }\n\n    const reminderBody = args.reminder || messageLink(pluginData.guild.id, msg.channel.id, msg.id);\n    const reminder = await pluginData.state.reminders.add(\n      msg.author.id,\n      msg.channel.id,\n      reminderTime.clone().tz(\"Etc/UTC\").format(\"YYYY-MM-DD HH:mm:ss\"),\n      reminderBody,\n      moment.utc().format(\"YYYY-MM-DD HH:mm:ss\"),\n    );\n\n    registerUpcomingReminder(reminder);\n\n    const msUntilReminder = reminderTime.diff(now);\n    const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });\n    const prettyReminderTime = (await timeAndDate.inMemberTz(msg.author.id, reminderTime)).format(\n      pluginData.getPlugin(TimeAndDatePlugin).getDateFormat(\"pretty_datetime\"),\n    );\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `I will remind you in **${timeUntilReminder}** at **${prettyReminderTime}**`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Reminders/commands/RemindersCmd.ts",
    "content": "import moment from \"moment-timezone\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { createChunkedMessage, DBDateFormat, sorter } from \"../../../utils.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { remindersCmd } from \"../types.js\";\n\nexport const RemindersCmd = remindersCmd({\n  trigger: \"reminders\",\n  permission: \"can_use\",\n\n  async run({ message: msg, pluginData }) {\n    const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id);\n    if (reminders.length === 0) {\n      void pluginData.state.common.sendErrorMessage(msg, \"No reminders\");\n      return;\n    }\n\n    const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n\n    reminders.sort(sorter(\"remind_at\"));\n    const longestNum = (reminders.length + 1).toString().length;\n    const lines = Array.from(reminders.entries()).map(([i, reminder]) => {\n      const num = i + 1;\n      const paddedNum = num.toString().padStart(longestNum, \" \");\n      const target = moment.utc(reminder.remind_at, \"YYYY-MM-DD HH:mm:ss\");\n      const diff = target.diff(moment.utc());\n      const result = humanizeDuration(diff, { largest: 2, round: true });\n      const prettyRemindAt = timeAndDate\n        .inGuildTz(moment.utc(reminder.remind_at, DBDateFormat))\n        .format(timeAndDate.getDateFormat(\"pretty_datetime\"));\n      return `\\`${paddedNum}.\\` \\`${prettyRemindAt} (${result})\\` ${reminder.body}`;\n    });\n\n    createChunkedMessage(msg.channel, lines.join(\"\\n\"));\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { clearUpcomingReminder } from \"../../../data/loops/upcomingRemindersLoop.js\";\nimport { sorter } from \"../../../utils.js\";\nimport { remindersCmd } from \"../types.js\";\n\nexport const RemindersDeleteCmd = remindersCmd({\n  trigger: [\"reminders delete\", \"reminders d\"],\n  permission: \"can_use\",\n\n  signature: {\n    num: ct.number(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id);\n    reminders.sort(sorter(\"remind_at\"));\n\n    if (args.num > reminders.length || args.num <= 0) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Unknown reminder\");\n      return;\n    }\n\n    const toDelete = reminders[args.num - 1];\n    clearUpcomingReminder(toDelete);\n    await pluginData.state.reminders.delete(toDelete.id);\n\n    void pluginData.state.common.sendSuccessMessage(msg, \"Reminder deleted\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Reminders/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zRemindersConfig } from \"./types.js\";\n\nexport const remindersPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Reminders\",\n  configSchema: zRemindersConfig,\n  type: \"stable\",\n};\n"
  },
  {
    "path": "backend/src/plugins/Reminders/functions/postReminder.ts",
    "content": "import { HTTPError, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { disableLinkPreviews } from \"vety/helpers\";\nimport moment from \"moment-timezone\";\nimport { Reminder } from \"../../../data/entities/Reminder.js\";\nimport { DBDateFormat } from \"../../../utils.js\";\nimport { RemindersPluginType } from \"../types.js\";\n\nexport async function postReminder(pluginData: GuildPluginData<RemindersPluginType>, reminder: Reminder) {\n  const channel = pluginData.guild.channels.cache.get(reminder.channel_id as Snowflake);\n  if (channel && (channel.isTextBased() || channel.isThread())) {\n    try {\n      // Only show created at date if one exists\n      if (moment.utc(reminder.created_at).isValid()) {\n        const createdAtTS = Math.floor(moment.utc(reminder.created_at, DBDateFormat).valueOf() / 1000);\n        await channel.send({\n          content: disableLinkPreviews(\n            `Reminder for <@!${reminder.user_id}>: ${reminder.body} \\nSet <t:${createdAtTS}:R>`,\n          ),\n          allowedMentions: {\n            users: [reminder.user_id as Snowflake],\n          },\n        });\n      } else {\n        await channel.send({\n          content: disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`),\n          allowedMentions: {\n            users: [reminder.user_id as Snowflake],\n          },\n        });\n      }\n    } catch (err) {\n      // tslint:disable-next-line:no-console\n      console.warn(`Error when posting reminder for ${reminder.user_id} in guild ${reminder.guild_id}: ${String(err)}`);\n\n      if (err instanceof HTTPError && err.status >= 500) {\n        // If we get a server error, try again later\n        return;\n      }\n    }\n  }\n\n  await pluginData.state.reminders.delete(reminder.id);\n}\n"
  },
  {
    "path": "backend/src/plugins/Reminders/types.ts",
    "content": "import { BasePluginType, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildReminders } from \"../../data/GuildReminders.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zRemindersConfig = z.strictObject({\n  can_use: z.boolean().default(false),\n});\n\nexport interface RemindersPluginType extends BasePluginType {\n  configSchema: typeof zRemindersConfig;\n  state: {\n    reminders: GuildReminders;\n    tries: Map<number, number>;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n\n    unregisterGuildEventListener: () => void;\n\n    unloaded: boolean;\n  };\n}\n\nexport const remindersCmd = guildPluginMessageCommand<RemindersPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildRoleButtons } from \"../../data/GuildRoleButtons.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../RoleManager/RoleManagerPlugin.js\";\nimport { resetButtonsCmd } from \"./commands/resetButtons.js\";\nimport { onButtonInteraction } from \"./events/buttonInteraction.js\";\nimport { applyAllRoleButtons } from \"./functions/applyAllRoleButtons.js\";\nimport { RoleButtonsPluginType, zRoleButtonsConfig } from \"./types.js\";\n\nexport const RoleButtonsPlugin = guildPlugin<RoleButtonsPluginType>()({\n  name: \"role_buttons\",\n\n  configSchema: zRoleButtonsConfig,\n  defaultOverrides: [\n    {\n      level: \">=100\",\n      config: {\n        can_reset: true,\n      },\n    },\n  ],\n\n  dependencies: () => [LogsPlugin, RoleManagerPlugin],\n\n  events: [onButtonInteraction],\n\n  messageCommands: [resetButtonsCmd],\n\n  beforeLoad(pluginData) {\n    pluginData.state.roleButtons = GuildRoleButtons.getGuildInstance(pluginData.guild.id);\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  async afterLoad(pluginData) {\n    await applyAllRoleButtons(pluginData);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/commands/resetButtons.ts",
    "content": "import { guildPluginMessageCommand } from \"vety\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { applyAllRoleButtons } from \"../functions/applyAllRoleButtons.js\";\nimport { RoleButtonsPluginType } from \"../types.js\";\n\nexport const resetButtonsCmd = guildPluginMessageCommand<RoleButtonsPluginType>()({\n  trigger: \"role_buttons reset\",\n  description:\n    \"In case of issues, you can run this command to have Zeppelin 'forget' about specific role buttons and re-apply them. This will also repost the message, if not targeting an existing message.\",\n  usage: \"!role_buttons reset my_roles\",\n  permission: \"can_reset\",\n  signature: {\n    name: ct.string(),\n  },\n  async run({ pluginData, args, message }) {\n    const config = pluginData.config.get();\n    if (!config.buttons[args.name]) {\n      void pluginData.state.common.sendErrorMessage(message, `Can't find role buttons with the name \"${args.name}\"`);\n      return;\n    }\n\n    await pluginData.state.roleButtons.deleteRoleButtonItem(args.name);\n    await applyAllRoleButtons(pluginData);\n    void pluginData.state.common.sendSuccessMessage(message, \"Done!\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zRoleButtonsConfig } from \"./types.js\";\n\nexport const roleButtonsPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Role buttons\",\n  description: trimPluginDescription(`\n    Allow users to pick roles by clicking on buttons\n  `),\n  configurationGuide: trimPluginDescription(`\n    Button roles are entirely config-based; this is in contrast to the old reaction roles. They can either be added to an existing message posted by Zeppelin or posted as a new message.\n    \n    ## Basic role buttons\n    ~~~yml\n    role_buttons:\n      config:\n        buttons:\n          my_roles: # You can use any name you want here, but make sure not to change it afterwards\n            message:\n              channel_id: \"967407495544983552\"\n              content: \"Click the reactions below to get roles! Click again to remove the role.\"\n            options:\n              - role_id: \"878339100015489044\"\n                label: \"Role 1\"\n              - role_id: \"967410091571703808\"\n                emoji: \"😁\" # Default emoji as a unicode emoji\n                label: \"Role 2\"\n              - role_id: \"967410091571703234\"\n                emoji: \"967412591683047445\" # Custom emoji ID\n              - role_id: \"967410091571703567\"\n                label: \"Role 4\"\n                style: DANGER # Button style (in all caps), see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles\n    ~~~\n    \n    ### Or with an embed:\n    ~~~yml\n    role_buttons:\n      config:\n        buttons:\n          my_roles:\n            message:\n              channel_id: \"967407495544983552\"\n              content:\n                embeds:\n                  - title: \"Pick your role below!\"\n                    color: 0x0088FF\n                    description: \"You can pick any role you want by clicking the buttons below.\"\n            options:\n              ... # See above for examples for options\n    ~~~\n    \n    ## Role buttons for an existing message\n    This message must be posted by Zeppelin.\n    ~~~yml\n    role_buttons:\n      config:\n        buttons:\n          my_roles:\n            message:\n              channel_id: \"967407495544983552\"\n              message_id: \"967407554412040193\"\n            options:\n              ... # See above for examples for options\n    ~~~\n    \n    ## Limiting to one role (\"exclusive\" roles)\n    When the \\`exclusive\\` option is enabled, only one role can be selected at a time.\n    ~~~yml\n    role_buttons:\n      config:\n        buttons:\n          my_roles:\n            message:\n              channel_id: \"967407495544983552\"\n              message_id: \"967407554412040193\"\n            exclusive: true # With this option set, only one role can be selected at a time\n            options:\n              ... # See above for examples for options\n    ~~~\n  `),\n  configSchema: zRoleButtonsConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/events/buttonInteraction.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { guildPluginEventListener } from \"vety\";\nimport { SECONDS } from \"../../../utils.js\";\nimport { renderRecursively } from \"../../../utils.js\";\nimport { parseCustomId } from \"../../../utils/parseCustomId.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { getAllRolesInButtons } from \"../functions/getAllRolesInButtons.js\";\nimport { RoleButtonsPluginType, TRoleButtonOption } from \"../types.js\";\nimport { renderTemplate, TemplateSafeValueContainer } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember, roleToTemplateSafeRole, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\n\nconst ROLE_BUTTON_CD = 5 * SECONDS;\n\nexport const onButtonInteraction = guildPluginEventListener<RoleButtonsPluginType>()({\n  event: \"interactionCreate\",\n  async listener({ pluginData, args }) {\n    if (!args.interaction.isButton()) {\n      return;\n    }\n\n    const { namespace, data } = parseCustomId(args.interaction.customId);\n    if (namespace !== \"roleButtons\") {\n      return;\n    }\n\n    const config = pluginData.config.get();\n    const { name, index: optionIndex } = data;\n    // For some reason TS's type inference fails here so using a type annotation\n    const buttons = config.buttons[name];\n    const option: TRoleButtonOption | undefined = buttons?.options[optionIndex];\n    if (!buttons || !option) {\n      args.interaction\n        .reply({\n          ephemeral: true,\n          content: \"Invalid option selected\",\n        })\n        .catch((err) => console.trace(err.message));\n      return;\n    }\n\n    const cdIdentifier = `${args.interaction.user.id}-${optionIndex}`;\n    if (pluginData.cooldowns.isOnCooldown(cdIdentifier)) {\n      args.interaction.reply({\n        ephemeral: true,\n        content: \"Please wait before clicking the button again\",\n      });\n      return;\n    }\n    pluginData.cooldowns.setCooldown(cdIdentifier, ROLE_BUTTON_CD);\n\n    const member = args.interaction.member as GuildMember;\n    const role = pluginData.guild.roles.cache.get(option.role_id);\n    const roleName = role?.name || option.role_id;\n\n    const rolesToRemove: string[] = [];\n    const rolesToAdd: string[] = [];\n\n    const renderTemplateText = async (str: string) =>\n      renderTemplate(\n        str,\n        new TemplateSafeValueContainer({\n          user: member ? memberToTemplateSafeMember(member) : userToTemplateSafeUser(args.interaction.user),\n          role: role ? roleToTemplateSafeRole(role) : new TemplateSafeValueContainer({ name: roleName, id: option.role_id }),\n        }),\n      );\n\n    if (member.roles.cache.has(option.role_id)) {\n      rolesToRemove.push(option.role_id);\n\n      const messageTemplate = config.buttons[name].remove_message || `The role **${roleName}** will be removed shortly!`;\n      const formatted = typeof messageTemplate === \"string\"\n        ? await renderTemplateText(messageTemplate)\n        : await renderRecursively(messageTemplate, renderTemplateText);\n\n      args.interaction\n        .reply({ ephemeral: true, ...(typeof formatted === \"string\" ? { content: formatted } : formatted) })\n        .catch((err) => console.trace(err.message));\n    } else {\n      rolesToAdd.push(option.role_id);\n\n      if (buttons.exclusive) {\n        for (const roleId of getAllRolesInButtons(buttons)) {\n          if (member.roles.cache.has(roleId)) {\n            rolesToRemove.push(roleId);\n          }\n        }\n      }\n\n      const messageTemplate = config.buttons[name].add_message || `You will receive the **${roleName}** role shortly!`;\n      const formatted = typeof messageTemplate === \"string\"\n        ? await renderTemplateText(messageTemplate)\n        : await renderRecursively(messageTemplate, renderTemplateText);\n\n      args.interaction\n        .reply({ ephemeral: true, ...(typeof formatted === \"string\" ? { content: formatted } : formatted) })\n        .catch((err) => console.trace(err.message));\n    }\n\n    for (const roleId of rolesToAdd) {\n      pluginData.getPlugin(RoleManagerPlugin).addRole(member.user.id, roleId);\n    }\n    for (const roleId of rolesToRemove) {\n      pluginData.getPlugin(RoleManagerPlugin).removeRole(member.user.id, roleId);\n    }\n  },\n});"
  },
  {
    "path": "backend/src/plugins/RoleButtons/functions/TooManyComponentsError.ts",
    "content": "export class TooManyComponentsError extends Error {}\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/functions/applyAllRoleButtons.ts",
    "content": "import { createHash } from \"crypto\";\nimport { GuildPluginData } from \"vety\";\nimport { RoleButtonsPluginType } from \"../types.js\";\nimport { applyRoleButtons } from \"./applyRoleButtons.js\";\n\nexport async function applyAllRoleButtons(pluginData: GuildPluginData<RoleButtonsPluginType>) {\n  const savedRoleButtons = await pluginData.state.roleButtons.getSavedRoleButtons();\n  const config = pluginData.config.get();\n  for (const [configName, configItem] of Object.entries(config.buttons)) {\n    // Use the hash of the config to quickly check if we need to update buttons\n    const configItemToHash = { ...configItem, name: configName }; // Add name property for backwards compatibility\n    const hash = createHash(\"md5\").update(JSON.stringify(configItemToHash)).digest(\"hex\");\n    const savedButtonsItem = savedRoleButtons.find((bt) => bt.name === configName);\n    if (savedButtonsItem?.hash === hash) {\n      // No changes\n      continue;\n    }\n\n    if (savedButtonsItem) {\n      await pluginData.state.roleButtons.deleteRoleButtonItem(configName);\n    }\n\n    const applyResult = await applyRoleButtons(pluginData, configItem, configName, savedButtonsItem ?? null);\n    if (!applyResult) {\n      return;\n    }\n\n    await pluginData.state.roleButtons.saveRoleButtonItem(\n      configName,\n      applyResult.channel_id,\n      applyResult.message_id,\n      hash,\n    );\n  }\n\n  // Remove saved role buttons from the DB that are no longer in the config\n  const savedRoleButtonsToDelete = savedRoleButtons\n    .filter((savedRoleButton) => !config.buttons[savedRoleButton.name])\n    .map((savedRoleButton) => savedRoleButton.name);\n  for (const name of savedRoleButtonsToDelete) {\n    await pluginData.state.roleButtons.deleteRoleButtonItem(name);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts",
    "content": "import { Message, MessageCreateOptions, MessageEditOptions } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { RoleButtonsItem } from \"../../../data/entities/RoleButtonsItem.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleButtonsPluginType, TRoleButtonsConfigItem } from \"../types.js\";\nimport { createButtonComponents } from \"./createButtonComponents.js\";\n\nexport async function applyRoleButtons(\n  pluginData: GuildPluginData<RoleButtonsPluginType>,\n  configItem: TRoleButtonsConfigItem,\n  configName: string,\n  existingSavedButtons: RoleButtonsItem | null,\n): Promise<{ channel_id: string; message_id: string } | null> {\n  let message: Message;\n\n  // Remove existing role buttons, if any\n  if (existingSavedButtons?.channel_id) {\n    const existingChannel = await pluginData.guild.channels.fetch(configItem.message.channel_id).catch(() => null);\n    const existingMessage = await (existingChannel?.isTextBased() &&\n      existingChannel.messages.fetch(existingSavedButtons.message_id).catch(() => null));\n    if (existingMessage && existingMessage.components.length) {\n      await existingMessage.edit({\n        components: [],\n      });\n    }\n  }\n\n  // Find or create message for role buttons\n  if (\"message_id\" in configItem.message) {\n    // channel id + message id: apply role buttons to existing message\n    const channel = await pluginData.guild.channels.fetch(configItem.message.channel_id).catch(() => null);\n    const messageCandidate = await (channel?.isTextBased() &&\n      channel.messages.fetch(configItem.message.message_id).catch(() => null));\n    if (!messageCandidate) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Message not found for role_buttons/${configName}`,\n      });\n      return null;\n    }\n    message = messageCandidate;\n  } else {\n    // channel id + message content: post new message to apply role buttons to\n    const contentIsValid =\n      typeof configItem.message.content === \"string\"\n        ? configItem.message.content.trim() !== \"\"\n        : Boolean(configItem.message.content.content?.trim()) || configItem.message.content.embeds?.length;\n    if (!contentIsValid) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Invalid message content for role_buttons/${configName}`,\n      });\n      return null;\n    }\n\n    const channel = await pluginData.guild.channels.fetch(configItem.message.channel_id).catch(() => null);\n    if (channel && (!channel.isTextBased || typeof channel.isTextBased !== \"function\")) {\n      // FIXME: Probably not relevant anymore?\n      // tslint:disable-next-line no-console\n      console.log(\"wtf\", pluginData.guild?.id, configItem.message.channel_id);\n    }\n    if (!channel || !channel?.isTextBased()) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Text channel not found for role_buttons/${configName}`,\n      });\n      return null;\n    }\n\n    let candidateMessage: Message | null = null;\n\n    if (existingSavedButtons?.channel_id === configItem.message.channel_id && existingSavedButtons.message_id) {\n      try {\n        candidateMessage = await channel.messages.fetch(existingSavedButtons.message_id);\n        // Make sure message contents are up-to-date\n        const editContent =\n          typeof configItem.message.content === \"string\"\n            ? { content: configItem.message.content }\n            : { ...configItem.message.content };\n        if (!editContent.content) {\n          // Editing with empty content doesn't go through at all for whatever reason, even if there's differences in e.g. the embeds,\n          // so send a space as the content instead. This still functions as if there's no content at all.\n          editContent.content = \" \";\n        }\n        await candidateMessage.edit(editContent as MessageEditOptions);\n      } catch (err) {\n        // Message was deleted or is inaccessible. Proceed with reposting it.\n      }\n    }\n\n    if (!candidateMessage) {\n      try {\n        candidateMessage = await channel.send(configItem.message.content as string | MessageCreateOptions);\n      } catch (err) {\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Error while posting message for role_buttons/${configName}: ${String(err)}`,\n        });\n        return null;\n      }\n    }\n\n    message = candidateMessage;\n  }\n\n  if (message.author.id !== pluginData.client.user?.id) {\n    pluginData.getPlugin(LogsPlugin).logBotAlert({\n      body: `Error applying role buttons for role_buttons/${configName}: target message must be posted by Zeppelin`,\n    });\n    return null;\n  }\n\n  // Apply role buttons\n  const components = createButtonComponents(configItem, configName);\n  await message.edit({ components }).catch((err) => {\n    pluginData.getPlugin(LogsPlugin).logBotAlert({\n      body: `Error applying role buttons for role_buttons/${configName}: ${String(err)}`,\n    });\n    return null;\n  });\n\n  return {\n    channel_id: message.channelId,\n    message_id: message.id,\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/functions/convertButtonStyleStringToEnum.ts",
    "content": "import { ButtonStyle } from \"discord.js\";\nimport { TRoleButtonOption } from \"../types.js\";\n\nexport function convertButtonStyleStringToEnum(input: TRoleButtonOption[\"style\"]): ButtonStyle | null | undefined {\n  switch (input) {\n    case \"PRIMARY\":\n      return ButtonStyle.Primary;\n    case \"SECONDARY\":\n      return ButtonStyle.Secondary;\n    case \"SUCCESS\":\n      return ButtonStyle.Success;\n    case \"DANGER\":\n      return ButtonStyle.Danger;\n    default:\n      return input;\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/functions/createButtonComponents.ts",
    "content": "import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from \"discord.js\";\nimport { buildCustomId } from \"../../../utils/buildCustomId.js\";\nimport { TRoleButtonsConfigItem } from \"../types.js\";\nimport { TooManyComponentsError } from \"./TooManyComponentsError.js\";\nimport { convertButtonStyleStringToEnum } from \"./convertButtonStyleStringToEnum.js\";\n\nexport function createButtonComponents(\n  configItem: TRoleButtonsConfigItem,\n  configName: string,\n): Array<ActionRowBuilder<ButtonBuilder>> {\n  const rows: Array<ActionRowBuilder<ButtonBuilder>> = [];\n\n  let currentRow = new ActionRowBuilder<ButtonBuilder>();\n  for (const [index, option] of configItem.options.entries()) {\n    if (currentRow.components.length === 5 || (currentRow.components.length > 0 && option.start_new_row)) {\n      rows.push(currentRow);\n      currentRow = new ActionRowBuilder<ButtonBuilder>();\n    }\n\n    const button = new ButtonBuilder()\n      .setLabel(option.label ?? \"\")\n      .setStyle(convertButtonStyleStringToEnum(option.style) ?? ButtonStyle.Primary)\n      .setCustomId(buildCustomId(\"roleButtons\", { name: configName, index }));\n\n    if (option.emoji) {\n      button.setEmoji(option.emoji);\n    }\n\n    currentRow.components.push(button);\n  }\n\n  if (currentRow.components.length > 0) {\n    rows.push(currentRow);\n  }\n\n  if (rows.length > 5) {\n    throw new TooManyComponentsError();\n  }\n\n  return rows;\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/functions/getAllRolesInButtons.ts",
    "content": "import { TRoleButtonsConfigItem } from \"../types.js\";\n\n// This function will be more complex in the future when the plugin supports select menus + sub-menus\nexport function getAllRolesInButtons(buttons: TRoleButtonsConfigItem): string[] {\n  const roles = new Set<string>();\n  for (const option of buttons.options) {\n    roles.add(option.role_id);\n  }\n  return Array.from(roles);\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleButtons/types.ts",
    "content": "import { ButtonStyle } from \"discord.js\";\nimport { BasePluginType, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildRoleButtons } from \"../../data/GuildRoleButtons.js\";\nimport { zBoundedCharacters, zBoundedRecord, zMessageContent, zSnowflake } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { TooManyComponentsError } from \"./functions/TooManyComponentsError.js\";\nimport { createButtonComponents } from \"./functions/createButtonComponents.js\";\n\nconst zRoleButtonOption = z.strictObject({\n  role_id: zSnowflake,\n  label: z.string().nullable().default(null),\n  emoji: z.string().nullable().default(null),\n  // https://discord.js.org/#/docs/discord.js/v13/typedef/MessageButtonStyle\n  style: z\n    .union([\n      z.literal(ButtonStyle.Primary),\n      z.literal(ButtonStyle.Secondary),\n      z.literal(ButtonStyle.Success),\n      z.literal(ButtonStyle.Danger),\n\n      // The following are deprecated\n      z.literal(\"PRIMARY\"),\n      z.literal(\"SECONDARY\"),\n      z.literal(\"SUCCESS\"),\n      z.literal(\"DANGER\"),\n      // z.literal(\"LINK\"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available\n    ])\n    .nullable()\n    .default(null),\n  start_new_row: z.boolean().default(false),\n});\nexport type TRoleButtonOption = z.infer<typeof zRoleButtonOption>;\n\nconst zRoleButtonsConfigItem = z\n  .strictObject({\n    message: z.union([\n      z.strictObject({\n        channel_id: zSnowflake,\n        message_id: zSnowflake,\n      }),\n      z.strictObject({\n        channel_id: zSnowflake,\n        content: zMessageContent,\n      }),\n    ]),\n    add_message: zMessageContent.optional(),\n    remove_message: zMessageContent.optional(),\n    options: z.array(zRoleButtonOption).max(25),\n    exclusive: z.boolean().default(false),\n  })\n  .refine(\n    (parsed) => {\n      try {\n        createButtonComponents(parsed, \"test\"); // We can use any configName here\n      } catch (err) {\n        if (err instanceof TooManyComponentsError) {\n          return false;\n        }\n        throw err;\n      }\n      return true;\n    },\n    {\n      message: \"Too many options; can only have max 5 buttons per row on max 5 rows.\",\n    },\n  );\nexport type TRoleButtonsConfigItem = z.infer<typeof zRoleButtonsConfigItem>;\n\nexport const zRoleButtonsConfig = z\n  .strictObject({\n    buttons: zBoundedRecord(z.record(zBoundedCharacters(1, 16), zRoleButtonsConfigItem), 0, 100).default({}),\n    can_reset: z.boolean().default(false),\n  })\n  .refine(\n    (parsed) => {\n      const seenMessages = new Set();\n      for (const button of Object.values(parsed.buttons)) {\n        if (button.message) {\n          if (\"message_id\" in button.message) {\n            if (seenMessages.has(button.message.message_id)) {\n              return false;\n            }\n            seenMessages.add(button.message.message_id);\n          }\n        }\n      }\n      return true;\n    },\n    {\n      message: \"Can't target the same message with two sets of role buttons\",\n    },\n  );\n\nexport interface RoleButtonsPluginType extends BasePluginType {\n  configSchema: typeof zRoleButtonsConfig;\n  state: {\n    roleButtons: GuildRoleButtons;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleManager/RoleManagerPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildRoleQueue } from \"../../data/GuildRoleQueue.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { addPriorityRole } from \"./functions/addPriorityRole.js\";\nimport { addRole } from \"./functions/addRole.js\";\nimport { removePriorityRole } from \"./functions/removePriorityRole.js\";\nimport { removeRole } from \"./functions/removeRole.js\";\nimport { runRoleAssignmentLoop } from \"./functions/runRoleAssignmentLoop.js\";\nimport { RoleManagerPluginType, zRoleManagerConfig } from \"./types.js\";\n\nexport const RoleManagerPlugin = guildPlugin<RoleManagerPluginType>()({\n  name: \"role_manager\",\n\n  dependencies: () => [LogsPlugin],\n  configSchema: zRoleManagerConfig,\n\n  public(pluginData) {\n    return {\n      addRole: makePublicFn(pluginData, addRole),\n      removeRole: makePublicFn(pluginData, removeRole),\n      addPriorityRole: makePublicFn(pluginData, addPriorityRole),\n      removePriorityRole: makePublicFn(pluginData, removePriorityRole),\n    };\n  },\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.roleQueue = GuildRoleQueue.getGuildInstance(guild.id);\n    state.pendingRoleAssignmentPromise = Promise.resolve();\n  },\n\n  afterLoad(pluginData) {\n    runRoleAssignmentLoop(pluginData);\n  },\n\n  async afterUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.abortRoleAssignmentLoop = true;\n    await state.pendingRoleAssignmentPromise;\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/RoleManager/constants.ts",
    "content": "export const PRIORITY_ROLE_PRIORITY = 10;\n"
  },
  {
    "path": "backend/src/plugins/RoleManager/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zRoleManagerConfig } from \"./types.js\";\n\nexport const roleManagerPluginDocs: ZeppelinPluginDocs = {\n  prettyName: \"Role manager\",\n  type: \"internal\",\n  configSchema: zRoleManagerConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/RoleManager/functions/addPriorityRole.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { PRIORITY_ROLE_PRIORITY } from \"../constants.js\";\nimport { RoleManagerPluginType } from \"../types.js\";\nimport { runRoleAssignmentLoop } from \"./runRoleAssignmentLoop.js\";\n\nexport async function addPriorityRole(\n  pluginData: GuildPluginData<RoleManagerPluginType>,\n  userId: string,\n  roleId: string,\n) {\n  await pluginData.state.roleQueue.addQueueItem(userId, roleId, true, PRIORITY_ROLE_PRIORITY);\n  runRoleAssignmentLoop(pluginData);\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleManager/functions/addRole.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { RoleManagerPluginType } from \"../types.js\";\nimport { runRoleAssignmentLoop } from \"./runRoleAssignmentLoop.js\";\n\nexport async function addRole(pluginData: GuildPluginData<RoleManagerPluginType>, userId: string, roleId: string) {\n  await pluginData.state.roleQueue.addQueueItem(userId, roleId, true);\n  runRoleAssignmentLoop(pluginData);\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleManager/functions/removePriorityRole.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { PRIORITY_ROLE_PRIORITY } from \"../constants.js\";\nimport { RoleManagerPluginType } from \"../types.js\";\nimport { runRoleAssignmentLoop } from \"./runRoleAssignmentLoop.js\";\n\nexport async function removePriorityRole(\n  pluginData: GuildPluginData<RoleManagerPluginType>,\n  userId: string,\n  roleId: string,\n) {\n  await pluginData.state.roleQueue.addQueueItem(userId, roleId, false, PRIORITY_ROLE_PRIORITY);\n  runRoleAssignmentLoop(pluginData);\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleManager/functions/removeRole.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { RoleManagerPluginType } from \"../types.js\";\nimport { runRoleAssignmentLoop } from \"./runRoleAssignmentLoop.js\";\n\nexport async function removeRole(pluginData: GuildPluginData<RoleManagerPluginType>, userId: string, roleId: string) {\n  await pluginData.state.roleQueue.addQueueItem(userId, roleId, false);\n  runRoleAssignmentLoop(pluginData);\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleManager/functions/runRoleAssignmentLoop.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { logger } from \"../../../logger.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleManagerPluginType } from \"../types.js\";\n\nconst ROLE_ASSIGNMENTS_PER_BATCH = 10;\n\nexport async function runRoleAssignmentLoop(pluginData: GuildPluginData<RoleManagerPluginType>) {\n  if (pluginData.state.roleAssignmentLoopRunning || pluginData.state.abortRoleAssignmentLoop) {\n    return;\n  }\n  pluginData.state.roleAssignmentLoopRunning = true;\n\n  while (true) {\n    // Abort on unload\n    if (pluginData.state.abortRoleAssignmentLoop) {\n      break;\n    }\n\n    if (!pluginData.state.roleAssignmentLoopRunning) {\n      break;\n    }\n\n    await (pluginData.state.pendingRoleAssignmentPromise = (async () => {\n      // Process assignments in batches, stopping once the queue's exhausted\n      const nextAssignments = await pluginData.state.roleQueue.consumeNextRoleAssignments(ROLE_ASSIGNMENTS_PER_BATCH);\n      if (nextAssignments.length === 0) {\n        pluginData.state.roleAssignmentLoopRunning = false;\n        return;\n      }\n\n      for (const assignment of nextAssignments) {\n        const member = await pluginData.guild.members.fetch(assignment.user_id).catch(() => null);\n        if (!member) {\n          return;\n        }\n\n        const operation = assignment.should_add\n          ? member.roles.add(assignment.role_id)\n          : member.roles.remove(assignment.role_id);\n\n        await operation.catch((err) => {\n          logger.warn(err);\n          pluginData.getPlugin(LogsPlugin).logBotAlert({\n            body: `Could not ${assignment.should_add ? \"assign\" : \"remove\"} role <@&${assignment.role_id}> (\\`${\n              assignment.role_id\n            }\\`) ${assignment.should_add ? \"to\" : \"from\"} <@!${assignment.user_id}> (\\`${assignment.user_id}\\`)`,\n          });\n        });\n      }\n    })());\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/RoleManager/types.ts",
    "content": "import { BasePluginType } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildRoleQueue } from \"../../data/GuildRoleQueue.js\";\n\nexport const zRoleManagerConfig = z.strictObject({});\n\nexport interface RoleManagerPluginType extends BasePluginType {\n  configSchema: typeof zRoleManagerConfig;\n  state: {\n    roleQueue: GuildRoleQueue;\n    roleAssignmentLoopRunning: boolean;\n    abortRoleAssignmentLoop: boolean;\n    pendingRoleAssignmentPromise: Promise<unknown>;\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/Roles/RolesPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../RoleManager/RoleManagerPlugin.js\";\nimport { AddRoleCmd } from \"./commands/AddRoleCmd.js\";\nimport { MassAddRoleCmd } from \"./commands/MassAddRoleCmd.js\";\nimport { MassRemoveRoleCmd } from \"./commands/MassRemoveRoleCmd.js\";\nimport { RemoveRoleCmd } from \"./commands/RemoveRoleCmd.js\";\nimport { RolesPluginType, zRolesConfig } from \"./types.js\";\n\nexport const RolesPlugin = guildPlugin<RolesPluginType>()({\n  name: \"roles\",\n\n  dependencies: () => [LogsPlugin, RoleManagerPlugin],\n  configSchema: zRolesConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_assign: true,\n      },\n    },\n    {\n      level: \">=100\",\n      config: {\n        can_mass_assign: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    AddRoleCmd,\n    RemoveRoleCmd,\n    MassAddRoleCmd,\n    MassRemoveRoleCmd,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.logs = new GuildLogs(guild.id);\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Roles/commands/AddRoleCmd.ts",
    "content": "import { GuildChannel } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { canActOn, resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { resolveRoleId, verboseUserMention } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { rolesCmd } from \"../types.js\";\n\nexport const AddRoleCmd = rolesCmd({\n  trigger: \"addrole\",\n  permission: \"can_assign\",\n  description: \"Add a role to the specified member\",\n\n  signature: {\n    member: ct.resolvedMember(),\n    role: ct.string({ catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const member = await resolveMessageMember(msg);\n    if (!canActOn(pluginData, member, args.member, true)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Cannot add roles to this user: insufficient permissions\");\n      return;\n    }\n\n    const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role);\n    if (!roleId) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Invalid role id\");\n      return;\n    }\n\n    const config = await pluginData.config.getForMessage(msg);\n    if (!config.assignable_roles.includes(roleId)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"You cannot assign that role\");\n      return;\n    }\n\n    // Sanity check: make sure the role is configured properly\n    const role = (msg.channel as GuildChannel).guild.roles.cache.get(roleId);\n    if (!role) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Unknown role configured for 'roles' plugin: ${roleId}`,\n      });\n      void pluginData.state.common.sendErrorMessage(msg, \"You cannot assign that role\");\n      return;\n    }\n\n    if (args.member.roles.cache.has(roleId)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Member already has that role\");\n      return;\n    }\n\n    pluginData.getPlugin(RoleManagerPlugin).addRole(args.member.id, roleId);\n\n    pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({\n      mod: msg.author,\n      member: args.member,\n      roles: [role],\n    });\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `Added role **${role.name}** to ${verboseUserMention(args.member.user)}!`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Roles/commands/MassAddRoleCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { logger } from \"../../../logger.js\";\nimport { canActOn, resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { resolveMember, resolveRoleId, successMessage } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { rolesCmd } from \"../types.js\";\n\nexport const MassAddRoleCmd = rolesCmd({\n  trigger: \"massaddrole\",\n  permission: \"can_mass_assign\",\n\n  signature: {\n    role: ct.string(),\n    members: ct.string({ rest: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    msg.channel.send(`Resolving members...`);\n\n    const authorMember = await resolveMessageMember(msg);\n\n    const members: GuildMember[] = [];\n    const unknownMembers: string[] = [];\n    for (const memberId of args.members) {\n      const member = await resolveMember(pluginData.client, pluginData.guild, memberId);\n      if (member) members.push(member);\n      else unknownMembers.push(memberId);\n    }\n\n    for (const member of members) {\n      if (!canActOn(pluginData, authorMember, member, true)) {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          \"Cannot add roles to 1 or more specified members: insufficient permissions\",\n        );\n        return;\n      }\n    }\n\n    const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role);\n    if (!roleId) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Invalid role id\");\n      return;\n    }\n\n    const config = await pluginData.config.getForMessage(msg);\n    if (!config.assignable_roles.includes(roleId)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"You cannot assign that role\");\n      return;\n    }\n\n    const role = pluginData.guild.roles.cache.get(roleId);\n    if (!role) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Unknown role configured for 'roles' plugin: ${roleId}`,\n      });\n      void pluginData.state.common.sendErrorMessage(msg, \"You cannot assign that role\");\n      return;\n    }\n\n    const membersWithoutTheRole = members.filter((m) => !m.roles.cache.has(roleId));\n    let assigned = 0;\n    const failed: string[] = [];\n    const alreadyHadRole = members.length - membersWithoutTheRole.length;\n\n    msg.channel.send(\n      `Adding role **${role.name}** to ${membersWithoutTheRole.length} ${\n        membersWithoutTheRole.length === 1 ? \"member\" : \"members\"\n      }...`,\n    );\n\n    for (const member of membersWithoutTheRole) {\n      try {\n        pluginData.getPlugin(RoleManagerPlugin).addRole(member.id, roleId);\n        pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({\n          member,\n          roles: [role],\n          mod: msg.author,\n        });\n        assigned++;\n      } catch (e) {\n        logger.warn(`Error when adding role via !massaddrole: ${e.message}`);\n        failed.push(member.id);\n      }\n    }\n\n    let resultMessage = `Added role **${role.name}** to ${assigned} ${assigned === 1 ? \"member\" : \"members\"}!`;\n    if (alreadyHadRole) {\n      resultMessage += ` ${alreadyHadRole} ${alreadyHadRole === 1 ? \"member\" : \"members\"} already had the role.`;\n    }\n\n    if (failed.length) {\n      resultMessage += `\\nFailed to add the role to the following members: ${failed.join(\", \")}`;\n    }\n\n    if (unknownMembers.length) {\n      resultMessage += `\\nUnknown members: ${unknownMembers.join(\", \")}`;\n    }\n\n    msg.channel.send(successMessage(resultMessage));\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { canActOn, resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { resolveMember, resolveRoleId, successMessage } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { rolesCmd } from \"../types.js\";\n\nexport const MassRemoveRoleCmd = rolesCmd({\n  trigger: \"massremoverole\",\n  permission: \"can_mass_assign\",\n\n  signature: {\n    role: ct.string(),\n    members: ct.string({ rest: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    msg.channel.send(`Resolving members...`);\n\n    const authorMember = await resolveMessageMember(msg);\n\n    const members: GuildMember[] = [];\n    const unknownMembers: string[] = [];\n    for (const memberId of args.members) {\n      const member = await resolveMember(pluginData.client, pluginData.guild, memberId);\n      if (member) members.push(member);\n      else unknownMembers.push(memberId);\n    }\n\n    for (const member of members) {\n      if (!canActOn(pluginData, authorMember, member, true)) {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          \"Cannot add roles to 1 or more specified members: insufficient permissions\",\n        );\n        return;\n      }\n    }\n\n    const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role);\n    if (!roleId) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Invalid role id\");\n      return;\n    }\n\n    const config = await pluginData.config.getForMessage(msg);\n    if (!config.assignable_roles.includes(roleId)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"You cannot remove that role\");\n      return;\n    }\n\n    const role = pluginData.guild.roles.cache.get(roleId);\n    if (!role) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Unknown role configured for 'roles' plugin: ${roleId}`,\n      });\n      void pluginData.state.common.sendErrorMessage(msg, \"You cannot remove that role\");\n      return;\n    }\n\n    const membersWithTheRole = members.filter((m) => m.roles.cache.has(roleId));\n    let assigned = 0;\n    const failed: string[] = [];\n    const didNotHaveRole = members.length - membersWithTheRole.length;\n\n    msg.channel.send(\n      `Removing role **${role.name}** from ${membersWithTheRole.length} ${\n        membersWithTheRole.length === 1 ? \"member\" : \"members\"\n      }...`,\n    );\n\n    for (const member of membersWithTheRole) {\n      pluginData.getPlugin(RoleManagerPlugin).removeRole(member.id, roleId);\n      pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({\n        member,\n        roles: [role],\n        mod: msg.author,\n      });\n      assigned++;\n    }\n\n    let resultMessage = `Removed role **${role.name}** from  ${assigned} ${assigned === 1 ? \"member\" : \"members\"}!`;\n    if (didNotHaveRole) {\n      resultMessage += ` ${didNotHaveRole} ${didNotHaveRole === 1 ? \"member\" : \"members\"} didn't have the role.`;\n    }\n\n    if (failed.length) {\n      resultMessage += `\\nFailed to remove the role from the following members: ${failed.join(\", \")}`;\n    }\n\n    if (unknownMembers.length) {\n      resultMessage += `\\nUnknown members: ${unknownMembers.join(\", \")}`;\n    }\n\n    msg.channel.send(successMessage(resultMessage));\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Roles/commands/RemoveRoleCmd.ts",
    "content": "import { GuildChannel } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { canActOn, resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { resolveRoleId, verboseUserMention } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RoleManagerPlugin } from \"../../RoleManager/RoleManagerPlugin.js\";\nimport { rolesCmd } from \"../types.js\";\n\nexport const RemoveRoleCmd = rolesCmd({\n  trigger: \"removerole\",\n  permission: \"can_assign\",\n  description: \"Remove a role from the specified member\",\n\n  signature: {\n    member: ct.resolvedMember(),\n    role: ct.string({ catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const authorMember = await resolveMessageMember(msg);\n    if (!canActOn(pluginData, authorMember, args.member, true)) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        \"Cannot remove roles from this user: insufficient permissions\",\n      );\n      return;\n    }\n\n    const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role);\n    if (!roleId) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Invalid role id\");\n      return;\n    }\n\n    const config = await pluginData.config.getForMessage(msg);\n    if (!config.assignable_roles.includes(roleId)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"You cannot remove that role\");\n      return;\n    }\n\n    // Sanity check: make sure the role is configured properly\n    const role = (msg.channel as GuildChannel).guild.roles.cache.get(roleId);\n    if (!role) {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Unknown role configured for 'roles' plugin: ${roleId}`,\n      });\n      void pluginData.state.common.sendErrorMessage(msg, \"You cannot remove that role\");\n      return;\n    }\n\n    if (!args.member.roles.cache.has(roleId)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Member doesn't have that role\");\n      return;\n    }\n\n    pluginData.getPlugin(RoleManagerPlugin).removeRole(args.member.id, roleId);\n    pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({\n      mod: msg.author,\n      member: args.member,\n      roles: [role],\n    });\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `Removed role **${role.name}** from ${verboseUserMention(args.member.user)}!`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Roles/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zRolesConfig } from \"./types.js\";\n\nexport const rolesPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Roles\",\n  description: trimPluginDescription(`\n    Enables authorised users to add and remove whitelisted roles with a command.\n  `),\n  configSchema: zRolesConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/Roles/types.ts",
    "content": "import { BasePluginType, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zRolesConfig = z.strictObject({\n  can_assign: z.boolean().default(false),\n  can_mass_assign: z.boolean().default(false),\n  assignable_roles: z.array(z.string()).max(100).default([]),\n});\n\nexport interface RolesPluginType extends BasePluginType {\n  configSchema: typeof zRolesConfig;\n  state: {\n    logs: GuildLogs;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const rolesCmd = guildPluginMessageCommand<RolesPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts",
    "content": "import { CooldownManager, guildPlugin } from \"vety\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { RoleAddCmd } from \"./commands/RoleAddCmd.js\";\nimport { RoleHelpCmd } from \"./commands/RoleHelpCmd.js\";\nimport { RoleRemoveCmd } from \"./commands/RoleRemoveCmd.js\";\nimport { SelfGrantableRolesPluginType, zSelfGrantableRolesConfig } from \"./types.js\";\n\nexport const SelfGrantableRolesPlugin = guildPlugin<SelfGrantableRolesPluginType>()({\n  name: \"self_grantable_roles\",\n\n  configSchema: zSelfGrantableRolesConfig,\n\n  // prettier-ignore\n  messageCommands: [\n    RoleHelpCmd,\n    RoleRemoveCmd,\n    RoleAddCmd,\n  ],\n\n  beforeLoad(pluginData) {\n    pluginData.state.cooldowns = new CooldownManager();\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts",
    "content": "import { Role, Snowflake } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { memberRolesLock } from \"../../../utils/lockNameHelpers.js\";\nimport { selfGrantableRolesCmd } from \"../types.js\";\nimport { findMatchingRoles } from \"../util/findMatchingRoles.js\";\nimport { getApplyingEntries } from \"../util/getApplyingEntries.js\";\nimport { normalizeRoleNames } from \"../util/normalizeRoleNames.js\";\nimport { splitRoleNames } from \"../util/splitRoleNames.js\";\n\nexport const RoleAddCmd = selfGrantableRolesCmd({\n  trigger: [\"role\", \"role add\"],\n  permission: null,\n\n  signature: {\n    roleNames: ct.string({ rest: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const lock = await pluginData.locks.acquire(memberRolesLock(msg.author));\n\n    const applyingEntries = await getApplyingEntries(pluginData, msg);\n    if (applyingEntries.length === 0) {\n      lock.unlock();\n      return;\n    }\n\n    const roleNames = normalizeRoleNames(splitRoleNames(args.roleNames));\n    const matchedRoleIds = findMatchingRoles(roleNames, applyingEntries);\n\n    const hasUnknownRoles = matchedRoleIds.length !== roleNames.length;\n\n    const rolesToAdd: Map<string, Role> = Array.from(matchedRoleIds.values())\n      .map((id) => pluginData.guild.roles.cache.get(id as Snowflake)!)\n      .filter(Boolean)\n      .reduce((map, role) => {\n        map.set(role.id, role);\n        return map;\n      }, new Map());\n\n    if (!rolesToAdd.size) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? \"role\" : \"roles\"}`,\n        {\n          users: [msg.author.id],\n        },\n      );\n      lock.unlock();\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n\n    // Grant the roles\n    const newRoleIds = new Set([...rolesToAdd.keys(), ...authorMember.roles.cache.keys()]);\n\n    // Remove extra roles (max_roles) for each entry\n    const skipped: Set<Role> = new Set();\n    const removed: Set<Role> = new Set();\n\n    for (const entry of applyingEntries) {\n      if (entry.max_roles === 0) continue;\n\n      let foundRoles = 0;\n\n      for (const roleId of newRoleIds) {\n        if (entry.roles[roleId]) {\n          if (foundRoles < entry.max_roles) {\n            foundRoles++;\n          } else {\n            newRoleIds.delete(roleId);\n            rolesToAdd.delete(roleId);\n\n            if (authorMember.roles.cache.has(roleId as Snowflake)) {\n              removed.add(pluginData.guild.roles.cache.get(roleId as Snowflake)!);\n            } else {\n              skipped.add(pluginData.guild.roles.cache.get(roleId as Snowflake)!);\n            }\n          }\n        }\n      }\n    }\n\n    try {\n      await authorMember.edit({\n        roles: Array.from(newRoleIds) as Snowflake[],\n      });\n    } catch {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `<@!${msg.author.id}> Got an error while trying to grant you the roles`,\n        {\n          users: [msg.author.id],\n        },\n      );\n      return;\n    }\n\n    const mentionRoles = pluginData.config.get().mention_roles;\n    const addedRolesStr = Array.from(rolesToAdd.values()).map((r) => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`));\n    const addedRolesWord = rolesToAdd.size === 1 ? \"role\" : \"roles\";\n\n    const messageParts: string[] = [];\n    messageParts.push(`Granted you the ${addedRolesStr.join(\", \")} ${addedRolesWord}`);\n\n    if (skipped.size || removed.size) {\n      const skippedRolesStr = skipped.size\n        ? \"skipped \" +\n          Array.from(skipped.values())\n            .map((r) => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`))\n            .join(\",\")\n        : null;\n      const removedRolesStr = removed.size\n        ? \"removed \" + Array.from(removed.values()).map((r) => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`))\n        : null;\n\n      const skippedRemovedStr = [skippedRolesStr, removedRolesStr].filter(Boolean).join(\" and \");\n\n      messageParts.push(`${skippedRemovedStr} due to role limits`);\n    }\n\n    if (hasUnknownRoles) {\n      messageParts.push(\"couldn't recognize some of the roles\");\n    }\n\n    void pluginData.state.common.sendSuccessMessage(msg, `<@!${msg.author.id}> ${messageParts.join(\"; \")}`, {\n      users: [msg.author.id],\n    });\n\n    lock.unlock();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/commands/RoleHelpCmd.ts",
    "content": "import { asSingleLine, trimLines } from \"../../../utils.js\";\nimport { selfGrantableRolesCmd } from \"../types.js\";\nimport { getApplyingEntries } from \"../util/getApplyingEntries.js\";\n\nexport const RoleHelpCmd = selfGrantableRolesCmd({\n  trigger: [\"role help\", \"role\"],\n  permission: null,\n\n  async run({ message: msg, pluginData }) {\n    const applyingEntries = await getApplyingEntries(pluginData, msg);\n    if (applyingEntries.length === 0) return;\n\n    const allPrimaryAliases: string[] = [];\n    for (const entry of applyingEntries) {\n      for (const aliases of Object.values(entry.roles)) {\n        if (aliases[0]) {\n          allPrimaryAliases.push(aliases[0]);\n        }\n      }\n    }\n\n    const prefix = pluginData.fullConfig.prefix;\n    const [firstRole, secondRole] = allPrimaryAliases;\n\n    const help1 = asSingleLine(`\n      To give yourself a role, type e.g. \\`${prefix}role ${firstRole}\\` where **${firstRole}** is the role you want.\n      ${secondRole ? `You can also add multiple roles at once, e.g. \\`${prefix}role ${firstRole} ${secondRole}\\`` : \"\"}\n    `);\n\n    const help2 = asSingleLine(`\n      To remove a role, type \\`${prefix}role remove ${firstRole}\\`,\n      again replacing **${firstRole}** with the role you want to remove.\n    `);\n\n    const helpMessage = trimLines(`\n      ${help1}\n\n      ${help2}\n\n      **Roles available to you:**\n      ${allPrimaryAliases.join(\", \")}\n    `);\n\n    const helpEmbed = {\n      title: \"How to get roles\",\n      description: helpMessage,\n      color: parseInt(\"42bff4\", 16),\n    };\n\n    msg.channel.send({ embeds: [helpEmbed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { memberRolesLock } from \"../../../utils/lockNameHelpers.js\";\nimport { selfGrantableRolesCmd } from \"../types.js\";\nimport { findMatchingRoles } from \"../util/findMatchingRoles.js\";\nimport { getApplyingEntries } from \"../util/getApplyingEntries.js\";\nimport { normalizeRoleNames } from \"../util/normalizeRoleNames.js\";\nimport { splitRoleNames } from \"../util/splitRoleNames.js\";\n\nexport const RoleRemoveCmd = selfGrantableRolesCmd({\n  trigger: \"role remove\",\n  permission: null,\n\n  signature: {\n    roleNames: ct.string({ rest: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const lock = await pluginData.locks.acquire(memberRolesLock(msg.author));\n\n    const applyingEntries = await getApplyingEntries(pluginData, msg);\n    if (applyingEntries.length === 0) {\n      lock.unlock();\n      return;\n    }\n\n    const roleNames = normalizeRoleNames(splitRoleNames(args.roleNames));\n    const matchedRoleIds = findMatchingRoles(roleNames, applyingEntries);\n\n    const rolesToRemove = Array.from(matchedRoleIds.values()).map(\n      (id) => pluginData.guild.roles.cache.get(id as Snowflake)!,\n    );\n    const roleIdsToRemove = rolesToRemove.map((r) => r.id);\n\n    const authorMember = await resolveMessageMember(msg);\n\n    // Remove the roles\n    if (rolesToRemove.length) {\n      const newRoleIds = authorMember.roles.cache.filter((role) => !roleIdsToRemove.includes(role.id));\n\n      try {\n        await authorMember.edit({\n          roles: newRoleIds,\n        });\n\n        const removedRolesStr = rolesToRemove.map((r) => `**${r.name}**`);\n        const removedRolesWord = rolesToRemove.length === 1 ? \"role\" : \"roles\";\n\n        if (rolesToRemove.length !== roleNames.length) {\n          void pluginData.state.common.sendSuccessMessage(\n            msg,\n            `<@!${msg.author.id}> Removed ${removedRolesStr.join(\", \")} ${removedRolesWord};` +\n              ` couldn't recognize the other roles you mentioned`,\n            { users: [msg.author.id] },\n          );\n        } else {\n          void pluginData.state.common.sendSuccessMessage(\n            msg,\n            `<@!${msg.author.id}> Removed ${removedRolesStr.join(\", \")} ${removedRolesWord}`,\n            {\n              users: [msg.author.id],\n            },\n          );\n        }\n      } catch {\n        void pluginData.state.common.sendSuccessMessage(\n          msg,\n          `<@!${msg.author.id}> Got an error while trying to remove the roles`,\n          {\n            users: [msg.author.id],\n          },\n        );\n      }\n    } else {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? \"role\" : \"roles\"}`,\n        {\n          users: [msg.author.id],\n        },\n      );\n    }\n\n    lock.unlock();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zSelfGrantableRolesConfig } from \"./types.js\";\n\nexport const selfGrantableRolesPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Self-grantable roles\",\n  description: trimPluginDescription(`\n          Allows users to grant themselves roles via a command\n      `),\n  configurationGuide: trimPluginDescription(`\n    ### Basic configuration\n    In this example, users can add themselves platform roles on the channel 473087035574321152 by using the\n    \\`!role\\` command. For example, \\`!role pc ps4\\` to add both the \"pc\" and \"ps4\" roles as specified below.\n\n    ~~~yml\n    self_grantable_roles:\n      config:\n        entries:\n          basic:\n            roles:\n              \"543184300250759188\": [\"pc\", \"computer\"]\n              \"534710505915547658\": [\"ps4\", \"ps\", \"playstation\"]\n              \"473085927053590538\": [\"xbox\", \"xb1\", \"xb\"]\n      overrides:\n        - channel: \"473087035574321152\"\n          config:\n            entries:\n              basic:\n                can_use: true\n    ~~~\n\n    ### Maximum number of roles\n    This is identical to the basic example above, but users can only choose 1 role.\n\n    ~~~yml\n    self_grantable_roles:\n      config:\n        entries:\n          basic:\n            roles:\n              \"543184300250759188\": [\"pc\", \"computer\"]\n              \"534710505915547658\": [\"ps4\", \"ps\", \"playstation\"]\n              \"473085927053590538\": [\"xbox\", \"xb1\", \"xb\"]\n            max_roles: 1\n      overrides:\n        - channel: \"473087035574321152\"\n          config:\n            entries:\n              basic:\n                can_use: true\n    ~~~\n  `),\n  configSchema: zSelfGrantableRolesConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/types.ts",
    "content": "import { BasePluginType, CooldownManager, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { zBoundedCharacters, zBoundedRecord } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nconst zRoleMap = z.record(\n  zBoundedCharacters(1, 100),\n  z\n    .array(zBoundedCharacters(1, 2000))\n    .max(100)\n    .transform((parsed) => parsed.map((v) => v.toLowerCase())),\n);\n\nconst zSelfGrantableRoleEntry = z.strictObject({\n  roles: zBoundedRecord(zRoleMap, 0, 100),\n  can_use: z.boolean().default(false),\n  can_ignore_cooldown: z.boolean().default(false),\n  max_roles: z.number().default(0),\n});\nexport type TSelfGrantableRoleEntry = z.infer<typeof zSelfGrantableRoleEntry>;\n\nexport const zSelfGrantableRolesConfig = z.strictObject({\n  entries: zBoundedRecord(z.record(zBoundedCharacters(0, 255), zSelfGrantableRoleEntry), 0, 100).default({}),\n  mention_roles: z.boolean().default(false),\n});\n\nexport interface SelfGrantableRolesPluginType extends BasePluginType {\n  configSchema: typeof zSelfGrantableRolesConfig;\n  state: {\n    cooldowns: CooldownManager;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const selfGrantableRolesCmd = guildPluginMessageCommand<SelfGrantableRolesPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/util/findMatchingRoles.ts",
    "content": "import { TSelfGrantableRoleEntry } from \"../types.js\";\n\nexport function findMatchingRoles(roleNames: string[], entries: TSelfGrantableRoleEntry[]): string[] {\n  const aliasToRoleId = entries.reduce((map, entry) => {\n    for (const [roleId, aliases] of Object.entries(entry.roles)) {\n      for (const alias of aliases) {\n        map.set(alias, roleId);\n      }\n    }\n\n    return map;\n  }, new Map());\n\n  return roleNames.map((roleName) => aliasToRoleId.get(roleName)).filter(Boolean);\n}\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/util/getApplyingEntries.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SelfGrantableRolesPluginType, TSelfGrantableRoleEntry } from \"../types.js\";\n\nexport async function getApplyingEntries(\n  pluginData: GuildPluginData<SelfGrantableRolesPluginType>,\n  msg,\n): Promise<TSelfGrantableRoleEntry[]> {\n  const config = await pluginData.config.getForMessage(msg);\n  return Object.entries(config.entries)\n    .filter(\n      ([k, e]) =>\n        e.can_use && !(!e.can_ignore_cooldown && pluginData.state.cooldowns.isOnCooldown(`${k}:${msg.author.id}`)),\n    )\n    .map((pair) => pair[1]);\n}\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/util/normalizeRoleNames.ts",
    "content": "export function normalizeRoleNames(roleNames: string[]) {\n  return roleNames.map((v) => v.toLowerCase());\n}\n"
  },
  {
    "path": "backend/src/plugins/SelfGrantableRoles/util/splitRoleNames.ts",
    "content": "export function splitRoleNames(roleNames: string[]) {\n  return roleNames\n    .map((v) => v.split(/[\\s,]+/))\n    .flat()\n    .filter(Boolean);\n}\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/SlowmodePlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { GuildSlowmodes } from \"../../data/GuildSlowmodes.js\";\nimport { SECONDS } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { SlowmodeClearCmd } from \"./commands/SlowmodeClearCmd.js\";\nimport { SlowmodeDisableCmd } from \"./commands/SlowmodeDisableCmd.js\";\nimport { SlowmodeGetCmd } from \"./commands/SlowmodeGetCmd.js\";\nimport { SlowmodeListCmd } from \"./commands/SlowmodeListCmd.js\";\nimport { SlowmodeSetCmd } from \"./commands/SlowmodeSetCmd.js\";\nimport { SlowmodePluginType, zSlowmodeConfig } from \"./types.js\";\nimport { clearExpiredSlowmodes } from \"./util/clearExpiredSlowmodes.js\";\nimport { onMessageCreate } from \"./util/onMessageCreate.js\";\n\nconst BOT_SLOWMODE_CLEAR_INTERVAL = 60 * SECONDS;\n\nexport const SlowmodePlugin = guildPlugin<SlowmodePluginType>()({\n  name: \"slowmode\",\n\n  // prettier-ignore\n  dependencies: () => [\n    LogsPlugin,\n  ],\n\n  configSchema: zSlowmodeConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_manage: true,\n        is_affected: false,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    SlowmodeDisableCmd,\n    SlowmodeClearCmd,\n    SlowmodeListCmd,\n    SlowmodeGetCmd,\n    SlowmodeSetCmd,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.slowmodes = GuildSlowmodes.getGuildInstance(guild.id);\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.logs = new GuildLogs(guild.id);\n    state.channelSlowmodeCache = new Map();\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.serverLogs = new GuildLogs(pluginData.guild.id);\n    state.clearInterval = setInterval(() => clearExpiredSlowmodes(pluginData), BOT_SLOWMODE_CLEAR_INTERVAL);\n\n    state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg);\n    state.savedMessages.events.on(\"create\", state.onMessageCreateFn);\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.savedMessages.events.off(\"create\", state.onMessageCreateFn);\n    clearInterval(state.clearInterval);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts",
    "content": "import { ChannelType, escapeInlineCode } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { asSingleLine, renderUsername } from \"../../../utils.js\";\nimport { getMissingChannelPermissions } from \"../../../utils/getMissingChannelPermissions.js\";\nimport { missingPermissionError } from \"../../../utils/missingPermissionError.js\";\nimport { BOT_SLOWMODE_CLEAR_PERMISSIONS } from \"../requiredPermissions.js\";\nimport { slowmodeCmd } from \"../types.js\";\nimport { clearBotSlowmodeFromUserId } from \"../util/clearBotSlowmodeFromUserId.js\";\n\nexport const SlowmodeClearCmd = slowmodeCmd({\n  trigger: [\"slowmode clear\", \"slowmode c\"],\n  permission: \"can_manage\",\n\n  signature: {\n    channel: ct.textChannel(),\n    user: ct.resolvedUserLoose(),\n\n    force: ct.bool({ option: true, isSwitch: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id);\n    if (!channelSlowmode) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Channel doesn't have slowmode!\");\n      return;\n    }\n\n    const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;\n    const missingPermissions = getMissingChannelPermissions(me, args.channel, BOT_SLOWMODE_CLEAR_PERMISSIONS);\n    if (missingPermissions) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `Unable to clear slowmode. ${missingPermissionError(missingPermissions)}`,\n      );\n      return;\n    }\n\n    try {\n      if (args.channel.type === ChannelType.GuildText) {\n        await clearBotSlowmodeFromUserId(pluginData, args.channel, args.user.id, args.force);\n      } else {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          asSingleLine(`\n            Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>:\n            Threads cannot have Bot Slowmode\n          `),\n        );\n        return;\n      }\n    } catch (e) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        asSingleLine(`\n          Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>:\n          \\`${escapeInlineCode(e.message)}\\`\n        `),\n      );\n      return;\n    }\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `Slowmode cleared from **${renderUsername(args.user)}** in <#${args.channel.id}>`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { slowmodeCmd } from \"../types.js\";\nimport { actualDisableSlowmodeCmd } from \"../util/actualDisableSlowmodeCmd.js\";\n\nexport const SlowmodeDisableCmd = slowmodeCmd({\n  trigger: [\"slowmode disable\", \"slowmode d\"],\n  permission: \"can_manage\",\n\n  signature: {\n    channel: ct.textChannel(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    // Workaround until you can call this cmd from SlowmodeSetChannelCmd\n    actualDisableSlowmodeCmd(msg, args, pluginData);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/commands/SlowmodeGetCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { slowmodeCmd } from \"../types.js\";\n\nexport const SlowmodeGetCmd = slowmodeCmd({\n  trigger: \"slowmode\",\n  permission: \"can_manage\",\n  source: \"guild\",\n\n  signature: {\n    channel: ct.textChannel({ option: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const channel = args.channel || msg.channel;\n\n    let currentSlowmode = channel.rateLimitPerUser;\n    let isNative = true;\n\n    if (!currentSlowmode) {\n      const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id);\n      if (botSlowmode) {\n        currentSlowmode = botSlowmode.slowmode_seconds;\n        isNative = false;\n      }\n    }\n\n    if (currentSlowmode) {\n      const humanized = humanizeDuration(currentSlowmode * 1000);\n      const slowmodeType = isNative ? \"native\" : \"bot-maintained\";\n      msg.channel.send(`The current slowmode of <#${channel.id}> is **${humanized}** (${slowmodeType})`);\n    } else {\n      msg.channel.send(\"Channel is not on slowmode\");\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts",
    "content": "import { GuildChannel, TextChannel } from \"discord.js\";\nimport { createChunkedMessage } from \"vety/helpers\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { errorMessage } from \"../../../utils.js\";\nimport { slowmodeCmd } from \"../types.js\";\n\nexport const SlowmodeListCmd = slowmodeCmd({\n  trigger: [\"slowmode list\", \"slowmode l\", \"slowmodes\"],\n  permission: \"can_manage\",\n\n  async run({ message: msg, pluginData }) {\n    const channels = pluginData.guild.channels;\n    const slowmodes: Array<{ channel: GuildChannel; seconds: number; native: boolean }> = [];\n\n    for (const channel of channels.cache.values()) {\n      if (!(channel instanceof TextChannel)) continue;\n\n      // Bot slowmode\n      const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id);\n      if (botSlowmode) {\n        slowmodes.push({ channel, seconds: botSlowmode.slowmode_seconds, native: false });\n        continue;\n      }\n\n      // Native slowmode\n      if (channel.rateLimitPerUser) {\n        slowmodes.push({ channel, seconds: channel.rateLimitPerUser, native: true });\n        continue;\n      }\n    }\n\n    if (slowmodes.length) {\n      const lines = slowmodes.map((slowmode) => {\n        const humanized = humanizeDuration(slowmode.seconds * 1000);\n\n        const type = slowmode.native ? \"native slowmode\" : \"bot slowmode\";\n\n        return `<#${slowmode.channel.id}> **${humanized}** ${type}`;\n      });\n\n      createChunkedMessage(msg.channel, lines.join(\"\\n\"));\n    } else {\n      msg.channel.send(errorMessage(\"No active slowmodes!\"));\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts",
    "content": "import { escapeInlineCode, PermissionsBitField } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { asSingleLine, DAYS, HOURS, MINUTES } from \"../../../utils.js\";\nimport { getMissingPermissions } from \"../../../utils/getMissingPermissions.js\";\nimport { missingPermissionError } from \"../../../utils/missingPermissionError.js\";\nimport { BOT_SLOWMODE_PERMISSIONS, NATIVE_SLOWMODE_PERMISSIONS } from \"../requiredPermissions.js\";\nimport { slowmodeCmd } from \"../types.js\";\nimport { actualDisableSlowmodeCmd } from \"../util/actualDisableSlowmodeCmd.js\";\nimport { disableBotSlowmodeForChannel } from \"../util/disableBotSlowmodeForChannel.js\";\n\nconst MAX_NATIVE_SLOWMODE = 6 * HOURS; // 6 hours\nconst MAX_BOT_SLOWMODE = DAYS * 365 * 100; // 100 years\nconst MIN_BOT_SLOWMODE = 15 * MINUTES;\n\nconst validModes = [\"bot\", \"native\"];\ntype TMode = \"bot\" | \"native\";\n\nexport const SlowmodeSetCmd = slowmodeCmd({\n  trigger: \"slowmode\",\n  permission: \"can_manage\",\n  source: \"guild\",\n\n  signature: [\n    {\n      time: ct.delay(),\n\n      mode: ct.string({ option: true, shortcut: \"m\" }),\n    },\n    {\n      channel: ct.textChannel(),\n      time: ct.delay(),\n\n      mode: ct.string({ option: true, shortcut: \"m\" }),\n    },\n  ],\n\n  async run({ message: msg, args, pluginData }) {\n    const channel = args.channel || msg.channel;\n\n    if (!channel.isTextBased() || channel.isThread()) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Slowmode can only be set on non-thread text-based channels\");\n      return;\n    }\n\n    if (args.time === 0) {\n      // Workaround until we can call SlowmodeDisableCmd from here\n      return actualDisableSlowmodeCmd(msg, { channel }, pluginData);\n    }\n\n    const defaultMode: TMode =\n      (await pluginData.config.getForChannel(channel)).use_native_slowmode && args.time <= MAX_NATIVE_SLOWMODE\n        ? \"native\"\n        : \"bot\";\n\n    const mode = (args.mode as TMode) || defaultMode;\n    if (!validModes.includes(mode)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"--mode must be 'bot' or 'native'\");\n      return;\n    }\n\n    // Validate durations\n    if (mode === \"native\" && args.time > MAX_NATIVE_SLOWMODE) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Native slowmode can only be set to 6h or less\");\n      return;\n    }\n\n    if (mode === \"bot\" && args.time > MAX_BOT_SLOWMODE) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `Sorry, bot managed slowmodes can be at most 100 years long. Maybe 99 would be enough?`,\n      );\n      return;\n    }\n\n    if (mode === \"bot\" && args.time < MIN_BOT_SLOWMODE) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        asSingleLine(`\n          Bot managed slowmode must be 15min or more.\n          Use \\`--mode native\\` to use native slowmodes for short slowmodes instead.\n        `),\n      );\n      return;\n    }\n\n    // Verify permissions\n    const channelPermissions = channel.permissionsFor(pluginData.client.user!.id);\n\n    if (mode === \"native\") {\n      const missingPermissions = getMissingPermissions(\n        channelPermissions ?? new PermissionsBitField(),\n        NATIVE_SLOWMODE_PERMISSIONS,\n      );\n      if (missingPermissions) {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          `Unable to set native slowmode. ${missingPermissionError(missingPermissions)}`,\n        );\n        return;\n      }\n    }\n\n    if (mode === \"bot\") {\n      const missingPermissions = getMissingPermissions(\n        channelPermissions ?? new PermissionsBitField(),\n        BOT_SLOWMODE_PERMISSIONS,\n      );\n      if (missingPermissions) {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          `Unable to set bot managed slowmode. ${missingPermissionError(missingPermissions)}`,\n        );\n        return;\n      }\n    }\n\n    // Apply the slowmode!\n    const rateLimitSeconds = Math.ceil(args.time / 1000);\n\n    if (mode === \"native\") {\n      // If there is an existing bot-maintained slowmode, disable that first\n      const existingBotSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id);\n      if (existingBotSlowmode && channel.isTextBased()) {\n        await disableBotSlowmodeForChannel(pluginData, channel);\n      }\n\n      // Set native slowmode\n      try {\n        await channel.setRateLimitPerUser(rateLimitSeconds);\n      } catch (e) {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          `Failed to set native slowmode: ${escapeInlineCode(e.message)}`,\n        );\n        return;\n      }\n    } else {\n      // If there is an existing native slowmode, disable that first\n      if (channel.rateLimitPerUser) {\n        await channel.setRateLimitPerUser(0);\n      }\n\n      // Set bot-maintained slowmode\n      await pluginData.state.slowmodes.setChannelSlowmode(channel.id, rateLimitSeconds);\n\n      // Update cache\n      const slowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id);\n      pluginData.state.channelSlowmodeCache.set(channel.id, slowmode ?? null);\n    }\n\n    const humanizedSlowmodeTime = humanizeDuration(args.time);\n    const slowmodeType = mode === \"native\" ? \"native slowmode\" : \"bot-maintained slowmode\";\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `Set ${humanizedSlowmodeTime} slowmode for <#${channel.id}> (${slowmodeType})`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zSlowmodeConfig } from \"./types.js\";\n\nexport const slowmodePluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Slowmode\",\n  configSchema: zSlowmodeConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/requiredPermissions.ts",
    "content": "import { PermissionsBitField } from \"discord.js\";\n\nconst p = PermissionsBitField.Flags;\n\nexport const NATIVE_SLOWMODE_PERMISSIONS = p.ViewChannel | p.ManageChannels;\nexport const BOT_SLOWMODE_PERMISSIONS = p.ViewChannel | p.ManageRoles | p.ManageMessages;\nexport const BOT_SLOWMODE_CLEAR_PERMISSIONS = p.ViewChannel | p.ManageRoles;\nexport const BOT_SLOWMODE_DISABLE_PERMISSIONS = p.ViewChannel | p.ManageRoles;\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { GuildSlowmodes } from \"../../data/GuildSlowmodes.js\";\nimport { SlowmodeChannel } from \"../../data/entities/SlowmodeChannel.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zSlowmodeConfig = z.strictObject({\n  use_native_slowmode: z.boolean().default(true),\n\n  can_manage: z.boolean().default(false),\n  is_affected: z.boolean().default(true),\n});\n\nexport interface SlowmodePluginType extends BasePluginType {\n  configSchema: typeof zSlowmodeConfig;\n  state: {\n    slowmodes: GuildSlowmodes;\n    savedMessages: GuildSavedMessages;\n    logs: GuildLogs;\n    clearInterval: NodeJS.Timeout;\n    serverLogs: GuildLogs;\n    channelSlowmodeCache: Map<string, SlowmodeChannel | null>;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n\n    onMessageCreateFn;\n  };\n}\n\nexport const slowmodeCmd = guildPluginMessageCommand<SlowmodePluginType>();\nexport const slowmodeEvt = guildPluginEventListener<SlowmodePluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts",
    "content": "import { Message } from \"discord.js\";\nimport { noop } from \"../../../utils.js\";\nimport { getMissingChannelPermissions } from \"../../../utils/getMissingChannelPermissions.js\";\nimport { missingPermissionError } from \"../../../utils/missingPermissionError.js\";\nimport { BOT_SLOWMODE_DISABLE_PERMISSIONS } from \"../requiredPermissions.js\";\nimport { disableBotSlowmodeForChannel } from \"./disableBotSlowmodeForChannel.js\";\n\nexport async function actualDisableSlowmodeCmd(msg: Message, args, pluginData) {\n  const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id);\n  const hasNativeSlowmode = args.channel.rateLimitPerUser;\n\n  if (!botSlowmode && hasNativeSlowmode === 0) {\n    void pluginData.state.common.sendErrorMessage(msg, \"Channel is not on slowmode!\");\n    return;\n  }\n\n  const me = pluginData.guild.members.cache.get(pluginData.client.user!.id);\n  const missingPermissions = getMissingChannelPermissions(me, args.channel, BOT_SLOWMODE_DISABLE_PERMISSIONS);\n  if (missingPermissions) {\n    void pluginData.state.common.sendErrorMessage(\n      msg,\n      `Unable to disable slowmode. ${missingPermissionError(missingPermissions)}`,\n    );\n    return;\n  }\n\n  const initMsg = await msg.reply(\"Disabling slowmode...\");\n\n  // Disable bot-maintained slowmode\n  let failedUsers: string[] = [];\n  if (botSlowmode) {\n    const result = await disableBotSlowmodeForChannel(pluginData, args.channel);\n    failedUsers = result.failedUsers;\n  }\n\n  // Disable native slowmode\n  if (hasNativeSlowmode) {\n    await args.channel.edit({ rateLimitPerUser: 0 });\n  }\n\n  if (failedUsers.length) {\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `Slowmode disabled! Failed to clear slowmode from the following users:\\n\\n<@!${failedUsers.join(\">\\n<@!\")}>`,\n    );\n  } else {\n    void pluginData.state.common.sendSuccessMessage(msg, \"Slowmode disabled!\");\n    initMsg.delete().catch(noop);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts",
    "content": "import { GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { logger } from \"../../../logger.js\";\nimport { UnknownUser, isDiscordAPIError, verboseChannelMention, verboseUserMention } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { SlowmodePluginType } from \"../types.js\";\n\nexport async function applyBotSlowmodeToUserId(\n  pluginData: GuildPluginData<SlowmodePluginType>,\n  channel: GuildTextBasedChannel,\n  userId: string,\n) {\n  // FIXME: Is there a better way to do this?\n  if (channel.isThread()) return;\n\n  // Deny sendMessage permission from the user. If there are existing permission overwrites, take those into account.\n  const existingOverride = channel.permissionOverwrites?.resolve(userId as Snowflake);\n  try {\n    pluginData.state.serverLogs.ignoreLog(LogType.CHANNEL_UPDATE, channel.id, 5 * 1000);\n    if (existingOverride) {\n      await existingOverride.edit({ SendMessages: false });\n    } else {\n      await channel.permissionOverwrites?.create(userId as Snowflake, { SendMessages: false }, { type: 1 });\n    }\n  } catch (e) {\n    const user = await pluginData.client.users.fetch(userId as Snowflake).catch(() => new UnknownUser({ id: userId }));\n\n    if (isDiscordAPIError(e) && e.code === 50013) {\n      logger.warn(\n        `Missing permissions to apply bot slowmode to user ${userId} on channel ${channel.name} (${channel.id}) on server ${pluginData.guild.name} (${pluginData.guild.id})`,\n      );\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Missing permissions to apply bot slowmode to ${verboseUserMention(user)} in ${verboseChannelMention(\n          channel,\n        )}`,\n      });\n    } else {\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Failed to apply bot slowmode to ${verboseUserMention(user)} in ${verboseChannelMention(channel)}`,\n      });\n      throw e;\n    }\n  }\n\n  await pluginData.state.slowmodes.addSlowmodeUser(channel.id, userId);\n}\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts",
    "content": "import { AnyThreadChannel, GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { SlowmodePluginType } from \"../types.js\";\n\nexport async function clearBotSlowmodeFromUserId(\n  pluginData: GuildPluginData<SlowmodePluginType>,\n  channel: Exclude<GuildTextBasedChannel, AnyThreadChannel>,\n  userId: string,\n  force = false,\n) {\n  try {\n    // Remove permission overrides from the channel for this user\n    // Previously we diffed the overrides so we could clear the \"send messages\" override without touching other\n    // overrides. Unfortunately, it seems that was a bit buggy - we didn't always receive the event for the changed\n    // overrides and then we also couldn't diff against them. For consistency's sake, we just delete the override now.\n    pluginData.state.serverLogs.ignoreLog(LogType.CHANNEL_UPDATE, channel.id, 3 * 1000);\n    await channel.permissionOverwrites.resolve(userId as Snowflake)?.delete();\n  } catch (e) {\n    if (!force) {\n      throw e;\n    }\n  }\n\n  await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, userId);\n}\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts",
    "content": "import { GuildChannel, Snowflake, TextChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { logger } from \"../../../logger.js\";\nimport { UnknownUser, verboseChannelMention, verboseUserMention } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { SlowmodePluginType } from \"../types.js\";\nimport { clearBotSlowmodeFromUserId } from \"./clearBotSlowmodeFromUserId.js\";\n\nexport async function clearExpiredSlowmodes(pluginData: GuildPluginData<SlowmodePluginType>) {\n  const expiredSlowmodeUsers = await pluginData.state.slowmodes.getExpiredSlowmodeUsers();\n  for (const user of expiredSlowmodeUsers) {\n    const channel = pluginData.guild.channels.cache.get(user.channel_id as Snowflake);\n    if (!channel) {\n      await pluginData.state.slowmodes.clearSlowmodeUser(user.channel_id, user.user_id);\n      continue;\n    }\n\n    try {\n      await clearBotSlowmodeFromUserId(pluginData, channel as GuildChannel & TextChannel, user.user_id);\n    } catch (e) {\n      logger.error(e);\n\n      const realUser = await pluginData.client\n        .users!.fetch(user.user_id as Snowflake)\n        .catch(() => new UnknownUser({ id: user.user_id }));\n\n      pluginData.getPlugin(LogsPlugin).logBotAlert({\n        body: `Failed to clear slowmode permissions from ${verboseUserMention(\n          await realUser,\n        )} in ${verboseChannelMention(channel)}`,\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts",
    "content": "import { AnyThreadChannel, GuildTextBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SlowmodePluginType } from \"../types.js\";\nimport { clearBotSlowmodeFromUserId } from \"./clearBotSlowmodeFromUserId.js\";\n\nexport async function disableBotSlowmodeForChannel(\n  pluginData: GuildPluginData<SlowmodePluginType>,\n  channel: Exclude<GuildTextBasedChannel, AnyThreadChannel>,\n) {\n  // Disable channel slowmode\n  await pluginData.state.slowmodes.deleteChannelSlowmode(channel.id);\n\n  // Remove currently applied slowmodes\n  const users = await pluginData.state.slowmodes.getChannelSlowmodeUsers(channel.id);\n  const failedUsers: string[] = [];\n\n  for (const slowmodeUser of users) {\n    try {\n      await clearBotSlowmodeFromUserId(pluginData, channel, slowmodeUser.user_id);\n    } catch {\n      // Removing the slowmode failed. Record this so the permissions can be changed manually, and remove the database entry.\n      failedUsers.push(slowmodeUser.user_id);\n      await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, slowmodeUser.user_id);\n    }\n  }\n\n  // Clear cache\n  pluginData.state.channelSlowmodeCache.set(channel.id, null);\n\n  return { failedUsers };\n}\n"
  },
  {
    "path": "backend/src/plugins/Slowmode/util/onMessageCreate.ts",
    "content": "import { ChannelType, GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { SlowmodeChannel } from \"../../../data/entities/SlowmodeChannel.js\";\nimport { hasPermission } from \"../../../pluginUtils.js\";\nimport { resolveMember } from \"../../../utils.js\";\nimport { getMissingChannelPermissions } from \"../../../utils/getMissingChannelPermissions.js\";\nimport { messageLock } from \"../../../utils/lockNameHelpers.js\";\nimport { missingPermissionError } from \"../../../utils/missingPermissionError.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { BOT_SLOWMODE_PERMISSIONS } from \"../requiredPermissions.js\";\nimport { SlowmodePluginType } from \"../types.js\";\nimport { applyBotSlowmodeToUserId } from \"./applyBotSlowmodeToUserId.js\";\n\nexport async function onMessageCreate(pluginData: GuildPluginData<SlowmodePluginType>, msg: SavedMessage) {\n  if (msg.is_bot) return;\n\n  const channel = pluginData.guild.channels.cache.get(msg.channel_id as Snowflake) as GuildTextBasedChannel;\n  if (!channel?.isTextBased() || channel.type === ChannelType.GuildStageVoice) return;\n\n  // Don't apply slowmode if the lock was interrupted earlier (e.g. the message was caught by word filters)\n  const thisMsgLock = await pluginData.locks.acquire(messageLock(msg));\n  if (thisMsgLock.interrupted) return;\n\n  // Check if this channel even *has* a bot-maintained slowmode\n  let channelSlowmode: SlowmodeChannel | null;\n  if (pluginData.state.channelSlowmodeCache.has(channel.id)) {\n    channelSlowmode = pluginData.state.channelSlowmodeCache.get(channel.id) ?? null;\n  } else {\n    channelSlowmode = (await pluginData.state.slowmodes.getChannelSlowmode(channel.id)) ?? null;\n    pluginData.state.channelSlowmodeCache.set(channel.id, channelSlowmode);\n  }\n  if (!channelSlowmode) {\n    return thisMsgLock.unlock();\n  }\n\n  // Make sure this user is affected by the slowmode\n  const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);\n  const isAffected = await hasPermission(pluginData, \"is_affected\", {\n    channelId: channel.id,\n    userId: msg.user_id,\n    member,\n  });\n  if (!isAffected) {\n    return thisMsgLock.unlock();\n  }\n\n  // Make sure we have the appropriate permissions to manage this slowmode\n  const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;\n  const missingPermissions = getMissingChannelPermissions(me, channel, BOT_SLOWMODE_PERMISSIONS);\n  if (missingPermissions) {\n    const logs = pluginData.getPlugin(LogsPlugin);\n    logs.logBotAlert({\n      body: `Unable to manage bot slowmode in <#${channel.id}>. ${missingPermissionError(missingPermissions)}`,\n    });\n    return;\n  }\n\n  // Delete any extra messages sent after a slowmode was already applied\n  const userHasSlowmode = await pluginData.state.slowmodes.userHasSlowmode(channel.id, msg.user_id);\n  if (userHasSlowmode) {\n    try {\n      // FIXME: Debug\n      // tslint:disable-next-line:no-console\n      console.log(\n        `[DEBUG] [SLOWMODE] Deleting message ${msg.id} from channel ${channel.id} in guild ${pluginData.guild.id}`,\n      );\n      await channel.messages.delete(msg.id);\n      thisMsgLock.interrupt();\n    } catch (err) {\n      thisMsgLock.unlock();\n    }\n\n    return;\n  }\n\n  await applyBotSlowmodeToUserId(pluginData, channel, msg.user_id);\n  thisMsgLock.unlock();\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/SpamPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildMutes } from \"../../data/GuildMutes.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { SpamVoiceStateUpdateEvt } from \"./events/SpamVoiceEvt.js\";\nimport { SpamPluginType, zSpamConfig } from \"./types.js\";\nimport { clearOldRecentActions } from \"./util/clearOldRecentActions.js\";\nimport { onMessageCreate } from \"./util/onMessageCreate.js\";\n\nexport const SpamPlugin = guildPlugin<SpamPluginType>()({\n  name: \"spam\",\n\n  dependencies: () => [LogsPlugin],\n  configSchema: zSpamConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        max_messages: null,\n        max_mentions: null,\n        max_links: null,\n        max_attachments: null,\n        max_emojis: null,\n        max_newlines: null,\n        max_duplicates: null,\n        max_characters: null,\n        max_voice_moves: null,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  events: [\n    SpamVoiceStateUpdateEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.logs = new GuildLogs(guild.id);\n    state.archives = GuildArchives.getGuildInstance(guild.id);\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.mutes = GuildMutes.getGuildInstance(guild.id);\n\n    state.recentActions = [];\n    state.lastHandledMsgIds = new Map();\n\n    state.spamDetectionQueue = Promise.resolve();\n  },\n\n  afterLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.expiryInterval = setInterval(() => clearOldRecentActions(pluginData), 1000 * 60);\n    state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg);\n    state.savedMessages.events.on(\"create\", state.onMessageCreateFn);\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.savedMessages.events.off(\"create\", state.onMessageCreateFn);\n    clearInterval(state.expiryInterval);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Spam/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zSpamConfig } from \"./types.js\";\n\nexport const spamPluginDocs: ZeppelinPluginDocs = {\n  type: \"legacy\",\n  prettyName: \"Spam protection\",\n  description: trimPluginDescription(`\n    Basic spam detection and auto-muting.\n    For more advanced spam filtering, check out the Automod plugin!\n  `),\n  configSchema: zSpamConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/Spam/events/SpamVoiceEvt.ts",
    "content": "import { RecentActionType, spamEvt } from \"../types.js\";\nimport { logAndDetectOtherSpam } from \"../util/logAndDetectOtherSpam.js\";\n\nexport const SpamVoiceStateUpdateEvt = spamEvt({\n  event: \"voiceStateUpdate\",\n\n  async listener(meta) {\n    const member = meta.args.newState.member;\n    if (!member) return;\n    const channel = meta.args.newState.channel;\n    if (!channel) return;\n    if (channel.id === meta.args.oldState?.channelId) return;\n\n    const config = await meta.pluginData.config.getMatchingConfig({ member, channelId: channel.id });\n    const maxVoiceMoves = config.max_voice_moves;\n    if (maxVoiceMoves) {\n      logAndDetectOtherSpam(\n        meta.pluginData,\n        RecentActionType.VoiceChannelMove,\n        maxVoiceMoves,\n        member.id,\n        1,\n        \"0\",\n        Date.now(),\n        null,\n        \"too many voice channel moves\",\n      );\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Spam/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildMutes } from \"../../data/GuildMutes.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { zSnowflake } from \"../../utils.js\";\n\nconst zBaseSingleSpamConfig = z.strictObject({\n  interval: z.number(),\n  count: z.number(),\n  mute: z.boolean().default(false),\n  mute_time: z.number().nullable().default(null),\n  remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),\n  restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),\n  clean: z.boolean().default(false),\n});\nexport type TBaseSingleSpamConfig = z.infer<typeof zBaseSingleSpamConfig>;\n\nexport const zSpamConfig = z.strictObject({\n  max_censor: zBaseSingleSpamConfig.nullable().default(null),\n  max_messages: zBaseSingleSpamConfig.nullable().default(null),\n  max_mentions: zBaseSingleSpamConfig.nullable().default(null),\n  max_links: zBaseSingleSpamConfig.nullable().default(null),\n  max_attachments: zBaseSingleSpamConfig.nullable().default(null),\n  max_emojis: zBaseSingleSpamConfig.nullable().default(null),\n  max_newlines: zBaseSingleSpamConfig.nullable().default(null),\n  max_duplicates: zBaseSingleSpamConfig.nullable().default(null),\n  max_characters: zBaseSingleSpamConfig.nullable().default(null),\n  max_voice_moves: zBaseSingleSpamConfig.nullable().default(null),\n});\n\nexport enum RecentActionType {\n  Message = 1,\n  Mention,\n  Link,\n  Attachment,\n  Emoji,\n  Newline,\n  Censor,\n  Character,\n  VoiceChannelMove,\n}\n\nexport interface IRecentAction<T> {\n  type: RecentActionType;\n  userId: string;\n  actionGroupId: string;\n  extraData: T;\n  timestamp: number;\n  count: number;\n}\n\nexport interface SpamPluginType extends BasePluginType {\n  configSchema: typeof zSpamConfig;\n  state: {\n    logs: GuildLogs;\n    archives: GuildArchives;\n    savedMessages: GuildSavedMessages;\n    mutes: GuildMutes;\n\n    onMessageCreateFn;\n\n    // Handle spam detection with a queue so we don't have overlapping detections on the same user\n    spamDetectionQueue: Promise<void>;\n\n    // List of recent potentially-spammy actions\n    recentActions: Array<IRecentAction<any>>;\n\n    // A map of userId => channelId => msgId\n    // Keeps track of the last handled (= spam detected and acted on) message ID per user, per channel\n    // TODO: Prevent this from growing infinitely somehow\n    lastHandledMsgIds: Map<string, Map<string, string>>;\n\n    expiryInterval;\n  };\n}\n\nexport const spamEvt = guildPluginEventListener<SpamPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/addRecentAction.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { RecentActionType, SpamPluginType } from \"../types.js\";\n\nexport function addRecentAction(\n  pluginData: GuildPluginData<SpamPluginType>,\n  type: RecentActionType,\n  userId: string,\n  actionGroupId: string,\n  extraData: any,\n  timestamp: number,\n  count = 1,\n) {\n  pluginData.state.recentActions.push({ type, userId, actionGroupId, extraData, timestamp, count });\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/clearOldRecentActions.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SpamPluginType } from \"../types.js\";\n\nconst MAX_INTERVAL = 300;\n\nexport function clearOldRecentActions(pluginData: GuildPluginData<SpamPluginType>) {\n  // TODO: Figure out expiry time from longest interval in the config?\n  const expiryTimestamp = Date.now() - 1000 * MAX_INTERVAL;\n  pluginData.state.recentActions = pluginData.state.recentActions.filter(\n    (action) => action.timestamp >= expiryTimestamp,\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/clearRecentUserActions.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { RecentActionType, SpamPluginType } from \"../types.js\";\n\nexport function clearRecentUserActions(\n  pluginData: GuildPluginData<SpamPluginType>,\n  type: RecentActionType,\n  userId: string,\n  actionGroupId: string,\n) {\n  pluginData.state.recentActions = pluginData.state.recentActions.filter((action) => {\n    return action.type !== type || action.userId !== userId || action.actionGroupId !== actionGroupId;\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/getRecentActionCount.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { RecentActionType, SpamPluginType } from \"../types.js\";\n\nexport function getRecentActionCount(\n  pluginData: GuildPluginData<SpamPluginType>,\n  type: RecentActionType,\n  userId: string,\n  actionGroupId: string,\n  since: number,\n): number {\n  return pluginData.state.recentActions.reduce((count, action) => {\n    if (action.timestamp < since) return count;\n    if (action.type !== type) return count;\n    if (action.actionGroupId !== actionGroupId) return count;\n    if (action.userId !== userId) return count;\n    return count + action.count;\n  }, 0);\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/getRecentActions.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { RecentActionType, SpamPluginType } from \"../types.js\";\n\nexport function getRecentActions(\n  pluginData: GuildPluginData<SpamPluginType>,\n  type: RecentActionType,\n  userId: string,\n  actionGroupId: string,\n  since: number,\n) {\n  return pluginData.state.recentActions.filter((action) => {\n    if (action.timestamp < since) return false;\n    if (action.type !== type) return false;\n    if (action.actionGroupId !== actionGroupId) return false;\n    if (action.userId !== userId) return false;\n    return true;\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts",
    "content": "import { GuildTextBasedChannel, Snowflake, TextChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { ERRORS, RecoverablePluginError } from \"../../../RecoverablePluginError.js\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { logger } from \"../../../logger.js\";\nimport { CasesPlugin } from \"../../../plugins/Cases/CasesPlugin.js\";\nimport { MutesPlugin } from \"../../../plugins/Mutes/MutesPlugin.js\";\nimport { MuteResult } from \"../../../plugins/Mutes/types.js\";\nimport { DBDateFormat, convertDelayStringToMS, noop, resolveMember, trimLines } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RecentActionType, SpamPluginType, TBaseSingleSpamConfig } from \"../types.js\";\nimport { addRecentAction } from \"./addRecentAction.js\";\nimport { clearRecentUserActions } from \"./clearRecentUserActions.js\";\nimport { getRecentActionCount } from \"./getRecentActionCount.js\";\nimport { getRecentActions } from \"./getRecentActions.js\";\nimport { saveSpamArchives } from \"./saveSpamArchives.js\";\n\nexport async function logAndDetectMessageSpam(\n  pluginData: GuildPluginData<SpamPluginType>,\n  savedMessage: SavedMessage,\n  type: RecentActionType,\n  spamConfig: TBaseSingleSpamConfig,\n  actionCount: number,\n  description: string,\n) {\n  if (actionCount === 0) return;\n\n  // Make sure we're not handling some messages twice\n  if (pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) {\n    const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id)!;\n    if (channelMap.has(savedMessage.channel_id)) {\n      const lastHandledMsgId = channelMap.get(savedMessage.channel_id)!;\n      if (lastHandledMsgId >= savedMessage.id) return;\n    }\n  }\n\n  pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then(\n    async () => {\n      const timestamp = moment.utc(savedMessage.posted_at, DBDateFormat).valueOf();\n      const member = await resolveMember(pluginData.client, pluginData.guild, savedMessage.user_id);\n      const logs = pluginData.getPlugin(LogsPlugin);\n\n      // Log this action...\n      addRecentAction(\n        pluginData,\n        type,\n        savedMessage.user_id,\n        savedMessage.channel_id,\n        savedMessage,\n        timestamp,\n        actionCount,\n      );\n\n      // ...and then check if it trips the spam filters\n      const since = timestamp - 1000 * spamConfig.interval;\n      const recentActionsCount = getRecentActionCount(\n        pluginData,\n        type,\n        savedMessage.user_id,\n        savedMessage.channel_id,\n        since,\n      );\n\n      // If the user tripped the spam filter...\n      if (recentActionsCount > spamConfig.count) {\n        const recentActions = getRecentActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id, since);\n\n        // Start by muting them, if enabled\n        let muteResult: MuteResult | null = null;\n        if (spamConfig.mute && member) {\n          const mutesPlugin = pluginData.getPlugin(MutesPlugin);\n          const muteTime =\n            (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000;\n\n          try {\n            const reason = \"Automatic spam detection\";\n\n            muteResult = await mutesPlugin.muteUser(\n              member.id,\n              muteTime,\n              reason,\n              reason,\n              {\n                caseArgs: {\n                  modId: pluginData.client.user!.id,\n                  postInCaseLogOverride: false,\n                },\n              },\n              spamConfig.remove_roles_on_mute,\n              spamConfig.restore_roles_on_mute,\n            );\n          } catch (e) {\n            if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {\n              logs.logBotAlert({\n                body: `Failed to mute <@!${member.id}> in \\`spam\\` plugin because a mute role has not been specified in server config`,\n              });\n            } else {\n              throw e;\n            }\n          }\n        }\n\n        // Get the offending message IDs\n        // We also get the IDs of any messages after the last offending message, to account for lag before detection\n        const savedMessages = recentActions.map((a) => a.extraData as SavedMessage);\n        const msgIds = savedMessages.map((m) => m.id);\n        const lastDetectedMsgId = msgIds[msgIds.length - 1];\n\n        const additionalMessages = await pluginData.state.savedMessages.getUserMessagesByChannelAfterId(\n          savedMessage.user_id,\n          savedMessage.channel_id,\n          lastDetectedMsgId,\n        );\n        additionalMessages.forEach((m) => msgIds.push(m.id));\n\n        // Then, if enabled, remove the spam messages\n        if (spamConfig.clean !== false) {\n          msgIds.forEach((id) => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id));\n          (pluginData.guild.channels.cache.get(savedMessage.channel_id as Snowflake)! as TextChannel | undefined)\n            ?.bulkDelete(msgIds as Snowflake[])\n            .catch(noop);\n        }\n\n        // Store the ID of the last handled message\n        const uniqueMessages = Array.from(new Set([...savedMessages, ...additionalMessages]));\n        uniqueMessages.sort((a, b) => (a.id > b.id ? 1 : -1));\n        const lastHandledMsgId = uniqueMessages\n          .map((m) => m.id)\n          .reduce((last, id): string => {\n            return id > last ? id : last;\n          });\n\n        if (!pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) {\n          pluginData.state.lastHandledMsgIds.set(savedMessage.user_id, new Map());\n        }\n\n        const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id)!;\n        channelMap.set(savedMessage.channel_id, lastHandledMsgId);\n\n        // Clear the handled actions from recentActions\n        clearRecentUserActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id);\n\n        // Generate a log from the detected messages\n        const channel = pluginData.guild.channels.cache.get(\n          savedMessage.channel_id as Snowflake,\n        ) as GuildTextBasedChannel;\n        const archiveUrl = await saveSpamArchives(pluginData, uniqueMessages);\n\n        // Create a case\n        const casesPlugin = pluginData.getPlugin(CasesPlugin);\n        if (muteResult) {\n          // If the user was muted, the mute already generated a case - in that case, just update the case with extra details\n          // This will also post the case in the case log channel, which we didn't do with the mute initially to avoid\n          // posting the case on the channel twice: once with the initial reason, and then again with the note from here\n          const updateText = trimLines(`\n              Details: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)\n              ${archiveUrl}\n            `);\n          casesPlugin.createCaseNote({\n            caseId: muteResult.case.id,\n            modId: muteResult.case.mod_id || \"0\",\n            body: updateText,\n            automatic: true,\n          });\n        } else {\n          // If the user was not muted, create a note case of the detected spam instead\n          const caseText = trimLines(`\n              Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)\n              ${archiveUrl}\n            `);\n\n          casesPlugin.createCase({\n            userId: savedMessage.user_id,\n            modId: pluginData.client.user!.id,\n            type: CaseTypes.Note,\n            reason: caseText,\n            automatic: true,\n          });\n        }\n\n        // Create a log entry\n        logs.logMessageSpamDetected({\n          member: member!,\n          channel: channel!,\n          description,\n          limit: spamConfig.count,\n          interval: spamConfig.interval,\n          archiveUrl,\n        });\n      }\n    },\n    (err) => {\n      logger.error(`Error while detecting spam:\\n${err}`);\n    },\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { ERRORS, RecoverablePluginError } from \"../../../RecoverablePluginError.js\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport { CasesPlugin } from \"../../../plugins/Cases/CasesPlugin.js\";\nimport { MutesPlugin } from \"../../../plugins/Mutes/MutesPlugin.js\";\nimport { convertDelayStringToMS, resolveMember } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { RecentActionType, SpamPluginType } from \"../types.js\";\nimport { addRecentAction } from \"./addRecentAction.js\";\nimport { clearRecentUserActions } from \"./clearRecentUserActions.js\";\nimport { getRecentActionCount } from \"./getRecentActionCount.js\";\n\nexport async function logAndDetectOtherSpam(\n  pluginData: GuildPluginData<SpamPluginType>,\n  type: RecentActionType,\n  spamConfig: any,\n  userId: string,\n  actionCount: number,\n  actionGroupId: string,\n  timestamp: number,\n  extraData = null,\n  description: string,\n) {\n  pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then(async () => {\n    // Log this action...\n    addRecentAction(pluginData, type, userId, actionGroupId, extraData, timestamp, actionCount);\n\n    // ...and then check if it trips the spam filters\n    const since = timestamp - 1000 * spamConfig.interval;\n    const recentActionsCount = getRecentActionCount(pluginData, type, userId, actionGroupId, since);\n\n    if (recentActionsCount > spamConfig.count) {\n      const member = await resolveMember(pluginData.client, pluginData.guild, userId);\n      const details = `${description} (over ${spamConfig.count} in ${spamConfig.interval}s)`;\n      const logs = pluginData.getPlugin(LogsPlugin);\n\n      if (spamConfig.mute && member) {\n        const mutesPlugin = pluginData.getPlugin(MutesPlugin);\n        const muteTime =\n          (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000;\n\n        try {\n          const reason = \"Automatic spam detection\";\n\n          await mutesPlugin.muteUser(\n            member.id,\n            muteTime,\n            reason,\n            reason,\n            {\n              caseArgs: {\n                modId: pluginData.client.user!.id,\n                extraNotes: [`Details: ${details}`],\n              },\n            },\n            spamConfig.remove_roles_on_mute,\n            spamConfig.restore_roles_on_mute,\n          );\n        } catch (e) {\n          if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {\n            logs.logBotAlert({\n              body: `Failed to mute <@!${member.id}> in \\`spam\\` plugin because a mute role has not been specified in server config`,\n            });\n          } else {\n            throw e;\n          }\n        }\n      } else {\n        // If we're not muting the user, just add a note on them\n        const casesPlugin = pluginData.getPlugin(CasesPlugin);\n        await casesPlugin.createCase({\n          userId,\n          modId: pluginData.client.user!.id,\n          type: CaseTypes.Note,\n          reason: `Automatic spam detection: ${details}`,\n        });\n      }\n\n      // Clear recent cases\n      clearRecentUserActions(pluginData, RecentActionType.VoiceChannelMove, userId, actionGroupId);\n\n      logs.logOtherSpamDetected({\n        member: member!,\n        description,\n        limit: spamConfig.count,\n        interval: spamConfig.interval,\n      });\n    }\n  });\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/logCensor.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { RecentActionType, SpamPluginType } from \"../types.js\";\nimport { logAndDetectMessageSpam } from \"./logAndDetectMessageSpam.js\";\n\nexport async function logCensor(pluginData: GuildPluginData<SpamPluginType>, savedMessage: SavedMessage) {\n  const member = pluginData.guild.members.cache.get(savedMessage.user_id as Snowflake);\n  const config = await pluginData.config.getMatchingConfig({\n    userId: savedMessage.user_id,\n    channelId: savedMessage.channel_id,\n    member,\n  });\n  const spamConfig = config.max_censor;\n\n  if (spamConfig) {\n    logAndDetectMessageSpam(\n      pluginData,\n      savedMessage,\n      RecentActionType.Censor,\n      spamConfig,\n      1,\n      \"too many censored messages\",\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/onMessageCreate.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { getEmojiInString, getRoleMentions, getUrlsInString, getUserMentions } from \"../../../utils.js\";\nimport { RecentActionType, SpamPluginType } from \"../types.js\";\nimport { logAndDetectMessageSpam } from \"./logAndDetectMessageSpam.js\";\n\nexport async function onMessageCreate(pluginData: GuildPluginData<SpamPluginType>, savedMessage: SavedMessage) {\n  if (savedMessage.is_bot) return;\n\n  const member = pluginData.guild.members.cache.get(savedMessage.user_id as Snowflake);\n  const config = await pluginData.config.getMatchingConfig({\n    userId: savedMessage.user_id,\n    channelId: savedMessage.channel_id,\n    member,\n  });\n\n  const maxMessages = config.max_messages;\n  if (maxMessages) {\n    logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Message, maxMessages, 1, \"too many messages\");\n  }\n\n  const maxMentions = config.max_mentions;\n  const mentions = savedMessage.data.content\n    ? [...getUserMentions(savedMessage.data.content), ...getRoleMentions(savedMessage.data.content)]\n    : [];\n  if (maxMentions && mentions.length) {\n    logAndDetectMessageSpam(\n      pluginData,\n      savedMessage,\n      RecentActionType.Mention,\n      maxMentions,\n      mentions.length,\n      \"too many mentions\",\n    );\n  }\n\n  const maxLinks = config.max_links;\n  if (maxLinks && savedMessage.data.content && typeof savedMessage.data.content === \"string\") {\n    const links = getUrlsInString(savedMessage.data.content);\n    logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Link, maxLinks, links.length, \"too many links\");\n  }\n\n  const maxAttachments = config.max_attachments;\n  if (maxAttachments && savedMessage.data.attachments) {\n    logAndDetectMessageSpam(\n      pluginData,\n      savedMessage,\n      RecentActionType.Attachment,\n      maxAttachments,\n      savedMessage.data.attachments.length,\n      \"too many attachments\",\n    );\n  }\n\n  const maxEmojis = config.max_emojis;\n  if (maxEmojis && savedMessage.data.content) {\n    const emojiCount = getEmojiInString(savedMessage.data.content).length;\n    logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Emoji, maxEmojis, emojiCount, \"too many emoji\");\n  }\n\n  const maxNewlines = config.max_newlines;\n  if (maxNewlines && savedMessage.data.content) {\n    const newlineCount = (savedMessage.data.content.match(/\\n/g) || []).length;\n    logAndDetectMessageSpam(\n      pluginData,\n      savedMessage,\n      RecentActionType.Newline,\n      maxNewlines,\n      newlineCount,\n      \"too many newlines\",\n    );\n  }\n\n  const maxCharacters = config.max_characters;\n  if (maxCharacters && savedMessage.data.content) {\n    const characterCount = [...savedMessage.data.content.trim()].length;\n    logAndDetectMessageSpam(\n      pluginData,\n      savedMessage,\n      RecentActionType.Character,\n      maxCharacters,\n      characterCount,\n      \"too many characters\",\n    );\n  }\n\n  // TODO: Max duplicates check\n}\n"
  },
  {
    "path": "backend/src/plugins/Spam/util/saveSpamArchives.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { getBaseUrl } from \"../../../pluginUtils.js\";\nimport { SpamPluginType } from \"../types.js\";\n\nconst SPAM_ARCHIVE_EXPIRY_DAYS = 90;\n\nexport async function saveSpamArchives(pluginData: GuildPluginData<SpamPluginType>, savedMessages: SavedMessage[]) {\n  const expiresAt = moment.utc().add(SPAM_ARCHIVE_EXPIRY_DAYS, \"days\");\n  const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild, expiresAt);\n\n  return pluginData.state.archives.getUrl(getBaseUrl(pluginData), archiveId);\n}\n"
  },
  {
    "path": "backend/src/plugins/Starboard/StarboardPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { GuildStarboardMessages } from \"../../data/GuildStarboardMessages.js\";\nimport { GuildStarboardReactions } from \"../../data/GuildStarboardReactions.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { MigratePinsCmd } from \"./commands/MigratePinsCmd.js\";\nimport { StarboardReactionAddEvt } from \"./events/StarboardReactionAddEvt.js\";\nimport { StarboardReactionRemoveAllEvt, StarboardReactionRemoveEvt } from \"./events/StarboardReactionRemoveEvts.js\";\nimport { StarboardPluginType, zStarboardConfig } from \"./types.js\";\nimport { onMessageDelete } from \"./util/onMessageDelete.js\";\n\nexport const StarboardPlugin = guildPlugin<StarboardPluginType>()({\n  name: \"starboard\",\n\n  configSchema: zStarboardConfig,\n  defaultOverrides: [\n    {\n      level: \">=100\",\n      config: {\n        can_migrate: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    MigratePinsCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    StarboardReactionAddEvt,\n    StarboardReactionRemoveEvt,\n    StarboardReactionRemoveAllEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.starboardMessages = GuildStarboardMessages.getGuildInstance(guild.id);\n    state.starboardReactions = GuildStarboardReactions.getGuildInstance(guild.id);\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg);\n    state.savedMessages.events.on(\"delete\", state.onMessageDeleteFn);\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.savedMessages.events.off(\"delete\", state.onMessageDeleteFn);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Starboard/commands/MigratePinsCmd.ts",
    "content": "import { Snowflake, TextChannel } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { starboardCmd } from \"../types.js\";\nimport { saveMessageToStarboard } from \"../util/saveMessageToStarboard.js\";\n\nexport const MigratePinsCmd = starboardCmd({\n  trigger: \"starboard migrate_pins\",\n  permission: \"can_migrate\",\n\n  description: \"Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.\",\n\n  signature: {\n    pinChannel: ct.textChannel(),\n    starboardName: ct.string(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const config = pluginData.config.get();\n    const starboard = config.boards[args.starboardName];\n    if (!starboard) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Unknown starboard specified\");\n      return;\n    }\n\n    const starboardChannel = pluginData.guild.channels.cache.get(starboard.channel_id as Snowflake);\n    if (!starboardChannel || !(starboardChannel instanceof TextChannel)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Starboard has an unknown/invalid channel id\");\n      return;\n    }\n\n    msg.channel.send(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`);\n\n    const pins = [...(await args.pinChannel.messages.fetchPinned().catch(() => [])).values()];\n    pins.reverse(); // Migrate pins starting from the oldest message\n\n    for (const pin of pins) {\n      const existingStarboardMessage = await pluginData.state.starboardMessages.getMatchingStarboardMessages(\n        starboardChannel.id,\n        pin.id,\n      );\n      if (existingStarboardMessage.length > 0) continue;\n      await saveMessageToStarboard(pluginData, pin, starboard);\n    }\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Starboard/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zStarboardConfig } from \"./types.js\";\n\nexport const starboardPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Starboard\",\n  description: trimPluginDescription(`\n    This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a \"starboard\" channel.\n  `),\n  configurationGuide: trimPluginDescription(`\n    ### Note on emojis\n    To specify emoji in the config, you need to use the emoji's \"raw form\".\n    To obtain this, post the emoji with a backslash in front of it.\n\n    - Example with a default emoji: \":star:\" => \"⭐\"\n    - Example with a custom emoji: \":mrvnSmile:\" => \"<:mrvnSmile:543000534102310933>\"\n\n    ### Basic starboard\n    Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226).\n\n    ~~~yml\n    starboard:\n      config:\n        boards:\n          basic:\n            channel_id: \"604342689038729226\"\n            stars_required: 5\n    ~~~\n\n    ### Basic starboard with custom color\n    Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226), with the given color (0x87CEEB).\n\n    ~~~yml\n    starboard:\n      config:\n        boards:\n          basic:\n            channel_id: \"604342689038729226\"\n            stars_required: 5\n            color: 0x87CEEB\n    ~~~\n\n    ### Custom star emoji\n    This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji\n\n    ~~~yml\n    starboard:\n      config:\n        boards:\n          basic:\n            channel_id: \"604342689038729226\"\n            star_emoji: [\"⭐\", \"<:mrvnSmile:543000534102310933>\"]\n            stars_required: 5\n    ~~~\n\n    ### Limit starboard to a specific channel\n    This is identical to the basic starboard above, but only works from a specific channel (473087035574321152).\n\n    ~~~yml\n    starboard:\n      config:\n        boards:\n          basic:\n            enabled: false # The starboard starts disabled and is then enabled in a channel override below\n            channel_id: \"604342689038729226\"\n            stars_required: 5\n      overrides:\n        - channel: \"473087035574321152\"\n          config:\n            boards:\n              basic:\n                enabled: true\n    ~~~\n\n    ### Limit starboard to a specific level (and above)\n    This is identical to the basic starboard above, but only works for a specific level (>=50).\n\n    ~~~yml\n    starboard:\n      config:\n        boards:\n          levelonly:\n            enabled: false # The starboard starts disabled and is then enabled in a level override below\n            channel_id: \"604342689038729226\"\n            stars_required: 1\n      overrides:\n        - level: \">=50\"\n          config:\n            boards:\n              levelonly:\n                enabled: true\n    ~~~\n  `),\n  configSchema: zStarboardConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts",
    "content": "import { Message, Snowflake, TextChannel } from \"discord.js\";\nimport { noop, resolveMember } from \"../../../utils.js\";\nimport { allStarboardsLock } from \"../../../utils/lockNameHelpers.js\";\nimport { starboardEvt } from \"../types.js\";\nimport { saveMessageToStarboard } from \"../util/saveMessageToStarboard.js\";\nimport { updateStarboardMessageStarCount } from \"../util/updateStarboardMessageStarCount.js\";\n\nexport const StarboardReactionAddEvt = starboardEvt({\n  event: \"messageReactionAdd\",\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n\n    let msg = meta.args.reaction.message as Message;\n    const userId = meta.args.user.id;\n    const emoji = meta.args.reaction.emoji;\n\n    if (!msg.author) {\n      // Message is not cached, fetch it\n      try {\n        msg = await msg.channel.messages.fetch(msg.id);\n      } catch {\n        // Sometimes we get this event for messages we can't fetch with getMessage; ignore silently\n        return;\n      }\n    }\n\n    const member = await resolveMember(pluginData.client, pluginData.guild, userId);\n    if (!member || member!.user.bot) return;\n\n    const config = await pluginData.config.getMatchingConfig({\n      userId,\n      member,\n      message: msg,\n    });\n\n    const boardLock = await pluginData.locks.acquire(allStarboardsLock());\n\n    const applicableStarboards = Object.values(config.boards)\n      .filter((board) => board.enabled)\n      // Can't star messages in the starboard channel itself\n      .filter((board) => board.channel_id !== msg.channel.id)\n      // Matching emoji\n      .filter((board) => {\n        return board.star_emoji!.some((boardEmoji: string) => {\n          if (emoji.id) {\n            // Custom emoji\n            const customEmojiMatch = boardEmoji.match(/^<?:.+?:(\\d+)>?$/);\n            if (customEmojiMatch) {\n              return customEmojiMatch[1] === emoji.id;\n            }\n\n            return boardEmoji === emoji.id;\n          } else {\n            // Unicode emoji\n            return emoji.name === boardEmoji;\n          }\n        });\n      });\n\n    const selfStar = msg.author.id === userId;\n    for (const starboard of applicableStarboards) {\n      if (selfStar && !starboard.allow_selfstars) continue;\n\n      // Save reaction into the database\n      await pluginData.state.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop);\n\n      const reactions = await pluginData.state.starboardReactions.getAllReactionsForMessageId(msg.id);\n      const reactionsCount = reactions.length;\n\n      const starboardMessages = await pluginData.state.starboardMessages.getMatchingStarboardMessages(\n        starboard.channel_id,\n        msg.id,\n      );\n      if (starboardMessages.length > 0) {\n        // If the message has already been posted to this starboard, update star counts\n        if (starboard.show_star_count) {\n          for (const starboardMessage of starboardMessages) {\n            const channel = pluginData.guild.channels.cache.get(\n              starboardMessage.starboard_channel_id as Snowflake,\n            ) as TextChannel;\n            const realStarboardMessage = await channel.messages.fetch(starboardMessage.starboard_message_id);\n            await updateStarboardMessageStarCount(\n              starboard,\n              msg,\n              realStarboardMessage,\n              starboard.star_emoji![0]!,\n              reactionsCount,\n            );\n          }\n        }\n      } else if (reactionsCount >= starboard.stars_required) {\n        // Otherwise, if the star count exceeds the required star count, save the message to the starboard\n        await saveMessageToStarboard(pluginData, msg, starboard);\n      }\n    }\n\n    boardLock.unlock();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts",
    "content": "import { allStarboardsLock } from \"../../../utils/lockNameHelpers.js\";\nimport { starboardEvt } from \"../types.js\";\n\nexport const StarboardReactionRemoveEvt = starboardEvt({\n  event: \"messageReactionRemove\",\n\n  async listener(meta) {\n    const boardLock = await meta.pluginData.locks.acquire(allStarboardsLock());\n    await meta.pluginData.state.starboardReactions.deleteStarboardReaction(\n      meta.args.reaction.message.id,\n      meta.args.user.id,\n    );\n    boardLock.unlock();\n  },\n});\n\nexport const StarboardReactionRemoveAllEvt = starboardEvt({\n  event: \"messageReactionRemoveAll\",\n\n  async listener(meta) {\n    const boardLock = await meta.pluginData.locks.acquire(allStarboardsLock());\n    await meta.pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(meta.args.message.id);\n    boardLock.unlock();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Starboard/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { GuildStarboardMessages } from \"../../data/GuildStarboardMessages.js\";\nimport { GuildStarboardReactions } from \"../../data/GuildStarboardReactions.js\";\nimport { zBoundedRecord, zSnowflake } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nconst zStarboardOpts = z.strictObject({\n  channel_id: zSnowflake,\n  stars_required: z.number(),\n  star_emoji: z.array(z.string()).default([\"⭐\"]),\n  allow_selfstars: z.boolean().default(false),\n  copy_full_embed: z.boolean().default(false),\n  enabled: z.boolean().default(true),\n  show_star_count: z.boolean().default(true),\n  color: z.number().nullable().default(null),\n});\nexport type TStarboardOpts = z.infer<typeof zStarboardOpts>;\n\nexport const zStarboardConfig = z.strictObject({\n  boards: zBoundedRecord(z.record(z.string(), zStarboardOpts), 0, 100).default({}),\n  can_migrate: z.boolean().default(false),\n});\n\nexport interface StarboardPluginType extends BasePluginType {\n  configSchema: typeof zStarboardConfig;\n  state: {\n    savedMessages: GuildSavedMessages;\n    starboardMessages: GuildStarboardMessages;\n    starboardReactions: GuildStarboardReactions;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n\n    onMessageDeleteFn;\n  };\n}\n\nexport const starboardCmd = guildPluginMessageCommand<StarboardPluginType>();\nexport const starboardEvt = guildPluginEventListener<StarboardPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts",
    "content": "import { GuildChannel, Message } from \"discord.js\";\nimport path from \"path\";\nimport { EMPTY_CHAR, EmbedWith, renderUsername } from \"../../../utils.js\";\n\nconst imageAttachmentExtensions = [\"jpeg\", \"jpg\", \"png\", \"gif\", \"webp\"];\nconst audioAttachmentExtensions = [\"wav\", \"mp3\", \"m4a\"];\nconst videoAttachmentExtensions = [\"mp4\", \"mkv\", \"mov\"];\n\ntype StarboardEmbed = EmbedWith<\"footer\" | \"author\" | \"fields\" | \"timestamp\">;\n\nexport function createStarboardEmbedFromMessage(\n  msg: Message,\n  copyFullEmbed: boolean,\n  color?: number | null,\n): StarboardEmbed {\n  const embed: StarboardEmbed = {\n    footer: {\n      text: `#${(msg.channel as GuildChannel).name}`,\n    },\n    author: {\n      name: renderUsername(msg.author),\n    },\n    fields: [],\n    timestamp: msg.createdAt.toISOString(),\n  };\n\n  if (color != null) {\n    embed.color = color;\n  }\n\n  embed.author.icon_url = (msg.member ?? msg.author).displayAvatarURL();\n\n  // The second condition here checks for messages with only an image link that is then embedded.\n  // The message content in that case is hidden by the Discord client, so we hide it here too.\n  if (msg.content && msg.embeds[0]?.thumbnail?.url !== msg.content) {\n    embed.description = msg.content;\n  }\n\n  // Merge media and - if copy_full_embed is enabled - fields and title from the first embed in the original message\n  if (msg.embeds.length > 0) {\n    if (msg.embeds[0].image) {\n      embed.image = msg.embeds[0].image;\n    } else if (msg.embeds[0].thumbnail) {\n      embed.image = { url: msg.embeds[0].thumbnail.url };\n    }\n\n    if (copyFullEmbed) {\n      if (msg.embeds[0].title) {\n        const titleText = msg.embeds[0].url ? `[${msg.embeds[0].title}](${msg.embeds[0].url})` : msg.embeds[0].title;\n        embed.fields.push({ name: EMPTY_CHAR, value: titleText });\n      }\n\n      if (msg.embeds[0].fields) {\n        embed.fields.push(...msg.embeds[0].fields);\n      }\n    }\n  }\n\n  // If there are no embeds, add the first image attachment explicitly\n  else if (msg.attachments.size) {\n    for (const attachment of msg.attachments) {\n      const ext = path.extname(attachment[1].name!).slice(1).toLowerCase();\n\n      if (imageAttachmentExtensions.includes(ext)) {\n        embed.image = { url: attachment[1].url };\n        break;\n      }\n\n      if (audioAttachmentExtensions.includes(ext)) {\n        embed.fields.push({ name: EMPTY_CHAR, value: `*Message contains an audio clip*` });\n        break;\n      }\n\n      if (videoAttachmentExtensions.includes(ext)) {\n        embed.fields.push({ name: EMPTY_CHAR, value: `*Message contains a video*` });\n        break;\n      }\n    }\n  }\n\n  return embed;\n}\n"
  },
  {
    "path": "backend/src/plugins/Starboard/util/createStarboardPseudoFooterForMessage.ts",
    "content": "import { EmbedField, Message } from \"discord.js\";\nimport { EMPTY_CHAR, messageLink } from \"../../../utils.js\";\nimport { TStarboardOpts } from \"../types.js\";\n\nexport function createStarboardPseudoFooterForMessage(\n  starboard: TStarboardOpts,\n  msg: Message,\n  starEmoji: string,\n  starCount: number,\n): EmbedField {\n  const jumpLink = `[Jump to message](${messageLink(msg)})`;\n\n  let content;\n  if (starboard.show_star_count) {\n    content =\n      starCount > 1\n        ? `${starEmoji} **${starCount}** \\u200B \\u200B \\u200B ${jumpLink}`\n        : `${starEmoji} \\u200B ${jumpLink}`;\n  } else {\n    content = jumpLink;\n  }\n\n  return {\n    name: EMPTY_CHAR,\n    value: content,\n    inline: false,\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/Starboard/util/onMessageDelete.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { StarboardPluginType } from \"../types.js\";\nimport { removeMessageFromStarboard } from \"./removeMessageFromStarboard.js\";\nimport { removeMessageFromStarboardMessages } from \"./removeMessageFromStarboardMessages.js\";\n\nexport async function onMessageDelete(pluginData: GuildPluginData<StarboardPluginType>, msg: SavedMessage) {\n  // Deleted source message\n  const starboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForMessageId(msg.id);\n  for (const starboardMessage of starboardMessages) {\n    removeMessageFromStarboard(pluginData, starboardMessage);\n  }\n\n  // Deleted message from the starboard\n  const deletedStarboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForStarboardMessageId(\n    msg.id,\n  );\n  if (deletedStarboardMessages.length === 0) return;\n\n  for (const starboardMessage of deletedStarboardMessages) {\n    removeMessageFromStarboardMessages(\n      pluginData,\n      starboardMessage.starboard_message_id,\n      starboardMessage.starboard_channel_id,\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts",
    "content": "import { ChannelType } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { StarboardMessage } from \"../../../data/entities/StarboardMessage.js\";\nimport { noop } from \"../../../utils.js\";\nimport { StarboardPluginType } from \"../types.js\";\n\nexport async function removeMessageFromStarboard(\n  pluginData: GuildPluginData<StarboardPluginType>,\n  msg: StarboardMessage,\n) {\n  // fixes stuck entries on starboard_reactions table after messages being deleted, probably should add a cleanup script for this as well, i.e. DELETE FROM starboard_reactions WHERE message_id NOT IN (SELECT id FROM starboard_messages)\n  await pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.message_id).catch(noop);\n\n  // this code is now Almeida-certified and no longer ugly :ok_hand: :cake:\n  const channel = pluginData.client.channels.cache.find((c) => c.id === msg.starboard_channel_id);\n  if (channel?.type !== ChannelType.GuildText) return;\n  const message = await channel.messages.fetch(msg.starboard_message_id).catch(noop);\n  if (!message?.deletable) return;\n  await message.delete().catch(noop);\n}\n"
  },
  {
    "path": "backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { StarboardPluginType } from \"../types.js\";\n\nexport async function removeMessageFromStarboardMessages(\n  pluginData: GuildPluginData<StarboardPluginType>,\n  starboard_message_id: string,\n  channel_id: string,\n) {\n  await pluginData.state.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id);\n}\n"
  },
  {
    "path": "backend/src/plugins/Starboard/util/saveMessageToStarboard.ts",
    "content": "import { APIEmbed, Message, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { StarboardPluginType, TStarboardOpts } from \"../types.js\";\nimport { createStarboardEmbedFromMessage } from \"./createStarboardEmbedFromMessage.js\";\nimport { createStarboardPseudoFooterForMessage } from \"./createStarboardPseudoFooterForMessage.js\";\n\nexport async function saveMessageToStarboard(\n  pluginData: GuildPluginData<StarboardPluginType>,\n  msg: Message,\n  starboard: TStarboardOpts,\n) {\n  const channel = pluginData.guild.channels.cache.get(starboard.channel_id as Snowflake);\n  if (!channel?.isTextBased()) return;\n\n  const starCount = (await pluginData.state.starboardReactions.getAllReactionsForMessageId(msg.id)).length;\n  const embed = createStarboardEmbedFromMessage(msg, Boolean(starboard.copy_full_embed), starboard.color);\n  embed.fields!.push(createStarboardPseudoFooterForMessage(starboard, msg, starboard.star_emoji![0], starCount));\n\n  const starboardMessage = await channel.send({ embeds: [embed as APIEmbed] });\n  await pluginData.state.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id);\n}\n"
  },
  {
    "path": "backend/src/plugins/Starboard/util/updateStarboardMessageStarCount.ts",
    "content": "import { Message } from \"discord.js\";\nimport { TStarboardOpts } from \"../types.js\";\nimport { createStarboardPseudoFooterForMessage } from \"./createStarboardPseudoFooterForMessage.js\";\nimport Timeout = NodeJS.Timeout;\n\nconst DEBOUNCE_DELAY = 1000;\nconst debouncedUpdates: Record<string, Timeout> = {};\n\nexport async function updateStarboardMessageStarCount(\n  starboard: TStarboardOpts,\n  originalMessage: Message,\n  starboardMessage: Message,\n  starEmoji: string,\n  starCount: number,\n) {\n  const key = `${originalMessage.id}-${starboardMessage.id}`;\n  if (debouncedUpdates[key]) {\n    clearTimeout(debouncedUpdates[key]);\n  }\n\n  debouncedUpdates[key] = setTimeout(() => {\n    delete debouncedUpdates[key];\n    const embed = starboardMessage.embeds[0]!;\n    embed.fields!.pop(); // Remove pseudo footer\n    embed.fields!.push(createStarboardPseudoFooterForMessage(starboard, originalMessage, starEmoji, starCount)); // Create new pseudo footer\n    starboardMessage.edit({ embeds: [embed] });\n  }, DEBOUNCE_DELAY);\n}\n"
  },
  {
    "path": "backend/src/plugins/Tags/TagsPlugin.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { guildPlugin } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { GuildTags } from \"../../data/GuildTags.js\";\nimport { humanizeDuration } from \"../../humanizeDuration.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { convertDelayStringToMS } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { TimeAndDatePlugin } from \"../TimeAndDate/TimeAndDatePlugin.js\";\nimport { TagCreateCmd } from \"./commands/TagCreateCmd.js\";\nimport { TagDeleteCmd } from \"./commands/TagDeleteCmd.js\";\nimport { TagEvalCmd } from \"./commands/TagEvalCmd.js\";\nimport { TagListCmd } from \"./commands/TagListCmd.js\";\nimport { TagSourceCmd } from \"./commands/TagSourceCmd.js\";\nimport { TagsPluginType, zTagsConfig } from \"./types.js\";\nimport { findTagByName } from \"./util/findTagByName.js\";\nimport { onMessageCreate } from \"./util/onMessageCreate.js\";\nimport { onMessageDelete } from \"./util/onMessageDelete.js\";\nimport { renderTagBody } from \"./util/renderTagBody.js\";\n\nexport const TagsPlugin = guildPlugin<TagsPluginType>()({\n  name: \"tags\",\n\n  dependencies: () => [LogsPlugin],\n  configSchema: zTagsConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_use: true,\n        can_create: true,\n        can_list: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    TagEvalCmd,\n    TagDeleteCmd,\n    TagListCmd,\n    TagSourceCmd,\n    TagCreateCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    onMessageDelete,\n  ],\n\n  public(pluginData) {\n    return {\n      renderTagBody: makePublicFn(pluginData, renderTagBody),\n      findTagByName: makePublicFn(pluginData, findTagByName),\n    };\n  },\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.archives = GuildArchives.getGuildInstance(guild.id);\n    state.tags = GuildTags.getGuildInstance(guild.id);\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.logs = new GuildLogs(guild.id);\n\n    state.tagFunctions = {};\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg);\n    state.savedMessages.events.on(\"create\", state.onMessageCreateFn);\n\n    const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n\n    const tz = timeAndDate.getGuildTz();\n    state.tagFunctions = {\n      parseDateTime(str) {\n        if (typeof str === \"number\") {\n          return str; // Unix timestamp\n        }\n\n        if (typeof str !== \"string\") {\n          return Date.now();\n        }\n\n        if (!Number.isNaN(Number(str))) {\n          return Number(str); // Unix timestamp as a string\n        }\n\n        return moment.tz(str, \"YYYY-MM-DD HH:mm:ss\", tz).valueOf();\n      },\n\n      countdown(toDate) {\n        const target = moment.utc(this.parseDateTime(toDate), \"x\");\n\n        const now = moment.utc();\n        if (!target.isValid()) return \"\";\n\n        const diff = target.diff(now);\n        const result = humanizeDuration(diff, { largest: 2, round: true });\n        return diff >= 0 ? result : `${result} ago`;\n      },\n\n      now() {\n        return Date.now();\n      },\n\n      timeAdd(...args) {\n        if (args.length === 0) return;\n        let reference;\n        let delay;\n\n        for (const [i, arg] of args.entries()) {\n          if (typeof arg === \"number\") {\n            args[i] = String(arg);\n          } else if (typeof arg !== \"string\") {\n            args[i] = \"\";\n          }\n        }\n\n        if (args.length >= 2) {\n          // (time, delay)\n          reference = this.parseDateTime(args[0]);\n          delay = args[1];\n        } else {\n          // (delay), implicit \"now\" as time\n          reference = Date.now();\n          delay = args[0];\n        }\n\n        const delayMS = convertDelayStringToMS(delay) ?? 0;\n        return moment.utc(reference, \"x\").add(delayMS).valueOf();\n      },\n\n      timeSub(...args) {\n        if (args.length === 0) return;\n        let reference;\n        let delay;\n\n        for (const [i, arg] of args.entries()) {\n          if (typeof arg === \"number\") {\n            args[i] = String(arg);\n          } else if (typeof arg !== \"string\") {\n            args[i] = \"\";\n          }\n        }\n\n        if (args.length >= 2) {\n          // (time, delay)\n          reference = this.parseDateTime(args[0]);\n          delay = args[1];\n        } else {\n          // (delay), implicit \"now\" as time\n          reference = Date.now();\n          delay = args[0];\n        }\n\n        const delayMS = convertDelayStringToMS(delay) ?? 0;\n        return moment.utc(reference, \"x\").subtract(delayMS).valueOf();\n      },\n\n      timeAgo(delay) {\n        return this.timeSub(delay);\n      },\n\n      formatTime(time, format) {\n        const parsed = this.parseDateTime(time);\n        return timeAndDate.inGuildTz(parsed).format(format);\n      },\n\n      discordDateFormat(time) {\n        const parsed = time ? this.parseDateTime(time) : Date.now();\n\n        return timeAndDate.inGuildTz(parsed).format(\"YYYY-MM-DD\");\n      },\n\n      mention: (input) => {\n        if (typeof input !== \"string\") {\n          return \"\";\n        }\n\n        if (input.match(/^<(?:@[!&]?|#)\\d+>$/)) {\n          return input;\n        }\n\n        if (\n          pluginData.guild.members.cache.has(input as Snowflake) ||\n          pluginData.client.users.resolve(input as Snowflake)\n        ) {\n          return `<@!${input}>`;\n        }\n\n        if (pluginData.guild.channels.cache.has(input as Snowflake)) {\n          return `<#${input}>`;\n        }\n\n        return \"\";\n      },\n\n      isMention: (input) => {\n        if (typeof input !== \"string\") {\n          return false;\n        }\n\n        return /^<(?:@[!&]?|#)\\d+>$/.test(input);\n      },\n    };\n\n    for (const [name, fn] of Object.entries(state.tagFunctions)) {\n      state.tagFunctions[name] = (fn as any).bind(state.tagFunctions);\n    }\n  },\n\n  beforeUnload(pluginData) {\n    const { state } = pluginData;\n\n    state.savedMessages.events.off(\"create\", state.onMessageCreateFn);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Tags/commands/TagCreateCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { TemplateParseError, parseTemplate } from \"../../../templateFormatter.js\";\nimport { tagsCmd } from \"../types.js\";\n\nexport const TagCreateCmd = tagsCmd({\n  trigger: \"tag\",\n  permission: \"can_create\",\n\n  signature: {\n    tag: ct.string(),\n    body: ct.string({ catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    try {\n      parseTemplate(args.body);\n    } catch (e) {\n      if (e instanceof TemplateParseError) {\n        void pluginData.state.common.sendErrorMessage(msg, `Invalid tag syntax: ${e.message}`);\n        return;\n      } else {\n        throw e;\n      }\n    }\n\n    await pluginData.state.tags.createOrUpdate(args.tag, args.body, msg.author.id);\n\n    const prefix = pluginData.config.get().prefix;\n    void pluginData.state.common.sendSuccessMessage(msg, `Tag set! Use it with: \\`${prefix}${args.tag}\\``);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Tags/commands/TagDeleteCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { tagsCmd } from \"../types.js\";\n\nexport const TagDeleteCmd = tagsCmd({\n  trigger: \"tag delete\",\n  permission: \"can_create\",\n\n  signature: {\n    tag: ct.string(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const tag = await pluginData.state.tags.find(args.tag);\n    if (!tag) {\n      void pluginData.state.common.sendErrorMessage(msg, \"No tag with that name\");\n      return;\n    }\n\n    await pluginData.state.tags.delete(args.tag);\n    void pluginData.state.common.sendSuccessMessage(msg, \"Tag deleted!\");\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Tags/commands/TagEvalCmd.ts",
    "content": "import { MessageCreateOptions } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { logger } from \"../../../logger.js\";\nimport { resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { TemplateParseError } from \"../../../templateFormatter.js\";\nimport { memberToTemplateSafeMember, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { tagsCmd } from \"../types.js\";\nimport { renderTagBody } from \"../util/renderTagBody.js\";\n\nexport const TagEvalCmd = tagsCmd({\n  trigger: \"tag eval\",\n  permission: \"can_create\",\n\n  signature: {\n    body: ct.string({ catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const authorMember = await resolveMessageMember(msg);\n    try {\n      const rendered = (await renderTagBody(\n        pluginData,\n        args.body,\n        [],\n        {\n          member: memberToTemplateSafeMember(authorMember),\n          user: userToTemplateSafeUser(msg.author),\n        },\n        { member: msg.member },\n      )) as MessageCreateOptions;\n\n      if (!rendered.content && !rendered.embeds?.length) {\n        void pluginData.state.common.sendErrorMessage(msg, \"Evaluation resulted in an empty text\");\n        return;\n      }\n\n      msg.channel.send(rendered);\n    } catch (e) {\n      const errorMessage = e instanceof TemplateParseError ? e.message : \"Internal error\";\n\n      void pluginData.state.common.sendErrorMessage(msg, `Failed to render tag: ${errorMessage}`);\n\n      if (!(e instanceof TemplateParseError)) {\n        logger.warn(`Internal error evaluating tag in ${pluginData.guild.id}: ${e}`);\n      }\n\n      return;\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Tags/commands/TagListCmd.ts",
    "content": "import escapeStringRegexp from \"escape-string-regexp\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { createChunkedMessage } from \"../../../utils.js\";\nimport { tagsCmd } from \"../types.js\";\n\nexport const TagListCmd = tagsCmd({\n  trigger: [\"tag list\", \"tags\", \"taglist\"],\n  permission: \"can_list\",\n\n  signature: {\n    search: ct.string({ required: false }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const tags = await pluginData.state.tags.all();\n    if (tags.length === 0) {\n      msg.channel.send(`No tags created yet! Use \\`tag create\\` command to create one.`);\n      return;\n    }\n\n    const prefix = (await pluginData.config.getForMessage(msg)).prefix;\n    const tagNames = tags.map((tag) => tag.tag).sort();\n    const searchRegex = args.search\n      ? new RegExp([...args.search].map((s) => escapeStringRegexp(s)).join(\".*\"), \"i\")\n      : null;\n\n    const filteredTags = args.search ? tagNames.filter((tag) => searchRegex!.test(tag)) : tagNames;\n\n    if (filteredTags.length === 0) {\n      msg.channel.send(\"No tags matched the filter\");\n      return;\n    }\n\n    const tagGroups = filteredTags.reduce((obj, tag) => {\n      const tagUpper = tag.toUpperCase();\n      const key = /[A-Z]/.test(tagUpper[0]) ? tagUpper[0] : \"#\";\n      if (!(key in obj)) {\n        obj[key] = [];\n      }\n      obj[key].push(tag);\n      return obj;\n    }, {});\n\n    const tagList = Object.keys(tagGroups)\n      .sort()\n      .map((key) => `[${key}] ${tagGroups[key].join(\", \")}`)\n      .join(\"\\n\");\n\n    createChunkedMessage(msg.channel, `Available tags (use with ${prefix}tag): \\`\\`\\`${tagList}\\`\\`\\``);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Tags/commands/TagSourceCmd.ts",
    "content": "import moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { getBaseUrl } from \"../../../pluginUtils.js\";\nimport { tagsCmd } from \"../types.js\";\n\nexport const TagSourceCmd = tagsCmd({\n  trigger: \"tag\",\n  permission: \"can_create\",\n\n  signature: {\n    tag: ct.string(),\n\n    delete: ct.bool({ option: true, shortcut: \"d\", isSwitch: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    if (args.delete) {\n      const actualTag = await pluginData.state.tags.find(args.tag);\n      if (!actualTag) {\n        void pluginData.state.common.sendErrorMessage(msg, \"No tag with that name\");\n        return;\n      }\n\n      await pluginData.state.tags.delete(args.tag);\n      void pluginData.state.common.sendSuccessMessage(msg, \"Tag deleted!\");\n      return;\n    }\n\n    const tag = await pluginData.state.tags.find(args.tag);\n    if (!tag) {\n      void pluginData.state.common.sendErrorMessage(msg, \"No tag with that name\");\n      return;\n    }\n\n    const archiveId = await pluginData.state.archives.create(tag.body, moment.utc().add(10, \"minutes\"));\n    const url = pluginData.state.archives.getUrl(getBaseUrl(pluginData), archiveId);\n\n    msg.channel.send(`Tag source:\\n${url}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Tags/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { TemplateFunctions } from \"./templateFunctions.js\";\nimport { TemplateFunction, zTagsConfig } from \"./types.js\";\n\nexport const tagsPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Tags\",\n  description: \"Tags are a way to store and reuse information.\",\n  configurationGuide: trimPluginDescription(`\n    ### Template Functions\n    You can use template functions in your tags. These functions are called when the tag is rendered.\n    You can use these functions to render dynamic content, or to access information from the message and/or user calling the tag.\n    You use them by adding a \\`{}\\` on your tag.\n\n    Here are the functions you can use in your tags:\n\n    ${generateTemplateMarkdown(TemplateFunctions)}\n  `),\n  configSchema: zTagsConfig,\n};\n\nfunction generateTemplateMarkdown(definitions: TemplateFunction[]): string {\n  return definitions\n    .map((def) => {\n      const usage = def.signature ?? `(${def.arguments.join(\", \")})`;\n      const examples = def.examples?.map((ex) => `> \\`{${ex}}\\``).join(\"\\n\") ?? null;\n      return trimPluginDescription(`\n        ## ${def.name}\n        **${def.description}**\\n\n        __Usage__: \\`{${def.name}${usage}}\\`\\n\n        ${examples ? `__Examples__:\\n${examples}` : \"\"}\\n\\n\n      `);\n    })\n    .join(\"\\n\\n\");\n}\n"
  },
  {
    "path": "backend/src/plugins/Tags/templateFunctions.ts",
    "content": "import { TemplateFunction } from \"./types.js\";\n\n// TODO: Generate this dynamically, lmao\nexport const TemplateFunctions: TemplateFunction[] = [\n  {\n    name: \"if\",\n    description: \"Checks if a condition is true or false and returns the corresponding ifTrue or ifFalse\",\n    returnValue: \"boolean\",\n    arguments: [\"condition\", \"ifTrue\", \"ifFalse\"],\n    examples: ['if(user.bot, \"User is a bot\", \"User is not a bot\")'],\n  },\n  {\n    name: \"and\",\n    description: \"Checks if all provided conditions are true\",\n    returnValue: \"boolean\",\n    arguments: [\"condition1\", \"condition2\", \"...\"],\n    examples: [\"and(user.bot, user.verified)\"],\n  },\n  {\n    name: \"or\",\n    description: \"Checks if atleast one of the provided conditions is true\",\n    returnValue: \"boolean\",\n    arguments: [\"condition1\", \"condition2\", \"...\"],\n    examples: [\"or(user.bot, user.verified)\"],\n  },\n  {\n    name: \"not\",\n    description: \"Checks if the provided condition is false\",\n    returnValue: \"boolean\",\n    arguments: [\"condition\"],\n    examples: [\"not(user.bot)\"],\n  },\n  {\n    name: \"concat\",\n    description: \"Concatenates several arguments into a string\",\n    returnValue: \"string\",\n    arguments: [\"argument1\", \"argument2\", \"...\"],\n    examples: ['concat(\"Hello \", user.username, \"!\")'],\n  },\n  {\n    name: \"concatArr\",\n    description: \"Joins a array with the provided separator\",\n    returnValue: \"string\",\n    arguments: [\"array\", \"separator\"],\n    examples: ['concatArr([\"Hello\", \"World\"], \" \")'],\n  },\n  {\n    name: \"eq\",\n    description: \"Checks if all provided arguments are equal to each other\",\n    returnValue: \"boolean\",\n    arguments: [\"argument1\", \"argument2\", \"...\"],\n    examples: ['eq(user.id, \"106391128718245888\")'],\n  },\n  {\n    name: \"gt\",\n    description: \"Checks if the first argument is greater than the second\",\n    returnValue: \"boolean\",\n    arguments: [\"argument1\", \"argument2\"],\n    examples: [\"gt(5, 2)\"],\n  },\n  {\n    name: \"gte\",\n    description: \"Checks if the first argument is greater or equal to the second\",\n    returnValue: \"boolean\",\n    arguments: [\"argument1\", \"argument2\"],\n    examples: [\"gte(2, 2)\"],\n  },\n  {\n    name: \"lt\",\n    description: \"Checks if the first argument is smaller than the second\",\n    returnValue: \"boolean\",\n    arguments: [\"argument1\", \"argument2\"],\n    examples: [\"lt(2, 5)\"],\n  },\n  {\n    name: \"lte\",\n    description: \"Checks if the first argument is smaller or equal to the second\",\n    returnValue: \"boolean\",\n    arguments: [\"argument1\", \"argument2\"],\n    examples: [\"lte(2, 2)\"],\n  },\n  {\n    name: \"slice\",\n    description: \"Slices a string argument at start and end\",\n    returnValue: \"string\",\n    arguments: [\"string\", \"start\", \"end\"],\n    examples: ['slice(\"Hello World\", 0, 5)'],\n  },\n  {\n    name: \"lower\",\n    description: \"Converts a string argument to lowercase\",\n    returnValue: \"string\",\n    arguments: [\"string\"],\n    examples: ['lower(\"Hello World\")'],\n  },\n  {\n    name: \"upper\",\n    description: \"Converts a string argument to uppercase\",\n    returnValue: \"string\",\n    arguments: [\"string\"],\n    examples: ['upper(\"Hello World\")'],\n  },\n  {\n    name: \"upperFirst\",\n    description: \"Converts the first character of a string argument to uppercase\",\n    returnValue: \"string\",\n    arguments: [\"string\"],\n    examples: ['upperFirst(\"hello World\")'],\n  },\n  {\n    name: \"rand\",\n    description: \"Returns a random number between from and to, optionally using seed\",\n    returnValue: \"number\",\n    arguments: [\"from\", \"to\", \"seed\"],\n    examples: [\"rand(1, 10)\"],\n  },\n  {\n    name: \"round\",\n    description: \"Rounds a number to the given decimal places\",\n    returnValue: \"number\",\n    arguments: [\"number\", \"decimalPlaces\"],\n    examples: [\"round(1.2345, 2)\"],\n  },\n  {\n    name: \"add\",\n    description: \"Adds two or more numbers\",\n    returnValue: \"number\",\n    arguments: [\"number1\", \"number2\", \"...\"],\n    examples: [\"add(1, 2)\"],\n  },\n  {\n    name: \"sub\",\n    description: \"Subtracts two or more numbers\",\n    returnValue: \"number\",\n    arguments: [\"number1\", \"number2\", \"...\"],\n    examples: [\"sub(3, 1)\"],\n  },\n  {\n    name: \"mul\",\n    description: \"Multiplies two or more numbers\",\n    returnValue: \"number\",\n    arguments: [\"number1\", \"number2\", \"...\"],\n    examples: [\"mul(2, 3)\"],\n  },\n  {\n    name: \"div\",\n    description: \"Divides two or more numbers\",\n    returnValue: \"number\",\n    arguments: [\"number1\", \"number2\", \"...\"],\n    examples: [\"div(6, 2)\"],\n  },\n  {\n    name: \"cases\",\n    description: \"Returns the argument at position\",\n    returnValue: \"any\",\n    arguments: [\"position\", \"argument1\", \"argument2\", \"...\"],\n    examples: ['cases(1, \"Hello\", \"World\")'],\n  },\n  {\n    name: \"choose\",\n    description: \"Returns a random argument\",\n    returnValue: \"any\",\n    arguments: [\"argument1\", \"argument2\", \"...\"],\n    examples: ['choose(\"Hello\", \"World\", \"!\")'],\n  },\n];\n"
  },
  {
    "path": "backend/src/plugins/Tags/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { GuildTags } from \"../../data/GuildTags.js\";\nimport { zBoundedCharacters, zStrictMessageContent } from \"../../utils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zTag = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]);\nexport type TTag = z.infer<typeof zTag>;\n\nexport const zTagCategory = z\n  .strictObject({\n    prefix: z.string().nullable().default(null),\n    delete_with_command: z.boolean().default(false),\n\n    user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag\n    user_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag category\n    global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag\n    allow_mentions: z.boolean().nullable().default(null),\n    global_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per category\n    auto_delete_command: z.boolean().nullable().default(null),\n\n    tags: z.record(z.string(), zTag),\n\n    can_use: z.boolean().nullable().default(null),\n  })\n  .refine((parsed) => !(parsed.auto_delete_command && parsed.delete_with_command), {\n    message: \"Cannot have both (category specific) delete_with_command and auto_delete_command enabled\",\n  });\nexport type TTagCategory = z.infer<typeof zTagCategory>;\n\nexport const zTagsConfig = z\n  .strictObject({\n    prefix: z.string().default(\"!!\"),\n    delete_with_command: z.boolean().default(true),\n\n    user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag\n    global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag\n    user_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user\n    allow_mentions: z.boolean().default(false), // Per user\n    global_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any tag use\n    auto_delete_command: z.boolean().default(false), // Any tag\n\n    categories: z.record(z.string(), zTagCategory).default({}),\n\n    can_create: z.boolean().default(false),\n    can_use: z.boolean().default(false),\n    can_list: z.boolean().default(false),\n  })\n  .refine((parsed) => !(parsed.auto_delete_command && parsed.delete_with_command), {\n    message: \"Cannot have both (category specific) delete_with_command and auto_delete_command enabled\",\n  });\n\nexport interface TagsPluginType extends BasePluginType {\n  configSchema: typeof zTagsConfig;\n  state: {\n    archives: GuildArchives;\n    tags: GuildTags;\n    savedMessages: GuildSavedMessages;\n    logs: GuildLogs;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n\n    onMessageCreateFn;\n\n    tagFunctions: any;\n  };\n}\n\nexport interface TemplateFunction {\n  name: string;\n  description: string;\n  arguments: string[];\n  returnValue: string;\n  signature?: string;\n  examples?: string[];\n}\n\nexport const tagsCmd = guildPluginMessageCommand<TagsPluginType>();\nexport const tagsEvt = guildPluginEventListener<TagsPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/Tags/util/findTagByName.ts",
    "content": "import { ExtendedMatchParams, GuildPluginData } from \"vety\";\nimport { TTag, TagsPluginType } from \"../types.js\";\n\nexport async function findTagByName(\n  pluginData: GuildPluginData<TagsPluginType>,\n  name: string,\n  matchParams: ExtendedMatchParams = {},\n): Promise<TTag | null> {\n  const config = await pluginData.config.getMatchingConfig(matchParams);\n\n  // Tag from a hardcoded category\n  // Format: \"category.tag\"\n  const categorySeparatorIndex = name.indexOf(\".\");\n  if (categorySeparatorIndex > 0) {\n    const categoryName = name.slice(0, categorySeparatorIndex);\n    if (!Object.hasOwn(config.categories, categoryName)) {\n      return null;\n    }\n    const category = config.categories[categoryName];\n\n    const tagName = name.slice(categorySeparatorIndex + 1);\n    if (!Object.hasOwn(category.tags, tagName)) {\n      return null;\n    }\n    return category.tags[tagName];\n  }\n\n  // Dynamic tag\n  // Format: \"tag\"\n  const dynamicTag = await pluginData.state.tags.find(name);\n  return dynamicTag?.body ?? null;\n}\n"
  },
  {
    "path": "backend/src/plugins/Tags/util/matchAndRenderTagFromString.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport escapeStringRegexp from \"escape-string-regexp\";\nimport { ExtendedMatchParams, GuildPluginData } from \"vety\";\nimport { StrictMessageContent } from \"../../../utils.js\";\nimport { TTagCategory, TagsPluginType } from \"../types.js\";\nimport { renderTagFromString } from \"./renderTagFromString.js\";\n\ninterface BaseResult {\n  renderedContent: StrictMessageContent;\n  tagName: string;\n}\n\ntype ResultWithCategory = BaseResult & {\n  categoryName: string;\n  category: TTagCategory;\n};\n\ntype ResultWithoutCategory = BaseResult & {\n  categoryName: null;\n  category: null;\n};\n\ntype Result = ResultWithCategory | ResultWithoutCategory;\n\nexport async function matchAndRenderTagFromString(\n  pluginData: GuildPluginData<TagsPluginType>,\n  str: string,\n  member: GuildMember,\n  extraMatchParams: ExtendedMatchParams = {},\n): Promise<Result | null> {\n  const config = await pluginData.config.getMatchingConfig({\n    ...extraMatchParams,\n    member,\n  });\n\n  // Hard-coded tags in categories\n  for (const [name, category] of Object.entries(config.categories)) {\n    const canUse = category.can_use != null ? category.can_use : config.can_use;\n    if (canUse !== true) continue;\n\n    const prefix = category.prefix != null ? category.prefix : config.prefix;\n    if (prefix !== \"\" && !str.startsWith(prefix)) continue;\n\n    const withoutPrefix = str.slice(prefix.length);\n\n    for (const [tagName, tagBody] of Object.entries(category.tags)) {\n      const regex = new RegExp(`^${escapeStringRegexp(tagName)}(?:\\\\s|$)`);\n      if (regex.test(withoutPrefix)) {\n        const renderedContent = await renderTagFromString(pluginData, str, prefix, tagName, tagBody, member);\n\n        if (renderedContent == null) {\n          return null;\n        }\n\n        return {\n          renderedContent,\n          tagName,\n          categoryName: name,\n          category,\n        };\n      }\n    }\n  }\n\n  // Dynamic tags\n  if (config.can_use !== true) {\n    return null;\n  }\n\n  const dynamicTagPrefix = config.prefix;\n  if (!str.startsWith(dynamicTagPrefix)) {\n    return null;\n  }\n\n  const dynamicTagNameMatch = str.slice(dynamicTagPrefix.length).match(/^\\S+/);\n  if (dynamicTagNameMatch === null) {\n    return null;\n  }\n\n  const dynamicTagName = dynamicTagNameMatch[0];\n  const dynamicTag = await pluginData.state.tags.find(dynamicTagName);\n  if (!dynamicTag) {\n    return null;\n  }\n\n  const renderedDynamicTagContent = await renderTagFromString(\n    pluginData,\n    str,\n    dynamicTagPrefix,\n    dynamicTagName,\n    dynamicTag.body,\n    member,\n  );\n\n  if (renderedDynamicTagContent == null) {\n    return null;\n  }\n\n  return {\n    renderedContent: renderedDynamicTagContent,\n    tagName: dynamicTagName,\n    categoryName: null,\n    category: null,\n  };\n}\n"
  },
  {
    "path": "backend/src/plugins/Tags/util/onMessageCreate.ts",
    "content": "import { Snowflake, TextChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { convertDelayStringToMS, resolveMember, zStrictMessageContent } from \"../../../utils.js\";\nimport { erisAllowedMentionsToDjsMentionOptions } from \"../../../utils/erisAllowedMentionsToDjsMentionOptions.js\";\nimport { messageIsEmpty } from \"../../../utils/messageIsEmpty.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { TagsPluginType } from \"../types.js\";\nimport { matchAndRenderTagFromString } from \"./matchAndRenderTagFromString.js\";\n\nexport async function onMessageCreate(pluginData: GuildPluginData<TagsPluginType>, msg: SavedMessage) {\n  if (msg.is_bot) return;\n  if (!msg.data.content) return;\n\n  const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);\n  if (!member) return;\n\n  const channel = pluginData.guild.channels.cache.get(msg.channel_id as Snowflake);\n  if (!channel?.isTextBased()) return;\n\n  const config = await pluginData.config.getMatchingConfig({\n    member,\n    channelId: msg.channel_id,\n    categoryId: channel.parentId,\n  });\n\n  const tagResult = await matchAndRenderTagFromString(pluginData, msg.data.content, member, {\n    channelId: msg.channel_id,\n    categoryId: channel.parentId,\n  });\n\n  if (!tagResult) {\n    return;\n  }\n\n  // Check for cooldowns\n  const cooldowns: any[] = [];\n\n  if (tagResult.category) {\n    // Category-specific cooldowns\n    if (tagResult.category.user_tag_cooldown) {\n      const delay = convertDelayStringToMS(String(tagResult.category.user_tag_cooldown), \"s\");\n      cooldowns.push([`tags-category-${tagResult.categoryName}-user-${msg.user_id}-tag-${tagResult.tagName}`, delay]);\n    }\n    if (tagResult.category.global_tag_cooldown) {\n      const delay = convertDelayStringToMS(String(tagResult.category.global_tag_cooldown), \"s\");\n      cooldowns.push([`tags-category-${tagResult.categoryName}-tag-${tagResult.tagName}`, delay]);\n    }\n    if (tagResult.category.user_category_cooldown) {\n      const delay = convertDelayStringToMS(String(tagResult.category.user_category_cooldown), \"s\");\n      cooldowns.push([`tags-category-${tagResult.categoryName}-user--${msg.user_id}`, delay]);\n    }\n    if (tagResult.category.global_category_cooldown) {\n      const delay = convertDelayStringToMS(String(tagResult.category.global_category_cooldown), \"s\");\n      cooldowns.push([`tags-category-${tagResult.categoryName}`, delay]);\n    }\n  } else {\n    // Dynamic tag cooldowns\n    if (config.user_tag_cooldown) {\n      const delay = convertDelayStringToMS(String(config.user_tag_cooldown), \"s\");\n      cooldowns.push([`tags-user-${msg.user_id}-tag-${tagResult.tagName}`, delay]);\n    }\n\n    if (config.global_tag_cooldown) {\n      const delay = convertDelayStringToMS(String(config.global_tag_cooldown), \"s\");\n      cooldowns.push([`tags-tag-${tagResult.tagName}`, delay]);\n    }\n\n    if (config.user_cooldown) {\n      const delay = convertDelayStringToMS(String(config.user_cooldown), \"s\");\n      cooldowns.push([`tags-user-${msg.user_id}`, delay]);\n    }\n\n    if (config.global_cooldown) {\n      const delay = convertDelayStringToMS(String(config.global_cooldown), \"s\");\n      cooldowns.push([`tags`, delay]);\n    }\n  }\n\n  const isOnCooldown = cooldowns.some((cd) => pluginData.cooldowns.isOnCooldown(cd[0]));\n  if (isOnCooldown) return;\n\n  for (const cd of cooldowns) {\n    pluginData.cooldowns.setCooldown(cd[0], cd[1]);\n  }\n\n  const validated = zStrictMessageContent.safeParse(tagResult.renderedContent);\n  if (!validated.success) {\n    pluginData.getPlugin(LogsPlugin).logBotAlert({\n      body: `Rendering tag ${tagResult.tagName} resulted in an invalid message: ${validated.error.message}`,\n    });\n    return;\n  }\n\n  if (messageIsEmpty(tagResult.renderedContent)) {\n    pluginData.getPlugin(LogsPlugin).logBotAlert({\n      body: `Tag \\`${tagResult.tagName}\\` resulted in an empty message, so it couldn't be sent`,\n    });\n    return;\n  }\n\n  const allowMentions = tagResult.category?.allow_mentions ?? config.allow_mentions;\n  const responseMsg = await channel.send({\n    ...tagResult.renderedContent,\n    allowedMentions: erisAllowedMentionsToDjsMentionOptions({ roles: allowMentions, users: allowMentions }),\n  });\n\n  // Save the command-response message pair once the message is in our database\n  const deleteWithCommand = tagResult.category?.delete_with_command ?? config.delete_with_command;\n  if (deleteWithCommand) {\n    await pluginData.state.tags.addResponse(msg.id, responseMsg.id);\n  }\n\n  const deleteInvoke = tagResult.category?.auto_delete_command ?? config.auto_delete_command;\n  if (!deleteWithCommand && deleteInvoke) {\n    // Try deleting the invoking message, ignore errors silently\n    (pluginData.guild.channels.resolve(msg.channel_id as Snowflake) as TextChannel).messages.delete(\n      msg.id as Snowflake,\n    );\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Tags/util/onMessageDelete.ts",
    "content": "import { guildPluginEventListener } from \"vety\";\nimport { noop } from \"../../../utils.js\";\n\nexport const onMessageDelete = guildPluginEventListener({\n  event: \"messageDelete\",\n  async listener({ pluginData, args: { message: msg } }) {\n    const channel = pluginData.guild.channels.cache.get(msg.channelId);\n    if (!channel?.isTextBased()) return;\n\n    // Command message was deleted -> delete the response as well\n    const commandMsgResponse = await pluginData.state.tags.findResponseByCommandMessageId(msg.id);\n    if (commandMsgResponse) {\n      await pluginData.state.tags.deleteResponseByCommandMessageId(msg.id);\n      await channel.messages.delete(commandMsgResponse.response_message_id).catch(noop);\n      return;\n    }\n\n    // Response was deleted -> delete the command message as well\n    const responseMsgResponse = await pluginData.state.tags.findResponseByResponseMessageId(msg.id);\n    if (responseMsgResponse) {\n      await pluginData.state.tags.deleteResponseByResponseMessageId(msg.id);\n      await channel.messages.delete(responseMsgResponse.command_message_id).catch(noop);\n      return;\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Tags/util/renderTagBody.ts",
    "content": "import { ExtendedMatchParams, GuildPluginData } from \"vety\";\nimport { TemplateSafeValue, TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport { StrictMessageContent, renderRecursively } from \"../../../utils.js\";\nimport { TTag, TagsPluginType } from \"../types.js\";\nimport { findTagByName } from \"./findTagByName.js\";\n\nconst MAX_TAG_FN_CALLS = 25;\n\n// This is used to disallow setting/getting default object properties (such as __proto__) in dynamicVars\nconst emptyObject = {};\n\nexport async function renderTagBody(\n  pluginData: GuildPluginData<TagsPluginType>,\n  body: TTag,\n  args: TemplateSafeValue[] = [],\n  extraData = {},\n  subTagPermissionMatchParams?: ExtendedMatchParams,\n  tagFnCallsObj = { calls: 0 },\n): Promise<StrictMessageContent> {\n  const dynamicVars = {};\n\n  const data = new TemplateSafeValueContainer({\n    args,\n    ...extraData,\n    ...pluginData.state.tagFunctions,\n    set(name, val) {\n      if (typeof name !== \"string\") return;\n      if (emptyObject[name]) return;\n      dynamicVars[name] = val;\n    },\n    setr(name, val) {\n      if (typeof name !== \"string\") return \"\";\n      if (emptyObject[name]) return;\n      dynamicVars[name] = val;\n      return val;\n    },\n    get(name) {\n      if (typeof name !== \"string\") return \"\";\n      if (emptyObject[name]) return;\n      return !Object.hasOwn(dynamicVars, name) || dynamicVars[name] == null ? \"\" : dynamicVars[name];\n    },\n    tag: async (name, ...subTagArgs) => {\n      if (++tagFnCallsObj.calls > MAX_TAG_FN_CALLS) return \"\";\n      if (typeof name !== \"string\") return \"\";\n      if (name === \"\") return \"\";\n\n      const subTagBody = await findTagByName(pluginData, name, subTagPermissionMatchParams);\n\n      if (!subTagBody) {\n        return \"\";\n      }\n\n      if (typeof subTagBody !== \"string\") {\n        return \"<embed>\";\n      }\n\n      const rendered = await renderTagBody(\n        pluginData,\n        subTagBody,\n        subTagArgs,\n        extraData,\n        subTagPermissionMatchParams,\n        tagFnCallsObj,\n      );\n      return rendered.content!;\n    },\n  });\n\n  if (typeof body === \"string\") {\n    // Plain text tag\n    return { content: await renderTemplate(body, data) };\n  } else {\n    // Embed\n    return renderRecursively(body, (str) => renderTemplate(str, data));\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Tags/util/renderTagFromString.ts",
    "content": "import { GuildMember } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { parseArguments } from \"knub-command-manager\";\nimport { logger } from \"../../../logger.js\";\nimport { TemplateParseError } from \"../../../templateFormatter.js\";\nimport { StrictMessageContent, validateAndParseMessageContent } from \"../../../utils.js\";\nimport { memberToTemplateSafeMember, userToTemplateSafeUser } from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { TTag, TagsPluginType } from \"../types.js\";\nimport { renderTagBody } from \"./renderTagBody.js\";\n\nexport async function renderTagFromString(\n  pluginData: GuildPluginData<TagsPluginType>,\n  str: string,\n  prefix: string,\n  tagName: string,\n  tagBody: TTag,\n  member: GuildMember,\n): Promise<StrictMessageContent | null> {\n  const variableStr = str.slice(prefix.length + tagName.length).trim();\n  const tagArgs = parseArguments(variableStr).map((v) => v.value);\n\n  // Format the string\n  try {\n    const rendered = await renderTagBody(\n      pluginData,\n      tagBody,\n      tagArgs,\n      {\n        member: memberToTemplateSafeMember(member),\n        user: userToTemplateSafeUser(member.user),\n      },\n      { member },\n    );\n\n    return validateAndParseMessageContent(rendered);\n  } catch (e) {\n    const logs = pluginData.getPlugin(LogsPlugin);\n    const errorMessage = e instanceof TemplateParseError ? e.message : \"Internal error\";\n    logs.logBotAlert({\n      body: `Failed to render tag \\`${prefix}${tagName}\\`: ${errorMessage}`,\n    });\n\n    if (!(e instanceof TemplateParseError)) {\n      logger.warn(`Internal error rendering tag ${tagName} in ${pluginData.guild.id}: ${e}`);\n    }\n\n    return null;\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildMemberTimezones } from \"../../data/GuildMemberTimezones.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { ResetTimezoneCmd } from \"./commands/ResetTimezoneCmd.js\";\nimport { SetTimezoneCmd } from \"./commands/SetTimezoneCmd.js\";\nimport { ViewTimezoneCmd } from \"./commands/ViewTimezoneCmd.js\";\nimport { getDateFormat } from \"./functions/getDateFormat.js\";\nimport { getGuildTz } from \"./functions/getGuildTz.js\";\nimport { getMemberTz } from \"./functions/getMemberTz.js\";\nimport { inGuildTz } from \"./functions/inGuildTz.js\";\nimport { inMemberTz } from \"./functions/inMemberTz.js\";\nimport { TimeAndDatePluginType, zTimeAndDateConfig } from \"./types.js\";\n\nexport const TimeAndDatePlugin = guildPlugin<TimeAndDatePluginType>()({\n  name: \"time_and_date\",\n\n  configSchema: zTimeAndDateConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_set_timezone: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    ResetTimezoneCmd,\n    SetTimezoneCmd,\n    ViewTimezoneCmd,\n  ],\n\n  public(pluginData) {\n    return {\n      getGuildTz: makePublicFn(pluginData, getGuildTz),\n      inGuildTz: makePublicFn(pluginData, inGuildTz),\n      getMemberTz: makePublicFn(pluginData, getMemberTz),\n      inMemberTz: makePublicFn(pluginData, inMemberTz),\n      getDateFormat: makePublicFn(pluginData, getDateFormat),\n    };\n  },\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.memberTimezones = GuildMemberTimezones.getGuildInstance(guild.id);\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts",
    "content": "import { getGuildTz } from \"../functions/getGuildTz.js\";\nimport { timeAndDateCmd } from \"../types.js\";\n\nexport const ResetTimezoneCmd = timeAndDateCmd({\n  trigger: \"timezone reset\",\n  permission: \"can_set_timezone\",\n\n  signature: {},\n\n  async run({ pluginData, message }) {\n    await pluginData.state.memberTimezones.reset(message.author.id);\n    const serverTimezone = getGuildTz(pluginData);\n    void pluginData.state.common.sendSuccessMessage(\n      message,\n      `Your timezone has been reset to server default, **${serverTimezone}**`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts",
    "content": "import { escapeInlineCode } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { trimLines } from \"../../../utils.js\";\nimport { parseFuzzyTimezone } from \"../../../utils/parseFuzzyTimezone.js\";\nimport { timeAndDateCmd } from \"../types.js\";\n\nexport const SetTimezoneCmd = timeAndDateCmd({\n  trigger: \"timezone\",\n  permission: \"can_set_timezone\",\n\n  signature: {\n    timezone: ct.string(),\n  },\n\n  async run({ pluginData, message, args }) {\n    const parsedTz = parseFuzzyTimezone(args.timezone);\n    if (!parsedTz) {\n      void pluginData.state.common.sendErrorMessage(\n        message,\n        trimLines(`\n        Invalid timezone: \\`${escapeInlineCode(args.timezone)}\\`\n        Zeppelin uses timezone locations rather than specific timezone names.\n        See the **TZ database name** column at <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones> for a list of valid options.\n      `),\n      );\n      return;\n    }\n\n    await pluginData.state.memberTimezones.set(message.author.id, parsedTz);\n    void pluginData.state.common.sendSuccessMessage(message, `Your timezone is now set to **${parsedTz}**`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/commands/ViewTimezoneCmd.ts",
    "content": "import { getGuildTz } from \"../functions/getGuildTz.js\";\nimport { timeAndDateCmd } from \"../types.js\";\n\nexport const ViewTimezoneCmd = timeAndDateCmd({\n  trigger: \"timezone\",\n  permission: \"can_set_timezone\",\n\n  signature: {},\n\n  async run({ pluginData, message }) {\n    const memberTimezone = await pluginData.state.memberTimezones.get(message.author.id);\n    if (memberTimezone) {\n      message.channel.send(`Your timezone is currently set to **${memberTimezone.timezone}**`);\n      return;\n    }\n\n    const serverTimezone = getGuildTz(pluginData);\n    message.channel.send(`Your timezone is currently set to **${serverTimezone}** (server default)`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/defaultDateFormats.ts",
    "content": "export const defaultDateFormats = {\n  date: \"MMM D, YYYY\",\n  time: \"H:mm\",\n  pretty_datetime: \"MMM D, YYYY [at] H:mm z\",\n};\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { trimPluginDescription } from \"../../utils.js\";\nimport { zTimeAndDateConfig } from \"./types.js\";\n\nexport const timeAndDatePluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Time and date\",\n  description: trimPluginDescription(`\n    Allows controlling the displayed time/date formats and timezones\n  `),\n  configSchema: zTimeAndDateConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/functions/getDateFormat.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { defaultDateFormats } from \"../defaultDateFormats.js\";\nimport { TimeAndDatePluginType } from \"../types.js\";\n\nexport function getDateFormat(\n  pluginData: GuildPluginData<TimeAndDatePluginType>,\n  formatName: keyof typeof defaultDateFormats,\n) {\n  return pluginData.config.get().date_formats?.[formatName] || defaultDateFormats[formatName];\n}\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/functions/getGuildTz.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { TimeAndDatePluginType } from \"../types.js\";\n\nexport function getGuildTz(pluginData: GuildPluginData<TimeAndDatePluginType>) {\n  return pluginData.config.get().timezone;\n}\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/functions/getMemberTz.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport { TimeAndDatePluginType } from \"../types.js\";\nimport { getGuildTz } from \"./getGuildTz.js\";\n\nexport async function getMemberTz(pluginData: GuildPluginData<TimeAndDatePluginType>, memberId: string) {\n  const memberTz = await pluginData.state.memberTimezones.get(memberId);\n  return memberTz?.timezone || getGuildTz(pluginData);\n}\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/functions/inGuildTz.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { TimeAndDatePluginType } from \"../types.js\";\nimport { getGuildTz } from \"./getGuildTz.js\";\n\nexport function inGuildTz(pluginData: GuildPluginData<TimeAndDatePluginType>, input?: moment.Moment | number) {\n  let momentObj: moment.Moment;\n  if (typeof input === \"number\") {\n    momentObj = moment.utc(input, \"x\");\n  } else if (moment.isMoment(input)) {\n    momentObj = input.clone();\n  } else {\n    momentObj = moment.utc();\n  }\n\n  return momentObj.tz(getGuildTz(pluginData));\n}\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/functions/inMemberTz.ts",
    "content": "import { GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { TimeAndDatePluginType } from \"../types.js\";\nimport { getMemberTz } from \"./getMemberTz.js\";\n\nexport async function inMemberTz(\n  pluginData: GuildPluginData<TimeAndDatePluginType>,\n  memberId: string,\n  input?: moment.Moment | number,\n) {\n  let momentObj: moment.Moment;\n  if (typeof input === \"number\") {\n    momentObj = moment.utc(input, \"x\");\n  } else if (moment.isMoment(input)) {\n    momentObj = input.clone();\n  } else {\n    momentObj = moment.utc();\n  }\n\n  return momentObj.tz(await getMemberTz(pluginData, memberId));\n}\n"
  },
  {
    "path": "backend/src/plugins/TimeAndDate/types.ts",
    "content": "import { BasePluginType, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildMemberTimezones } from \"../../data/GuildMemberTimezones.js\";\nimport { keys } from \"../../utils.js\";\nimport { zValidTimezone } from \"../../utils/zValidTimezone.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { defaultDateFormats } from \"./defaultDateFormats.js\";\n\nconst dateFormatTypeMap = keys(defaultDateFormats).reduce(\n  (map, key) => {\n    map[key] = z.string().default(defaultDateFormats[key]);\n    return map;\n  },\n  {} as Record<keyof typeof defaultDateFormats, z.ZodDefault<z.ZodString>>,\n);\n\nexport const zTimeAndDateConfig = z.strictObject({\n  timezone: zValidTimezone(z.string()).default(\"Etc/UTC\"),\n  date_formats: z.strictObject(dateFormatTypeMap).default(defaultDateFormats),\n  can_set_timezone: z.boolean().default(false),\n});\n\nexport interface TimeAndDatePluginType extends BasePluginType {\n  configSchema: typeof zTimeAndDateConfig;\n  state: {\n    memberTimezones: GuildMemberTimezones;\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const timeAndDateCmd = guildPluginMessageCommand<TimeAndDatePluginType>();\n"
  },
  {
    "path": "backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { Queue } from \"../../Queue.js\";\nimport { UsernameHistory } from \"../../data/UsernameHistory.js\";\nimport { MessageCreateUpdateUsernameEvt, VoiceChannelJoinUpdateUsernameEvt } from \"./events/UpdateUsernameEvts.js\";\nimport { UsernameSaverPluginType, zUsernameSaverConfig } from \"./types.js\";\n\nexport const UsernameSaverPlugin = guildPlugin<UsernameSaverPluginType>()({\n  name: \"username_saver\",\n\n  configSchema: zUsernameSaverConfig,\n\n  // prettier-ignore\n  events: [\n    MessageCreateUpdateUsernameEvt,\n    VoiceChannelJoinUpdateUsernameEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state } = pluginData;\n\n    state.usernameHistory = new UsernameHistory();\n    state.updateQueue = new Queue();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/UsernameSaver/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zUsernameSaverConfig } from \"./types.js\";\n\nexport const usernameSaverPluginDocs: ZeppelinPluginDocs = {\n  type: \"internal\",\n  prettyName: \"Username saver\",\n  configSchema: zUsernameSaverConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/UsernameSaver/events/UpdateUsernameEvts.ts",
    "content": "import { usernameSaverEvt } from \"../types.js\";\nimport { updateUsername } from \"../updateUsername.js\";\n\nexport const MessageCreateUpdateUsernameEvt = usernameSaverEvt({\n  event: \"messageCreate\",\n\n  async listener(meta) {\n    if (meta.args.message.author.bot) return;\n    meta.pluginData.state.updateQueue.add(() => updateUsername(meta.pluginData, meta.args.message.author));\n  },\n});\n\nexport const VoiceChannelJoinUpdateUsernameEvt = usernameSaverEvt({\n  event: \"voiceStateUpdate\",\n\n  async listener(meta) {\n    if (meta.args.newState.member?.user.bot) return;\n    meta.pluginData.state.updateQueue.add(() => updateUsername(meta.pluginData, meta.args.newState.member!.user));\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/UsernameSaver/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener } from \"vety\";\nimport { z } from \"zod\";\nimport { Queue } from \"../../Queue.js\";\nimport { UsernameHistory } from \"../../data/UsernameHistory.js\";\n\nexport const zUsernameSaverConfig = z.strictObject({});\n\nexport interface UsernameSaverPluginType extends BasePluginType {\n  configSchema: typeof zUsernameSaverConfig;\n  state: {\n    usernameHistory: UsernameHistory;\n    updateQueue: Queue;\n  };\n}\n\nexport const usernameSaverEvt = guildPluginEventListener<UsernameSaverPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/UsernameSaver/updateUsername.ts",
    "content": "import { User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { renderUsername } from \"../../utils.js\";\nimport { UsernameSaverPluginType } from \"./types.js\";\n\nexport async function updateUsername(pluginData: GuildPluginData<UsernameSaverPluginType>, user: User) {\n  if (!user) return;\n  const newUsername = renderUsername(user);\n  const latestEntry = await pluginData.state.usernameHistory.getLastEntry(user.id);\n  if (!latestEntry || newUsername !== latestEntry.username) {\n    await pluginData.state.usernameHistory.addEntry(user.id, newUsername);\n  }\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/UtilityPlugin.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { guildPlugin } from \"vety\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { Supporters } from \"../../data/Supporters.js\";\nimport { makePublicFn } from \"../../pluginUtils.js\";\nimport { discardRegExpRunner, getRegExpRunner } from \"../../regExpRunners.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { ModActionsPlugin } from \"../ModActions/ModActionsPlugin.js\";\nimport { TimeAndDatePlugin } from \"../TimeAndDate/TimeAndDatePlugin.js\";\nimport { AboutCmd } from \"./commands/AboutCmd.js\";\nimport { AvatarCmd } from \"./commands/AvatarCmd.js\";\nimport { BanSearchCmd } from \"./commands/BanSearchCmd.js\";\nimport { ChannelInfoCmd } from \"./commands/ChannelInfoCmd.js\";\nimport { CleanCmd } from \"./commands/CleanCmd.js\";\nimport { ContextCmd } from \"./commands/ContextCmd.js\";\nimport { EmojiInfoCmd } from \"./commands/EmojiInfoCmd.js\";\nimport { HelpCmd } from \"./commands/HelpCmd.js\";\nimport { InfoCmd } from \"./commands/InfoCmd.js\";\nimport { InviteInfoCmd } from \"./commands/InviteInfoCmd.js\";\nimport { JumboCmd } from \"./commands/JumboCmd.js\";\nimport { LevelCmd } from \"./commands/LevelCmd.js\";\nimport { MessageInfoCmd } from \"./commands/MessageInfoCmd.js\";\nimport { NicknameCmd } from \"./commands/NicknameCmd.js\";\nimport { NicknameResetCmd } from \"./commands/NicknameResetCmd.js\";\nimport { PingCmd } from \"./commands/PingCmd.js\";\nimport { ReloadGuildCmd } from \"./commands/ReloadGuildCmd.js\";\nimport { RoleInfoCmd } from \"./commands/RoleInfoCmd.js\";\nimport { RolesCmd } from \"./commands/RolesCmd.js\";\nimport { SearchCmd } from \"./commands/SearchCmd.js\";\nimport { ServerInfoCmd } from \"./commands/ServerInfoCmd.js\";\nimport { SnowflakeInfoCmd } from \"./commands/SnowflakeInfoCmd.js\";\nimport { SourceCmd } from \"./commands/SourceCmd.js\";\nimport { UserInfoCmd } from \"./commands/UserInfoCmd.js\";\nimport { VcdisconnectCmd } from \"./commands/VcdisconnectCmd.js\";\nimport { VcmoveAllCmd, VcmoveCmd } from \"./commands/VcmoveCmd.js\";\nimport { AutoJoinThreadEvt, AutoJoinThreadSyncEvt } from \"./events/AutoJoinThreadEvt.js\";\nimport { cleanMessages } from \"./functions/cleanMessages.js\";\nimport { fetchChannelMessagesToClean } from \"./functions/fetchChannelMessagesToClean.js\";\nimport { getUserInfoEmbed } from \"./functions/getUserInfoEmbed.js\";\nimport { hasPermission } from \"./functions/hasPermission.js\";\nimport { activeReloads } from \"./guildReloads.js\";\nimport { refreshMembersIfNeeded } from \"./refreshMembers.js\";\nimport { UtilityPluginType, zUtilityConfig } from \"./types.js\";\n\nexport const UtilityPlugin = guildPlugin<UtilityPluginType>()({\n  name: \"utility\",\n\n  dependencies: () => [TimeAndDatePlugin, ModActionsPlugin, LogsPlugin],\n\n  configSchema: zUtilityConfig,\n  defaultOverrides: [\n    {\n      level: \">=50\",\n      config: {\n        can_roles: true,\n        can_level: true,\n        can_search: true,\n        can_clean: true,\n        can_info: true,\n        can_server: true,\n        can_inviteinfo: true,\n        can_channelinfo: true,\n        can_messageinfo: true,\n        can_userinfo: true,\n        can_roleinfo: true,\n        can_emojiinfo: true,\n        can_snowflake: true,\n        can_nickname: true,\n        can_vcmove: true,\n        can_vckick: true,\n        can_help: true,\n        can_context: true,\n        can_jumbo: true,\n        can_avatar: true,\n        can_source: true,\n      },\n    },\n    {\n      level: \">=100\",\n      config: {\n        can_reload_guild: true,\n        can_ping: true,\n        can_about: true,\n      },\n    },\n  ],\n\n  // prettier-ignore\n  messageCommands: [\n    SearchCmd,\n    BanSearchCmd,\n    UserInfoCmd,\n    LevelCmd,\n    RolesCmd,\n    ServerInfoCmd,\n    NicknameResetCmd,\n    NicknameCmd,\n    PingCmd,\n    SourceCmd,\n    ContextCmd,\n    VcmoveCmd,\n    VcdisconnectCmd,\n    VcmoveAllCmd,\n    HelpCmd,\n    AboutCmd,\n    ReloadGuildCmd,\n    JumboCmd,\n    AvatarCmd,\n    CleanCmd,\n    InviteInfoCmd,\n    ChannelInfoCmd,\n    MessageInfoCmd,\n    InfoCmd,\n    SnowflakeInfoCmd,\n    RoleInfoCmd,\n    EmojiInfoCmd,\n  ],\n\n  // prettier-ignore\n  events: [\n    AutoJoinThreadEvt,\n    AutoJoinThreadSyncEvt,\n  ],\n\n  public(pluginData) {\n    return {\n      fetchChannelMessagesToClean: makePublicFn(pluginData, fetchChannelMessagesToClean),\n      cleanMessages: makePublicFn(pluginData, cleanMessages),\n      userInfo: (userId: Snowflake) => getUserInfoEmbed(pluginData, userId, false),\n      hasPermission: makePublicFn(pluginData, hasPermission),\n    };\n  },\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.logs = new GuildLogs(guild.id);\n    state.cases = GuildCases.getGuildInstance(guild.id);\n    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);\n    state.archives = GuildArchives.getGuildInstance(guild.id);\n    state.supporters = new Supporters();\n\n    state.regexRunner = getRegExpRunner(`guild-${pluginData.guild.id}`);\n\n    state.lastReload = Date.now();\n\n    // FIXME: Temp fix for role change detection for specific servers, load all guild members in the background on bot start\n    const roleChangeDetectionFixServers = [\n      \"786212572285763605\",\n      \"653681924384096287\",\n      \"493351982887862283\",\n      \"513338222810497041\",\n      \"523043978178723840\",\n      \"718076393295970376\",\n      \"803251072877199400\",\n      \"750492934343753798\",\n    ];\n    if (roleChangeDetectionFixServers.includes(pluginData.guild.id)) {\n      refreshMembersIfNeeded(pluginData.guild);\n    }\n  },\n\n  beforeStart(pluginData) {\n    pluginData.state.common = pluginData.getPlugin(CommonPlugin);\n  },\n\n  afterLoad(pluginData) {\n    const { guild } = pluginData;\n\n    if (activeReloads.has(guild.id)) {\n      pluginData.state.common.sendSuccessMessage(activeReloads.get(guild.id)!, \"Reloaded!\");\n      activeReloads.delete(guild.id);\n    }\n  },\n\n  beforeUnload(pluginData) {\n    discardRegExpRunner(`guild-${pluginData.guild.id}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/AboutCmd.ts",
    "content": "import { APIEmbed, GuildChannel } from \"discord.js\";\nimport { shuffle } from \"lodash-es\";\nimport moment from \"moment-timezone\";\nimport { accessSync, readFileSync } from \"node:fs\";\nimport { rootDir } from \"../../../paths.js\";\nimport { getBotStartTime } from \"../../../uptime.js\";\nimport { resolveMember, sorter } from \"../../../utils.js\";\nimport { TimeAndDatePlugin } from \"../../TimeAndDate/TimeAndDatePlugin.js\";\nimport { utilityCmd } from \"../types.js\";\n\nlet commitHash: string | null = null;\ntry {\n  accessSync(`${rootDir}/.commit-hash`);\n  commitHash = readFileSync(`${rootDir}/.commit-hash`, \"utf-8\").trim();\n} catch {}\n\nlet buildTime: string | null = null;\ntry {\n  accessSync(`${rootDir}/.build-time`);\n  buildTime = readFileSync(`${rootDir}/.build-time`, \"utf-8\").trim();\n} catch {}\n\nexport const AboutCmd = utilityCmd({\n  trigger: \"about\",\n  description: \"Show information about Zeppelin's status on the server\",\n  permission: \"can_about\",\n\n  async run({ message: msg, pluginData }) {\n    const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);\n\n    const botStartTime = getBotStartTime();\n    const buildTimeMoment = buildTime ? moment.utc(buildTime, \"YYYY-MM-DDTHH:mm:ss[Z]\") : null;\n\n    const basicInfoRows = [\n      [\"Bot start time\", `<t:${Math.floor(botStartTime / 1000)}:R>`],\n      [\"Last config reload\", `<t:${Math.floor(pluginData.state.lastReload / 1000)}:R>`],\n      [\"Last bot update\", buildTimeMoment ? `<t:${Math.floor(buildTimeMoment.unix())}:f>` : \"Unknown\"],\n      [\"Version\", commitHash?.slice(0, 7) || \"Unknown\"],\n      [\"API latency\", `${pluginData.client.ws.ping}ms`],\n      [\"Server timezone\", timeAndDate.getGuildTz()],\n    ];\n\n    const loadedPlugins = Array.from(\n      pluginData.getVetyInstance().getLoadedGuild(pluginData.guild.id)!.loadedPlugins.keys(),\n    );\n    loadedPlugins.sort();\n\n    const aboutEmbed: APIEmbed = {\n      title: `About ${pluginData.client.user!.username}`,\n      fields: [\n        {\n          name: \"Status\",\n          value: basicInfoRows.map(([label, value]) => `${label}: **${value}**`).join(\"\\n\"),\n        },\n        {\n          name: `Loaded plugins on this server (${loadedPlugins.length})`,\n          value: loadedPlugins.join(\", \"),\n        },\n      ],\n    };\n\n    const supporters = await pluginData.state.supporters.getAll();\n    const shuffledSupporters = shuffle(supporters);\n\n    if (supporters.length) {\n      const formattedSupporters = shuffledSupporters\n        // Bold every other supporter to make them easy to recognize from each other\n        .map((s, i) => (i % 2 === 0 ? `**${s.name}**` : `__${s.name}__`))\n        .join(\" \");\n\n      aboutEmbed.fields!.push({\n        name: \"Zeppelin supporters 🎉\",\n        value: \"These amazing people have supported Zeppelin development:\\n\\n\" + formattedSupporters,\n        inline: false,\n      });\n    }\n\n    // For the embed color, find the highest colored role the bot has - this is their color on the server as well\n    const botMember = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id);\n    let botRoles = botMember?.roles.cache.map((r) => (msg.channel as GuildChannel).guild.roles.cache.get(r.id)!) || [];\n    botRoles = botRoles.filter((r) => !!r); // Drop any unknown roles\n    botRoles = botRoles.filter((r) => r.color); // Filter to those with a color\n    botRoles.sort(sorter(\"position\", \"DESC\")); // Sort by position (highest first)\n    if (botRoles.length) {\n      aboutEmbed.color = botRoles[0].color;\n    }\n\n    // Use the bot avatar as the embed image\n    if (pluginData.client.user!.displayAvatarURL()) {\n      aboutEmbed.thumbnail = { url: pluginData.client.user!.displayAvatarURL()! };\n    }\n\n    msg.channel.send({ embeds: [aboutEmbed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/AvatarCmd.ts",
    "content": "import { APIEmbed, ImageFormat } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { UnknownUser, renderUsername } from \"../../../utils.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const AvatarCmd = utilityCmd({\n  trigger: [\"avatar\", \"av\"],\n  description: \"Retrieves a user's profile picture\",\n  permission: \"can_avatar\",\n\n  signature: {\n    user: ct.resolvedMember({ required: false }) || ct.resolvedUserLoose({ required: false }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const user = args.user ?? msg.member ?? msg.author;\n    if (!(user instanceof UnknownUser)) {\n      const embed: APIEmbed = {\n        image: {\n          url: user.displayAvatarURL({ extension: ImageFormat.PNG, size: 2048 }),\n        },\n        title: `Avatar of ${renderUsername(user)}:`,\n      };\n      msg.channel.send({ embeds: [embed] });\n    } else {\n      void pluginData.state.common.sendErrorMessage(msg, \"Invalid user ID\");\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/BanSearchCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { archiveSearch, displaySearch, SearchType } from \"../search.js\";\nimport { utilityCmd } from \"../types.js\";\n\n// Separate from BanSearchCmd to avoid a circular reference from ./search.ts\nexport const banSearchSignature = {\n  query: ct.string({ catchAll: true }),\n\n  page: ct.number({ option: true, shortcut: \"p\" }),\n  sort: ct.string({ option: true }),\n  \"case-sensitive\": ct.switchOption({ def: false, shortcut: \"cs\" }),\n  export: ct.switchOption({ def: false, shortcut: \"e\" }),\n  ids: ct.switchOption(),\n  regex: ct.switchOption({ def: false, shortcut: \"re\" }),\n};\n\nexport const BanSearchCmd = utilityCmd({\n  trigger: [\"bansearch\", \"bs\"],\n  description: \"Search banned users\",\n  usage: \"!bansearch dragory\",\n  permission: \"can_search\",\n\n  signature: banSearchSignature,\n\n  run({ pluginData, message, args }) {\n    if (args.export) {\n      return archiveSearch(pluginData, args, SearchType.BanSearch, message);\n    } else {\n      return displaySearch(pluginData, args, SearchType.BanSearch, message);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/ChannelInfoCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { getChannelInfoEmbed } from \"../functions/getChannelInfoEmbed.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const ChannelInfoCmd = utilityCmd({\n  trigger: [\"channel\", \"channelinfo\"],\n  description: \"Show information about a channel\",\n  usage: \"!channel 534722016549404673\",\n  permission: \"can_channelinfo\",\n\n  signature: {\n    channel: ct.channelId({ required: false }),\n  },\n\n  async run({ message, args, pluginData }) {\n    const embed = await getChannelInfoEmbed(pluginData, args.channel);\n    if (!embed) {\n      void pluginData.state.common.sendErrorMessage(message, \"Unknown channel\");\n      return;\n    }\n\n    message.channel.send({ embeds: [embed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/CleanCmd.ts",
    "content": "import { Message, Snowflake } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { ContextResponse, deleteContextResponse } from \"../../../pluginUtils.js\";\nimport { ModActionsPlugin } from \"../../../plugins/ModActions/ModActionsPlugin.js\";\nimport { SECONDS, noop } from \"../../../utils.js\";\nimport { cleanMessages } from \"../functions/cleanMessages.js\";\nimport { fetchChannelMessagesToClean } from \"../functions/fetchChannelMessagesToClean.js\";\nimport { utilityCmd } from \"../types.js\";\n\nconst CLEAN_COMMAND_DELETE_DELAY = 10 * SECONDS;\n\nconst opts = {\n  user: ct.userId({ option: true, shortcut: \"u\" }),\n  channel: ct.channelId({ option: true, shortcut: \"c\" }),\n  bots: ct.switchOption({ def: false, shortcut: \"b\" }),\n  \"delete-pins\": ct.switchOption({ def: false, shortcut: \"p\" }),\n  \"has-invites\": ct.switchOption({ def: false, shortcut: \"i\" }),\n  match: ct.regex({ option: true, shortcut: \"m\" }),\n  \"to-id\": ct.anyId({ option: true, shortcut: \"id\" }),\n};\n\nexport const CleanCmd = utilityCmd({\n  trigger: [\"clean\", \"clear\"],\n  description: \"Remove a number of recent messages\",\n  usage: \"!clean 20\",\n  permission: \"can_clean\",\n\n  signature: [\n    {\n      count: ct.number(),\n      update: ct.number({ option: true, shortcut: \"up\" }),\n\n      ...opts,\n    },\n    {\n      count: ct.number(),\n      update: ct.switchOption({ def: false, shortcut: \"up\" }),\n\n      ...opts,\n    },\n  ],\n\n  async run({ message: msg, args, pluginData }) {\n    const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel;\n    if (!targetChannel?.isTextBased()) {\n      void pluginData.state.common.sendErrorMessage(\n        msg,\n        `Invalid channel specified`,\n        undefined,\n        args[\"response-interaction\"],\n      );\n      return;\n    }\n\n    if (targetChannel.id !== msg.channel.id) {\n      const configForTargetChannel = await pluginData.config.getMatchingConfig({\n        userId: msg.author.id,\n        member: msg.member,\n        channelId: targetChannel.id,\n        categoryId: targetChannel.parentId,\n      });\n      if (configForTargetChannel.can_clean !== true) {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          `Missing permissions to use clean on that channel`,\n          undefined,\n          args[\"response-interaction\"],\n        );\n        return;\n      }\n    }\n\n    let cleaningMessage: Message | undefined = undefined;\n    if (!args[\"response-interaction\"]) {\n      cleaningMessage = await msg.channel.send(\"Cleaning...\");\n    }\n\n    const fetchMessagesResult = await fetchChannelMessagesToClean(pluginData, targetChannel, {\n      beforeId: msg.id,\n      count: args.count,\n      authorId: args.user,\n      includePins: args[\"delete-pins\"],\n      onlyBotMessages: args.bots,\n      onlyWithInvites: args[\"has-invites\"],\n      upToId: args[\"to-id\"],\n      matchContent: args.match,\n    });\n    if (\"error\" in fetchMessagesResult) {\n      void pluginData.state.common.sendErrorMessage(msg, fetchMessagesResult.error);\n      return;\n    }\n\n    const { messages: messagesToClean, note } = fetchMessagesResult;\n\n    let responseMsg: ContextResponse | null = null;\n    if (messagesToClean.length > 0) {\n      const cleanResult = await cleanMessages(pluginData, targetChannel, messagesToClean, msg.author);\n\n      let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? \"message\" : \"messages\"}`;\n      if (note) {\n        responseText += ` (${note})`;\n      }\n      if (targetChannel.id !== msg.channel.id) {\n        responseText += ` in <#${targetChannel.id}>: ${cleanResult.archiveUrl}`;\n      }\n\n      if (args.update) {\n        const modActions = pluginData.getPlugin(ModActionsPlugin);\n        const channelId = targetChannel.id !== msg.channel.id ? targetChannel.id : msg.channel.id;\n        const updateMessage = `Cleaned ${messagesToClean.length} ${\n          messagesToClean.length === 1 ? \"message\" : \"messages\"\n        } in <#${channelId}>: ${cleanResult.archiveUrl}`;\n        if (typeof args.update === \"number\") {\n          modActions.updateCase(msg, args.update, updateMessage);\n        } else {\n          modActions.updateCase(msg, null, updateMessage);\n        }\n      }\n\n      responseMsg = await pluginData.state.common.sendSuccessMessage(\n        msg,\n        responseText,\n        undefined,\n        args[\"response-interaction\"],\n      );\n    } else {\n      const responseText = `Found no messages to clean${note ? ` (${note})` : \"\"}!`;\n      responseMsg = await pluginData.state.common.sendErrorMessage(\n        msg,\n        responseText,\n        undefined,\n        args[\"response-interaction\"],\n      );\n    }\n\n    cleaningMessage?.delete();\n\n    if (targetChannel.id === msg.channel.id) {\n      // Delete the !clean command and the bot response if a different channel wasn't specified\n      // (so as not to spam the cleaned channel with the command itself)\n      msg.delete().catch(noop);\n      setTimeout(() => {\n        deleteContextResponse(responseMsg).catch(noop);\n        responseMsg?.delete().catch(noop);\n      }, CLEAN_COMMAND_DELETE_DELAY);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/ContextCmd.ts",
    "content": "import { Snowflake, TextChannel } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { messageLink } from \"../../../utils.js\";\nimport { canReadChannel } from \"../../../utils/canReadChannel.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const ContextCmd = utilityCmd({\n  trigger: \"context\",\n  description: \"Get a link to the context of the specified message\",\n  usage: \"!context 94882524378968064 650391267720822785\",\n  permission: \"can_context\",\n\n  signature: [\n    {\n      message: ct.messageTarget(),\n    },\n    {\n      channel: ct.channel(),\n      messageId: ct.string(),\n    },\n  ],\n\n  async run({ message: msg, args, pluginData }) {\n    if (args.channel && !(args.channel instanceof TextChannel)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Channel must be a text channel\");\n      return;\n    }\n\n    const channel = args.channel ?? args.message.channel;\n    const messageId = args.messageId ?? args.message.messageId;\n\n    const authorMember = await resolveMessageMember(msg);\n    if (!canReadChannel(channel, authorMember)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Message context not found\");\n      return;\n    }\n\n    const previousMessage = (\n      await (pluginData.guild.channels.cache.get(channel.id) as TextChannel).messages.fetch({\n        limit: 1,\n        before: messageId as Snowflake,\n      })\n    )[0];\n    if (!previousMessage) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Message context not found\");\n      return;\n    }\n\n    msg.channel.send(messageLink(pluginData.guild.id, previousMessage.channel.id, previousMessage.id));\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/EmojiInfoCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { getCustomEmojiId } from \"../functions/getCustomEmojiId.js\";\nimport { getEmojiInfoEmbed } from \"../functions/getEmojiInfoEmbed.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const EmojiInfoCmd = utilityCmd({\n  trigger: [\"emoji\", \"emojiinfo\"],\n  description: \"Show information about an emoji\",\n  usage: \"!emoji 106391128718245888\",\n  permission: \"can_emojiinfo\",\n\n  signature: {\n    emoji: ct.string({ required: true }),\n  },\n\n  async run({ message, args, pluginData }) {\n    const emojiId = getCustomEmojiId(args.emoji);\n    if (!emojiId) {\n      void pluginData.state.common.sendErrorMessage(message, \"Emoji not found\");\n      return;\n    }\n\n    const embed = await getEmojiInfoEmbed(pluginData, emojiId);\n    if (!embed) {\n      void pluginData.state.common.sendErrorMessage(message, \"Emoji not found\");\n      return;\n    }\n\n    message.channel.send({ embeds: [embed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/HelpCmd.ts",
    "content": "import { LoadedGuildPlugin, PluginCommandDefinition } from \"vety\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { env } from \"../../../env.js\";\nimport { createChunkedMessage } from \"../../../utils.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const HelpCmd = utilityCmd({\n  trigger: \"help\",\n  description: \"Show a quick reference for the specified command's usage\",\n  usage: \"!help clean\",\n  permission: \"can_help\",\n\n  signature: {\n    command: ct.string({ catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const searchStr = args.command.toLowerCase();\n\n    const matchingCommands: Array<{\n      plugin: LoadedGuildPlugin<any>;\n      command: PluginCommandDefinition;\n    }> = [];\n\n    const guildData = pluginData.getVetyInstance().getLoadedGuild(pluginData.guild.id)!;\n    for (const plugin of guildData.loadedPlugins.values()) {\n      const registeredCommands = plugin.pluginData.messageCommands.getAll();\n      for (const registeredCommand of registeredCommands) {\n        for (const trigger of registeredCommand.originalTriggers) {\n          const strTrigger = typeof trigger === \"string\" ? trigger : trigger.source;\n\n          if (strTrigger.startsWith(searchStr)) {\n            matchingCommands.push({\n              plugin,\n              command: registeredCommand,\n            });\n            break;\n          }\n        }\n      }\n    }\n\n    const totalResults = matchingCommands.length;\n    const limitedResults = matchingCommands.slice(0, 3);\n    const commandSnippets = limitedResults.map(({ plugin, command }) => {\n      const prefix: string = command.originalPrefix\n        ? typeof command.originalPrefix === \"string\"\n          ? command.originalPrefix\n          : command.originalPrefix.source\n        : \"\";\n\n      const originalTrigger = command.originalTriggers[0];\n      const trigger: string = originalTrigger\n        ? typeof originalTrigger === \"string\"\n          ? originalTrigger\n          : originalTrigger.source\n        : \"\";\n\n      const description = command.config!.extra!.blueprint.description;\n      const usage = command.config!.extra!.blueprint.usage;\n      const commandSlug = trigger.trim().toLowerCase().replace(/\\s/g, \"-\");\n\n      let snippet = `**${prefix}${trigger}**`;\n      if (description) snippet += `\\n${description}`;\n      if (usage) snippet += `\\nBasic usage: \\`${usage}\\``;\n      snippet += `\\n<${env.DASHBOARD_URL}/docs/plugins/${plugin.blueprint.name}/usage#command-${commandSlug}>`;\n\n      return snippet;\n    });\n\n    if (totalResults === 0) {\n      msg.channel.send(\"No matching commands found!\");\n      return;\n    }\n\n    let message =\n      totalResults !== limitedResults.length\n        ? `Results (${totalResults} total, showing first ${limitedResults.length}):\\n\\n`\n        : \"\";\n\n    message += commandSnippets.join(\"\\n\\n\");\n    createChunkedMessage(msg.channel, message);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/InfoCmd.ts",
    "content": "import { Snowflake } from \"discord.js\";\nimport { getChannelId, getRoleId } from \"vety/helpers\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { isValidSnowflake, noop, parseInviteCodeInput, resolveInvite, resolveUser } from \"../../../utils.js\";\nimport { canReadChannel } from \"../../../utils/canReadChannel.js\";\nimport { resolveMessageTarget } from \"../../../utils/resolveMessageTarget.js\";\nimport { getChannelInfoEmbed } from \"../functions/getChannelInfoEmbed.js\";\nimport { getCustomEmojiId } from \"../functions/getCustomEmojiId.js\";\nimport { getEmojiInfoEmbed } from \"../functions/getEmojiInfoEmbed.js\";\nimport { getGuildPreview } from \"../functions/getGuildPreview.js\";\nimport { getInviteInfoEmbed } from \"../functions/getInviteInfoEmbed.js\";\nimport { getMessageInfoEmbed } from \"../functions/getMessageInfoEmbed.js\";\nimport { getRoleInfoEmbed } from \"../functions/getRoleInfoEmbed.js\";\nimport { getServerInfoEmbed } from \"../functions/getServerInfoEmbed.js\";\nimport { getSnowflakeInfoEmbed } from \"../functions/getSnowflakeInfoEmbed.js\";\nimport { getUserInfoEmbed } from \"../functions/getUserInfoEmbed.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const InfoCmd = utilityCmd({\n  trigger: \"info\",\n  description: \"Show information about the specified thing\",\n  usage: \"!info\",\n  permission: \"can_info\",\n\n  signature: {\n    value: ct.string({ required: false }),\n\n    compact: ct.switchOption({ def: false, shortcut: \"c\" }),\n  },\n\n  async run({ message, args, pluginData }) {\n    const value = args.value || message.author.id;\n    const userCfg = await pluginData.config.getMatchingConfig({\n      member: message.member,\n      channelId: message.channel.id,\n      message,\n    });\n\n    // 1. Channel\n    if (userCfg.can_channelinfo) {\n      const channelId = getChannelId(value);\n      const channel = channelId && pluginData.guild.channels.cache.get(channelId as Snowflake);\n      if (channel) {\n        const embed = await getChannelInfoEmbed(pluginData, channelId!);\n        if (embed) {\n          message.channel.send({ embeds: [embed] });\n          return;\n        }\n      }\n    }\n\n    // 2. Server\n    if (userCfg.can_server) {\n      const guild = await pluginData.client.guilds.fetch(value as Snowflake).catch(noop);\n      if (guild) {\n        const embed = await getServerInfoEmbed(pluginData, value);\n        if (embed) {\n          message.channel.send({ embeds: [embed] });\n          return;\n        }\n      }\n    }\n\n    // 3. User\n    if (userCfg.can_userinfo) {\n      const user = await resolveUser(pluginData.client, value, \"Utility:InfoCmd\");\n      if (user && userCfg.can_userinfo) {\n        const embed = await getUserInfoEmbed(pluginData, user.id, Boolean(args.compact));\n        if (embed) {\n          message.channel.send({ embeds: [embed] });\n          return;\n        }\n      }\n    }\n\n    // 4. Message\n    if (userCfg.can_messageinfo) {\n      const messageTarget = await resolveMessageTarget(pluginData, value);\n      if (messageTarget) {\n        const authorMember = await resolveMessageMember(message);\n        if (canReadChannel(messageTarget.channel, authorMember)) {\n          const embed = await getMessageInfoEmbed(pluginData, messageTarget.channel.id, messageTarget.messageId);\n          if (embed) {\n            message.channel.send({ embeds: [embed] });\n            return;\n          }\n        }\n      }\n    }\n\n    // 5. Invite\n    if (userCfg.can_inviteinfo) {\n      const inviteCode = parseInviteCodeInput(value) ?? value;\n      if (inviteCode) {\n        const invite = await resolveInvite(pluginData.client, inviteCode, true);\n        if (invite) {\n          const embed = await getInviteInfoEmbed(pluginData, inviteCode);\n          if (embed) {\n            message.channel.send({ embeds: [embed] });\n            return;\n          }\n        }\n      }\n    }\n\n    // 6. Server again (fallback for discovery servers)\n    if (userCfg.can_server) {\n      const serverPreview = await getGuildPreview(pluginData.client, value).catch(() => null);\n      if (serverPreview) {\n        const embed = await getServerInfoEmbed(pluginData, value);\n        if (embed) {\n          message.channel.send({ embeds: [embed] });\n          return;\n        }\n      }\n    }\n\n    // 7. Role\n    if (userCfg.can_roleinfo) {\n      const roleId = getRoleId(value);\n      const role = roleId && pluginData.guild.roles.cache.get(roleId as Snowflake);\n      if (role) {\n        const embed = await getRoleInfoEmbed(pluginData, role);\n        message.channel.send({ embeds: [embed] });\n        return;\n      }\n    }\n\n    // 8. Emoji\n    if (userCfg.can_emojiinfo) {\n      const emojiId = getCustomEmojiId(value);\n      if (emojiId) {\n        const embed = await getEmojiInfoEmbed(pluginData, emojiId);\n        if (embed) {\n          message.channel.send({ embeds: [embed] });\n          return;\n        }\n      }\n    }\n\n    // 9. Arbitrary ID\n    if (isValidSnowflake(value) && userCfg.can_snowflake) {\n      const embed = await getSnowflakeInfoEmbed(value, true);\n      message.channel.send({ embeds: [embed] });\n      return;\n    }\n\n    // 10. No can do\n    void pluginData.state.common.sendErrorMessage(\n      message,\n      \"Could not find anything with that value or you are lacking permission for the snowflake type\",\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/InviteInfoCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { parseInviteCodeInput } from \"../../../utils.js\";\nimport { getInviteInfoEmbed } from \"../functions/getInviteInfoEmbed.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const InviteInfoCmd = utilityCmd({\n  trigger: [\"invite\", \"inviteinfo\"],\n  description: \"Show information about an invite\",\n  usage: \"!invite overwatch\",\n  permission: \"can_inviteinfo\",\n\n  signature: {\n    inviteCode: ct.string(),\n  },\n\n  async run({ message, args, pluginData }) {\n    const inviteCode = parseInviteCodeInput(args.inviteCode);\n    const embed = await getInviteInfoEmbed(pluginData, inviteCode);\n    if (!embed) {\n      void pluginData.state.common.sendErrorMessage(message, \"Unknown invite\");\n      return;\n    }\n\n    message.channel.send({ embeds: [embed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/JumboCmd.ts",
    "content": "import photon from \"@silvia-odwyer/photon-node\";\nimport { AttachmentBuilder } from \"discord.js\";\nimport fs from \"fs\";\nimport twemoji from \"twemoji\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { downloadFile, isEmoji, SECONDS } from \"../../../utils.js\";\nimport { utilityCmd } from \"../types.js\";\n\nconst fsp = fs.promises;\n\nasync function getBufferFromUrl(url: string): Promise<Buffer> {\n  const downloadedEmoji = await downloadFile(url);\n  return fsp.readFile(downloadedEmoji.path);\n}\n\nfunction bufferToPhotonImage(input: Buffer): photon.PhotonImage {\n  const base64 = input.toString(\"base64\").replace(/^data:image\\/\\w+;base64,/, \"\");\n\n  return photon.PhotonImage.new_from_base64(base64);\n}\n\nfunction photonImageToBuffer(image: photon.PhotonImage): Buffer {\n  const base64 = image.get_base64().replace(/^data:image\\/\\w+;base64,/, \"\");\n  return Buffer.from(base64, \"base64\");\n}\n\nfunction resizeBuffer(input: Buffer, width: number, height: number): Buffer {\n  const photonImage = bufferToPhotonImage(input);\n  photon.resize(photonImage, width, height, photon.SamplingFilter.Lanczos3);\n  return photonImageToBuffer(photonImage);\n}\n\nexport const JumboCmd = utilityCmd({\n  trigger: \"jumbo\",\n  description: \"Makes an emoji jumbo\",\n  permission: \"can_jumbo\",\n  cooldown: 5 * SECONDS,\n\n  signature: {\n    emoji: ct.string(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    // Get emoji url\n    const config = pluginData.config.get();\n    const size = config.jumbo_size > 2048 ? 2048 : config.jumbo_size;\n    const emojiRegex = new RegExp(`(<.*:).*:(\\\\d+)`);\n    const results = emojiRegex.exec(args.emoji);\n    let extension = \".png\";\n    let file: AttachmentBuilder | undefined;\n\n    if (!isEmoji(args.emoji)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Invalid emoji\");\n      return;\n    }\n\n    if (results) {\n      let url = \"https://cdn.discordapp.com/emojis/\";\n      if (results[1] === \"<a:\") {\n        extension = \".gif\";\n      }\n      url += `${results[2]}${extension}`;\n      if (extension === \".png\") {\n        const image = resizeBuffer(await getBufferFromUrl(url), size, size);\n        file = new AttachmentBuilder(image, { name: `emoji${extension}` });\n      } else {\n        const image = await getBufferFromUrl(url);\n        file = new AttachmentBuilder(image, { name: `emoji${extension}` });\n      }\n    } else {\n      let url = `${twemoji.base}${twemoji.size}/${twemoji.convert.toCodePoint(args.emoji)}${twemoji.ext}`;\n      let image: Buffer | undefined;\n      try {\n        const downloadedBuffer = await getBufferFromUrl(url);\n        image = resizeBuffer(downloadedBuffer, size, size);\n      } catch (err) {\n        if (url.toLocaleLowerCase().endsWith(\"fe0f.png\")) {\n          url = url.slice(0, url.lastIndexOf(\"-fe0f\")) + \".png\";\n          try {\n            image = resizeBuffer(await getBufferFromUrl(url), size, size);\n          } catch {\n            // It's fine if this fails, we just don't jumbo then.\n            // The errors here are usually some internal errors in the photon WASM code anyway,\n            // so we can't do anything about it.\n          }\n        }\n      }\n      if (!image) {\n        void pluginData.state.common.sendErrorMessage(msg, \"Error occurred while jumboing default emoji\");\n        return;\n      }\n\n      file = new AttachmentBuilder(image, { name: \"emoji.png\" });\n    }\n\n    msg.channel.send({ files: [file] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/LevelCmd.ts",
    "content": "import { helpers } from \"vety\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { renderUsername } from \"../../../utils.js\";\nimport { utilityCmd } from \"../types.js\";\n\nconst { getMemberLevel } = helpers;\n\nexport const LevelCmd = utilityCmd({\n  trigger: \"level\",\n  description: \"Show the permission level of a user\",\n  usage: \"!level 106391128718245888\",\n  permission: \"can_level\",\n\n  signature: {\n    member: ct.resolvedMember({ required: false }),\n  },\n\n  run({ message, args, pluginData }) {\n    const member = args.member || message.member;\n    const level = getMemberLevel(pluginData, member);\n    message.channel.send(`The permission level of ${renderUsername(member)} is **${level}**`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/MessageInfoCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { canReadChannel } from \"../../../utils/canReadChannel.js\";\nimport { getMessageInfoEmbed } from \"../functions/getMessageInfoEmbed.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const MessageInfoCmd = utilityCmd({\n  trigger: [\"message\", \"messageinfo\"],\n  description: \"Show information about a message\",\n  usage: \"!message 534722016549404673-534722219696455701\",\n  permission: \"can_messageinfo\",\n\n  signature: {\n    message: ct.messageTarget(),\n  },\n\n  async run({ message, args, pluginData }) {\n    const messageMember = await resolveMessageMember(message);\n    if (!canReadChannel(args.message.channel, messageMember)) {\n      void pluginData.state.common.sendErrorMessage(message, \"Unknown message\");\n      return;\n    }\n\n    const embed = await getMessageInfoEmbed(pluginData, args.message.channel.id, args.message.messageId);\n    if (!embed) {\n      void pluginData.state.common.sendErrorMessage(message, \"Unknown message\");\n      return;\n    }\n\n    message.channel.send({ embeds: [embed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/NicknameCmd.ts",
    "content": "import { escapeBold } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { canActOn, resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { errorMessage } from \"../../../utils.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const NicknameCmd = utilityCmd({\n  trigger: [\"nickname\", \"nick\"],\n  description: \"Set a member's nickname\",\n  usage: \"!nickname 106391128718245888 Drag\",\n  permission: \"can_nickname\",\n\n  signature: {\n    member: ct.resolvedMember(),\n    nickname: ct.string({ catchAll: true, required: false }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    if (!args.nickname) {\n      if (!args.member.nickname) {\n        msg.channel.send(`<@!${args.member.id}> does not have a nickname`);\n      } else {\n        msg.channel.send(`The nickname of <@!${args.member.id}> is **${escapeBold(args.member.nickname)}**`);\n      }\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n    if (msg.author.id !== args.member.id && !canActOn(pluginData, authorMember, args.member)) {\n      msg.channel.send(errorMessage(\"Cannot change nickname: insufficient permissions\"));\n      return;\n    }\n\n    const nicknameLength = [...args.nickname].length;\n    if (nicknameLength < 2 || nicknameLength > 32) {\n      msg.channel.send(errorMessage(\"Nickname must be between 2 and 32 characters long\"));\n      return;\n    }\n\n    const oldNickname = args.member.nickname || \"<none>\";\n\n    try {\n      await args.member.setNickname(args.nickname ?? null);\n    } catch {\n      msg.channel.send(errorMessage(\"Failed to change nickname\"));\n      return;\n    }\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `Changed nickname of <@!${args.member.id}> from **${oldNickname}** to **${args.nickname}**`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/NicknameResetCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { canActOn, resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { errorMessage } from \"../../../utils.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const NicknameResetCmd = utilityCmd({\n  trigger: [\"nickname reset\", \"nick reset\"],\n  description: \"Reset a member's nickname to their username\",\n  usage: \"!nickname reset 106391128718245888\",\n  permission: \"can_nickname\",\n\n  signature: {\n    member: ct.resolvedMember(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const authorMember = await resolveMessageMember(msg);\n    if (msg.author.id !== args.member.id && !canActOn(pluginData, authorMember, args.member)) {\n      msg.channel.send(errorMessage(\"Cannot reset nickname: insufficient permissions\"));\n      return;\n    }\n\n    if (!args.member.nickname) {\n      msg.channel.send(errorMessage(\"User does not have a nickname\"));\n      return;\n    }\n\n    try {\n      await args.member.setNickname(null);\n    } catch {\n      msg.channel.send(errorMessage(\"Failed to reset nickname\"));\n      return;\n    }\n\n    void pluginData.state.common.sendSuccessMessage(msg, `The nickname of <@!${args.member.id}> has been reset`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/PingCmd.ts",
    "content": "import { Message } from \"discord.js\";\nimport { performance } from \"perf_hooks\";\nimport { noop, trimLines } from \"../../../utils.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const PingCmd = utilityCmd({\n  trigger: [\"ping\", \"pong\"],\n  description: \"Test the bot's ping to the Discord API\",\n  permission: \"can_ping\",\n\n  async run({ message: msg, pluginData }) {\n    const times: number[] = [];\n    const messages: Message[] = [];\n    let msgToMsgDelay: number | undefined;\n\n    for (let i = 0; i < 4; i++) {\n      const start = performance.now();\n      const message = await msg.channel.send(`Calculating ping... ${i + 1}`);\n      times.push(performance.now() - start);\n      messages.push(message);\n\n      if (msgToMsgDelay === undefined) {\n        msgToMsgDelay = message.createdTimestamp - msg.createdTimestamp;\n      }\n    }\n\n    const highest = Math.round(Math.max(...times));\n    const lowest = Math.round(Math.min(...times));\n    const mean = Math.round(times.reduce((total, ms) => total + ms, 0) / times.length);\n\n    msg.channel.send(\n      trimLines(`\n      **Ping:**\n      Lowest: **${lowest}ms**\n      Highest: **${highest}ms**\n      Mean: **${mean}ms**\n      Time between ping command and first reply: **${msgToMsgDelay!}ms**\n      Shard latency: **${pluginData.client.ws.ping}ms**\n    `),\n    );\n\n    // Clean up test messages\n    msg.channel.bulkDelete(messages).catch(noop);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/ReloadGuildCmd.ts",
    "content": "import { TextChannel } from \"discord.js\";\nimport { activeReloads } from \"../guildReloads.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const ReloadGuildCmd = utilityCmd({\n  trigger: \"reload_guild\",\n  description: \"Reload the Zeppelin configuration and all plugins for the server. This can sometimes fix issues.\",\n  permission: \"can_reload_guild\",\n\n  async run({ message: msg, pluginData }) {\n    if (activeReloads.has(pluginData.guild.id)) return;\n    activeReloads.set(pluginData.guild.id, msg.channel as TextChannel);\n\n    msg.channel.send(\"Reloading...\");\n    pluginData.getVetyInstance().reloadGuild(pluginData.guild.id);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/RoleInfoCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { getRoleInfoEmbed } from \"../functions/getRoleInfoEmbed.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const RoleInfoCmd = utilityCmd({\n  trigger: [\"roleinfo\"],\n  description: \"Show information about a role\",\n  usage: \"!role 106391128718245888\",\n  permission: \"can_roleinfo\",\n\n  signature: {\n    role: ct.role({ required: true }),\n  },\n\n  async run({ message, args, pluginData }) {\n    const embed = await getRoleInfoEmbed(pluginData, args.role);\n    message.channel.send({ embeds: [embed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/RolesCmd.ts",
    "content": "import { Role } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { chunkArray, sorter, trimLines } from \"../../../utils.js\";\nimport { refreshMembersIfNeeded } from \"../refreshMembers.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const RolesCmd = utilityCmd({\n  trigger: \"roles\",\n  description: \"List all roles or roles matching a search\",\n  usage: \"!roles mod\",\n  permission: \"can_roles\",\n\n  signature: {\n    search: ct.string({ required: false, catchAll: true }),\n\n    counts: ct.switchOption(),\n    sort: ct.string({ option: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const { guild } = pluginData;\n\n    let roles: Role[] = Array.from(guild.roles.cache.values());\n    let sort = args.sort;\n\n    if (args.search) {\n      const searchStr = args.search.toLowerCase();\n      roles = roles.filter((r) => r.name.toLowerCase().includes(searchStr) || r.id === searchStr);\n    }\n\n    let roleCounts: Map<string, number> | null = null;\n    if (args.counts) {\n      await refreshMembersIfNeeded(guild);\n\n      roleCounts = new Map<string, number>(guild.roles.cache.map((r) => [r.id, 0]));\n\n      for (const member of guild.members.cache.values()) {\n        for (const id of member.roles.cache.keys()) {\n          roleCounts.set(id, (roleCounts.get(id) ?? 0) + 1);\n        }\n      }\n\n      // The \"@everyone\" role always has all members in it\n      roleCounts.set(guild.id, guild.memberCount);\n\n      if (!sort) sort = \"-memberCount\";\n    }\n\n    if (!sort) sort = \"name\";\n\n    let sortDir: \"ASC\" | \"DESC\" = \"ASC\";\n    if (sort[0] === \"-\") {\n      sort = sort.slice(1);\n      sortDir = \"DESC\";\n    }\n\n    if (sort === \"position\" || sort === \"order\") {\n      roles.sort(sorter(\"position\", sortDir));\n    } else if (sort === \"memberCount\" && args.counts) {\n      roles.sort((first, second) => roleCounts!.get(second.id)! - roleCounts!.get(first.id)!);\n    } else if (sort === \"name\") {\n      roles.sort(sorter((r) => r.name.toLowerCase(), sortDir));\n    } else {\n      void pluginData.state.common.sendErrorMessage(msg, \"Unknown sorting method\");\n      return;\n    }\n\n    const longestId = roles.reduce((longest, role) => Math.max(longest, role.id.length), 0);\n\n    const chunks = chunkArray(roles, 20);\n    for (const [i, chunk] of chunks.entries()) {\n      const roleLines = chunk.map((role) => {\n        const paddedId = role.id.padEnd(longestId, \" \");\n        let line = `${paddedId} ${role.name}`;\n        const memberCount = roleCounts?.get(role.id);\n        if (memberCount !== undefined) {\n          line += ` (${memberCount} ${memberCount === 1 ? \"member\" : \"members\"})`;\n        }\n        return line;\n      });\n\n      const codeBlock = \"```py\\n\" + roleLines.join(\"\\n\") + \"```\";\n      if (i === 0) {\n        msg.channel.send(\n          trimLines(`\n          ${args.search ? \"Total roles found\" : \"Total roles\"}: ${roles.length}\n          ${codeBlock}\n        `),\n        );\n      } else {\n        msg.channel.send(codeBlock);\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/SearchCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { archiveSearch, displaySearch, SearchType } from \"../search.js\";\nimport { utilityCmd } from \"../types.js\";\n\n// Separate from SearchCmd to avoid a circular reference from ./search.ts\nexport const searchCmdSignature = {\n  query: ct.string({ catchAll: true, required: false }),\n\n  page: ct.number({ option: true, shortcut: \"p\" }),\n  role: ct.string({ option: true, shortcut: \"r\" }),\n  voice: ct.switchOption({ def: false, shortcut: \"v\" }),\n  bot: ct.switchOption({ def: false, shortcut: \"b\" }),\n  sort: ct.string({ option: true }),\n  \"case-sensitive\": ct.switchOption({ def: false, shortcut: \"cs\" }),\n  export: ct.switchOption({ def: false, shortcut: \"e\" }),\n  ids: ct.switchOption(),\n  regex: ct.switchOption({ def: false, shortcut: \"re\" }),\n  // \"status-search\": ct.switchOption({ def: false, shortcut: \"ss\" }),\n};\n\nexport const SearchCmd = utilityCmd({\n  trigger: [\"search\", \"s\"],\n  description: \"Search server members\",\n  usage: \"!search dragory\",\n  permission: \"can_search\",\n\n  signature: searchCmdSignature,\n\n  run({ pluginData, message, args }) {\n    if (args.export) {\n      return archiveSearch(pluginData, args, SearchType.MemberSearch, message);\n    } else {\n      return displaySearch(pluginData, args, SearchType.MemberSearch, message);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/ServerInfoCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { getServerInfoEmbed } from \"../functions/getServerInfoEmbed.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const ServerInfoCmd = utilityCmd({\n  trigger: [\"server\", \"serverinfo\"],\n  description: \"Show server information\",\n  usage: \"!server\",\n  permission: \"can_server\",\n\n  signature: {\n    serverId: ct.string({ required: false }),\n  },\n\n  async run({ message, pluginData, args }) {\n    const serverId = args.serverId || pluginData.guild.id;\n    const serverInfoEmbed = await getServerInfoEmbed(pluginData, serverId);\n    if (!serverInfoEmbed) {\n      void pluginData.state.common.sendErrorMessage(message, \"Could not find information for that server\");\n      return;\n    }\n\n    message.channel.send({ embeds: [serverInfoEmbed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/SnowflakeInfoCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { getSnowflakeInfoEmbed } from \"../functions/getSnowflakeInfoEmbed.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const SnowflakeInfoCmd = utilityCmd({\n  trigger: [\"snowflake\", \"snowflakeinfo\"],\n  description: \"Show information about a snowflake ID\",\n  usage: \"!snowflake 534722016549404673\",\n  permission: \"can_snowflake\",\n\n  signature: {\n    id: ct.anyId(),\n  },\n\n  async run({ message, args }) {\n    const embed = await getSnowflakeInfoEmbed(args.id, false);\n    message.channel.send({ embeds: [embed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/SourceCmd.ts",
    "content": "import moment from \"moment-timezone\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { getBaseUrl, resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { canReadChannel } from \"../../../utils/canReadChannel.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const SourceCmd = utilityCmd({\n  trigger: \"source\",\n  description: \"View the message source of the specified message id\",\n  usage: \"!source 534722219696455701\",\n  permission: \"can_source\",\n\n  signature: {\n    message: ct.messageTarget(),\n  },\n\n  async run({ message: cmdMessage, args, pluginData }) {\n    const cmdAuthorMember = await resolveMessageMember(cmdMessage);\n    if (!canReadChannel(args.message.channel, cmdAuthorMember)) {\n      void pluginData.state.common.sendErrorMessage(cmdMessage, \"Unknown message\");\n      return;\n    }\n\n    const message = await args.message.channel.messages.fetch(args.message.messageId);\n    if (!message) {\n      void pluginData.state.common.sendErrorMessage(cmdMessage, \"Unknown message\");\n      return;\n    }\n\n    const textSource = message.content || \"<no text content>\";\n    const fullSource = JSON.stringify({\n      id: message.id,\n      content: message.content,\n      attachments: message.attachments,\n      embeds: message.embeds,\n      stickers: message.stickers,\n    });\n\n    const source = `${textSource}\\n\\nSource:\\n\\n${fullSource}`;\n\n    const archiveId = await pluginData.state.archives.create(source, moment.utc().add(1, \"hour\"));\n    const baseUrl = getBaseUrl(pluginData);\n    const url = pluginData.state.archives.getUrl(baseUrl, archiveId);\n    cmdMessage.channel.send(`Message source: ${url}`);\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/UserInfoCmd.ts",
    "content": "import { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { getUserInfoEmbed } from \"../functions/getUserInfoEmbed.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const UserInfoCmd = utilityCmd({\n  trigger: [\"user\", \"userinfo\", \"whois\"],\n  description: \"Show information about a user\",\n  usage: \"!user 106391128718245888\",\n  permission: \"can_userinfo\",\n\n  signature: {\n    user: ct.resolvedUserLoose({ required: false }),\n\n    compact: ct.switchOption({ def: false, shortcut: \"c\" }),\n  },\n\n  async run({ message, args, pluginData }) {\n    const userId = args.user?.id || message.author.id;\n    const embed = await getUserInfoEmbed(pluginData, userId, args.compact);\n    if (!embed) {\n      void pluginData.state.common.sendErrorMessage(message, \"User not found\");\n      return;\n    }\n\n    message.channel.send({ embeds: [embed] });\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/VcdisconnectCmd.ts",
    "content": "import { VoiceChannel } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { canActOn, resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { renderUsername } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const VcdisconnectCmd = utilityCmd({\n  trigger: [\"vcdisconnect\", \"vcdisc\", \"vcdc\", \"vckick\", \"vck\"],\n  description: \"Disconnect a member from their voice channel\",\n  usage: \"!vcdc @Dark\",\n  permission: \"can_vckick\",\n\n  signature: {\n    member: ct.resolvedMember(),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    const authorMember = await resolveMessageMember(msg);\n    if (!canActOn(pluginData, authorMember, args.member)) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Cannot move: insufficient permissions\");\n      return;\n    }\n\n    if (!args.member.voice?.channelId) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Member is not in a voice channel\");\n      return;\n    }\n    const channel = pluginData.guild.channels.cache.get(args.member.voice.channelId) as VoiceChannel;\n\n    try {\n      await args.member.voice.disconnect();\n    } catch {\n      void pluginData.state.common.sendErrorMessage(msg, \"Failed to disconnect member\");\n      return;\n    }\n\n    pluginData.getPlugin(LogsPlugin).logVoiceChannelForceDisconnect({\n      mod: msg.author,\n      member: args.member,\n      oldChannel: channel,\n    });\n\n    pluginData.state.common.sendSuccessMessage(\n      msg,\n      `**${renderUsername(args.member)}** disconnected from **${channel.name}**`,\n    );\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/commands/VcmoveCmd.ts",
    "content": "import { ChannelType, Snowflake, VoiceChannel } from \"discord.js\";\nimport { commandTypeHelpers as ct } from \"../../../commandTypes.js\";\nimport { canActOn, resolveMessageMember } from \"../../../pluginUtils.js\";\nimport { channelMentionRegex, isSnowflake, renderUsername, simpleClosestStringMatch } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { utilityCmd } from \"../types.js\";\n\nexport const VcmoveCmd = utilityCmd({\n  trigger: \"vcmove\",\n  description: \"Move a member to another voice channel\",\n  usage: \"!vcmove @Dragory 473223047822704651\",\n  permission: \"can_vcmove\",\n\n  signature: {\n    member: ct.resolvedMember(),\n    channel: ct.string({ catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    let channel: VoiceChannel;\n\n    if (isSnowflake(args.channel)) {\n      // Snowflake -> resolve channel directly\n      const potentialChannel = pluginData.guild.channels.cache.get(args.channel as Snowflake);\n      if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) {\n        void pluginData.state.common.sendErrorMessage(msg, \"Unknown or non-voice channel\");\n        return;\n      }\n\n      channel = potentialChannel;\n    } else if (channelMentionRegex.test(args.channel)) {\n      // Channel mention -> parse channel id and resolve channel from that\n      const channelId = args.channel.match(channelMentionRegex)![1];\n      const potentialChannel = pluginData.guild.channels.cache.get(channelId as Snowflake);\n      if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) {\n        void pluginData.state.common.sendErrorMessage(msg, \"Unknown or non-voice channel\");\n        return;\n      }\n\n      channel = potentialChannel;\n    } else {\n      // Search string -> find closest matching voice channel name\n      const voiceChannels = [...pluginData.guild.channels.cache.values()].filter(\n        (c): c is VoiceChannel => c.type === ChannelType.GuildVoice,\n      );\n      const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name);\n      if (!closestMatch) {\n        void pluginData.state.common.sendErrorMessage(msg, \"No matching voice channels\");\n        return;\n      }\n\n      channel = closestMatch;\n    }\n\n    if (!args.member.voice?.channelId) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Member is not in a voice channel\");\n      return;\n    }\n\n    if (args.member.voice.channelId === channel.id) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Member is already on that channel!\");\n      return;\n    }\n\n    const oldVoiceChannel = pluginData.guild.channels.cache.get(args.member.voice.channelId) as VoiceChannel;\n\n    try {\n      await args.member.edit({\n        channel: channel.id,\n      });\n    } catch {\n      void pluginData.state.common.sendErrorMessage(msg, \"Failed to move member\");\n      return;\n    }\n\n    pluginData.getPlugin(LogsPlugin).logVoiceChannelForceMove({\n      mod: msg.author,\n      member: args.member,\n      oldChannel: oldVoiceChannel,\n      newChannel: channel,\n    });\n\n    void pluginData.state.common.sendSuccessMessage(\n      msg,\n      `**${renderUsername(args.member)}** moved to **${channel.name}**`,\n    );\n  },\n});\n\nexport const VcmoveAllCmd = utilityCmd({\n  trigger: \"vcmoveall\",\n  description: \"Move all members of a voice channel to another voice channel\",\n  usage: \"!vcmoveall 551767166395875334 767497573560352798\",\n  permission: \"can_vcmove\",\n\n  signature: {\n    oldChannel: ct.voiceChannel(),\n    channel: ct.string({ catchAll: true }),\n  },\n\n  async run({ message: msg, args, pluginData }) {\n    let channel: VoiceChannel;\n\n    if (isSnowflake(args.channel)) {\n      // Snowflake -> resolve channel directly\n      const potentialChannel = pluginData.guild.channels.cache.get(args.channel as Snowflake);\n      if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) {\n        void pluginData.state.common.sendErrorMessage(msg, \"Unknown or non-voice channel\");\n        return;\n      }\n\n      channel = potentialChannel;\n    } else if (channelMentionRegex.test(args.channel)) {\n      // Channel mention -> parse channel id and resolve channel from that\n      const channelId = args.channel.match(channelMentionRegex)![1];\n      const potentialChannel = pluginData.guild.channels.cache.get(channelId as Snowflake);\n      if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) {\n        void pluginData.state.common.sendErrorMessage(msg, \"Unknown or non-voice channel\");\n        return;\n      }\n\n      channel = potentialChannel;\n    } else {\n      // Search string -> find closest matching voice channel name\n      const voiceChannels = [...pluginData.guild.channels.cache.values()].filter(\n        (c): c is VoiceChannel => c.type === ChannelType.GuildVoice,\n      );\n      const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name);\n      if (!closestMatch) {\n        void pluginData.state.common.sendErrorMessage(msg, \"No matching voice channels\");\n        return;\n      }\n\n      channel = closestMatch;\n    }\n\n    if (args.oldChannel.members.size === 0) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Voice channel is empty\");\n      return;\n    }\n\n    if (args.oldChannel.id === channel.id) {\n      void pluginData.state.common.sendErrorMessage(msg, \"Cant move from and to the same channel!\");\n      return;\n    }\n\n    const authorMember = await resolveMessageMember(msg);\n\n    // Cant leave null, otherwise we get an assignment error in the catch\n    let currMember = authorMember;\n    const moveAmt = args.oldChannel.members.size;\n    let errAmt = 0;\n    for (const memberWithId of args.oldChannel.members) {\n      currMember = memberWithId[1];\n\n      // Check for permissions but allow self-moves\n      if (currMember.id !== authorMember.id && !canActOn(pluginData, authorMember, currMember)) {\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          `Failed to move ${renderUsername(currMember)} (${currMember.id}): You cannot act on this member`,\n        );\n        errAmt++;\n        continue;\n      }\n\n      try {\n        currMember.edit({\n          channel: channel.id,\n        });\n      } catch {\n        if (authorMember.id === currMember.id) {\n          void pluginData.state.common.sendErrorMessage(msg, \"Unknown error when trying to move members\");\n          return;\n        }\n        void pluginData.state.common.sendErrorMessage(\n          msg,\n          `Failed to move ${renderUsername(currMember)} (${currMember.id})`,\n        );\n        errAmt++;\n        continue;\n      }\n\n      pluginData.getPlugin(LogsPlugin).logVoiceChannelForceMove({\n        mod: msg.author,\n        member: currMember,\n        oldChannel: args.oldChannel,\n        newChannel: channel,\n      });\n    }\n\n    if (moveAmt !== errAmt) {\n      void pluginData.state.common.sendSuccessMessage(\n        msg,\n        `${moveAmt - errAmt} members from **${args.oldChannel.name}** moved to **${channel.name}**`,\n      );\n    } else {\n      void pluginData.state.common.sendErrorMessage(msg, `Failed to move any members.`);\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zUtilityConfig } from \"./types.js\";\n\nexport const utilityPluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Utility\",\n  configSchema: zUtilityConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/Utility/events/AutoJoinThreadEvt.ts",
    "content": "import { utilityEvt } from \"../types.js\";\n\nexport const AutoJoinThreadEvt = utilityEvt({\n  event: \"threadCreate\",\n\n  async listener(meta) {\n    const config = meta.pluginData.config.get();\n    if (config.autojoin_threads && meta.args.thread.joinable) {\n      await meta.args.thread.join();\n    }\n  },\n});\n\nexport const AutoJoinThreadSyncEvt = utilityEvt({\n  event: \"threadListSync\",\n\n  async listener(meta) {\n    const config = meta.pluginData.config.get();\n    if (!config.autojoin_threads) return;\n    for (const thread of meta.args.threads.values()) {\n      if (!thread.joined && thread.joinable) {\n        await thread.join();\n      }\n    }\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/cleanMessages.ts",
    "content": "import { GuildBasedChannel, Snowflake, TextBasedChannel, User } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { LogType } from \"../../../data/LogType.js\";\nimport { getBaseUrl } from \"../../../pluginUtils.js\";\nimport { chunkArray } from \"../../../utils.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { UtilityPluginType } from \"../types.js\";\n\nexport async function cleanMessages(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  channel: GuildBasedChannel & TextBasedChannel,\n  savedMessages: SavedMessage[],\n  mod: User,\n) {\n  pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, savedMessages[0].id);\n  pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, savedMessages[0].id);\n\n  // Delete & archive in ID order\n  savedMessages = Array.from(savedMessages).sort((a, b) => (a.id > b.id ? 1 : -1));\n  const idsToDelete = savedMessages.map((m) => m.id) as Snowflake[];\n\n  // Make sure the deletions aren't double logged\n  idsToDelete.forEach((id) => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id));\n  pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, idsToDelete[0]);\n\n  // Actually delete the messages (in chunks of 100)\n\n  const chunks = chunkArray(idsToDelete, 100);\n  await Promise.all(\n    chunks.map((chunk) =>\n      Promise.all([channel.bulkDelete(chunk), pluginData.state.savedMessages.markBulkAsDeleted(chunk)]),\n    ),\n  );\n\n  // Create an archive\n  const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild);\n  const baseUrl = getBaseUrl(pluginData);\n  const archiveUrl = pluginData.state.archives.getUrl(baseUrl, archiveId);\n\n  pluginData.getPlugin(LogsPlugin).logClean({\n    mod,\n    channel,\n    count: savedMessages.length,\n    archiveUrl,\n  });\n\n  return { archiveUrl };\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/fetchChannelMessagesToClean.ts",
    "content": "import { GuildBasedChannel, Message, OmitPartialGroupDMChannel, Snowflake, TextBasedChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { SavedMessage } from \"../../../data/entities/SavedMessage.js\";\nimport { humanizeDurationShort } from \"../../../humanizeDuration.js\";\nimport { allowTimeout } from \"../../../RegExpRunner.js\";\nimport { DAYS, getInviteCodesInString } from \"../../../utils.js\";\nimport { snowflakeToTimestamp } from \"../../../utils/snowflakeToTimestamp.js\";\nimport { UtilityPluginType } from \"../types.js\";\n\nconst MAX_CLEAN_COUNT = 300;\nconst MAX_CLEAN_TIME = 1 * DAYS;\nconst MAX_CLEAN_API_REQUESTS = 20;\n\nexport interface FetchChannelMessagesToCleanOpts {\n  count: number;\n  beforeId: string;\n  upToId?: string;\n  authorId?: string;\n  includePins?: boolean;\n  onlyBotMessages?: boolean;\n  onlyWithInvites?: boolean;\n  matchContent?: RegExp;\n}\n\nexport interface SuccessResult {\n  messages: SavedMessage[];\n  note: string;\n}\n\nexport interface ErrorResult {\n  error: string;\n}\n\nexport type FetchChannelMessagesToCleanResult = SuccessResult | ErrorResult;\n\nexport async function fetchChannelMessagesToClean(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  targetChannel: GuildBasedChannel & TextBasedChannel,\n  opts: FetchChannelMessagesToCleanOpts,\n): Promise<FetchChannelMessagesToCleanResult> {\n  if (opts.count > MAX_CLEAN_COUNT || opts.count <= 0) {\n    return { error: `Clean count must be between 1 and ${MAX_CLEAN_COUNT}` };\n  }\n\n  const result: FetchChannelMessagesToCleanResult = {\n    messages: [],\n    note: \"\",\n  };\n\n  const timestampCutoff = snowflakeToTimestamp(opts.beforeId) - MAX_CLEAN_TIME;\n  let foundId = false;\n\n  let pinIds: Set<Snowflake> = new Set();\n  if (!opts.includePins) {\n    pinIds = new Set((await targetChannel.messages.fetchPinned()).keys());\n  }\n\n  const rawMessagesToClean: Array<OmitPartialGroupDMChannel<Message<true>>> = [];\n  let beforeId = opts.beforeId;\n  let requests = 0;\n  while (rawMessagesToClean.length < opts.count) {\n    const potentialMessages = await targetChannel.messages.fetch({\n      before: beforeId,\n      limit: 100,\n    });\n    if (potentialMessages.size === 0) break;\n\n    requests++;\n\n    const filtered: Array<OmitPartialGroupDMChannel<Message<true>>> = [];\n    for (const message of potentialMessages.values()) {\n      const contentString = message.content || \"\";\n      if (opts.authorId && message.author.id !== opts.authorId) continue;\n      if (opts.onlyBotMessages && !message.author.bot) continue;\n      if (pinIds.has(message.id)) continue;\n      if (opts.onlyWithInvites && getInviteCodesInString(contentString).length === 0) continue;\n      if (opts.upToId && message.id < opts.upToId) {\n        foundId = true;\n        break;\n      }\n      if (message.createdTimestamp < timestampCutoff) continue;\n      if (\n        opts.matchContent &&\n        !(await pluginData.state.regexRunner.exec(opts.matchContent, contentString).catch(allowTimeout))\n      ) {\n        continue;\n      }\n\n      filtered.push(message);\n    }\n    const remaining = opts.count - rawMessagesToClean.length;\n    const withoutOverflow = filtered.slice(0, remaining);\n    rawMessagesToClean.push(...withoutOverflow);\n\n    beforeId = potentialMessages.lastKey()!;\n\n    if (foundId) {\n      break;\n    }\n\n    if (rawMessagesToClean.length < opts.count) {\n      if (potentialMessages.last()!.createdTimestamp < timestampCutoff) {\n        result.note = `stopped looking after reaching ${humanizeDurationShort(MAX_CLEAN_TIME)} old messages`;\n        break;\n      }\n\n      if (requests >= MAX_CLEAN_API_REQUESTS) {\n        result.note = `stopped looking after ${requests * 100} messages`;\n        break;\n      }\n    }\n  }\n\n  // Discord messages -> SavedMessages\n  if (rawMessagesToClean.length > 0) {\n    const existingStored = await pluginData.state.savedMessages.getMultiple(rawMessagesToClean.map((m) => m.id));\n    const alreadyStored = existingStored.map((stored) => stored.id);\n    const messagesToStore = rawMessagesToClean.filter((potentialMsg) => !alreadyStored.includes(potentialMsg.id));\n    await pluginData.state.savedMessages.createFromMessages(messagesToStore);\n\n    result.messages = await pluginData.state.savedMessages.getMultiple(rawMessagesToClean.map((m) => m.id));\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts",
    "content": "import { APIEmbed, ChannelType, Snowflake, StageChannel, VoiceChannel } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { humanizeDuration } from \"../../../humanizeDuration.js\";\nimport { EmbedWith, MINUTES, formatNumber, preEmbedPadding, trimLines, verboseUserMention } from \"../../../utils.js\";\nimport { UtilityPluginType } from \"../types.js\";\n\nconst TEXT_CHANNEL_ICON =\n  \"https://cdn.discordapp.com/attachments/740650744830623756/740656843545772062/text-channel.png\";\nconst VOICE_CHANNEL_ICON =\n  \"https://cdn.discordapp.com/attachments/740650744830623756/740656845982662716/voice-channel.png\";\nconst ANNOUNCEMENT_CHANNEL_ICON =\n  \"https://cdn.discordapp.com/attachments/740650744830623756/740656841687564348/announcement-channel.png\";\nconst STAGE_CHANNEL_ICON =\n  \"https://cdn.discordapp.com/attachments/740650744830623756/839930647711186995/stage-channel.png\";\nconst PUBLIC_THREAD_ICON =\n  \"https://cdn.discordapp.com/attachments/740650744830623756/870343055855738921/public-thread.png\";\nconst PRIVATE_THREAD_ICON =\n  \"https://cdn.discordapp.com/attachments/740650744830623756/870343402447839242/private-thread.png\";\nconst FORUM_CHANNEL_ICON =\n  \"https://cdn.discordapp.com/attachments/740650744830623756/1091681253364875294/forum-channel-icon.png\";\n\nconst MEDIA_CHANNEL_ICON = \"https://cdn.discordapp.com/attachments/876134205229252658/1178335624940490792/media.png\";\n\nexport async function getChannelInfoEmbed(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  channelId: string,\n): Promise<APIEmbed | null> {\n  const channel = pluginData.guild.channels.cache.get(channelId as Snowflake);\n  if (!channel) {\n    return null;\n  }\n\n  const embed: EmbedWith<\"fields\"> = {\n    fields: [],\n  };\n\n  const icon =\n    {\n      [ChannelType.GuildVoice]: VOICE_CHANNEL_ICON,\n      [ChannelType.GuildAnnouncement]: ANNOUNCEMENT_CHANNEL_ICON,\n      [ChannelType.GuildStageVoice]: STAGE_CHANNEL_ICON,\n      [ChannelType.PublicThread]: PUBLIC_THREAD_ICON,\n      [ChannelType.PrivateThread]: PRIVATE_THREAD_ICON,\n      [ChannelType.AnnouncementThread]: PUBLIC_THREAD_ICON,\n      [ChannelType.GuildForum]: FORUM_CHANNEL_ICON,\n      [ChannelType.GuildMedia]: MEDIA_CHANNEL_ICON,\n    }[channel.type] ?? TEXT_CHANNEL_ICON;\n\n  const channelType =\n    {\n      [ChannelType.GuildText]: \"Text channel\",\n      [ChannelType.GuildVoice]: \"Voice channel\",\n      [ChannelType.GuildCategory]: \"Category channel\",\n      [ChannelType.GuildAnnouncement]: \"Announcement channel\",\n      [ChannelType.GuildStageVoice]: \"Stage channel\",\n      [ChannelType.PublicThread]: \"Public Thread channel\",\n      [ChannelType.PrivateThread]: \"Private Thread channel\",\n      [ChannelType.AnnouncementThread]: \"News Thread channel\",\n      [ChannelType.GuildDirectory]: \"Hub channel\",\n      [ChannelType.GuildForum]: \"Forum channel\",\n      [ChannelType.GuildMedia]: \"Media channel\",\n    }[channel.type] ?? \"Channel\";\n\n  embed.author = {\n    name: `${channelType}:  ${channel.name}`,\n    icon_url: icon,\n  };\n\n  let channelName = `#${channel.name}`;\n  if (\n    channel.type === ChannelType.GuildVoice ||\n    channel.type === ChannelType.GuildCategory ||\n    channel.type === ChannelType.GuildStageVoice\n  ) {\n    channelName = channel.name;\n  }\n\n  const showMention = channel.type !== ChannelType.GuildCategory;\n\n  embed.fields.push({\n    name: preEmbedPadding + \"Channel information\",\n    value: trimLines(`\n      Name: **${channelName}**\n      ID: \\`${channel.id}\\`\n      Created: **<t:${Math.round(channel.createdTimestamp! / 1000)}:R>**\n      Type: **${channelType}**\n      ${showMention ? `Mention: <#${channel.id}>` : \"\"}\n    `),\n  });\n\n  if (channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice) {\n    const voiceMembers = Array.from((channel as VoiceChannel | StageChannel).members.values());\n    const muted = voiceMembers.filter((vm) => vm.voice.mute || vm.voice.selfMute);\n    const deafened = voiceMembers.filter((vm) => vm.voice.deaf || vm.voice.selfDeaf);\n    const voiceOrStage = channel.type === ChannelType.GuildVoice ? \"Voice\" : \"Stage\";\n\n    embed.fields.push({\n      name: preEmbedPadding + `${voiceOrStage} information`,\n      value: trimLines(`\n        Users on ${voiceOrStage.toLowerCase()} channel: **${formatNumber(voiceMembers.length)}**\n        Muted: **${formatNumber(muted.length)}**\n        Deafened: **${formatNumber(deafened.length)}**\n      `),\n    });\n  }\n\n  if (channel.type === ChannelType.GuildCategory) {\n    const textChannels = pluginData.guild.channels.cache.filter(\n      (ch) => ch.parentId === channel.id && ch.type !== ChannelType.GuildVoice,\n    );\n    const voiceChannels = pluginData.guild.channels.cache.filter(\n      (ch) =>\n        ch.parentId === channel.id && (ch.type === ChannelType.GuildVoice || ch.type === ChannelType.GuildStageVoice),\n    );\n\n    embed.fields.push({\n      name: preEmbedPadding + \"Category information\",\n      value: trimLines(`\n        Text channels: **${textChannels.size}**\n        Voice channels: **${voiceChannels.size}**\n      `),\n    });\n  }\n\n  if (channel.isThread()) {\n    const parentChannelName = channel.parent?.name ?? `<#${channel.parentId}>`;\n    const memberCount = channel.memberCount ?? channel.members.cache.size;\n    const owner = await channel.fetchOwner().catch(() => null);\n    const ownerMention = owner?.user ? verboseUserMention(owner.user) : \"Unknown#0000\";\n    const humanizedArchiveTime = `Archive duration: **${humanizeDuration(\n      (channel.autoArchiveDuration ?? 0) * MINUTES,\n    )}**`;\n\n    embed.fields.push({\n      name: preEmbedPadding + \"Thread information\",\n      value: trimLines(`\n      Parent channel: **#${parentChannelName}**\n      Member count: **${memberCount}**\n      Thread creator: ${ownerMention}\n      ${channel.archived ? \"Archived: **True**\" : humanizedArchiveTime}`),\n    });\n  }\n\n  return embed;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getCustomEmojiId.ts",
    "content": "const customEmojiRegex = /(?:<a?:[a-z0-9_]{2,32}:)?([1-9]\\d+)>?/i;\n\nexport function getCustomEmojiId(str: string): string | null {\n  const emojiIdMatch = str.match(customEmojiRegex);\n  return emojiIdMatch?.[1] ?? null;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getEmojiInfoEmbed.ts",
    "content": "import { APIEmbed } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { EmbedWith, preEmbedPadding, trimLines } from \"../../../utils.js\";\nimport { UtilityPluginType } from \"../types.js\";\n\nexport async function getEmojiInfoEmbed(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  emojiId: string,\n): Promise<APIEmbed | null> {\n  const emoji = pluginData.guild.emojis.cache.find((e) => e.id === emojiId);\n  if (!emoji) {\n    return null;\n  }\n\n  const embed: EmbedWith<\"fields\" | \"author\"> = {\n    fields: [],\n    author: {\n      name: `Emoji:  ${emoji.name}`,\n      icon_url: emoji.url,\n    },\n  };\n\n  embed.fields!.push({\n    name: preEmbedPadding + \"Emoji information\",\n    value: trimLines(`\n      Name: **${emoji.name}**\n      ID: \\`${emoji.id}\\`\n      Animated: **${emoji.animated ? \"Yes\" : \"No\"}**\n    `),\n  });\n\n  return embed;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getGuildPreview.ts",
    "content": "import { Client, GuildPreview, Snowflake } from \"discord.js\";\nimport { MINUTES, memoize } from \"../../../utils.js\";\n\n/**\n * Memoized getGuildPreview\n */\nexport function getGuildPreview(client: Client, guildId: string): Promise<GuildPreview | null> {\n  return memoize(\n    () => client.fetchGuildPreview(guildId as Snowflake).catch(() => null),\n    `getGuildPreview_${guildId}`,\n    10 * MINUTES,\n  );\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts",
    "content": "import { APIEmbed, ChannelType } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport {\n  EmbedWith,\n  formatNumber,\n  inviteHasCounts,\n  isGroupDMInvite,\n  isGuildInvite,\n  preEmbedPadding,\n  renderUsername,\n  resolveInvite,\n  trimLines,\n} from \"../../../utils.js\";\nimport { snowflakeToTimestamp } from \"../../../utils/snowflakeToTimestamp.js\";\nimport { UtilityPluginType } from \"../types.js\";\n\nexport async function getInviteInfoEmbed(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  inviteCode: string,\n): Promise<APIEmbed | null> {\n  const invite = await resolveInvite(pluginData.client, inviteCode, true);\n  if (!invite) {\n    return null;\n  }\n\n  if (isGuildInvite(invite)) {\n    const embed: EmbedWith<\"fields\"> = {\n      fields: [],\n    };\n\n    embed.author = {\n      name: `Server invite:  ${invite.guild.name}`,\n      url: `https://discord.gg/${invite.code}`,\n    };\n\n    if (invite.guild.icon) {\n      embed.author.icon_url = `https://cdn.discordapp.com/icons/${invite.guild.id}/${invite.guild.icon}.png?size=256`;\n    }\n\n    if (invite.guild.description) {\n      embed.description = invite.guild.description;\n    }\n\n    const serverCreatedAtTimestamp = snowflakeToTimestamp(invite.guild.id);\n\n    const memberCount = inviteHasCounts(invite) ? invite.memberCount : 0;\n\n    const presenceCount = inviteHasCounts(invite) ? invite.presenceCount : 0;\n\n    embed.fields.push({\n      name: preEmbedPadding + \"Server information\",\n      value: trimLines(`\n        Name: **${invite.guild.name}**\n        ID: \\`${invite.guild.id}\\`\n        Created: **<t:${Math.round(serverCreatedAtTimestamp / 1000)}:R>**\n        Members: **${formatNumber(memberCount)}** (${formatNumber(presenceCount)} online)\n      `),\n      inline: true,\n    });\n    if (invite.channel) {\n      const channelName =\n        invite.channel.type === ChannelType.GuildVoice ? `🔉 ${invite.channel.name}` : `#${invite.channel.name}`;\n\n      const channelCreatedAtTimestamp = snowflakeToTimestamp(invite.channel.id);\n\n      let channelInfo = trimLines(`\n        Name: **${channelName}**\n        ID: \\`${invite.channel.id}\\`\n        Created: **<t:${Math.round(channelCreatedAtTimestamp / 1000)}:R>**\n    `);\n\n      if (invite.channel.type !== ChannelType.GuildVoice) {\n        channelInfo += `\\nMention: <#${invite.channel.id}>`;\n      }\n\n      embed.fields.push({\n        name: preEmbedPadding + \"Channel information\",\n        value: channelInfo,\n        inline: true,\n      });\n    }\n\n    if (invite.inviter) {\n      embed.fields.push({\n        name: preEmbedPadding + \"Invite creator\",\n        value: trimLines(`\n          Name: **${renderUsername(invite.inviter)}**\n          ID: \\`${invite.inviter.id}\\`\n          Mention: <@!${invite.inviter.id}>\n        `),\n      });\n    }\n\n    return embed;\n  }\n\n  if (isGroupDMInvite(invite)) {\n    const embed: EmbedWith<\"fields\"> = {\n      fields: [],\n    };\n\n    embed.author = {\n      name: invite.channel.name ? `Group DM invite:  ${invite.channel.name}` : `Group DM invite`,\n      url: `https://discord.gg/${invite.code}`,\n    }; // FIXME pending invite re-think\n\n    /*if (invite.channel.icon) {\n      embed.author.icon_url = `https://cdn.discordapp.com/channel-icons/${invite.channel.id}/${invite.channel.icon}.png?size=256`;\n    }*/\n\n    const channelCreatedAtTimestamp = snowflakeToTimestamp(invite.channel!.id);\n\n    embed.fields.push({\n      name: preEmbedPadding + \"Group DM information\",\n      value: trimLines(`\n        Name: ${invite.channel!.name ? `**${invite.channel!.name}**` : `_Unknown_`}\n        ID: \\`${invite.channel!.id}\\`\n        Created: **<t:${Math.round(channelCreatedAtTimestamp / 1000)}:R>**\n        Members: **${formatNumber((invite as any).memberCount)}**\n      `),\n      inline: true,\n    });\n\n    if (invite.inviter) {\n      embed.fields.push({\n        name: preEmbedPadding + \"Invite creator\",\n        value: trimLines(`\n          Name: **${renderUsername(invite.inviter.username, invite.inviter.discriminator)}**\n          ID: \\`${invite.inviter.id}\\`\n          Mention: <@!${invite.inviter.id}>\n        `),\n        inline: true,\n      });\n    }\n\n    return embed;\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts",
    "content": "import { APIEmbed, MessageType, Snowflake, TextChannel } from \"discord.js\";\nimport { GuildPluginData, getDefaultMessageCommandPrefix } from \"vety\";\nimport {\n  EmbedWith,\n  chunkMessageLines,\n  messageLink,\n  preEmbedPadding,\n  renderUsername,\n  trimEmptyLines,\n  trimLines,\n} from \"../../../utils.js\";\nimport { UtilityPluginType } from \"../types.js\";\n\nconst MESSAGE_ICON = \"https://cdn.discordapp.com/attachments/740650744830623756/740685652152025088/message.png\";\n\nexport async function getMessageInfoEmbed(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  channelId: string,\n  messageId: string,\n): Promise<APIEmbed | null> {\n  const message = await (pluginData.guild.channels.resolve(channelId as Snowflake) as TextChannel).messages\n    .fetch(messageId as Snowflake)\n    .catch(() => null);\n  if (!message) {\n    return null;\n  }\n\n  const embed: EmbedWith<\"fields\" | \"author\"> = {\n    fields: [],\n    author: {\n      name: `Message:  ${message.id}`,\n      icon_url: MESSAGE_ICON,\n    },\n  };\n\n  const type =\n    {\n      [MessageType.Default]: \"Regular message\",\n      [MessageType.ChannelPinnedMessage]: \"System message\",\n      [MessageType.UserJoin]: \"System message\",\n      [MessageType.GuildBoost]: \"System message\",\n      [MessageType.GuildBoostTier1]: \"System message\",\n      [MessageType.GuildBoostTier2]: \"System message\",\n      [MessageType.GuildBoostTier3]: \"System message\",\n      [MessageType.ChannelFollowAdd]: \"System message\",\n      [MessageType.GuildDiscoveryDisqualified]: \"System message\",\n      [MessageType.GuildDiscoveryRequalified]: \"System message\",\n    }[message.type] ?? \"Unknown\";\n\n  embed.fields.push({\n    name: preEmbedPadding + \"Message information\",\n    value: trimEmptyLines(\n      trimLines(`\n      ID: \\`${message.id}\\`\n      Channel: <#${message.channel.id}>\n      Channel ID: \\`${message.channel.id}\\`\n      Created: **<t:${Math.round(message.createdTimestamp / 1000)}:R>**\n      ${message.editedTimestamp ? `Edited at: **<t:${Math.round(message.editedTimestamp / 1000)}:R>**` : \"\"}\n      Type: **${type}**\n      Link: [**Go to message ➔**](${messageLink(pluginData.guild.id, message.channel.id, message.id)})\n    `),\n    ),\n  });\n\n  const authorJoinedAtTS = message.member?.joinedTimestamp;\n\n  embed.fields.push({\n    name: preEmbedPadding + \"Author information\",\n    value: trimLines(`\n      Name: **${renderUsername(message.author)}**\n      ID: \\`${message.author.id}\\`\n      Created: **<t:${Math.round(message.author.createdTimestamp / 1000)}:R>**\n      ${authorJoinedAtTS ? `Joined: **<t:${Math.round(authorJoinedAtTS / 1000)}:R>**` : \"\"}\n      Mention: <@!${message.author.id}>\n    `),\n  });\n\n  const textContent = message.content || \"<no text content>\";\n  const chunked = chunkMessageLines(textContent, 1014);\n  for (const [i, chunk] of chunked.entries()) {\n    embed.fields.push({\n      name: i === 0 ? preEmbedPadding + \"Text content\" : \"[...]\",\n      value: chunk,\n    });\n  }\n\n  if (message.attachments.size) {\n    const attachmentUrls = message.attachments.map((att) => att.url);\n    embed.fields.push({\n      name: preEmbedPadding + \"Attachments\",\n      value: attachmentUrls.join(\"\\n\"),\n    });\n  }\n\n  if (message.embeds.length) {\n    const prefix = pluginData.fullConfig.prefix || getDefaultMessageCommandPrefix(pluginData.client);\n    embed.fields.push({\n      name: preEmbedPadding + \"Embeds\",\n      value: `Message contains an embed, use \\`${prefix}source\\` to see the embed source`,\n    });\n  }\n\n  return embed;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts",
    "content": "import { APIEmbed, PermissionFlagsBits, Role } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { EmbedWith, preEmbedPadding, trimLines } from \"../../../utils.js\";\nimport { PERMISSION_NAMES } from \"../../../utils/permissionNames.js\";\nimport { UtilityPluginType } from \"../types.js\";\n\nconst MENTION_ICON = \"https://cdn.discordapp.com/attachments/705009450855039042/839284872152481792/mention.png\";\n\nexport async function getRoleInfoEmbed(pluginData: GuildPluginData<UtilityPluginType>, role: Role): Promise<APIEmbed> {\n  const embed: EmbedWith<\"fields\" | \"author\" | \"color\"> = {\n    fields: [],\n    author: {\n      name: `Role:  ${role.name}`,\n      icon_url: MENTION_ICON,\n    },\n    color: role.color,\n  };\n\n  const rolePerms = role.permissions.has(PermissionFlagsBits.Administrator)\n    ? [PERMISSION_NAMES.Administrator]\n    : role.permissions.toArray().map((p) => PERMISSION_NAMES[p]);\n\n  // -1 because of the @everyone role\n  const totalGuildRoles = pluginData.guild.roles.cache.size - 1;\n\n  embed.fields.push({\n    name: preEmbedPadding + \"Role information\",\n    value: trimLines(`\n      Name: **${role.name}**\n      ID: \\`${role.id}\\`\n      Created: **<t:${Math.round(role.createdTimestamp / 1000)}:R>**\n      Position: **${role.position} / ${totalGuildRoles}**\n      Color: **#${role.color.toString(16).toUpperCase().padStart(6, \"0\")}**\n      Mentionable: **${role.mentionable ? \"Yes\" : \"No\"}**\n      Hoisted: **${role.hoist ? \"Yes\" : \"No\"}**\n      Permissions: \\`${rolePerms.length ? rolePerms.join(\", \") : \"None\"}\\`\n      Mention: <@&${role.id}> (\\`<@&${role.id}>\\`)\n    `),\n  });\n\n  return embed;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getServerInfoEmbed.ts",
    "content": "import { APIEmbed, ChannelType, GuildPremiumTier, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport {\n  EmbedWith,\n  MINUTES,\n  formatNumber,\n  inviteHasCounts,\n  memoize,\n  preEmbedPadding,\n  renderUsername,\n  resolveInvite,\n  resolveUser,\n  trimLines,\n} from \"../../../utils.js\";\nimport { idToTimestamp } from \"../../../utils/idToTimestamp.js\";\nimport { UtilityPluginType } from \"../types.js\";\nimport { getGuildPreview } from \"./getGuildPreview.js\";\n\nconst prettifyFeature = (feature: string): string =>\n  `\\`${feature\n    .split(\"_\")\n    .map((e) => `${e.substring(0, 1).toUpperCase()}${e.substring(1).toLowerCase()}`)\n    .join(\" \")}\\``;\n\nexport async function getServerInfoEmbed(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  serverId: string,\n): Promise<APIEmbed | null> {\n  const thisServer = serverId === pluginData.guild.id ? pluginData.guild : null;\n  const [restGuild, guildPreview] = await Promise.all([\n    thisServer\n      ? memoize(() => pluginData.client.guilds.fetch(serverId as Snowflake), `getRESTGuild_${serverId}`, 10 * MINUTES)\n      : null,\n    getGuildPreview(pluginData.client, serverId),\n  ]);\n\n  if (!restGuild && !guildPreview) {\n    return null;\n  }\n\n  const features = (restGuild || guildPreview)!.features;\n  if (!thisServer && !features.includes(\"DISCOVERABLE\")) {\n    return null;\n  }\n\n  const embed: EmbedWith<\"fields\"> = {\n    fields: [],\n  };\n\n  embed.author = {\n    name: `Server:  ${(guildPreview || restGuild)!.name}`,\n    icon_url: (guildPreview || restGuild)!.iconURL() ?? undefined,\n  };\n\n  // BASIC INFORMATION\n  const createdAtTs = Number(idToTimestamp((guildPreview || restGuild)!.id)!);\n\n  const basicInformation: string[] = [];\n  basicInformation.push(`Created: **<t:${Math.round(createdAtTs / 1000)}:R>**`);\n\n  if (thisServer) {\n    const owner = await resolveUser(pluginData.client, thisServer.ownerId, \"Utility:getServerInfoEmbed\");\n    const ownerName = renderUsername(owner.username, owner.discriminator);\n\n    basicInformation.push(`Owner: **${ownerName}** (\\`${thisServer.ownerId}\\`)`);\n    // basicInformation.push(`Voice region: **${thisServer.region}**`); Outdated, as automatic voice regions are fully live\n  }\n\n  if (features.length > 0) {\n    basicInformation.push(`Features: ${features.map(prettifyFeature).join(\", \")}`);\n  }\n\n  embed.description = `${preEmbedPadding}**Basic Information**\\n${basicInformation.join(\"\\n\")}`;\n\n  // IMAGE LINKS\n  const iconUrl = `[Link](${(restGuild || guildPreview)!.iconURL()})`;\n  const bannerUrl = restGuild?.banner ? `[Link](${restGuild.bannerURL()})` : \"None\";\n  const splashUrl = (restGuild || guildPreview)!.splash\n    ? `[Link](${(restGuild || guildPreview)!.splashURL()})`\n    : \"None\";\n\n  embed.fields.push(\n    {\n      name: \"Server icon\",\n      value: iconUrl,\n      inline: true,\n    },\n    {\n      name: \"Invite splash\",\n      value: splashUrl,\n      inline: true,\n    },\n    {\n      name: \"Server banner\",\n      value: bannerUrl,\n      inline: true,\n    },\n  );\n\n  // MEMBER COUNTS\n  const totalMembers =\n    guildPreview?.approximateMemberCount ||\n    restGuild?.approximateMemberCount ||\n    restGuild?.memberCount ||\n    thisServer?.memberCount ||\n    thisServer?.members.cache.size ||\n    0;\n\n  let onlineMemberCount = (guildPreview?.approximatePresenceCount || restGuild?.approximatePresenceCount)!;\n\n  if (onlineMemberCount == null && restGuild?.vanityURLCode) {\n    // For servers with a vanity URL, we can also use the numbers from the invite for online count\n    const invite = await resolveInvite(pluginData.client, restGuild.vanityURLCode!, true);\n    if (invite && inviteHasCounts(invite)) {\n      onlineMemberCount = invite.presenceCount;\n    }\n  }\n\n  if (!onlineMemberCount && thisServer) {\n    onlineMemberCount = thisServer.members.cache.filter((m) => m.presence?.status !== \"offline\").size; // Extremely inaccurate fallback\n  }\n\n  const offlineMemberCount = totalMembers - onlineMemberCount;\n\n  let memberCountTotalLines = `Total: **${formatNumber(totalMembers)}**`;\n  if (restGuild?.maximumMembers) {\n    memberCountTotalLines += `\\nMax: **${formatNumber(restGuild.maximumMembers)}**`;\n  }\n\n  let memberCountOnlineLines = `Online: **${formatNumber(onlineMemberCount)}**`;\n  if (restGuild?.maximumPresences) {\n    memberCountOnlineLines += `\\nMax online: **${formatNumber(restGuild.maximumPresences)}**`;\n  }\n\n  embed.fields.push({\n    name: preEmbedPadding + \"Members\",\n    inline: true,\n    value: trimLines(`\n          ${memberCountTotalLines}\n          ${memberCountOnlineLines}\n          Offline: **${formatNumber(offlineMemberCount)}**\n        `),\n  });\n\n  // CHANNEL COUNTS\n  if (thisServer) {\n    const categories = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildCategory);\n    const textChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildText);\n    const voiceChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildVoice);\n    const forumChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildForum);\n    const mediaChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildMedia);\n    const threadChannelsText = thisServer.channels.cache.filter(\n      (channel) => channel.isThread() && channel.parent?.type !== ChannelType.GuildForum,\n    );\n    const threadChannelsForums = thisServer.channels.cache.filter(\n      (channel) => channel.isThread() && channel.parent?.type === ChannelType.GuildForum,\n    );\n    const threadChannelsMedia = thisServer.channels.cache.filter(\n      (channel) => channel.isThread() && channel.parent?.type === ChannelType.GuildMedia,\n    );\n    const announcementChannels = thisServer.channels.cache.filter(\n      (channel) => channel.type === ChannelType.GuildAnnouncement,\n    );\n    const stageChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildStageVoice);\n    const totalChannels = thisServer.channels.cache.filter((channel) => !channel.isThread()).size;\n\n    embed.fields.push({\n      name: preEmbedPadding + \"Channels\",\n      inline: true,\n      value: trimLines(`\n          Total: **${totalChannels}** / 500\n          Categories: **${categories.size}**\n          Text: **${textChannels.size}** (**${threadChannelsText.size} threads**)\n          Forums: **${forumChannels.size}** (**${threadChannelsForums.size} threads**)\n          Media: **${mediaChannels.size}** (**${threadChannelsMedia.size} threads**)\n          Announcement: **${announcementChannels.size}**\n          Voice: **${voiceChannels.size}**\n          Stage: **${stageChannels.size}**\n        `),\n    });\n  }\n\n  // OTHER STATS\n  const otherStats: string[] = [];\n\n  if (thisServer) {\n    otherStats.push(`Roles: **${thisServer.roles.cache.size}** / 250`);\n  }\n\n  const roleLockedEmojis =\n    (restGuild\n      ? restGuild?.emojis?.cache.filter((e) => e.roles.cache.size)\n      : guildPreview?.emojis.filter((e) => e.roles.length)\n    )?.size ?? 0;\n\n  if (restGuild) {\n    const maxEmojis =\n      {\n        [GuildPremiumTier.None]: 50,\n        [GuildPremiumTier.Tier1]: 100,\n        [GuildPremiumTier.Tier2]: 150,\n        [GuildPremiumTier.Tier3]: 250,\n      }[restGuild.premiumTier] ?? 50;\n    const maxStickers =\n      {\n        [GuildPremiumTier.None]: 0,\n        [GuildPremiumTier.Tier1]: 15,\n        [GuildPremiumTier.Tier2]: 30,\n        [GuildPremiumTier.Tier3]: 60,\n      }[restGuild.premiumTier] ?? 0;\n\n    const availableEmojis = restGuild.emojis.cache.filter((e) => e.available);\n    otherStats.push(\n      `Emojis: **${availableEmojis.size}** / ${maxEmojis * 2}${\n        roleLockedEmojis ? ` (__${roleLockedEmojis} role-locked__)` : \"\"\n      }${\n        availableEmojis.size < restGuild.emojis.cache.size\n          ? ` (__+${restGuild.emojis.cache.size - availableEmojis.size} unavailable__)`\n          : \"\"\n      }`,\n    );\n    otherStats.push(`Stickers: **${restGuild.stickers.cache.size}** / ${maxStickers}`);\n  } else {\n    otherStats.push(\n      `Emojis: **${guildPreview!.emojis.size}**${roleLockedEmojis ? ` (__${roleLockedEmojis} role-locked__)` : \"\"}`,\n    );\n    // otherStats.push(`Stickers: **${guildPreview!.stickers.size}**`); Wait on DJS\n  }\n\n  if (thisServer) {\n    otherStats.push(\n      `Boosts: **${thisServer.premiumSubscriptionCount ?? 0}**${\n        thisServer.premiumTier ? ` (level ${thisServer.premiumTier})` : \"\"\n      }`,\n    );\n  }\n\n  embed.fields.push({\n    name: preEmbedPadding + \"Other stats\",\n    inline: true,\n    value: otherStats.join(\"\\n\"),\n  });\n\n  if (!thisServer) {\n    embed.footer = {\n      text: \"⚠️ Only showing publicly available information for this server\",\n    };\n  }\n\n  return embed;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts",
    "content": "import { APIEmbed } from \"discord.js\";\nimport { EmbedWith, preEmbedPadding } from \"../../../utils.js\";\nimport { snowflakeToTimestamp } from \"../../../utils/snowflakeToTimestamp.js\";\n\nconst SNOWFLAKE_ICON = \"https://cdn.discordapp.com/attachments/740650744830623756/742020790471491668/snowflake.png\";\n\nexport async function getSnowflakeInfoEmbed(snowflake: string, showUnknownWarning = false): Promise<APIEmbed> {\n  const embed: EmbedWith<\"fields\" | \"author\"> = {\n    fields: [],\n    author: {\n      name: `Snowflake:  ${snowflake}`,\n      icon_url: SNOWFLAKE_ICON,\n    },\n  };\n\n  if (showUnknownWarning) {\n    embed.description =\n      \"This is a valid [snowflake ID](https://discord.com/developers/docs/reference#snowflakes), but I don't know what it's for.\";\n  }\n\n  const createdAtMS = snowflakeToTimestamp(snowflake);\n\n  embed.fields.push({\n    name: preEmbedPadding + \"Basic information\",\n    value: `Created: **<t:${Math.round(createdAtMS / 1000)}:R>**`,\n  });\n\n  return embed;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/getUserInfoEmbed.ts",
    "content": "import { APIEmbed } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { CaseTypes } from \"../../../data/CaseTypes.js\";\nimport {\n  EmbedWith,\n  messageLink,\n  preEmbedPadding,\n  renderUsername,\n  resolveMember,\n  resolveUser,\n  sorter,\n  trimEmptyLines,\n  trimLines,\n  UnknownUser,\n} from \"../../../utils.js\";\nimport { UtilityPluginType } from \"../types.js\";\n\nconst MAX_ROLES_TO_DISPLAY = 15;\n\nconst trimRoles = (roles: string[]) =>\n  roles.length > MAX_ROLES_TO_DISPLAY\n    ? roles.slice(0, MAX_ROLES_TO_DISPLAY).join(\", \") + `, and ${roles.length - MAX_ROLES_TO_DISPLAY} more roles`\n    : roles.join(\", \");\n\nexport async function getUserInfoEmbed(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  userId: string,\n  compact = false,\n): Promise<APIEmbed | null> {\n  const user = await resolveUser(pluginData.client, userId, \"Utility:getUserInfoEmbed\");\n  if (!user || user instanceof UnknownUser) {\n    return null;\n  }\n\n  const member = await resolveMember(pluginData.client, pluginData.guild, user.id);\n\n  const embed: EmbedWith<\"fields\"> = {\n    fields: [],\n  };\n\n  embed.author = {\n    name: `${user.bot ? \"Bot\" : \"User\"}:  ${renderUsername(user)}`,\n  };\n\n  const avatarURL = (member ?? user).displayAvatarURL();\n  embed.author.icon_url = avatarURL;\n\n  if (compact) {\n    embed.fields.push({\n      name: preEmbedPadding + `${user.bot ? \"Bot\" : \"User\"} information`,\n      value: trimLines(`\n          Profile: <@!${user.id}>\n          Created: **<t:${Math.round(user.createdTimestamp / 1000)}:R>**\n          `),\n    });\n    if (member) {\n      embed.fields[0].value += `\\n${user.bot ? \"Added\" : \"Joined\"}: **<t:${Math.round(\n        member.joinedTimestamp! / 1000,\n      )}:R>**`;\n    } else {\n      embed.fields.push({\n        name: preEmbedPadding + \"!! NOTE !!\",\n        value: `${user.bot ? \"Bot\" : \"User\"} is not on the server`,\n      });\n    }\n\n    return embed;\n  }\n\n  const userInfoLines = [`ID: \\`${user.id}\\``, `Username: **${user.username}**`];\n  if (user.discriminator !== \"0\") userInfoLines.push(`Discriminator: **${user.discriminator}**`);\n  if (user.globalName) userInfoLines.push(`Display Name: **${user.globalName}**`);\n  userInfoLines.push(`Created: **<t:${Math.round(user.createdTimestamp / 1000)}:R>**`);\n  userInfoLines.push(`Mention: <@!${user.id}>`);\n\n  embed.fields.push({\n    name: preEmbedPadding + `${user.bot ? \"Bot\" : \"User\"} information`,\n    value: userInfoLines.join(\"\\n\"),\n  });\n\n  if (member) {\n    const roles = Array.from(member.roles.cache.values()).filter((r) => r.id !== pluginData.guild.id);\n    roles.sort(sorter(\"position\", \"DESC\"));\n\n    embed.fields.push({\n      name: preEmbedPadding + \"Member information\",\n      value: trimLines(`\n          ${user.bot ? \"Added\" : \"Joined\"}: **<t:${Math.round(member.joinedTimestamp! / 1000)}:R>**\n          ${roles.length > 0 ? \"Roles: \" + trimRoles(roles.map((r) => `<@&${r.id}>`)) : \"\"}\n        `),\n    });\n\n    const voiceChannel = member.voice.channelId ? pluginData.guild.channels.cache.get(member.voice.channelId) : null;\n    if (voiceChannel || member.voice.mute || member.voice.deaf) {\n      embed.fields.push({\n        name: preEmbedPadding + \"Voice information\",\n        value: trimEmptyLines(`\n          ${voiceChannel ? `Current voice channel: **${voiceChannel.name ?? \"None\"}**` : \"\"}\n          ${member.voice.serverMute ? \"Server-muted: **Yes**\" : \"\"}\n          ${member.voice.serverDeaf ? \"Server-deafened: **Yes**\" : \"\"}\n          ${member.voice.selfMute ? \"Self-muted: **Yes**\" : \"\"}\n          ${member.voice.selfDeaf ? \"Self-deafened: **Yes**\" : \"\"}\n        `),\n      });\n    }\n  } else {\n    embed.fields.push({\n      name: preEmbedPadding + \"Member information\",\n      value: `⚠ ${user.bot ? \"Bot\" : \"User\"} is not on the server`,\n    });\n  }\n  const cases = (await pluginData.state.cases.getByUserId(user.id)).filter((c) => !c.is_hidden);\n\n  if (cases.length > 0) {\n    cases.sort((a, b) => {\n      return a.created_at < b.created_at ? 1 : -1;\n    });\n\n    const caseSummary = cases.slice(0, 3).map((c) => {\n      const summaryText = `${CaseTypes[c.type]} (#${c.case_number})`;\n\n      if (c.log_message_id) {\n        const [channelId, messageId] = c.log_message_id.split(\"-\");\n        return `[${summaryText}](${messageLink(pluginData.guild.id, channelId, messageId)})`;\n      }\n\n      return summaryText;\n    });\n\n    const summaryLabel = cases.length > 3 ? \"Last 3 cases\" : \"Summary\";\n\n    embed.fields.push({\n      name: preEmbedPadding + \"Cases\",\n      value: trimLines(`\n          Total cases: **${cases.length}**\n          ${summaryLabel}: ${caseSummary.join(\", \")}\n        `),\n    });\n  }\n\n  return embed;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/functions/hasPermission.ts",
    "content": "import { GuildMember, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { UtilityPluginType } from \"../types.js\";\n\nexport async function hasPermission(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  member: GuildMember,\n  channelId: Snowflake,\n  permission: string,\n) {\n  return (await pluginData.config.getMatchingConfig({ member, channelId }))[permission];\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/guildReloads.ts",
    "content": "import { TextChannel } from \"discord.js\";\n\nexport const activeReloads: Map<string, TextChannel> = new Map();\n"
  },
  {
    "path": "backend/src/plugins/Utility/refreshMembers.ts",
    "content": "import { Guild } from \"discord.js\";\nimport { HOURS, noop } from \"../../utils.js\";\n\nconst MEMBER_REFRESH_FREQUENCY = 1 * HOURS; // How often to do a full member refresh when using commands that need it\nconst memberRefreshLog = new Map<string, { time: number; promise: Promise<void> }>();\n\nexport async function refreshMembersIfNeeded(guild: Guild) {\n  const lastRefresh = memberRefreshLog.get(guild.id);\n  if (lastRefresh && Date.now() < lastRefresh.time + MEMBER_REFRESH_FREQUENCY) {\n    return lastRefresh.promise;\n  }\n\n  const loadPromise = guild.members.fetch().then(noop);\n  memberRefreshLog.set(guild.id, {\n    time: Date.now(),\n    promise: loadPromise,\n  });\n\n  return loadPromise;\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/search.ts",
    "content": "import {\n  ActionRowBuilder,\n  ButtonBuilder,\n  ButtonStyle,\n  GuildMember,\n  Message,\n  MessageComponentInteraction,\n  OmitPartialGroupDMChannel,\n  PermissionsBitField,\n  Snowflake,\n  User,\n} from \"discord.js\";\nimport escapeStringRegexp from \"escape-string-regexp\";\nimport { ArgsFromSignatureOrArray, GuildPluginData } from \"vety\";\nimport moment from \"moment-timezone\";\nimport { RegExpRunner, allowTimeout } from \"../../RegExpRunner.js\";\nimport { getBaseUrl } from \"../../pluginUtils.js\";\nimport {\n  InvalidRegexError,\n  MINUTES,\n  inputPatternToRegExp,\n  multiSorter,\n  renderUsername,\n  sorter,\n  trimLines,\n} from \"../../utils.js\";\nimport { asyncFilter } from \"../../utils/async.js\";\nimport { hasDiscordPermissions } from \"../../utils/hasDiscordPermissions.js\";\nimport { banSearchSignature } from \"./commands/BanSearchCmd.js\";\nimport { searchCmdSignature } from \"./commands/SearchCmd.js\";\nimport { getUserInfoEmbed } from \"./functions/getUserInfoEmbed.js\";\nimport { refreshMembersIfNeeded } from \"./refreshMembers.js\";\nimport { UtilityPluginType } from \"./types.js\";\nimport Timeout = NodeJS.Timeout;\n\nconst SEARCH_RESULTS_PER_PAGE = 15;\nconst SEARCH_ID_RESULTS_PER_PAGE = 50;\nconst SEARCH_EXPORT_LIMIT = 1_000_000;\n\nexport enum SearchType {\n  MemberSearch,\n  BanSearch,\n}\n\nclass SearchError extends Error {}\n\ntype MemberSearchParams = ArgsFromSignatureOrArray<typeof searchCmdSignature>;\ntype BanSearchParams = ArgsFromSignatureOrArray<typeof banSearchSignature>;\n\ntype RegexRunner = InstanceType<typeof RegExpRunner>[\"exec\"];\nfunction getOptimizedRegExpRunner(pluginData: GuildPluginData<UtilityPluginType>, isSafeRegex: boolean): RegexRunner {\n  if (isSafeRegex) {\n    return async (regex: RegExp, str: string) => {\n      if (!regex.global) {\n        const singleMatch = regex.exec(str);\n        return singleMatch ? [singleMatch] : null;\n      }\n\n      const matches: RegExpExecArray[] = [];\n      let match: RegExpExecArray | null;\n      // tslint:disable-next-line:no-conditional-assignment\n      while ((match = regex.exec(str)) != null) {\n        matches.push(match);\n      }\n\n      return matches.length ? matches : null;\n    };\n  }\n\n  return pluginData.state.regexRunner.exec.bind(pluginData.state.regexRunner);\n}\n\nexport async function displaySearch(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  args: MemberSearchParams,\n  searchType: SearchType.MemberSearch,\n  msg: OmitPartialGroupDMChannel<Message>,\n);\nexport async function displaySearch(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  args: BanSearchParams,\n  searchType: SearchType.BanSearch,\n  msg: OmitPartialGroupDMChannel<Message>,\n);\nexport async function displaySearch(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  args: MemberSearchParams | BanSearchParams,\n  searchType: SearchType,\n  msg: OmitPartialGroupDMChannel<Message>,\n) {\n  // If we're not exporting, load 1 page of search results at a time and allow the user to switch pages with reactions\n  let originalSearchMsg: OmitPartialGroupDMChannel<Message>;\n  let searching = false;\n  let currentPage = args.page || 1;\n  let stopCollectionFn: () => void;\n  let stopCollectionTimeout: Timeout;\n\n  const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE;\n\n  const loadSearchPage = async (page) => {\n    if (searching) return;\n    searching = true;\n\n    // The initial message is created here, as well as edited to say \"Searching...\" on subsequent requests\n    // We don't \"await\" this so we can start loading the search results immediately instead of after the message has been created/edited\n    let searchMsgPromise: Promise<Message>;\n    if (originalSearchMsg) {\n      searchMsgPromise = originalSearchMsg.edit(\"Searching...\");\n    } else {\n      searchMsgPromise = msg.channel.send(\"Searching...\");\n      searchMsgPromise.then((m) => (originalSearchMsg = m as OmitPartialGroupDMChannel<Message>));\n    }\n\n    let searchResult;\n    try {\n      switch (searchType) {\n        case SearchType.MemberSearch:\n          searchResult = await performMemberSearch(pluginData, args as MemberSearchParams, page, perPage);\n          break;\n        case SearchType.BanSearch:\n          searchResult = await performBanSearch(pluginData, args as BanSearchParams, page, perPage);\n          break;\n      }\n    } catch (e) {\n      if (e instanceof SearchError) {\n        void pluginData.state.common.sendErrorMessage(msg, e.message);\n        return;\n      }\n\n      if (e instanceof InvalidRegexError) {\n        void pluginData.state.common.sendErrorMessage(msg, e.message);\n        return;\n      }\n\n      throw e;\n    }\n\n    if (searchResult.totalResults === 0) {\n      void pluginData.state.common.sendErrorMessage(msg, \"No results found\");\n      return;\n    }\n\n    const resultWord = searchResult.totalResults === 1 ? \"matching member\" : \"matching members\";\n    const headerText =\n      searchResult.totalResults > perPage\n        ? trimLines(`\n            **Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults})\n          `)\n        : `Found ${searchResult.totalResults} ${resultWord}`;\n\n    const resultList = args.ids\n      ? formatSearchResultIdList(searchResult.results)\n      : formatSearchResultList(searchResult.results);\n\n    const result = trimLines(`\n        ${headerText}\n        \\`\\`\\`js\n        ${resultList}\n        \\`\\`\\`\n      `);\n\n    const searchMsg = await searchMsgPromise;\n\n    const cfg = await pluginData.config.getForUser(msg.author);\n    if (cfg.info_on_single_result && searchResult.totalResults === 1) {\n      const embed = await getUserInfoEmbed(pluginData, searchResult.results[0].id, false);\n      if (embed) {\n        searchMsg.edit(\"Only one result:\");\n        msg.channel.send({ embeds: [embed] });\n        return;\n      }\n    }\n\n    currentPage = searchResult.page;\n\n    // Set up pagination reactions if needed. The reactions are cleared after a timeout.\n    if (searchResult.totalResults > perPage) {\n      const idMod = `${searchMsg.id}:${moment.utc().valueOf()}`;\n      const buttons: ButtonBuilder[] = [\n        new ButtonBuilder()\n          .setStyle(ButtonStyle.Secondary)\n          .setEmoji(\"⬅\")\n          .setCustomId(`previousButton:${idMod}`)\n          .setDisabled(currentPage === 1),\n        new ButtonBuilder()\n          .setStyle(ButtonStyle.Secondary)\n          .setEmoji(\"➡\")\n          .setCustomId(`nextButton:${idMod}`)\n          .setDisabled(currentPage === searchResult.lastPage),\n        new ButtonBuilder().setStyle(ButtonStyle.Secondary).setEmoji(\"🔄\").setCustomId(`reloadButton:${idMod}`),\n      ];\n\n      const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buttons);\n      await searchMsg.edit({ content: result, components: [row] });\n\n      const collector = searchMsg.createMessageComponentCollector({ time: 2 * MINUTES });\n\n      collector.on(\"collect\", async (interaction: MessageComponentInteraction) => {\n        if (msg.author.id !== interaction.user.id) {\n          interaction\n            .reply({ content: `You are not permitted to use these buttons.`, ephemeral: true })\n            // tslint:disable-next-line no-console\n            .catch((err) => console.trace(err.message));\n        } else {\n          if (interaction.customId === `previousButton:${idMod}` && currentPage > 1) {\n            collector.stop();\n            await interaction.deferUpdate();\n            await loadSearchPage(currentPage - 1);\n          } else if (interaction.customId === `nextButton:${idMod}` && currentPage < searchResult.lastPage) {\n            collector.stop();\n            await interaction.deferUpdate();\n            await loadSearchPage(currentPage + 1);\n          } else if (interaction.customId === `reloadButton:${idMod}`) {\n            collector.stop();\n            await interaction.deferUpdate();\n            await loadSearchPage(currentPage);\n          } else {\n            await interaction.deferUpdate();\n          }\n        }\n      });\n\n      stopCollectionFn = async () => {\n        collector.stop();\n        await searchMsg.edit({ content: searchMsg.content, components: [] });\n      };\n\n      clearTimeout(stopCollectionTimeout);\n      stopCollectionTimeout = setTimeout(stopCollectionFn, 2 * MINUTES);\n    } else {\n      searchMsg.edit(result);\n    }\n\n    searching = false;\n  };\n\n  loadSearchPage(currentPage);\n}\n\nexport async function archiveSearch(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  args: MemberSearchParams,\n  searchType: SearchType.MemberSearch,\n  msg: OmitPartialGroupDMChannel<Message>,\n);\nexport async function archiveSearch(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  args: BanSearchParams,\n  searchType: SearchType.BanSearch,\n  msg: OmitPartialGroupDMChannel<Message>,\n);\nexport async function archiveSearch(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  args: MemberSearchParams | BanSearchParams,\n  searchType: SearchType,\n  msg: OmitPartialGroupDMChannel<Message>,\n) {\n  let results;\n  try {\n    switch (searchType) {\n      case SearchType.MemberSearch:\n        results = await performMemberSearch(pluginData, args as MemberSearchParams, 1, SEARCH_EXPORT_LIMIT);\n        break;\n      case SearchType.BanSearch:\n        results = await performBanSearch(pluginData, args as BanSearchParams, 1, SEARCH_EXPORT_LIMIT);\n        break;\n    }\n  } catch (e) {\n    if (e instanceof SearchError) {\n      void pluginData.state.common.sendErrorMessage(msg, e.message);\n      return;\n    }\n\n    if (e instanceof InvalidRegexError) {\n      void pluginData.state.common.sendErrorMessage(msg, e.message);\n      return;\n    }\n\n    throw e;\n  }\n\n  if (results.totalResults === 0) {\n    void pluginData.state.common.sendErrorMessage(msg, \"No results found\");\n    return;\n  }\n\n  const resultList = args.ids ? formatSearchResultIdList(results.results) : formatSearchResultList(results.results);\n\n  const archiveId = await pluginData.state.archives.create(\n    trimLines(`\n      Search results (total ${results.totalResults}):\n\n      ${resultList}\n    `),\n    moment.utc().add(1, \"hour\"),\n  );\n\n  const baseUrl = getBaseUrl(pluginData);\n  const url = await pluginData.state.archives.getUrl(baseUrl, archiveId);\n\n  await msg.channel.send(`Exported search results: ${url}`);\n}\n\nasync function performMemberSearch(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  args: MemberSearchParams,\n  page = 1,\n  perPage = SEARCH_RESULTS_PER_PAGE,\n): Promise<{ results: GuildMember[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> {\n  await refreshMembersIfNeeded(pluginData.guild);\n\n  let matchingMembers = Array.from(pluginData.guild.members.cache.values());\n\n  if (args.role) {\n    const roleIds = args.role.split(\",\");\n    matchingMembers = matchingMembers.filter((member) => {\n      for (const role of roleIds) {\n        if (!member.roles.cache.has(role as Snowflake)) return false;\n      }\n\n      return true;\n    });\n  }\n\n  if (args.voice) {\n    matchingMembers = matchingMembers.filter((m) => m.voice.channelId);\n  }\n\n  if (args.bot) {\n    matchingMembers = matchingMembers.filter((m) => m.user.bot);\n  }\n\n  if (args.query) {\n    let isSafeRegex = true;\n    let queryRegex: RegExp;\n    if (args.regex) {\n      const flags = args[\"case-sensitive\"] ? \"\" : \"i\";\n      queryRegex = inputPatternToRegExp(args.query.trimStart());\n      queryRegex = new RegExp(queryRegex.source, flags);\n      isSafeRegex = false;\n    } else {\n      queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args[\"case-sensitive\"] ? \"\" : \"i\");\n    }\n\n    const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex);\n\n    /* FIXME if we ever get the intent for this again\n    if (args[\"status-search\"]) {\n      matchingMembers = await asyncFilter(matchingMembers, async member => {\n        if (member.game) {\n          if (member.game.name && (await execRegExp(queryRegex, member.game.name).catch(allowTimeout))) {\n            return true;\n          }\n\n          if (member.game.state && (await execRegExp(queryRegex, member.game.state).catch(allowTimeout))) {\n            return true;\n          }\n\n          if (member.game.details && (await execRegExp(queryRegex, member.game.details).catch(allowTimeout))) {\n            return true;\n          }\n\n          if (member.game.assets) {\n            if (\n              member.game.assets.small_text &&\n              (await execRegExp(queryRegex, member.game.assets.small_text).catch(allowTimeout))\n            ) {\n              return true;\n            }\n\n            if (\n              member.game.assets.large_text &&\n              (await execRegExp(queryRegex, member.game.assets.large_text).catch(allowTimeout))\n            ) {\n              return true;\n            }\n          }\n\n          if (member.game.emoji && (await execRegExp(queryRegex, member.game.emoji.name).catch(allowTimeout))) {\n            return true;\n          }\n        }\n        return false;\n      });\n    } else {\n    */\n    matchingMembers = await asyncFilter(matchingMembers, async (member) => {\n      if (member.nickname && (await execRegExp(queryRegex, member.nickname).catch(allowTimeout))) {\n        return true;\n      }\n\n      const fullUsername = renderUsername(member);\n      if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true;\n\n      return false;\n    });\n    // } FIXME in conjunction with above comment\n  }\n\n  const [, sortDir, sortBy] = (args.sort && args.sort.match(/^(-?)(.*)$/)) ?? [null, \"ASC\", \"name\"];\n  const realSortDir = sortDir === \"-\" ? \"DESC\" : \"ASC\";\n\n  if (sortBy === \"id\") {\n    matchingMembers.sort(sorter((m) => BigInt(m.id), realSortDir));\n  } else {\n    matchingMembers.sort(\n      multiSorter([\n        [(m) => m.user.username.toLowerCase(), realSortDir],\n        [(m) => m.discriminator, realSortDir],\n      ]),\n    );\n  }\n\n  const lastPage = Math.max(1, Math.ceil(matchingMembers.length / perPage));\n  page = Math.min(lastPage, Math.max(1, page));\n\n  const from = (page - 1) * perPage;\n  const to = Math.min(from + perPage, matchingMembers.length);\n\n  const pageMembers = matchingMembers.slice(from, to);\n\n  return {\n    results: pageMembers,\n    totalResults: matchingMembers.length,\n    page,\n    lastPage,\n    from: from + 1,\n    to,\n  };\n}\n\nasync function performBanSearch(\n  pluginData: GuildPluginData<UtilityPluginType>,\n  args: BanSearchParams,\n  page = 1,\n  perPage = SEARCH_RESULTS_PER_PAGE,\n): Promise<{ results: User[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> {\n  const member = pluginData.guild.members.cache.get(pluginData.client.user!.id);\n  if (member && !hasDiscordPermissions(member.permissions, PermissionsBitField.Flags.BanMembers)) {\n    throw new SearchError(`Unable to search bans: missing \"Ban Members\" permission`);\n  }\n\n  let matchingBans = (await pluginData.guild.bans.fetch({ cache: false })).map((x) => x.user);\n\n  if (args.query) {\n    let isSafeRegex = true;\n    let queryRegex: RegExp;\n    if (args.regex) {\n      const flags = args[\"case-sensitive\"] ? \"\" : \"i\";\n      queryRegex = inputPatternToRegExp(args.query.trimStart());\n      queryRegex = new RegExp(queryRegex.source, flags);\n      isSafeRegex = false;\n    } else {\n      queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args[\"case-sensitive\"] ? \"\" : \"i\");\n    }\n\n    const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex);\n    matchingBans = await asyncFilter(matchingBans, async (user) => {\n      const fullUsername = renderUsername(user);\n      if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true;\n      return false;\n    });\n  }\n\n  const [, sortDir, sortBy] = (args.sort && args.sort.match(/^(-?)(.*)$/)) ?? [null, \"ASC\", \"name\"];\n  const realSortDir = sortDir === \"-\" ? \"DESC\" : \"ASC\";\n\n  if (sortBy === \"id\") {\n    matchingBans.sort(sorter((m) => BigInt(m.id), realSortDir));\n  } else {\n    matchingBans.sort(\n      multiSorter([\n        [(m) => m.username.toLowerCase(), realSortDir],\n        [(m) => m.discriminator, realSortDir],\n      ]),\n    );\n  }\n\n  const lastPage = Math.max(1, Math.ceil(matchingBans.length / perPage));\n  page = Math.min(lastPage, Math.max(1, page));\n\n  const from = (page - 1) * perPage;\n  const to = Math.min(from + perPage, matchingBans.length);\n\n  const pageMembers = matchingBans.slice(from, to);\n\n  return {\n    results: pageMembers,\n    totalResults: matchingBans.length,\n    page,\n    lastPage,\n    from: from + 1,\n    to,\n  };\n}\n\nfunction formatSearchResultList(members: Array<GuildMember | User>): string {\n  const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0);\n  const lines = members.map((member) => {\n    const paddedId = member.id.padEnd(longestId, \" \");\n    let line;\n    if (member instanceof GuildMember) {\n      line = `${paddedId} ${renderUsername(member)}`;\n      if (member.nickname) line += ` (${member.nickname})`;\n    } else {\n      line = `${paddedId} ${renderUsername(member)}`;\n    }\n    return line;\n  });\n  return lines.join(\"\\n\");\n}\n\nfunction formatSearchResultIdList(members: Array<GuildMember | User>): string {\n  return members.map((m) => m.id).join(\" \");\n}\n"
  },
  {
    "path": "backend/src/plugins/Utility/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from \"vety\";\nimport { z } from \"zod\";\nimport { RegExpRunner } from \"../../RegExpRunner.js\";\nimport { GuildArchives } from \"../../data/GuildArchives.js\";\nimport { GuildCases } from \"../../data/GuildCases.js\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { GuildSavedMessages } from \"../../data/GuildSavedMessages.js\";\nimport { Supporters } from \"../../data/Supporters.js\";\nimport { CommonPlugin } from \"../Common/CommonPlugin.js\";\n\nexport const zUtilityConfig = z.strictObject({\n  can_roles: z.boolean().default(false),\n  can_level: z.boolean().default(false),\n  can_search: z.boolean().default(false),\n  can_clean: z.boolean().default(false),\n  can_info: z.boolean().default(false),\n  can_server: z.boolean().default(false),\n  can_inviteinfo: z.boolean().default(false),\n  can_channelinfo: z.boolean().default(false),\n  can_messageinfo: z.boolean().default(false),\n  can_userinfo: z.boolean().default(false),\n  can_roleinfo: z.boolean().default(false),\n  can_emojiinfo: z.boolean().default(false),\n  can_snowflake: z.boolean().default(false),\n  can_reload_guild: z.boolean().default(false),\n  can_nickname: z.boolean().default(false),\n  can_ping: z.boolean().default(false),\n  can_source: z.boolean().default(false),\n  can_vcmove: z.boolean().default(false),\n  can_vckick: z.boolean().default(false),\n  can_help: z.boolean().default(false),\n  can_about: z.boolean().default(false),\n  can_context: z.boolean().default(false),\n  can_jumbo: z.boolean().default(false),\n  jumbo_size: z.number().default(128),\n  can_avatar: z.boolean().default(false),\n  info_on_single_result: z.boolean().default(true),\n  autojoin_threads: z.boolean().default(true),\n});\n\nexport interface UtilityPluginType extends BasePluginType {\n  configSchema: typeof zUtilityConfig;\n  state: {\n    logs: GuildLogs;\n    cases: GuildCases;\n    savedMessages: GuildSavedMessages;\n    archives: GuildArchives;\n    supporters: Supporters;\n    regexRunner: RegExpRunner;\n\n    lastReload: number;\n\n    common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;\n  };\n}\n\nexport const utilityCmd = guildPluginMessageCommand<UtilityPluginType>();\nexport const utilityEvt = guildPluginEventListener<UtilityPluginType>();\n"
  },
  {
    "path": "backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts",
    "content": "import { guildPlugin } from \"vety\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { LogsPlugin } from \"../Logs/LogsPlugin.js\";\nimport { SendWelcomeMessageEvt } from \"./events/SendWelcomeMessageEvt.js\";\nimport { WelcomeMessagePluginType, zWelcomeMessageConfig } from \"./types.js\";\n\nexport const WelcomeMessagePlugin = guildPlugin<WelcomeMessagePluginType>()({\n  name: \"welcome_message\",\n\n  dependencies: () => [LogsPlugin],\n  configSchema: zWelcomeMessageConfig,\n\n  // prettier-ignore\n  events: [\n    SendWelcomeMessageEvt,\n  ],\n\n  beforeLoad(pluginData) {\n    const { state, guild } = pluginData;\n\n    state.logs = new GuildLogs(guild.id);\n    state.sentWelcomeMessages = new Set();\n  },\n});\n"
  },
  {
    "path": "backend/src/plugins/WelcomeMessage/docs.ts",
    "content": "import { ZeppelinPluginDocs } from \"../../types.js\";\nimport { zWelcomeMessageConfig } from \"./types.js\";\n\nexport const welcomeMessagePluginDocs: ZeppelinPluginDocs = {\n  type: \"stable\",\n  prettyName: \"Welcome message\",\n  configSchema: zWelcomeMessageConfig,\n};\n"
  },
  {
    "path": "backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts",
    "content": "import { PermissionsBitField, Snowflake, TextChannel } from \"discord.js\";\nimport { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from \"../../../templateFormatter.js\";\nimport {\n  createChunkedMessage,\n  renderRecursively,\n  verboseChannelMention,\n  verboseUserMention\n} from \"../../../utils.js\";\nimport { MessageContent } from \"../../../utils.js\";\nimport { hasDiscordPermissions } from \"../../../utils/hasDiscordPermissions.js\";\nimport { sendDM } from \"../../../utils/sendDM.js\";\nimport {\n  guildToTemplateSafeGuild,\n  memberToTemplateSafeMember,\n  userToTemplateSafeUser,\n} from \"../../../utils/templateSafeObjects.js\";\nimport { LogsPlugin } from \"../../Logs/LogsPlugin.js\";\nimport { welcomeMessageEvt } from \"../types.js\";\n\nexport const SendWelcomeMessageEvt = welcomeMessageEvt({\n  event: \"guildMemberAdd\",\n\n  async listener(meta) {\n    const pluginData = meta.pluginData;\n    const member = meta.args.member;\n\n    const config = pluginData.config.get();\n    if (!config.message) return;\n    if (!config.send_dm && !config.send_to_channel) return;\n\n    // Only send welcome messages once per user (even if they rejoin) until the plugin is reloaded\n    if (pluginData.state.sentWelcomeMessages.has(member.id)) {\n      return;\n    }\n\n    pluginData.state.sentWelcomeMessages.add(member.id);\n\n    const templateValues = new TemplateSafeValueContainer({\n      member: memberToTemplateSafeMember(member),\n      user: userToTemplateSafeUser(member.user),\n      guild: guildToTemplateSafeGuild(member.guild),\n    });\n\n    const renderMessageText = (str: string) => renderTemplate(str, templateValues);\n\n    let formatted: MessageContent;\n\n    try {\n      formatted = typeof config.message === \"string\"\n        ? await renderMessageText(config.message)\n        : ((await renderRecursively(config.message, renderMessageText)) as MessageContent);\n    } catch (e) {\n      if (e instanceof TemplateParseError) {\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Error formatting welcome message: ${e.message}`,\n        });\n        return;\n      }\n      throw e;\n    }\n\n    if (config.send_dm) {\n      try {\n        await sendDM(member.user, formatted, \"welcome message\");\n      } catch {\n        pluginData.getPlugin(LogsPlugin).logDmFailed({\n          source: \"welcome message\",\n          user: member.user,\n        });\n      }\n    }\n\n    if (config.send_to_channel) {\n      const channel = meta.args.member.guild.channels.cache.get(config.send_to_channel as Snowflake);\n      if (!channel || !(channel instanceof TextChannel)) return;\n\n      if (\n        !hasDiscordPermissions(\n          channel.permissionsFor(pluginData.client.user!.id),\n          PermissionsBitField.Flags.SendMessages | PermissionsBitField.Flags.ViewChannel,\n        )\n      ) {\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Missing permissions to send welcome message in ${verboseChannelMention(channel)}`,\n        });\n        return;\n      }\n\n      if (\n        typeof formatted === \"object\" && formatted.embeds && formatted.embeds.length > 0 &&\n        !hasDiscordPermissions(\n          channel.permissionsFor(pluginData.client.user!.id),\n          PermissionsBitField.Flags.EmbedLinks,\n        )\n      ) {\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Missing permissions to send welcome message **with embeds** in ${verboseChannelMention(channel)}`,\n        });\n        return;\n      }\n\n      try {\n        if (typeof formatted === \"string\") {\n          await createChunkedMessage(channel, formatted, {\n            parse: [\"users\"],\n          });\n        } else {\n          await channel.send({\n            ...formatted,\n            allowedMentions: {\n              parse: [\"users\"],\n            },\n          });\n        }\n      } catch {\n        pluginData.getPlugin(LogsPlugin).logBotAlert({\n          body: `Failed to send welcome message for ${verboseUserMention(member.user)} to ${verboseChannelMention(channel)}`,\n        });\n      }\n    }\n  },\n});"
  },
  {
    "path": "backend/src/plugins/WelcomeMessage/types.ts",
    "content": "import { BasePluginType, guildPluginEventListener } from \"vety\";\nimport { z } from \"zod\";\nimport { GuildLogs } from \"../../data/GuildLogs.js\";\nimport { zMessageContent } from \"../../utils.js\";\n\nexport const zWelcomeMessageConfig = z.strictObject({\n  send_dm: z.boolean().default(false),\n  send_to_channel: z.string().nullable().default(null),\n  message: zMessageContent.nullable().default(null),\n});\n\nexport interface WelcomeMessagePluginType extends BasePluginType {\n  configSchema: typeof zWelcomeMessageConfig;\n  state: {\n    logs: GuildLogs;\n    sentWelcomeMessages: Set<string>;\n  };\n}\n\nexport const welcomeMessageEvt = guildPluginEventListener<WelcomeMessagePluginType>();\n"
  },
  {
    "path": "backend/src/plugins/availablePlugins.ts",
    "content": "import { ZeppelinGlobalPluginInfo, ZeppelinGuildPluginInfo } from \"../types.js\";\nimport { AutoDeletePlugin } from \"./AutoDelete/AutoDeletePlugin.js\";\nimport { autoDeletePluginDocs } from \"./AutoDelete/docs.js\";\nimport { AutoReactionsPlugin } from \"./AutoReactions/AutoReactionsPlugin.js\";\nimport { autoReactionsPluginDocs } from \"./AutoReactions/docs.js\";\nimport { AutomodPlugin } from \"./Automod/AutomodPlugin.js\";\nimport { automodPluginDocs } from \"./Automod/docs.js\";\nimport { BotControlPlugin } from \"./BotControl/BotControlPlugin.js\";\nimport { botControlPluginDocs } from \"./BotControl/docs.js\";\nimport { CasesPlugin } from \"./Cases/CasesPlugin.js\";\nimport { casesPluginDocs } from \"./Cases/docs.js\";\nimport { CensorPlugin } from \"./Censor/CensorPlugin.js\";\nimport { censorPluginDocs } from \"./Censor/docs.js\";\nimport { CommonPlugin } from \"./Common/CommonPlugin.js\";\nimport { commonPluginDocs } from \"./Common/docs.js\";\nimport { CompanionChannelsPlugin } from \"./CompanionChannels/CompanionChannelsPlugin.js\";\nimport { companionChannelsPluginDocs } from \"./CompanionChannels/docs.js\";\nimport { ContextMenuPlugin } from \"./ContextMenus/ContextMenuPlugin.js\";\nimport { contextMenuPluginDocs } from \"./ContextMenus/docs.js\";\nimport { CountersPlugin } from \"./Counters/CountersPlugin.js\";\nimport { countersPluginDocs } from \"./Counters/docs.js\";\nimport { CustomEventsPlugin } from \"./CustomEvents/CustomEventsPlugin.js\";\nimport { customEventsPluginDocs } from \"./CustomEvents/docs.js\";\nimport { GuildAccessMonitorPlugin } from \"./GuildAccessMonitor/GuildAccessMonitorPlugin.js\";\nimport { guildAccessMonitorPluginDocs } from \"./GuildAccessMonitor/docs.js\";\nimport { GuildConfigReloaderPlugin } from \"./GuildConfigReloader/GuildConfigReloaderPlugin.js\";\nimport { guildConfigReloaderPluginDocs } from \"./GuildConfigReloader/docs.js\";\nimport { GuildInfoSaverPlugin } from \"./GuildInfoSaver/GuildInfoSaverPlugin.js\";\nimport { guildInfoSaverPluginDocs } from \"./GuildInfoSaver/docs.js\";\nimport { InternalPosterPlugin } from \"./InternalPoster/InternalPosterPlugin.js\";\nimport { internalPosterPluginDocs } from \"./InternalPoster/docs.js\";\nimport { LocateUserPlugin } from \"./LocateUser/LocateUserPlugin.js\";\nimport { locateUserPluginDocs } from \"./LocateUser/docs.js\";\nimport { LogsPlugin } from \"./Logs/LogsPlugin.js\";\nimport { logsPluginDocs } from \"./Logs/docs.js\";\nimport { MessageSaverPlugin } from \"./MessageSaver/MessageSaverPlugin.js\";\nimport { messageSaverPluginDocs } from \"./MessageSaver/docs.js\";\nimport { ModActionsPlugin } from \"./ModActions/ModActionsPlugin.js\";\nimport { modActionsPluginDocs } from \"./ModActions/docs.js\";\nimport { MutesPlugin } from \"./Mutes/MutesPlugin.js\";\nimport { mutesPluginDocs } from \"./Mutes/docs.js\";\nimport { NameHistoryPlugin } from \"./NameHistory/NameHistoryPlugin.js\";\nimport { nameHistoryPluginDocs } from \"./NameHistory/docs.js\";\nimport { PersistPlugin } from \"./Persist/PersistPlugin.js\";\nimport { persistPluginDocs } from \"./Persist/docs.js\";\nimport { PhishermanPlugin } from \"./Phisherman/PhishermanPlugin.js\";\nimport { phishermanPluginDocs } from \"./Phisherman/docs.js\";\nimport { PingableRolesPlugin } from \"./PingableRoles/PingableRolesPlugin.js\";\nimport { pingableRolesPluginDocs } from \"./PingableRoles/docs.js\";\nimport { PostPlugin } from \"./Post/PostPlugin.js\";\nimport { postPluginDocs } from \"./Post/docs.js\";\nimport { ReactionRolesPlugin } from \"./ReactionRoles/ReactionRolesPlugin.js\";\nimport { reactionRolesPluginDocs } from \"./ReactionRoles/docs.js\";\nimport { RemindersPlugin } from \"./Reminders/RemindersPlugin.js\";\nimport { remindersPluginDocs } from \"./Reminders/docs.js\";\nimport { RoleButtonsPlugin } from \"./RoleButtons/RoleButtonsPlugin.js\";\nimport { roleButtonsPluginDocs } from \"./RoleButtons/docs.js\";\nimport { RoleManagerPlugin } from \"./RoleManager/RoleManagerPlugin.js\";\nimport { roleManagerPluginDocs } from \"./RoleManager/docs.js\";\nimport { RolesPlugin } from \"./Roles/RolesPlugin.js\";\nimport { rolesPluginDocs } from \"./Roles/docs.js\";\nimport { SelfGrantableRolesPlugin } from \"./SelfGrantableRoles/SelfGrantableRolesPlugin.js\";\nimport { selfGrantableRolesPluginDocs } from \"./SelfGrantableRoles/docs.js\";\nimport { SlowmodePlugin } from \"./Slowmode/SlowmodePlugin.js\";\nimport { slowmodePluginDocs } from \"./Slowmode/docs.js\";\nimport { SpamPlugin } from \"./Spam/SpamPlugin.js\";\nimport { spamPluginDocs } from \"./Spam/docs.js\";\nimport { StarboardPlugin } from \"./Starboard/StarboardPlugin.js\";\nimport { starboardPluginDocs } from \"./Starboard/docs.js\";\nimport { TagsPlugin } from \"./Tags/TagsPlugin.js\";\nimport { tagsPluginDocs } from \"./Tags/docs.js\";\nimport { TimeAndDatePlugin } from \"./TimeAndDate/TimeAndDatePlugin.js\";\nimport { timeAndDatePluginDocs } from \"./TimeAndDate/docs.js\";\nimport { UsernameSaverPlugin } from \"./UsernameSaver/UsernameSaverPlugin.js\";\nimport { usernameSaverPluginDocs } from \"./UsernameSaver/docs.js\";\nimport { UtilityPlugin } from \"./Utility/UtilityPlugin.js\";\nimport { utilityPluginDocs } from \"./Utility/docs.js\";\nimport { WelcomeMessagePlugin } from \"./WelcomeMessage/WelcomeMessagePlugin.js\";\nimport { welcomeMessagePluginDocs } from \"./WelcomeMessage/docs.js\";\nimport { CommandAliasesPlugin } from \"./CommandAliases/CommandAliasesPlugin.js\";\nimport { commandAliasesPluginDocs } from \"./CommandAliases/docs.js\";\n\nexport const availableGuildPlugins: ZeppelinGuildPluginInfo[] = [\n  {\n    plugin: AutoDeletePlugin,\n    docs: autoDeletePluginDocs,\n  },\n  {\n    plugin: AutomodPlugin,\n    docs: automodPluginDocs,\n  },\n  {\n    plugin: AutoReactionsPlugin,\n    docs: autoReactionsPluginDocs,\n  },\n  {\n    plugin: CasesPlugin,\n    docs: casesPluginDocs,\n    autoload: true,\n  },\n  {\n    plugin: CensorPlugin,\n    docs: censorPluginDocs,\n  },\n  {\n    plugin: CommandAliasesPlugin,\n    docs: commandAliasesPluginDocs,\n  },\n  {\n    plugin: CompanionChannelsPlugin,\n    docs: companionChannelsPluginDocs,\n  },\n  {\n    plugin: ContextMenuPlugin,\n    docs: contextMenuPluginDocs,\n  },\n  {\n    plugin: CountersPlugin,\n    docs: countersPluginDocs,\n  },\n  {\n    plugin: CustomEventsPlugin,\n    docs: customEventsPluginDocs,\n  },\n  {\n    plugin: GuildInfoSaverPlugin,\n    docs: guildInfoSaverPluginDocs,\n    autoload: true,\n  },\n  // FIXME: New caching thing, or fix deadlocks with this plugin\n  // {\n  //   plugin: GuildMemberCachePlugin,\n  //   docs: guildMemberCachePluginDocs,\n  //   autoload: true,\n  // },\n  {\n    plugin: InternalPosterPlugin,\n    docs: internalPosterPluginDocs,\n  },\n  {\n    plugin: LocateUserPlugin,\n    docs: locateUserPluginDocs,\n  },\n  {\n    plugin: LogsPlugin,\n    docs: logsPluginDocs,\n  },\n  {\n    plugin: MessageSaverPlugin,\n    docs: messageSaverPluginDocs,\n    autoload: true,\n  },\n  {\n    plugin: ModActionsPlugin,\n    docs: modActionsPluginDocs,\n  },\n  {\n    plugin: MutesPlugin,\n    docs: mutesPluginDocs,\n    autoload: true,\n  },\n  {\n    plugin: NameHistoryPlugin,\n    docs: nameHistoryPluginDocs,\n    autoload: true,\n  },\n  {\n    plugin: PersistPlugin,\n    docs: persistPluginDocs,\n  },\n  {\n    plugin: PhishermanPlugin,\n    docs: phishermanPluginDocs,\n  },\n  {\n    plugin: PingableRolesPlugin,\n    docs: pingableRolesPluginDocs,\n  },\n  {\n    plugin: PostPlugin,\n    docs: postPluginDocs,\n  },\n  {\n    plugin: ReactionRolesPlugin,\n    docs: reactionRolesPluginDocs,\n  },\n  {\n    plugin: RemindersPlugin,\n    docs: remindersPluginDocs,\n  },\n  {\n    plugin: RoleButtonsPlugin,\n    docs: roleButtonsPluginDocs,\n  },\n  {\n    plugin: RoleManagerPlugin,\n    docs: roleManagerPluginDocs,\n  },\n  {\n    plugin: RolesPlugin,\n    docs: rolesPluginDocs,\n  },\n  {\n    plugin: SelfGrantableRolesPlugin,\n    docs: selfGrantableRolesPluginDocs,\n  },\n  {\n    plugin: SlowmodePlugin,\n    docs: slowmodePluginDocs,\n  },\n  {\n    plugin: SpamPlugin,\n    docs: spamPluginDocs,\n  },\n  {\n    plugin: StarboardPlugin,\n    docs: starboardPluginDocs,\n  },\n  {\n    plugin: TagsPlugin,\n    docs: tagsPluginDocs,\n  },\n  {\n    plugin: TimeAndDatePlugin,\n    docs: timeAndDatePluginDocs,\n    autoload: true,\n  },\n  {\n    plugin: UsernameSaverPlugin,\n    docs: usernameSaverPluginDocs,\n  },\n  {\n    plugin: UtilityPlugin,\n    docs: utilityPluginDocs,\n  },\n  {\n    plugin: WelcomeMessagePlugin,\n    docs: welcomeMessagePluginDocs,\n  },\n  {\n    plugin: CommonPlugin,\n    docs: commonPluginDocs,\n    autoload: true,\n  },\n];\n\nexport const availableGlobalPlugins: ZeppelinGlobalPluginInfo[] = [\n  {\n    plugin: GuildConfigReloaderPlugin,\n    docs: guildConfigReloaderPluginDocs,\n  },\n  {\n    plugin: BotControlPlugin,\n    docs: botControlPluginDocs,\n  },\n  {\n    plugin: GuildAccessMonitorPlugin,\n    docs: guildAccessMonitorPluginDocs,\n  },\n];\n"
  },
  {
    "path": "backend/src/profiler.ts",
    "content": "import type { Vety } from \"vety\";\n\ntype Profiler = Vety[\"profiler\"];\nlet profiler: Profiler | null = null;\n\nexport function getProfiler(): Profiler | null {\n  return profiler;\n}\n\nexport function setProfiler(_profiler: Profiler) {\n  profiler = _profiler;\n}\n"
  },
  {
    "path": "backend/src/rateLimitStats.ts",
    "content": "import { RateLimitData } from \"discord.js\";\n\ntype RateLimitLogItem = {\n  timestamp: number;\n  data: RateLimitData;\n};\n\nconst rateLimitLog: RateLimitLogItem[] = [];\n\nconst MAX_RATE_LIMIT_LOG_ITEMS = 100;\n\nexport function logRateLimit(data: RateLimitData) {\n  rateLimitLog.push({\n    timestamp: Date.now(),\n    data,\n  });\n  if (rateLimitLog.length > MAX_RATE_LIMIT_LOG_ITEMS) {\n    rateLimitLog.splice(0, rateLimitLog.length - MAX_RATE_LIMIT_LOG_ITEMS);\n  }\n}\n\nexport function getRateLimitStats(): RateLimitLogItem[] {\n  return Array.from(rateLimitLog);\n}\n"
  },
  {
    "path": "backend/src/regExpRunners.ts",
    "content": "import { RegExpRunner } from \"./RegExpRunner.js\";\n\ninterface RunnerInfo {\n  users: number;\n  runner: RegExpRunner;\n}\n\nconst runners: Map<string, RunnerInfo> = new Map();\n\nexport function getRegExpRunner(key: string) {\n  if (!runners.has(key)) {\n    const runner = new RegExpRunner();\n    runners.set(key, {\n      users: 0,\n      runner,\n    });\n  }\n\n  const info = runners.get(key)!;\n  info.users++;\n\n  return info.runner;\n}\n\nexport function discardRegExpRunner(key: string) {\n  if (!runners.has(key)) {\n    throw new Error(`No runners with key ${key}, cannot discard`);\n  }\n\n  const info = runners.get(key)!;\n  info.users--;\n\n  if (info.users <= 0) {\n    info.runner.dispose();\n    runners.delete(key);\n  }\n}\n"
  },
  {
    "path": "backend/src/restCallStats.ts",
    "content": "import { sorter } from \"./utils.js\";\n\nError.stackTraceLimit = Infinity;\n\ntype CallStats = { method: string; path: string; source: string; count: number };\nconst restCallStats: Map<string, CallStats> = new Map();\n\nconst looseSnowflakeRegex = /\\d{15,}/g;\nconst queryParamsRegex = /\\?.*$/g;\n\nexport function logRestCall(method: string, path: string) {\n  const anonymizedPath = path.replace(looseSnowflakeRegex, \"0000\").replace(queryParamsRegex, \"\");\n  const stackLines = (new Error().stack || \"\").split(\"\\n\").slice(10); // Remove initial fluff\n  const firstSrcLine = stackLines.findIndex((line) => line.includes(\"/backend/src\"));\n  const source = stackLines\n    .slice(firstSrcLine !== -1 ? firstSrcLine : -5)\n    .filter((l) => !l.includes(\"processTicksAndRejections\"))\n    .join(\"\\n\");\n  const key = `${method}|${anonymizedPath}|${source}`;\n  if (!restCallStats.has(key)) {\n    restCallStats.set(key, {\n      method,\n      path: anonymizedPath,\n      source,\n      count: 0,\n    });\n  }\n  restCallStats.get(key)!.count++;\n}\n\nexport function getTopRestCallStats(count: number): CallStats[] {\n  const stats = Array.from(restCallStats.values());\n  stats.sort(sorter(\"count\", \"DESC\"));\n  return stats.slice(0, count);\n}\n"
  },
  {
    "path": "backend/src/staff.ts",
    "content": "import { env } from \"./env.js\";\n\n/**\n * Zeppelin staff have full access to the dashboard\n */\nexport function isStaff(userId: string) {\n  return (env.STAFF ?? []).includes(userId);\n}\n"
  },
  {
    "path": "backend/src/templateFormatter.test.ts",
    "content": "import test from \"ava\";\nimport {\n  parseTemplate,\n  renderParsedTemplate,\n  renderTemplate,\n  TemplateSafeValueContainer,\n} from \"./templateFormatter.js\";\n\ntest(\"Parses plain string templates correctly\", (t) => {\n  const result = parseTemplate(\"foo bar baz\");\n  t.deepEqual(result, [\"foo bar baz\"]);\n});\n\ntest(\"Parses templates with variables correctly\", (t) => {\n  const result = parseTemplate(\"foo {bar} baz\");\n  t.deepEqual(result, [\n    \"foo \",\n    {\n      identifier: \"bar\",\n      args: [],\n    },\n    \" baz\",\n  ]);\n});\n\ntest(\"Parses templates with function variables correctly\", (t) => {\n  const result = parseTemplate('foo {bar(\"str\", 5.07)} baz');\n  t.deepEqual(result, [\n    \"foo \",\n    {\n      identifier: \"bar\",\n      args: [\"str\", 5.07],\n    },\n    \" baz\",\n  ]);\n});\n\ntest(\"Parses function variables with variable arguments correctly\", (t) => {\n  const result = parseTemplate('foo {bar(\"str\", 5.07, someVar)} baz');\n  t.deepEqual(result, [\n    \"foo \",\n    {\n      identifier: \"bar\",\n      args: [\n        \"str\",\n        5.07,\n        {\n          identifier: \"someVar\",\n          args: [],\n        },\n      ],\n    },\n    \" baz\",\n  ]);\n});\n\ntest(\"Parses function variables with function variable arguments correctly\", (t) => {\n  const result = parseTemplate('foo {bar(\"str\", 5.07, deeply(nested(8)))} baz');\n  t.deepEqual(result, [\n    \"foo \",\n    {\n      identifier: \"bar\",\n      args: [\n        \"str\",\n        5.07,\n        {\n          identifier: \"deeply\",\n          args: [\n            {\n              identifier: \"nested\",\n              args: [8],\n            },\n          ],\n        },\n      ],\n    },\n    \" baz\",\n  ]);\n});\n\ntest(\"Renders a parsed template correctly\", async (t) => {\n  const parseResult = parseTemplate('foo {bar(\"str\", 5.07, deeply(nested(8)))} baz');\n  const values = new TemplateSafeValueContainer({\n    bar(strArg, numArg, varArg) {\n      return `${strArg} ${numArg} !${varArg}!`;\n    },\n    deeply(varArg) {\n      return `<${varArg}>`;\n    },\n    nested(numArg) {\n      return `?${numArg}?`;\n    },\n  });\n\n  const renderResult = await renderParsedTemplate(parseResult, values);\n  t.is(renderResult, \"foo str 5.07 !<?8?>! baz\");\n});\n\ntest(\"Supports base values in renderTemplate\", async (t) => {\n  const result = await renderTemplate('{if(\"\", \"+\", \"-\")} {if(1, \"+\", \"-\")}');\n  t.is(result, \"- +\");\n});\n\ntest(\"Edge case #1\", async (t) => {\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  const result = await renderTemplate(\"{foo} {bar()}\");\n  // No \"Unclosed function\" exception = success\n  t.pass();\n});\n\ntest(\"Parses empty string args as empty strings\", async (t) => {\n  const result = parseTemplate('{foo(\"\")}');\n  t.deepEqual(result, [\n    {\n      identifier: \"foo\",\n      args: [\"\"],\n    },\n  ]);\n});\n"
  },
  {
    "path": "backend/src/templateFormatter.ts",
    "content": "import seedrandom from \"seedrandom\";\nimport { get, has } from \"./utils.js\";\n\nconst TEMPLATE_CACHE_SIZE = 200;\nconst templateCache: Map<string, ParsedTemplate> = new Map();\n\nexport class TemplateParseError extends Error {}\n\ninterface ITemplateVar {\n  identifier: string;\n  args: Array<string | number | ITemplateVar>;\n  _state: {\n    currentArg: string | ITemplateVar;\n    currentArgType: \"string\" | \"number\" | \"var\" | null;\n    inArg: boolean;\n    inQuote: boolean;\n  };\n  _parent: ITemplateVar | null;\n}\n\nfunction newTemplateVar(): ITemplateVar {\n  return {\n    identifier: \"\",\n    args: [],\n    _state: {\n      inArg: false,\n      currentArg: \"\",\n      currentArgType: null,\n      inQuote: false,\n    },\n    _parent: null,\n  };\n}\n\ntype ParsedTemplate = Array<string | ITemplateVar>;\n\nexport type TemplateSafeValue =\n  | string\n  | number\n  | boolean\n  | null\n  | undefined\n  | ((...args: any[]) => TemplateSafeValue | Promise<TemplateSafeValue>)\n  | TemplateSafeValueContainer\n  | TemplateSafeValue[];\n\nfunction isTemplateSafeValue(value: unknown): value is TemplateSafeValue {\n  return (\n    value == null ||\n    typeof value === \"string\" ||\n    typeof value === \"number\" ||\n    typeof value === \"boolean\" ||\n    typeof value === \"function\" ||\n    (Array.isArray(value) && value.every((v) => isTemplateSafeValue(v))) ||\n    value instanceof TemplateSafeValueContainer\n  );\n}\n\nexport class TemplateSafeValueContainer {\n  // Fake property used for stricter type checks since TypeScript uses structural typing\n  _isTemplateSafeValueContainer: true;\n  [key: string]: TemplateSafeValue;\n\n  constructor(data?: Record<string, TemplateSafeValue>) {\n    if (data) {\n      ingestDataIntoTemplateSafeValueContainer(this, data);\n    }\n  }\n}\n\nexport type TypedTemplateSafeValueContainer<T> = TemplateSafeValueContainer & T;\n\nexport function ingestDataIntoTemplateSafeValueContainer(\n  target: TemplateSafeValueContainer,\n  data: Record<string, TemplateSafeValue> = {},\n) {\n  for (const [key, value] of Object.entries(data)) {\n    if (!isTemplateSafeValue(value)) {\n      // tslint:disable:no-console\n      console.error(\"=== CONTEXT FOR UNSAFE VALUE ===\");\n      console.error(\"stringified:\", JSON.stringify(value));\n      console.error(\"typeof:\", typeof value);\n      console.error(\"constructor name:\", (value as any)?.constructor?.name);\n      console.error(\"=== /CONTEXT FOR UNSAFE VALUE ===\");\n      // tslint:enable:no-console\n      throw new Error(`Unsafe value for key \"${key}\" in SafeTemplateValueContainer`);\n    }\n    target[key] = value;\n  }\n}\n\nexport function createTypedTemplateSafeValueContainer<T extends Record<string, TemplateSafeValue>>(\n  data: T,\n): TypedTemplateSafeValueContainer<T> {\n  return new TemplateSafeValueContainer(data) as TypedTemplateSafeValueContainer<T>;\n}\n\nfunction cleanUpParseResult(arr) {\n  arr.forEach((item) => {\n    if (typeof item === \"object\") {\n      delete item._state;\n      delete item._parent;\n      if (item.args && item.args.length) {\n        cleanUpParseResult(item.args);\n      }\n    }\n  });\n}\n\nexport function parseTemplate(str: string): ParsedTemplate {\n  const chars = [...str];\n  const result: ParsedTemplate = [];\n\n  let inVar = false;\n  let currentString = \"\";\n  let currentVar: ITemplateVar | null = null;\n  let rootVar: ITemplateVar | null = null;\n\n  let escapeNext = false;\n\n  const dumpArg = () => {\n    if (!currentVar) return;\n\n    if (currentVar._state.currentArgType) {\n      if (currentVar._state.currentArgType === \"number\") {\n        if (isNaN(currentVar._state.currentArg as any)) {\n          throw new TemplateParseError(`Invalid numeric argument: ${currentVar._state.currentArg}`);\n        }\n\n        currentVar.args.push(parseFloat(currentVar._state.currentArg as string));\n      } else {\n        currentVar.args.push(currentVar._state.currentArg);\n      }\n    }\n\n    currentVar._state.currentArg = \"\";\n    currentVar._state.currentArgType = null;\n  };\n\n  const returnToParentVar = () => {\n    if (!currentVar) return;\n    currentVar = currentVar._parent;\n    dumpArg();\n  };\n\n  const exitInjectedVar = () => {\n    if (rootVar) {\n      if (currentVar && currentVar !== rootVar) {\n        throw new TemplateParseError(`Unclosed function!`);\n      }\n\n      result.push(rootVar);\n      rootVar = null;\n    }\n\n    inVar = false;\n  };\n\n  for (const [i, char] of chars.entries()) {\n    if (inVar) {\n      if (currentVar) {\n        if (currentVar._state.inArg) {\n          // We're parsing arguments\n          if (currentVar._state.inQuote) {\n            // We're in an open quote\n            if (escapeNext) {\n              currentVar._state.currentArg += char;\n              escapeNext = false;\n            } else if (char === \"\\\\\") {\n              escapeNext = true;\n            } else if (char === '\"') {\n              currentVar._state.inQuote = false;\n            } else {\n              currentVar._state.currentArg += char;\n            }\n          } else if (char === \")\") {\n            // Done with arguments\n            dumpArg();\n            returnToParentVar();\n          } else if (char === \",\") {\n            // Comma -> dump argument, start new argument\n            dumpArg();\n          } else if (currentVar._state.currentArgType === \"number\") {\n            // We're parsing a number argument\n            // The actual validation of whether this is a number is in dumpArg()\n            currentVar._state.currentArg += char;\n          } else if (/\\s/.test(char)) {\n            // Whitespace, ignore\n            continue;\n          } else if (char === '\"') {\n            // A double quote can start a string argument, but only if we haven't committed to some other type of argument already\n            if (currentVar._state.currentArgType !== null) {\n              throw new TemplateParseError(`Unexpected char ${char} at ${i}`);\n            }\n\n            currentVar._state.currentArgType = \"string\";\n            currentVar._state.inQuote = true;\n          } else if (char.match(/(\\d|-)/)) {\n            // A number can start a string argument, but only if we haven't committed to some other type of argument already\n            if (currentVar._state.currentArgType !== null) {\n              throw new TemplateParseError(`Unexpected char ${char} at ${i}`);\n            }\n\n            currentVar._state.currentArgType = \"number\";\n            currentVar._state.currentArg += char;\n          } else if (currentVar._state.currentArgType === null) {\n            // Any other character starts a new var argument if we haven't committed to some other type of argument already\n            currentVar._state.currentArgType = \"var\";\n\n            const newVar = newTemplateVar();\n            newVar._parent = currentVar;\n            newVar.identifier += char;\n            currentVar._state.currentArg = newVar;\n            currentVar = newVar;\n          } else {\n            throw new TemplateParseError(`Unexpected char ${char} at ${i}`);\n          }\n        } else {\n          if (char === \"(\") {\n            currentVar._state.inArg = true;\n          } else if (char === \",\") {\n            // We encountered a comma without ever getting into args\n            // -> We're a value property, not a function, and we can return to our parent var\n            returnToParentVar();\n          } else if (char === \")\") {\n            // We encountered a closing bracket without ever getting into args\n            // -> We're a value property, and this closing bracket actually closes out PARENT var\n            // -> \"Return to parent var\" twice\n            returnToParentVar();\n            returnToParentVar();\n          } else if (char === \"}\") {\n            // We encountered a closing curly bracket without ever getting into args\n            // -> We're a value property, and the current injected var ends here\n            exitInjectedVar();\n          } else {\n            currentVar.identifier += char;\n          }\n        }\n      } else {\n        if (char === \"}\") {\n          exitInjectedVar();\n        } else {\n          throw new TemplateParseError(`Unexpected char ${char} at ${i}`);\n        }\n      }\n    } else {\n      if (escapeNext) {\n        currentString += char;\n        escapeNext = false;\n      } else if (char === \"\\\\\") {\n        escapeNext = true;\n      } else if (char === \"{\") {\n        if (currentString !== \"\") {\n          result.push(currentString);\n          currentString = \"\";\n        }\n\n        const newVar = newTemplateVar();\n        currentVar = newVar;\n        rootVar = newVar;\n        inVar = true;\n      } else {\n        currentString += char;\n      }\n    }\n  }\n\n  if (inVar) {\n    throw new TemplateParseError(\"Unterminated injected variable!\");\n  }\n\n  if (currentString !== \"\") {\n    result.push(currentString);\n  }\n\n  // Clean-up\n  cleanUpParseResult(result);\n\n  return result;\n}\n\nasync function evaluateTemplateVariable(\n  theVar: ITemplateVar,\n  values: TemplateSafeValueContainer,\n): Promise<TemplateSafeValue> {\n  if (!(values instanceof TemplateSafeValueContainer)) {\n    throw new Error(\"evaluateTemplateVariable() called with unsafe values\");\n  }\n\n  const value = has(values, theVar.identifier) ? get(values, theVar.identifier) : undefined;\n\n  if (typeof value === \"function\") {\n    // Don't allow running functions in nested objects\n    if (values[theVar.identifier] == null) {\n      return \"\";\n    }\n\n    const args: any[] = [];\n    for (const arg of theVar.args) {\n      if (typeof arg === \"object\") {\n        const argValue = await evaluateTemplateVariable(arg as ITemplateVar, values);\n        args.push(argValue);\n      } else {\n        args.push(arg);\n      }\n    }\n\n    const result = await value(...args);\n    if (!isTemplateSafeValue(result)) {\n      throw new Error(`Template function ${theVar.identifier} returned unsafe value`);\n    }\n\n    return result == null ? \"\" : result;\n  }\n\n  return value == null ? \"\" : value;\n}\n\nexport async function renderParsedTemplate(parsedTemplate: ParsedTemplate, values: TemplateSafeValueContainer) {\n  let result = \"\";\n\n  for (const part of parsedTemplate) {\n    if (typeof part === \"object\") {\n      result += await evaluateTemplateVariable(part, values);\n    } else {\n      result += part.toString();\n    }\n  }\n\n  return result;\n}\n\nconst baseValues = {\n  if(clause, andThen, andElse) {\n    return clause ? andThen : andElse;\n  },\n  and(...args) {\n    for (const arg of args) {\n      if (!arg) return false;\n    }\n    return true;\n  },\n  or(...args) {\n    for (const arg of args) {\n      if (arg) return true;\n    }\n    return false;\n  },\n  not(arg) {\n    return !arg;\n  },\n  concat(...args) {\n    return [...args].join(\"\");\n  },\n  concatArr(arr, separator = \"\") {\n    if (!Array.isArray(arr)) return \"\";\n    return arr.join(separator);\n  },\n  eq(...args) {\n    if (args.length < 2) return true;\n    for (let i = 1; i < args.length; i++) {\n      if (args[i] !== args[i - 1]) return false;\n    }\n    return true;\n  },\n  gt(arg1, arg2) {\n    return arg1 > arg2;\n  },\n  gte(arg1, arg2) {\n    return arg1 >= arg2;\n  },\n  lt(arg1, arg2) {\n    return arg1 < arg2;\n  },\n  lte(arg1, arg2) {\n    return arg1 <= arg2;\n  },\n  slice(arg1, start, end) {\n    if (typeof arg1 !== \"string\") return \"\";\n    if (isNaN(start)) return \"\";\n    if (end != null && isNaN(end)) return \"\";\n    return arg1.slice(parseInt(start, 10), end && parseInt(end, 10));\n  },\n  lower(arg) {\n    if (typeof arg !== \"string\") return arg;\n    return arg.toLowerCase();\n  },\n  upper(arg) {\n    if (typeof arg !== \"string\") return arg;\n    return arg.toUpperCase();\n  },\n  upperFirst(arg) {\n    if (typeof arg !== \"string\") return arg;\n    return arg.charAt(0).toUpperCase() + arg.slice(1);\n  },\n  ucfirst(arg) {\n    return baseValues.upperFirst(arg);\n  },\n  strlen(arg) {\n    if (typeof arg !== \"string\") return 0;\n    return [...arg].length;\n  },\n  rand(from, to, seed = null) {\n    if (isNaN(from)) return 0;\n\n    if (to == null) {\n      to = from;\n      from = 1;\n    }\n\n    if (isNaN(to)) return 0;\n\n    if (to > from) {\n      [from, to] = [to, from];\n    }\n\n    const randValue = seed != null ? seedrandom(seed)() : Math.random();\n\n    return Math.round(randValue * (to - from) + from);\n  },\n  round(arg, decimals = 0) {\n    if (typeof arg !== \"number\") {\n      arg = parseFloat(arg);\n    }\n    if (Number.isNaN(arg)) return 0;\n    return decimals === 0 ? Math.round(arg) : arg.toFixed(Math.max(0, Math.min(decimals, 100)));\n  },\n  add(...args) {\n    return args.reduce((result, arg) => {\n      if (isNaN(arg)) return result;\n      return result + parseFloat(arg);\n    }, 0);\n  },\n  sub(...args) {\n    if (args.length === 0) return 0;\n    return args.slice(1).reduce((result, arg) => {\n      if (isNaN(arg)) return result;\n      return result - parseFloat(arg);\n    }, args[0]);\n  },\n  mul(...args) {\n    if (args.length === 0) return 0;\n    return args.slice(1).reduce((result, arg) => {\n      if (isNaN(arg)) return result;\n      return result * parseFloat(arg);\n    }, args[0]);\n  },\n  div(...args) {\n    if (args.length === 0) return 0;\n    return args.slice(1).reduce((result, arg) => {\n      if (isNaN(arg) || parseFloat(arg) === 0) return result;\n      return result / parseFloat(arg);\n    }, args[0]);\n  },\n  cases(mod, ...cases) {\n    if (cases.length === 0) return \"\";\n    if (isNaN(mod)) return \"\";\n    mod = parseInt(mod, 10) - 1;\n    return cases[Math.max(0, mod % cases.length)];\n  },\n  choose(...cases) {\n    const mod = Math.floor(Math.random() * cases.length) + 1;\n    return baseValues.cases(mod, ...cases);\n  },\n};\n\nexport async function renderTemplate(\n  template: string,\n  values: TemplateSafeValueContainer = new TemplateSafeValueContainer(),\n  includeBaseValues = true,\n) {\n  if (includeBaseValues) {\n    values = new TemplateSafeValueContainer(Object.assign({}, baseValues, values));\n  }\n\n  let parseResult: ParsedTemplate;\n  if (templateCache.has(template)) {\n    parseResult = templateCache.get(template)!;\n  } else {\n    parseResult = parseTemplate(template);\n\n    // If our template cache is full, delete the first item\n    if (templateCache.size >= TEMPLATE_CACHE_SIZE) {\n      const firstKey = templateCache.keys().next().value!;\n      templateCache.delete(firstKey);\n    }\n\n    templateCache.set(template, parseResult);\n  }\n\n  return renderParsedTemplate(parseResult, values);\n}\n"
  },
  {
    "path": "backend/src/threadsSignalFix.ts",
    "content": "/**\n * Hack for wiping out the threads signal handlers\n * See: https://github.com/andywer/threads.js/issues/388\n * Make sure:\n * - This is imported before any real imports from \"threads\"\n * - This is imported as early as possible to avoid removing our own signal handlers\n */\nimport \"threads\";\nimport { env } from \"./env.js\";\nif (!env.DEBUG) {\n  process.removeAllListeners(\"SIGINT\");\n  process.removeAllListeners(\"SIGTERM\");\n}\n"
  },
  {
    "path": "backend/src/types.ts",
    "content": "import { GlobalPluginBlueprint, GuildPluginBlueprint } from \"vety\";\nimport { z } from \"zod\";\nimport { zSnowflake } from \"./utils.js\";\n\nexport const zZeppelinGuildConfig = z.strictObject({\n  // From BaseConfig\n  prefix: z.string().optional(),\n  levels: z.record(zSnowflake, z.number()).optional(),\n  plugins: z.record(z.string(), z.unknown()).optional(),\n});\n\n/**\n * Wrapper for the string type that indicates the text will be parsed as Markdown later\n */\nexport type TMarkdown = string;\n\nexport interface ZeppelinGuildPluginInfo {\n  plugin: GuildPluginBlueprint<any, any>;\n  docs: ZeppelinPluginDocs;\n  autoload?: boolean;\n}\n\nexport interface ZeppelinGlobalPluginInfo {\n  plugin: GlobalPluginBlueprint<any, any>;\n  docs: ZeppelinPluginDocs;\n}\n\nexport type DocsPluginType = \"stable\" | \"legacy\" | \"internal\";\n\nexport interface ZeppelinPluginDocs {\n  type: DocsPluginType;\n  configSchema: z.ZodType;\n\n  prettyName?: string;\n  description?: TMarkdown;\n  usageGuide?: TMarkdown;\n  configurationGuide?: TMarkdown;\n}\n\nexport interface CommandInfo {\n  description?: TMarkdown;\n  basicUsage?: TMarkdown;\n  examples?: TMarkdown;\n  usageGuide?: TMarkdown;\n  parameterDescriptions?: {\n    [key: string]: TMarkdown;\n  };\n  optionDescriptions?: {\n    [key: string]: TMarkdown;\n  };\n}\n"
  },
  {
    "path": "backend/src/uptime.ts",
    "content": "let start = 0;\n\nexport function startUptimeCounter() {\n  start = Date.now();\n}\n\nexport function getCurrentUptime() {\n  return Date.now() - start;\n}\n\nexport function getBotStartTime() {\n  return start;\n}\n"
  },
  {
    "path": "backend/src/utils/DecayingCounter.ts",
    "content": "/**\n * This is not related to Zeppelin's counters feature\n */\nexport class DecayingCounter {\n  protected value = 0;\n\n  constructor(protected decayInterval: number) {\n    setInterval(() => {\n      this.value = Math.max(0, this.value - 1);\n    }, decayInterval);\n  }\n\n  add(count = 1): number {\n    this.value += count;\n    return this.value;\n  }\n\n  get(): number {\n    return this.value;\n  }\n}\n"
  },
  {
    "path": "backend/src/utils/MessageBuffer.ts",
    "content": "import { StrictMessageContent } from \"../utils.js\";\nimport { calculateEmbedSize } from \"./calculateEmbedSize.js\";\nimport Timeout = NodeJS.Timeout;\n\ntype ConsumeFn = (part: StrictMessageContent) => void;\n\ntype ContentType = \"mixed\" | \"plain\" | \"embeds\";\n\nexport type MessageBufferContent = Pick<StrictMessageContent, \"content\" | \"embeds\">;\n\ntype Chunk = {\n  type: ContentType;\n  content: MessageBufferContent;\n};\n\nexport interface MessageBufferOpts {\n  consume?: ConsumeFn;\n  timeout?: number;\n  textSeparator?: string;\n}\n\nconst MAX_CHARS_PER_MESSAGE = 2000;\nconst MAX_EMBED_LENGTH_PER_MESSAGE = 6000;\nconst MAX_EMBEDS_PER_MESSAGE = 10;\n\n/**\n * Allows buffering and automatic partitioning of message contents. Useful for e.g. high volume log channels, message chunking, etc.\n */\nexport class MessageBuffer {\n  protected autoConsumeFn: ConsumeFn | null = null;\n\n  protected timeoutMs: number | null = null;\n\n  protected textSeparator = \"\";\n\n  protected chunk: Chunk | null = null;\n\n  protected chunkTimeout: Timeout | null = null;\n\n  protected finalizedChunks: MessageBufferContent[] = [];\n\n  constructor(opts: MessageBufferOpts = {}) {\n    if (opts.consume) {\n      this.autoConsumeFn = opts.consume;\n    }\n\n    if (opts.timeout) {\n      this.timeoutMs = opts.timeout;\n    }\n\n    if (opts.textSeparator) {\n      this.textSeparator = opts.textSeparator;\n    }\n  }\n\n  push(content: MessageBufferContent): void {\n    let contentType: ContentType;\n    if (content.content && !content.embeds?.length) {\n      contentType = \"plain\";\n    } else if (content.embeds?.length && !content.content) {\n      contentType = \"embeds\";\n    } else {\n      contentType = \"mixed\";\n    }\n\n    // Plain text can't be merged with mixed or embeds\n    if (contentType === \"plain\" && this.chunk && this.chunk.type !== \"plain\") {\n      this.startNewChunk(contentType);\n    }\n    // Mixed can't be merged at all\n    if (contentType === \"mixed\" && this.chunk) {\n      this.startNewChunk(contentType);\n    }\n\n    if (!this.chunk) this.startNewChunk(contentType);\n    const chunk = this.chunk!;\n\n    if (content.content) {\n      if (chunk.content.content && chunk.content.content.length + content.content.length > MAX_CHARS_PER_MESSAGE) {\n        this.startNewChunk(contentType);\n      }\n\n      if (chunk.content.content == null || chunk.content.content === \"\") {\n        chunk.content.content = content.content;\n      } else {\n        chunk.content.content += this.textSeparator + content.content;\n      }\n    }\n\n    if (content.embeds) {\n      if (chunk.content.embeds) {\n        if (chunk.content.embeds.length + content.embeds.length > MAX_EMBEDS_PER_MESSAGE) {\n          this.startNewChunk(contentType);\n        } else {\n          const existingEmbedsLength = chunk.content.embeds.reduce((sum, embed) => sum + calculateEmbedSize(embed), 0);\n          const embedsLength = content.embeds.reduce((sum, embed) => sum + calculateEmbedSize(embed), 0);\n          if (existingEmbedsLength + embedsLength > MAX_EMBED_LENGTH_PER_MESSAGE) {\n            this.startNewChunk(contentType);\n          }\n        }\n      }\n\n      if (chunk.content.embeds == null) chunk.content.embeds = [];\n      chunk.content.embeds.push(...content.embeds);\n    }\n  }\n\n  protected startNewChunk(type: ContentType): void {\n    if (this.chunk) {\n      this.finalizeChunk();\n    }\n    this.chunk = {\n      type,\n      content: {},\n    };\n    if (this.timeoutMs) {\n      this.chunkTimeout = setTimeout(() => this.finalizeChunk(), this.timeoutMs);\n    }\n  }\n\n  protected finalizeChunk(): void {\n    if (!this.chunk) return;\n    const chunk = this.chunk;\n    this.chunk = null;\n\n    if (this.chunkTimeout) {\n      clearTimeout(this.chunkTimeout);\n      this.chunkTimeout = null;\n    }\n\n    // Discard empty chunks\n    if (!chunk.content.content && !chunk.content.embeds?.length) return;\n\n    if (this.autoConsumeFn) {\n      this.autoConsumeFn(chunk.content);\n      return;\n    }\n\n    this.finalizedChunks.push(chunk.content);\n  }\n\n  consume(): StrictMessageContent[] {\n    return Array.from(this.finalizedChunks);\n    this.finalizedChunks = [];\n  }\n\n  finalizeAndConsume(): StrictMessageContent[] {\n    this.finalizeChunk();\n    return this.consume();\n  }\n}\n"
  },
  {
    "path": "backend/src/utils/async.ts",
    "content": "import { Awaitable } from \"./typeUtils.js\";\n\nexport async function asyncReduce<T, V>(\n  arr: T[],\n  callback: (accumulator: V, currentValue: T, index: number, array: T[]) => Awaitable<V>,\n  initialValue?: V,\n): Promise<V> {\n  let accumulator;\n  let arrayToIterate;\n  if (initialValue !== undefined) {\n    accumulator = initialValue;\n    arrayToIterate = arr;\n  } else {\n    accumulator = arr[0];\n    arrayToIterate = arr.slice(1);\n  }\n\n  for (const [i, currentValue] of arrayToIterate.entries()) {\n    accumulator = await callback(accumulator, currentValue, i, arr);\n  }\n\n  return accumulator;\n}\n\nexport function asyncFilter<T>(\n  arr: T[],\n  callback: (element: T, index: number, array: T[]) => Awaitable<boolean>,\n): Promise<T[]> {\n  return asyncReduce<T, T[]>(\n    arr,\n    async (newArray, element, i, _arr) => {\n      if (await callback(element, i, _arr)) {\n        newArray.push(element);\n      }\n\n      return newArray;\n    },\n    [],\n  );\n}\n\nexport function asyncMap<T, V>(\n  arr: T[],\n  callback: (currentValue: T, index: number, array: T[]) => Awaitable<V>,\n): Promise<V[]> {\n  return asyncReduce<T, V[]>(\n    arr,\n    async (newArray, element, i, _arr) => {\n      newArray.push(await callback(element, i, _arr));\n      return newArray;\n    },\n    [],\n  );\n}\n"
  },
  {
    "path": "backend/src/utils/buildCustomId.ts",
    "content": "export function buildCustomId(namespace: string, data: any = {}) {\n  return `${namespace}:${Date.now()}:${JSON.stringify(data)}`;\n}\n"
  },
  {
    "path": "backend/src/utils/calculateEmbedSize.ts",
    "content": "import { APIEmbed, EmbedData } from \"discord.js\";\n\nfunction sumStringLengthsRecursively(obj: any): number {\n  if (obj == null) return 0;\n  if (typeof obj === \"string\") return obj.length;\n  if (Array.isArray(obj)) {\n    return obj.reduce((sum, item) => sum + sumStringLengthsRecursively(item), 0);\n  }\n  if (typeof obj === \"object\") {\n    return Array.from(Object.values(obj)).reduce((sum: number, item) => sum + sumStringLengthsRecursively(item), 0);\n  }\n  return 0;\n}\n\nexport function calculateEmbedSize(embed: APIEmbed | EmbedData): number {\n  return sumStringLengthsRecursively(embed);\n}\n"
  },
  {
    "path": "backend/src/utils/canAssignRole.ts",
    "content": "import { Guild, GuildMember, PermissionsBitField, Role, Snowflake } from \"discord.js\";\nimport { getMissingPermissions } from \"./getMissingPermissions.js\";\nimport { hasDiscordPermissions } from \"./hasDiscordPermissions.js\";\n\nexport function canAssignRole(guild: Guild, member: GuildMember, roleId: string) {\n  if (getMissingPermissions(member.permissions, PermissionsBitField.Flags.ManageRoles)) {\n    return false;\n  }\n\n  if (roleId === guild.id) {\n    return false;\n  }\n\n  const targetRole = guild.roles.cache.get(roleId as Snowflake);\n  if (!targetRole) {\n    return false;\n  }\n\n  const memberRoles = member.roles.cache;\n  const highestRoleWithManageRoles = memberRoles.reduce<Role | null>((highest, role) => {\n    if (!hasDiscordPermissions(role.permissions, PermissionsBitField.Flags.ManageRoles)) return highest;\n    if (highest == null) return role;\n    if (role.position > highest.position) return role;\n    return highest;\n  }, null);\n\n  return highestRoleWithManageRoles && highestRoleWithManageRoles.position > targetRole.position;\n}\n"
  },
  {
    "path": "backend/src/utils/canReadChannel.ts",
    "content": "import { GuildMember, GuildTextBasedChannel } from \"discord.js\";\nimport { getMissingChannelPermissions } from \"./getMissingChannelPermissions.js\";\nimport { readChannelPermissions } from \"./readChannelPermissions.js\";\n\nexport function canReadChannel(channel: GuildTextBasedChannel, member: GuildMember) {\n  // Not missing permissions required to read the channel = can read channel\n  return !getMissingChannelPermissions(member, channel, readChannelPermissions);\n}\n"
  },
  {
    "path": "backend/src/utils/categorize.ts",
    "content": "type Categories<T> = {\n  [key: string]: (item: T) => boolean;\n};\n\ntype CategoryReturnType<T, C extends Categories<T>> = {\n  [key in keyof C]: T[];\n};\n\nfunction initCategories<T, C extends Categories<T>>(categories: C): CategoryReturnType<T, C> {\n  return Object.keys(categories).reduce((map, key) => {\n    map[key] = [];\n    return map;\n  }, {}) as CategoryReturnType<T, C>;\n}\n\nexport function categorize<T, C extends Categories<T>>(arr: T[], categories: C): CategoryReturnType<T, C> {\n  const result = initCategories<T, C>(categories);\n  const categoryEntries = Object.entries(categories);\n\n  itemLoop: for (const item of arr) {\n    for (const [category, fn] of categoryEntries) {\n      if (fn(item)) {\n        result[category].push(item);\n        continue itemLoop;\n      }\n    }\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "backend/src/utils/createPaginatedMessage.ts",
    "content": "import { Client, Message, MessageReaction, PartialMessageReaction, PartialUser, User } from \"discord.js\";\nimport { ContextResponseOptions, fetchContextChannel, GenericCommandSource } from \"../pluginUtils.js\";\nimport { MINUTES, noop } from \"../utils.js\";\nimport { Awaitable } from \"./typeUtils.js\";\nimport Timeout = NodeJS.Timeout;\n\nexport type LoadPageFn = (page: number) => Awaitable<ContextResponseOptions>;\n\nexport interface PaginateMessageOpts {\n  timeout: number;\n  limitToUserId: string | null;\n}\n\nconst defaultOpts: PaginateMessageOpts = {\n  timeout: 5 * MINUTES,\n  limitToUserId: null,\n};\n\nexport async function createPaginatedMessage(\n  client: Client,\n  context: GenericCommandSource,\n  totalPages: number,\n  loadPageFn: LoadPageFn,\n  opts: Partial<PaginateMessageOpts> = {},\n): Promise<Message> {\n  const fullOpts = { ...defaultOpts, ...opts } as PaginateMessageOpts;\n  const channel = await fetchContextChannel(context);\n  if (!channel.isSendable()) {\n    throw new Error(\"Context channel is not sendable\");\n  }\n\n  const firstPageContent = await loadPageFn(1);\n  const message = await channel.send(firstPageContent);\n\n  let page = 1;\n  let pageLoadId = 0; // Used to avoid race conditions when rapidly switching pages\n  const reactionListener = async (\n    reactionMessage: MessageReaction | PartialMessageReaction,\n    reactor: User | PartialUser,\n  ) => {\n    if (reactionMessage.message.id !== message.id) {\n      return;\n    }\n\n    if (fullOpts.limitToUserId && reactor.id !== fullOpts.limitToUserId) {\n      return;\n    }\n\n    if (reactor.id === client.user!.id) {\n      return;\n    }\n\n    let pageDelta = 0;\n    if (reactionMessage.emoji.name === \"⬅️\") {\n      pageDelta = -1;\n    } else if (reactionMessage.emoji.name === \"➡️\") {\n      pageDelta = 1;\n    }\n\n    if (!pageDelta) {\n      return;\n    }\n\n    const newPage = Math.max(Math.min(page + pageDelta, totalPages), 1);\n    if (newPage === page) {\n      return;\n    }\n\n    page = newPage;\n    const thisPageLoadId = ++pageLoadId;\n    const newPageContent = await loadPageFn(page);\n    if (thisPageLoadId !== pageLoadId) {\n      return;\n    }\n\n    message.edit(newPageContent).catch(noop);\n    reactionMessage.users.remove(reactor.id).catch(noop);\n    refreshTimeout();\n  };\n  client.on(\"messageReactionAdd\", reactionListener);\n\n  // The timeout after which reactions are removed and the pagination stops working\n  // is refreshed each time the page is changed\n  let timeout: Timeout;\n  const refreshTimeout = () => {\n    clearTimeout(timeout);\n    timeout = setTimeout(() => {\n      message.reactions.removeAll().catch(noop);\n      client.off(\"messageReactionAdd\", reactionListener);\n    }, fullOpts.timeout);\n  };\n\n  refreshTimeout();\n\n  // Add reactions\n  message.react(\"⬅️\").catch(noop);\n  message.react(\"➡️\").catch(noop);\n\n  return message;\n}\n"
  },
  {
    "path": "backend/src/utils/crypt.test.ts",
    "content": "import test from \"ava\";\nimport { decrypt, encrypt } from \"./crypt.js\";\n\ntest(\"encrypt() followed by decrypt()\", async (t) => {\n  const original = \"banana 123 👀 💕\"; // Includes emojis to verify utf8 stuff works\n  const encrypted = await encrypt(original);\n  const decrypted = await decrypt(encrypted);\n  t.is(decrypted, original);\n});\n"
  },
  {
    "path": "backend/src/utils/crypt.ts",
    "content": "import { Pool, spawn, Worker } from \"threads\";\nimport { env } from \"../env.js\";\nimport \"../threadsSignalFix.js\";\nimport { MINUTES } from \"../utils.js\";\n\nconst pool = Pool(() => spawn(new Worker(\"./cryptWorker\"), { timeout: 10 * MINUTES }), 8);\n\nexport async function encrypt(data: string) {\n  return pool.queue((w) => w.encrypt(data, env.KEY));\n}\n\nexport async function decrypt(data: string) {\n  return pool.queue((w) => w.decrypt(data, env.KEY));\n}\n"
  },
  {
    "path": "backend/src/utils/cryptHelpers.ts",
    "content": "import { decrypt, encrypt } from \"./crypt.js\";\n\nexport async function encryptJson(obj: any): Promise<string> {\n  const serialized = JSON.stringify(obj);\n  return encrypt(serialized);\n}\n\nexport async function decryptJson(encrypted: string): Promise<unknown> {\n  const decrypted = await decrypt(encrypted);\n  return JSON.parse(decrypted);\n}\n"
  },
  {
    "path": "backend/src/utils/cryptWorker.ts",
    "content": "import crypto from \"crypto\";\nimport { expose } from \"threads/worker\";\n\nconst ALGORITHM = \"aes-256-gcm\";\n\nfunction encrypt(str, key) {\n  // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81\n\n  const iv = crypto.randomBytes(16);\n  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);\n\n  let encrypted = cipher.update(str, \"utf8\", \"base64\");\n  encrypted += cipher.final(\"base64\");\n  return `${iv.toString(\"base64\")}.${cipher.getAuthTag().toString(\"base64\")}.${encrypted}`;\n}\n\nfunction decrypt(encrypted, key) {\n  // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81\n\n  const [iv, authTag, encryptedStr] = encrypted.split(\".\");\n  const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, \"base64\"));\n  decipher.setAuthTag(Buffer.from(authTag, \"base64\"));\n\n  let decrypted = decipher.update(encryptedStr, \"base64\", \"utf8\");\n  decrypted += decipher.final(\"utf8\");\n  return decrypted;\n}\n\nconst toExpose = { encrypt, decrypt };\nexpose(toExpose);\nexport type CryptFns = typeof toExpose;\n"
  },
  {
    "path": "backend/src/utils/easyProfiler.ts",
    "content": "import type { Vety } from \"vety\";\nimport { performance } from \"perf_hooks\";\nimport { noop, SECONDS } from \"../utils.js\";\n\ntype Profiler = Vety[\"profiler\"];\n\nlet _profilingEnabled = false;\n\nexport const profilingEnabled = () => {\n  return _profilingEnabled;\n};\n\nexport const enableProfiling = () => {\n  _profilingEnabled = true;\n};\n\nexport const disableProfiling = () => {\n  _profilingEnabled = false;\n};\n\nexport const startProfiling = (profiler: Profiler, key: string) => {\n  if (!profilingEnabled()) {\n    return noop;\n  }\n\n  const startTime = performance.now();\n  return () => {\n    profiler.addDataPoint(key, performance.now() - startTime);\n  };\n};\n\nexport const calculateBlocking = (coarseness = 10) => {\n  if (!profilingEnabled()) {\n    return () => 0;\n  }\n\n  let last = performance.now();\n  let result = 0;\n  const interval = setInterval(() => {\n    const now = performance.now();\n    const blockedTime = Math.max(0, now - last - coarseness);\n    result += blockedTime;\n    last = now;\n  }, coarseness);\n\n  setTimeout(() => clearInterval(interval), 10 * SECONDS);\n\n  return () => {\n    clearInterval(interval);\n    return result;\n  };\n};\n"
  },
  {
    "path": "backend/src/utils/erisAllowedMentionsToDjsMentionOptions.ts",
    "content": "import { MessageMentionOptions, MessageMentionTypes, Snowflake } from \"discord.js\";\n\nexport function erisAllowedMentionsToDjsMentionOptions(\n  allowedMentions: ErisAllowedMentionFormat | undefined,\n): MessageMentionOptions | undefined {\n  if (allowedMentions === undefined) return undefined;\n\n  const parse: MessageMentionTypes[] = [];\n  let users: Snowflake[] | undefined;\n  let roles: Snowflake[] | undefined;\n\n  if (Array.isArray(allowedMentions.users)) {\n    users = allowedMentions.users as Snowflake[];\n  } else if (allowedMentions.users === true) {\n    parse.push(\"users\");\n  }\n\n  if (Array.isArray(allowedMentions.roles)) {\n    roles = allowedMentions.roles as Snowflake[];\n  } else if (allowedMentions.roles === true) {\n    parse.push(\"roles\");\n  }\n\n  if (allowedMentions.everyone === true) {\n    parse.push(\"everyone\");\n  }\n\n  const mentions: MessageMentionOptions = {\n    parse,\n    users,\n    roles,\n    repliedUser: allowedMentions.repliedUser,\n  };\n\n  return mentions;\n}\n\nexport interface ErisAllowedMentionFormat {\n  everyone?: boolean | undefined;\n  users?: boolean | string[] | undefined;\n  roles?: boolean | string[] | undefined;\n  repliedUser?: boolean | undefined;\n}\n"
  },
  {
    "path": "backend/src/utils/filterObject.ts",
    "content": "type FilterResult<T> = {\n  [K in keyof T]?: T[K];\n};\n\n/**\n * Filter an object's properties based on its values and keys\n * @return New object with filtered properties\n */\nexport function filterObject<T extends object>(\n  object: T,\n  filterFn: <K extends keyof T>(value: T[K], key: K) => boolean,\n): FilterResult<T> {\n  return Object.fromEntries(\n    Object.entries(object).filter(([key, value]) => filterFn(value as any, key as keyof T)),\n  ) as FilterResult<T>;\n}\n"
  },
  {
    "path": "backend/src/utils/findMatchingAuditLogEntry.ts",
    "content": "import { AuditLogEvent, Guild, GuildAuditLogsEntry } from \"discord.js\";\nimport { SECONDS, sleep } from \"../utils.js\";\n\nconst BATCH_DEBOUNCE_TIME = 2 * SECONDS;\nconst BATCH_FETCH_COUNT_INCREMENT = 10;\n\ntype Batch = {\n  _waitUntil: number;\n  _fetchCount: number;\n  _promise: Promise<GuildAuditLogsEntry[]>;\n  join: () => Promise<GuildAuditLogsEntry[]>;\n};\n\nconst batches = new Map<string, Batch>();\n\n/**\n * Find a recent audit log entry matching the given criteria.\n * This function will debounce and batch simultaneous calls into one audit log request.\n */\nexport async function findMatchingAuditLogEntry(\n  guild: Guild,\n  action?: AuditLogEvent,\n  targetId?: string,\n): Promise<GuildAuditLogsEntry | undefined> {\n  let candidates: GuildAuditLogsEntry[];\n\n  if (batches.has(guild.id)) {\n    candidates = await batches.get(guild.id)!.join();\n  } else {\n    const batch: Batch = {\n      _waitUntil: Date.now(),\n      _fetchCount: 0,\n      _promise: new Promise(async (resolve) => {\n        await sleep(BATCH_DEBOUNCE_TIME);\n\n        do {\n          await sleep(Math.max(0, batch._waitUntil - Date.now()));\n        } while (Date.now() < batch._waitUntil);\n\n        const result = await guild\n          .fetchAuditLogs({\n            limit: batch._fetchCount,\n          })\n          .catch((err) => {\n            // tslint:disable-next-line:no-console\n            console.warn(`[DEBUG] Audit log error in ${guild.id} (${guild.name}): ${err.message}`);\n            return null;\n          });\n        const _candidates = Array.from(result?.entries.values() ?? []);\n\n        batches.delete(guild.id);\n        // TODO: Figure out the type\n        resolve(_candidates as any);\n      }),\n      join() {\n        batch._waitUntil = Date.now() + BATCH_DEBOUNCE_TIME;\n        batch._fetchCount = Math.min(100, batch._fetchCount + BATCH_FETCH_COUNT_INCREMENT);\n        return batch._promise;\n      },\n    };\n    batches.set(guild.id, batch);\n    candidates = await batch.join();\n  }\n\n  return candidates.find(\n    (entry) =>\n      (action == null || entry.action === action) && (targetId == null || (entry.target as any)?.id === targetId),\n  );\n}\n"
  },
  {
    "path": "backend/src/utils/formatZodIssue.ts",
    "content": "import { ZodIssue } from \"zod\";\n\nexport function formatZodIssue(issue: ZodIssue): string {\n  const path = issue.path.join(\"/\");\n  return `${path}: ${issue.message}`;\n}\n"
  },
  {
    "path": "backend/src/utils/getChunkedEmbedFields.ts",
    "content": "import { EmbedField } from \"discord.js\";\nimport { chunkMessageLines, emptyEmbedValue } from \"../utils.js\";\n\nexport function getChunkedEmbedFields(name: string, value: string): EmbedField[] {\n  const fields: EmbedField[] = [];\n\n  const chunks = chunkMessageLines(value, 1014);\n  for (let i = 0; i < chunks.length; i++) {\n    if (i === 0) {\n      fields.push({\n        name,\n        value: chunks[i],\n        inline: false,\n      });\n    } else {\n      fields.push({\n        name: emptyEmbedValue,\n        value: chunks[i],\n        inline: false,\n      });\n    }\n  }\n\n  return fields;\n}\n"
  },
  {
    "path": "backend/src/utils/getGuildPrefix.ts",
    "content": "import { getDefaultMessageCommandPrefix, GuildPluginData } from \"vety\";\n\nexport function getGuildPrefix(pluginData: GuildPluginData<any>) {\n  return pluginData.fullConfig.prefix || getDefaultMessageCommandPrefix(pluginData.client);\n}\n"
  },
  {
    "path": "backend/src/utils/getMissingChannelPermissions.ts",
    "content": "import { GuildMember, GuildTextBasedChannel } from \"discord.js\";\nimport { getMissingPermissions } from \"./getMissingPermissions.js\";\n\n/**\n * @param requiredPermissions Bitmask of required permissions\n * @return Bitmask of missing permissions\n */\nexport function getMissingChannelPermissions(\n  member: GuildMember,\n  channel: GuildTextBasedChannel,\n  requiredPermissions: number | bigint,\n): bigint {\n  const memberChannelPermissions = channel.permissionsFor(member.id);\n  if (!memberChannelPermissions) return BigInt(requiredPermissions);\n  return getMissingPermissions(memberChannelPermissions, requiredPermissions);\n}\n"
  },
  {
    "path": "backend/src/utils/getMissingPermissions.ts",
    "content": "import { PermissionsBitField } from \"discord.js\";\n\n/**\n * @param resolvedPermissions A Permission object from e.g. GuildChannel#permissionsFor() or Member#permission\n * @param requiredPermissions Bitmask of required permissions\n * @return Bitmask of missing permissions\n */\nexport function getMissingPermissions(\n  resolvedPermissions: PermissionsBitField | Readonly<PermissionsBitField>,\n  requiredPermissions: number | bigint,\n): bigint {\n  const allowedPermissions = resolvedPermissions;\n  const nRequiredPermissions = requiredPermissions;\n\n  if (allowedPermissions.bitfield & PermissionsBitField.Flags.Administrator) {\n    return BigInt(0);\n  }\n\n  return BigInt(nRequiredPermissions) & ~allowedPermissions.bitfield;\n}\n"
  },
  {
    "path": "backend/src/utils/getOrFetchGuildMember.ts",
    "content": "import { Guild, GuildMember } from \"discord.js\";\n\nconst getOrFetchGuildMemberPromises: Map<string, Promise<GuildMember | undefined>> = new Map();\n/**\n * Gets a guild member from cache or fetches it from the API if not cached.\n * Concurrent requests are merged.\n */\nexport async function getOrFetchGuildMember(guild: Guild, memberId: string): Promise<GuildMember | undefined> {\n  const cachedMember = guild.members.cache.get(memberId);\n  if (cachedMember) {\n    return cachedMember;\n  }\n\n  const key = `${guild.id}-${memberId}`;\n  if (!getOrFetchGuildMemberPromises.has(key)) {\n    getOrFetchGuildMemberPromises.set(\n      key,\n      guild.members\n        .fetch(memberId)\n        .catch(() => undefined)\n        .finally(() => {\n          getOrFetchGuildMemberPromises.delete(key);\n        }),\n    );\n  }\n  return getOrFetchGuildMemberPromises.get(key)!;\n}\n"
  },
  {
    "path": "backend/src/utils/getOrFetchUser.ts",
    "content": "import { Client, User } from \"discord.js\";\nimport { redis } from \"../data/redis.js\";\nimport { incrementDebugCounter } from \"../debugCounters.js\";\n\nconst getOrFetchUserPromises: Map<string, Promise<User | undefined>> = new Map();\n\nconst UNKNOWN_KEY = \"__UNKNOWN__\";\n\nconst baseCacheTimeSeconds = 60 * 60; // 1 hour\nconst cacheTimeJitterSeconds = 5 * 60; // 5 minutes\n\n// Use jitter on cache time to avoid tons of keys expiring at the same time\nconst generateCacheTime = () => {\n  const jitter = Math.floor(Math.random() * cacheTimeJitterSeconds);\n  return baseCacheTimeSeconds + jitter;\n};\n\n/**\n * Gets a user from cache or fetches it from the API if not cached.\n * Concurrent requests are merged.\n */\nexport async function getOrFetchUser(bot: Client, userId: string): Promise<User | undefined> {\n  // 1. Check Discord.js cache\n  const cachedUser = bot.users.cache.get(userId);\n  if (cachedUser) {\n    incrementDebugCounter(\"getOrFetchUser:djsCache\");\n    return cachedUser;\n  }\n\n  // 2. Check Redis\n  const redisCacheKey = `cache:user:${userId}`;\n  const userData = await redis.get(redisCacheKey);\n  if (userData) {\n    if (userData === UNKNOWN_KEY) {\n      incrementDebugCounter(\"getOrFetchUser:redisCache:unknown\");\n      return undefined;\n    }\n    incrementDebugCounter(\"getOrFetchUser:redisCache:hit\");\n    // @ts-expect-error Replace with a proper solution once that exists\n    return new User(bot, JSON.parse(userData));\n  }\n\n  if (!getOrFetchUserPromises.has(userId)) {\n    incrementDebugCounter(\"getOrFetchUser:fresh\");\n    getOrFetchUserPromises.set(\n      userId,\n      bot.users\n        .fetch(userId)\n        .catch(async () => {\n          return undefined;\n        })\n        .then(async (user) => {\n          const cacheValue = user ? JSON.stringify(user.toJSON()) : UNKNOWN_KEY;\n          await redis.set(redisCacheKey, cacheValue, {\n            expiration: {\n              type: \"EX\",\n              value: generateCacheTime(),\n            },\n          });\n          return user;\n        })\n        .finally(() => {\n          getOrFetchUserPromises.delete(userId);\n        }),\n    );\n  }\n  return getOrFetchUserPromises.get(userId)!;\n}\n"
  },
  {
    "path": "backend/src/utils/getPermissionNames.ts",
    "content": "import { PermissionsBitField } from \"discord.js\";\n\nconst permissionNumberToName: Map<bigint, string> = new Map();\nconst ignoredPermissionConstants = [\"all\", \"allGuild\", \"allText\", \"allVoice\"];\n\nfor (const key in PermissionsBitField.Flags) {\n  if (ignoredPermissionConstants.includes(key)) continue;\n  permissionNumberToName.set(BigInt(PermissionsBitField.Flags[key]), key);\n}\n\n/**\n * @param permissions Bitmask of permissions to get the names for\n */\nexport function getPermissionNames(permissions: number | bigint): string[] {\n  const permissionNames: string[] = [];\n  for (const [permissionNumber, permissionName] of permissionNumberToName.entries()) {\n    if (BigInt(permissions) & permissionNumber) {\n      permissionNames.push(permissionName);\n    }\n  }\n  return permissionNames;\n}\n"
  },
  {
    "path": "backend/src/utils/hasDiscordPermissions.ts",
    "content": "import { PermissionsBitField } from \"discord.js\";\n\n/**\n * @param resolvedPermissions A Permission object from e.g. GuildChannel#permissionsOf() or Member#permission\n * @param requiredPermissions Bitmask of required permissions\n */\nexport function hasDiscordPermissions(\n  resolvedPermissions: PermissionsBitField | Readonly<PermissionsBitField> | null,\n  requiredPermissions: number | bigint,\n) {\n  if (resolvedPermissions == null) {\n    return false;\n  }\n\n  if (resolvedPermissions.has(PermissionsBitField.Flags.Administrator)) {\n    return true;\n  }\n\n  const nRequiredPermissions = BigInt(requiredPermissions);\n  return Boolean((resolvedPermissions.bitfield! & nRequiredPermissions) === nRequiredPermissions);\n}\n"
  },
  {
    "path": "backend/src/utils/idToTimestamp.ts",
    "content": "import { Snowflake, SnowflakeUtil } from \"discord.js\";\n\nexport function idToTimestamp(id: string): string | null {\n  if (typeof id === \"number\") return null;\n  return SnowflakeUtil.deconstruct(id as Snowflake).timestamp.toString();\n}\n"
  },
  {
    "path": "backend/src/utils/intToRgb.ts",
    "content": "export function intToRgb(int: number): [number, number, number] {\n  const r = int >> 16;\n  const g = (int - (r << 16)) >> 8;\n  const b = int - (r << 16) - (g << 8);\n  return [r, g, b];\n}\n"
  },
  {
    "path": "backend/src/utils/isDefaultSticker.ts",
    "content": "const defaultStickerIds = [\n  \"749044136589393960\",\n  \"749045492352155769\",\n  \"749045743976710154\",\n  \"749046077629399122\",\n  \"749046696482439188\",\n  \"749047112028651530\",\n  \"749049128012742676\",\n  \"749051158542417980\",\n  \"749051341325729913\",\n  \"749051517964648458\",\n  \"749051844663181383\",\n  \"749052011751932006\",\n  \"749052505308266645\",\n  \"749052707536371812\",\n  \"749052944682582036\",\n  \"749053210760577245\",\n  \"749053441527251087\",\n  \"749053689419006003\",\n  \"749053927907131433\",\n  \"749054120345993216\",\n  \"749054292937277450\",\n  \"749054660769218631\",\n  \"749054894585151518\",\n  \"749055120263872532\",\n  \"754112474868875294\",\n  \"755244355563815073\",\n  \"755244428305760266\",\n  \"755244598799892490\",\n  \"755244649655959615\",\n  \"755490897143136446\",\n  \"781291131828699156\",\n  \"781291442961383434\",\n  \"781291606493495306\",\n  \"781321379546398740\",\n  \"781321702805340200\",\n  \"781321874650562560\",\n  \"781321970301796372\",\n  \"781322427820277791\",\n  \"781322566060343296\",\n  \"781322673527193620\",\n  \"781322765641973770\",\n  \"781322967127818240\",\n  \"781323072102858782\",\n  \"781323157239103548\",\n  \"781323249505927198\",\n  \"781323366921404426\",\n  \"781323471249604648\",\n  \"781323560756707328\",\n  \"781323628267962408\",\n  \"781323712640974858\",\n  \"781323769960202280\",\n  \"781323880723251220\",\n  \"781324010952458270\",\n  \"781324114685329417\",\n  \"781324245468315668\",\n  \"781324376884248596\",\n  \"781324451014246460\",\n  \"781324562905432064\",\n  \"781324642736144424\",\n  \"781324722394103808\",\n  \"813950454420471818\",\n  \"813950661292064808\",\n  \"813950759296172092\",\n  \"813950952436531213\",\n  \"813951067557462106\",\n  \"813951129544818708\",\n  \"813951478803857408\",\n  \"813951723822645278\",\n  \"813951924604895242\",\n  \"813952523408113694\",\n  \"813952588650381332\",\n  \"813952646083772486\",\n  \"813952751200763934\",\n  \"813952825520291902\",\n  \"813952903064584202\",\n  \"809207198856904764\",\n  \"809207265092698112\",\n  \"809207315822936064\",\n  \"809207399054442526\",\n  \"809207795999572038\",\n  \"809207857773936710\",\n  \"809207919115239525\",\n  \"809208197235343410\",\n  \"809208263987953694\",\n  \"809208353884471376\",\n  \"809208424419426344\",\n  \"809208728251138088\",\n  \"809208771834019850\",\n  \"809209078261874688\",\n  \"809209146846871562\",\n  \"809209216966852628\",\n  \"809209266556764241\",\n  \"809209320494333952\",\n  \"809209482902503444\",\n  \"809209627450671114\",\n  \"809209856321650698\",\n  \"809209923765272586\",\n  \"809210027524620328\",\n  \"809210129978228766\",\n  \"809210201033932891\",\n  \"809210344311619584\",\n  \"809210578433736724\",\n  \"809210750702583868\",\n  \"809210904263917618\",\n  \"809211336633614346\",\n  \"818596923887583302\",\n  \"818596976521248819\",\n  \"818597244017049652\",\n  \"818597355619483688\",\n  \"818597454483161098\",\n  \"818597555608092722\",\n  \"818597623397220362\",\n  \"818597707132043285\",\n  \"818597810047680532\",\n  \"818597885671243776\",\n  \"818598022798770186\",\n  \"818598125077266432\",\n  \"818598371324592218\",\n  \"818598476883165194\",\n  \"818599312882794506\",\n  \"754104467573571584\",\n  \"754106820079124480\",\n  \"754107009720385556\",\n  \"754107496884338698\",\n  \"754107539200671765\",\n  \"754107634172297306\",\n  \"754108691493683221\",\n  \"754108771852222564\",\n  \"754108811354046554\",\n  \"754108835895181322\",\n  \"754108890559283200\",\n  \"754108923509997568\",\n  \"754108948356792320\",\n  \"754108992195919903\",\n  \"754109038693974057\",\n  \"754109076933443614\",\n  \"754109137830281297\",\n  \"754109419821727885\",\n  \"754109474691612782\",\n  \"754109519113617478\",\n  \"754109542526091434\",\n  \"754109580069437481\",\n  \"754109748999225374\",\n  \"754109772449710080\",\n  \"754109815877402634\",\n  \"754109869325549638\",\n  \"754109908995276810\",\n  \"754109937872928857\",\n  \"754109983108497468\",\n  \"754110021574328400\",\n  \"823973720266899506\",\n  \"823973812748025937\",\n  \"823974092700254238\",\n  \"823974203929526292\",\n  \"823974288897343518\",\n  \"823974429834477578\",\n  \"823974530686648440\",\n  \"823974669748666418\",\n  \"823974764057722910\",\n  \"823974837156446208\",\n  \"823974930399232020\",\n  \"823975146263412786\",\n  \"823976022025306152\",\n  \"823976102976290866\",\n  \"823976251269054494\",\n  \"751604756748959874\",\n  \"751605093065031760\",\n  \"751605170818777108\",\n  \"751605236476543086\",\n  \"751605353585836101\",\n  \"751605453842022562\",\n  \"751605541687787550\",\n  \"751605606070091887\",\n  \"751605670654246972\",\n  \"751605738270359592\",\n  \"751605803932319754\",\n  \"751605873083678802\",\n  \"751605941375598672\",\n  \"751606014054236261\",\n  \"751606065447305216\",\n  \"751606120493350982\",\n  \"751606190383038604\",\n  \"751606254073544784\",\n  \"751606317936017458\",\n  \"751606379340365864\",\n  \"751606441315401848\",\n  \"751606491542192200\",\n  \"751606539600527410\",\n  \"751606636698927157\",\n  \"751606719611928586\",\n  \"751606808837357608\",\n  \"751606868115193948\",\n  \"751606917494734959\",\n  \"751606992849862706\",\n  \"751607061762277396\",\n  \"819128604311027752\",\n  \"819129296374595614\",\n  \"819130301702995968\",\n  \"819131032259133440\",\n  \"819131232642007062\",\n  \"819131655738228796\",\n  \"819131835635466280\",\n  \"819131978401316864\",\n  \"819132831023628298\",\n  \"819139462373310494\",\n  \"819139728128344064\",\n  \"819140386551365642\",\n  \"819140940018352178\",\n  \"819141435474706472\",\n  \"819145601031733269\",\n  \"772963467622744075\",\n  \"772963523630071828\",\n  \"772963562553081886\",\n  \"772970847232458782\",\n  \"772972089963577354\",\n  \"772973760457605182\",\n  \"772974139053047829\",\n  \"772974519786799114\",\n  \"772975031487168582\",\n  \"772975484874522674\",\n  \"772975929998835722\",\n  \"772976230718111764\",\n  \"772976562831097906\",\n  \"772976718939160606\",\n  \"772976899152543745\",\n  \"773897032485175296\",\n  \"773898515990315028\",\n  \"773899933131604008\",\n  \"773901313578369044\",\n  \"773902656442728488\",\n  \"773903221272870973\",\n  \"773903633951490098\",\n  \"773904449440579624\",\n  \"773909554005540894\",\n  \"773911171987144754\",\n  \"773912319839043625\",\n  \"773912616425881630\",\n  \"773914273075429387\",\n  \"773914595756081172\",\n  \"773914892800622612\",\n  \"776241800930787349\",\n  \"776241838813216788\",\n  \"776241877862187048\",\n  \"776241909374910534\",\n  \"776242510334525460\",\n  \"776242536820899861\",\n  \"776242590148198400\",\n  \"776242614663512095\",\n  \"776242643717455882\",\n  \"776242672562077696\",\n  \"776242703834284132\",\n  \"776242899662405712\",\n  \"776242924542492672\",\n  \"776242962072993792\",\n  \"776243107654008872\",\n  \"776243140750606366\",\n  \"776243172258611200\",\n  \"776243957259698226\",\n  \"776243988038025287\",\n  \"776244033244627004\",\n  \"776244136725970974\",\n  \"776244212806713344\",\n  \"776244240753098752\",\n  \"776244473184256000\",\n  \"776244492948209674\",\n  \"776244520928542731\",\n  \"776244545096122379\",\n  \"776244577509179412\",\n  \"776244610917335070\",\n  \"776244640239452200\",\n  \"816086581509095424\",\n  \"816086770541396028\",\n  \"816086882823831613\",\n  \"816086934266839040\",\n  \"816087074310193162\",\n  \"816087132447178774\",\n  \"816087220753924096\",\n  \"816087273548415006\",\n  \"816087483640709131\",\n  \"816087668630618152\",\n  \"816087792291282944\",\n  \"816087883252760617\",\n  \"816087973053464606\",\n  \"816088051121258596\",\n  \"816088135334494267\",\n  \"831569193391489054\",\n  \"831569335696228352\",\n  \"831569414746144798\",\n  \"831569534459576381\",\n  \"831569719814914108\",\n  \"831569909288140832\",\n  \"831570117057970176\",\n  \"831570479415689309\",\n  \"831570715471380550\",\n  \"831571053569769492\",\n  \"831571151535996988\",\n  \"831571320377835540\",\n  \"831571470824112138\",\n  \"831571594055778304\",\n  \"831571726223540294\",\n];\n\nexport function isDefaultSticker(id: string): boolean {\n  return defaultStickerIds.includes(id);\n}\n"
  },
  {
    "path": "backend/src/utils/isDmChannel.ts",
    "content": "import type { Channel, DMChannel } from \"discord.js\";\n\nexport function isDmChannel(channel: Channel): channel is DMChannel {\n  return channel.isDMBased();\n}\n"
  },
  {
    "path": "backend/src/utils/isGuildChannel.ts",
    "content": "import type { Channel, GuildBasedChannel } from \"discord.js\";\n\nexport function isGuildChannel(channel: Channel): channel is GuildBasedChannel {\n  return \"guild\" in channel && channel.guild !== null;\n}\n"
  },
  {
    "path": "backend/src/utils/isScalar.ts",
    "content": "export function isScalar(value: unknown): value is string | number | boolean | null | undefined {\n  return value == null || typeof value === \"string\" || typeof value === \"number\" || typeof value === \"boolean\";\n}\n"
  },
  {
    "path": "backend/src/utils/isThreadChannel.ts",
    "content": "import type { AnyThreadChannel, Channel } from \"discord.js\";\n\nexport function isThreadChannel(channel: Channel): channel is AnyThreadChannel {\n  return channel.isThread();\n}\n"
  },
  {
    "path": "backend/src/utils/isValidTimezone.ts",
    "content": "import moment from \"moment-timezone\";\n\nconst validTimezones = moment.tz.names();\n\nexport function isValidTimezone(input: string) {\n  return validTimezones.includes(input);\n}\n"
  },
  {
    "path": "backend/src/utils/loadYamlSafely.ts",
    "content": "import yaml from \"js-yaml\";\nimport { validateNoObjectAliases } from \"./validateNoObjectAliases.js\";\n\n/**\n * Loads a YAML file safely while removing object anchors/aliases (including arrays)\n */\nexport function loadYamlSafely(yamlStr: string): any {\n  let loaded = yaml.load(yamlStr);\n  if (loaded == null || typeof loaded !== \"object\") {\n    loaded = {};\n  }\n  validateNoObjectAliases(loaded);\n  return loaded;\n}\n"
  },
  {
    "path": "backend/src/utils/lockNameHelpers.ts",
    "content": "import { GuildMember, Message, User } from \"discord.js\";\nimport { SavedMessage } from \"../data/entities/SavedMessage.js\";\n\nexport function allStarboardsLock() {\n  return `starboards`;\n}\n\nexport function banLock(user: GuildMember | User | { id: string }) {\n  return `ban-${user.id}`;\n}\n\nexport function counterIdLock(counterId: number | string) {\n  return `counter-${counterId}`;\n}\n\nexport function memberRolesLock(member: GuildMember | User | { id: string }) {\n  return `member-roles-${member.id}`;\n}\n\nexport function messageLock(message: Message | SavedMessage | { id: string }) {\n  return `message-${message.id}`;\n}\n\nexport function muteLock(user: GuildMember | User | { id: string }) {\n  return `mute-${user.id}`;\n}\n"
  },
  {
    "path": "backend/src/utils/mergeRegexes.ts",
    "content": "import { categorize } from \"./categorize.js\";\n\nconst hasBackreference = /(?:^|[^\\\\]|[\\\\]{2})\\\\\\d+/;\n\nexport function mergeRegexes(sourceRegexes: RegExp[], flags: string): RegExp[] {\n  const categories = categorize(sourceRegexes, {\n    hasBackreferences: (regex) => hasBackreference.exec(regex.source) !== null,\n    safeToMerge: () => true,\n  });\n  const regexes: RegExp[] = [];\n  if (categories.safeToMerge.length) {\n    const merged = categories.safeToMerge.map((r) => `(?:${r.source})`).join(\"|\");\n    regexes.push(new RegExp(merged, flags));\n  }\n  regexes.push(...categories.hasBackreferences);\n  return regexes;\n}\n"
  },
  {
    "path": "backend/src/utils/mergeWordsIntoRegex.ts",
    "content": "import escapeStringRegexp from \"escape-string-regexp\";\n\nexport function mergeWordsIntoRegex(words: string[], flags?: string) {\n  const source = words.map((word) => `(?:${escapeStringRegexp(word)})`).join(\"|\");\n  return new RegExp(source, flags);\n}\n"
  },
  {
    "path": "backend/src/utils/messageHasContent.ts",
    "content": "import { MessageCreateOptions } from \"discord.js\";\nimport { StrictMessageContent } from \"../utils.js\";\n\nfunction embedHasContent(embed: any) {\n  for (const [, value] of Object.entries(embed)) {\n    if (typeof value === \"string\" && value.trim() !== \"\") {\n      return true;\n    }\n\n    if (typeof value === \"object\" && value != null && embedHasContent(value)) {\n      return true;\n    }\n\n    if (value != null) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\nexport function messageHasContent(content: string | MessageCreateOptions | StrictMessageContent): boolean {\n  if (typeof content === \"string\") {\n    return content.trim() !== \"\";\n  }\n\n  if (content.content != null && content.content.trim() !== \"\") {\n    return true;\n  }\n\n  if (content.embeds) {\n    for (const embed of content.embeds) {\n      if (embed && embedHasContent(embed)) {\n        return true;\n      }\n    }\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "backend/src/utils/messageIsEmpty.ts",
    "content": "import { MessageCreateOptions } from \"discord.js\";\nimport { StrictMessageContent } from \"../utils.js\";\nimport { messageHasContent } from \"./messageHasContent.js\";\n\nexport function messageIsEmpty(content: string | MessageCreateOptions | StrictMessageContent): boolean {\n  return !messageHasContent(content);\n}\n"
  },
  {
    "path": "backend/src/utils/missingPermissionError.ts",
    "content": "import { getPermissionNames } from \"./getPermissionNames.js\";\n\nexport function missingPermissionError(missingPermissions: number | bigint): string {\n  const permissionNames = getPermissionNames(missingPermissions);\n  return `Missing permissions: **${permissionNames.join(\"**, **\")}**`;\n}\n"
  },
  {
    "path": "backend/src/utils/multipleSlashOptions.ts",
    "content": "import { AttachmentSlashCommandOption, slashOptions } from \"vety\";\n\ntype AttachmentSlashOptions = Omit<AttachmentSlashCommandOption, \"type\" | \"resolveValue\" | \"getExtraAPIProps\">;\n\nexport function generateAttachmentSlashOptions(amount: number, options: AttachmentSlashOptions) {\n  return new Array(amount).fill(0).map((_, i) => {\n    return slashOptions.attachment({\n      name: amount > 1 ? `${options.name}${i + 1}` : options.name,\n      description: options.description,\n      required: options.required ?? false,\n    });\n  });\n}\n\nexport function retrieveMultipleOptions(amount: number, options: any, name: string) {\n  return new Array(amount)\n    .fill(0)\n    .map((_, i) => options[amount > 1 ? `${name}${i + 1}` : name])\n    .filter((a) => a);\n}\n"
  },
  {
    "path": "backend/src/utils/normalizeText.test.ts",
    "content": "import test from \"ava\";\nimport { normalizeText } from \"./normalizeText.js\";\n\ntest(\"Replaces special characters\", (t) => {\n  const from = \"𝗧:regional_indicator_e:ᔕ7 𝗧:regional_indicator_e:ᔕ7 𝗧:regional_indicator_e:ᔕ7\";\n  const to = \"test test test\";\n\n  t.deepEqual(normalizeText(from), to);\n});\n\ntest(\"Does not change lowercase ASCII text\", (t) => {\n  const text = \"lorem ipsum dolor sit amet consectetur adipiscing elit\";\n  t.deepEqual(normalizeText(text), text);\n});\n\ntest(\"Replaces whitespace\", (t) => {\n  const from = \"foo    bar\";\n  const to = \"foo bar\";\n  t.deepEqual(normalizeText(from), to);\n});\n\ntest(\"Result is always lowercase\", (t) => {\n  const from = \"TEST\";\n  const to = \"test\";\n  t.deepEqual(normalizeText(from), to);\n});\n"
  },
  {
    "path": "backend/src/utils/normalizeText.ts",
    "content": "import stripMarks from \"strip-combining-marks\";\n\nconst REPLACED_CHARS_PATTERNS = {\n  \"0\": \"0|⓪|₀|⁰|𝟢|𝟘|０|𝟎|𝟬|𝟶\",\n  \"1\": \"⑴|➀|❶|⓵|①|₁|¹|𝟣|𝟙|１|𝟏|𝟭|𝟷\",\n  \"2\": \"⑵|➋|➁|❷|⓶|②|₂|²|𝟤|𝟚|２|𝟐|𝟮|𝟸\",\n  \"3\": \"⑶|➌|➂|❸|⓷|③|₃|³|𝟥|𝟛|３|𝟑|𝟯|𝟹\",\n  \"4\": \"⑷|➍|➃|❹|⓸|④|₄|⁴|𝟦|𝟜|４|𝟒|𝟰|𝟺\",\n  \"5\": \"⑸|➎|➄|❺|⓹|⑤|₅|⁵|𝟧|𝟝|５|𝟓|𝟱|𝟻\",\n  \"6\": \"⑹|➏|➅|❻|⓺|⑥|₆|⁶|𝟨|𝟞|６|𝟔|𝟲|𝟼\",\n  \"7\": \"⑺|➐|➆|❼|⓻|⑦|₇|⁷|𝟩|𝟟|７|𝟕|𝟳|𝟽\",\n  \"8\": \"⑻|➑|➇|❽|⓼|⑧|₈|⁸|𝟪|𝟠|８|𝟖|𝟴|𝟾\",\n  \"9\": \"⑼|➒|➈|❾|⓽|⑨|₉|⁹|𝟫|𝟡|９|𝟗|𝟵|𝟿\",\n  a: [\n    \"ﾑ|ａ|Ａ|＠|🇦|🅰|🅐|🄰|𝞪|𝞐|𝝰|𝝖|𝜶|𝜜|𝛼|𝛢|𝛂|𝚨|𝚊|𝙰|𝙖|𝘼|𝘢|𝘈|𝗮|𝗔|𝖺|𝖠|𝖆|𝕬|𝕒|𝔸|𝔞|𝔄|𝓪|𝓐|𝒶|𝒜|𝒂|𝑨|𝑎\",\n    \"𝐴|𝐚|𝐀|𐊠|ꭺ|ꓯ|ꓮ|ꋬ|卂|Ɐ|ⓐ|Ⓐ|⒜|⍺|∆|∀|₳|ₐ|ᾼ|Ὰ|Ᾱ|Ᾰ|ᾷ|ᾶ|ᾴ|ᾳ|ᾲ|ᾱ|ᾰ|ᾏ|ᾎ|ᾍ|ᾌ|ᾋ|ᾊ|ᾉ|ᾈ|ᾇ|ᾆ|ᾅ|ᾄ|ᾃ|ᾂ|ᾁ\",\n    \"ᾀ|ὰ|ἇ|ἆ|ἅ|ἄ|ἃ|ἂ|ἁ|ἀ|ặ|Ặ|ẵ|Ẵ|ẳ|Ẳ|ằ|Ằ|ắ|Ắ|ậ|Ậ|ẫ|Ẫ|ẩ|Ẩ|ầ|Ầ|ấ|Ấ|ả|Ả|ạ|Ạ|ẚ|ḁ|Ḁ|ᵃ|ᴬ|ᴀ|ᗩ|ᗅ|ᗄ|Ꮧ|Ꭿ\",\n    \"Ꭺ|ለ|ค|බ|Թ|ӓ|Ӓ|Ѧ|а|Д|А|α|ά|Λ|Δ|Α|Ά|ɒ|ɑ|ɐ|Ⱥ|ȧ|Ȧ|ǻ|Ǻ|ǟ|ǎ|Ǎ|ą|Ą|ă|Ă|ā|Ā|å|ä|ã|â|á|à|Å|Ä|Ã|Â|Á|À\",\n    \"ª|a|A|@|:regional_indicator_a:|4\",\n  ].join(\"|\"),\n  b: [\n    \"ｂ|Ｂ|🇧|🅱|🅑|🄱|𝞫|𝞑|𝝱|𝝗|𝜷|𝜝|𝛽|𝛣|𝛃|𝚩|𝚋|𝙱|𝙗|𝘽|𝘣|𝘉|𝗯|𝗕|𝖻|𝖡|𝖇|𝕻|𝕭|𝕓|𝔹|𝔟|𝔓|𝔅|𝓫|𝓑|𝒷|𝒃|𝑩|𝑏|𝐵|𝐛\",\n    \"𝐁|𐑂|𐌁|𐊡|𐊂|ꮟ|ꞵ|Ꞵ|ꓭ|ꓐ|乃|ⓑ|Ⓑ|⒝|ℬ|ḇ|Ḇ|ḅ|Ḅ|ḃ|Ḃ|ᵇ|ᴮ|ᛒ|ᙠ|ᗷ|ᖯ|ᏼ|Ᏼ|Ᏸ|Ꮟ|ც|Ⴆ|๖|๒|฿|ط|ҍ|ѣ|ь|ъ|в|Ь|В\",\n    \"Б|ϐ|β|Β|ʙ|ɮ|ɞ|ƅ|Ƅ|ƀ|ß|b|B|:regional_indicator_b:\",\n  ].join(\"|\"),\n  c: [\n    \"ｃ|Ｃ|🝌|🇨|🅲|🅒|🄲|𝚌|𝙲|𝙘|𝘾|𝘤|𝘊|𝗰|𝗖|𝖼|𝖢|𝖈|𝕮|𝕔|𝔠|𝓬|𝓒|𝒸|𝒞|𝒄|𝑪|𝑐|𝐶|𝐜|𝐂|𐑋|𐐽|𐐣|𐐕|𐌂|𐊢|ꮯ|ꓛ|ꓚ|匚|ⲥ|Ⲥ|ⓒ|Ⓒ|⒞\",\n    \"↻|ↄ|Ↄ|ⅽ|Ⅽ|ℭ|℃|ℂ|₵|ḉ|Ḉ|ᶜ|ᴐ|ᴄ|ᑢ|ᑕ|Ꮳ|Ꮯ|ፈ|ር|ᄃ|ၥ|၁|ང|උ|ҫ|Ҁ|с|С|Ͻ|Ϲ|ϲ|Ϛ|ς|ͻ|ʗ|ɕ|ɔ|Ȼ|ƈ|Ɔ|č|Č|ċ|Ċ|ĉ\",\n    \"Ĉ|ć|Ć|ç|Ç|©|¢|c|C|:regional_indicator_c:\",\n  ].join(\"|\"),\n  d: [\n    \"ｄ|Ｄ|🇩|🅳|🅓|🄳|𝚍|𝙳|𝙙|𝘿|𝘥|𝘋|𝗱|𝗗|𝖽|𝖣|𝖉|𝕯|𝕕|𝔻|𝔡|𝔇|𝓭|𝓓|𝒹|𝒟|𝒅|𝑫|𝑑|𝐷|𝐝|𝐃|ꭰ|ꓷ|ꓓ|ꓒ|ⓓ|Ⓓ|⒟|∂|ↁ|ⅾ|Ⅾ\",\n    \"ⅆ|ⅅ|₫|ḓ|Ḓ|ḑ|Ḑ|ḏ|Ḏ|ḍ|Ḍ|ḋ|Ḋ|ᵈ|ᴰ|ᴅ|ᗬ|ᗪ|ᗡ|ᗞ|ᕲ|ᑯ|Ꮷ|Ꮄ|Ꭰ|໓|๔|ծ|ժ|ԃ|ԁ|ɗ|ɖ|ƌ|Ɗ|đ|Đ|ď|Ď|Ð|d|D|:regional_indicator_d:\",\n  ].join(\"|\"),\n  e: [\n    \"ﾐ|ｅ|Ｅ|ﻉ|🇪|🅴|🅔|🄴|𝞷|𝞢|𝞔|𝝽|𝝨|𝝚|𝝃|𝜮|𝜠|𝜉|𝛴|𝛦|𝛏|𝚺|𝚬|𝚎|𝙴|𝙚|𝙀|𝘦|𝘌|𝗲|𝗘|𝖾|𝖤|𝖊|𝕰|𝕖|𝔼|𝔢|𝔈|𝓮|𝓔|𝒆|𝑬|𝑒|𝐸\",\n    \"𝐞|𝐄|𐐩|𐐁|𐊆|ꮛ|ꭼ|ꬲ|ꞓ|ꝫ|ꓱ|ꓰ|乇|㉫|ⵉ|ⴺ|ⴹ|ⳍ|ⲉ|ⓔ|Ⓔ|⒠|⋿|⋴|∑|∊|∈|∃|ⅇ|⅀|ℰ|ℯ|℮|ℇ|€|ₑ|Ὲ|ὲ|Ἕ|Ἔ|Ἓ|Ἒ|Ἑ|Ἐ|ἕ\",\n    \"ἔ|ἓ|ἒ|ἑ|ἐ|ệ|Ệ|ễ|Ễ|ể|Ể|ề|Ề|ế|Ế|ẽ|Ẽ|ẻ|Ẻ|Ẹ|ḝ|Ḝ|ḛ|Ḛ|ḙ|Ḙ|ḗ|Ḗ|ḕ|Ḕ|ᵉ|ᴱ|ᴈ|ᴇ|ᘿ|ᗴ|Ꮛ|Ꭼ|ჳ|ཇ|ԑ|Ԑ|ӡ|ә|Ә|ҿ|ҽ\",\n    \"є|э|з|е|Е|ϵ|ξ|ε|έ|Σ|Ξ|Ε|ʒ|ɜ|ɛ|ə|ɘ|Ɇ|ȝ|ǝ|ƺ|Ʃ|Ɛ|Ə|Ǝ|ě|Ě|ę|Ę|ė|Ė|ĕ|Ĕ|ē|Ē|ë|ê|é|è|Ë|Ê|É|È|£|e|E|:regional_indicator_e:|3\",\n  ].join(\"|\"),\n  f: [\n    \"ｆ|Ｆ|ךּ|🇫|🅵|🅕|🄵|𝟋|𝚏|𝙵|𝙛|𝙁|𝘧|𝘍|𝗳|𝗙|𝖿|𝖥|𝖋|𝕱|𝕗|𝔽|𝔣|𝔉|𝓯|𝓕|𝒻|𝒇|𝑭|𝑓|𝐹|𝐟|𝐅|𐊥|𐊇|ꬵ|ꟻ|ꞙ|Ꞙ|ꜰ|ꓞ|ꓝ|千|ⓕ|Ⓕ\",\n    \"⒡|Ⅎ|ℱ|℉|₣|ẝ|ḟ|Ḟ|ᶠ|ᖷ|ᖵ|ᖴ|Ꮈ|ན|ғ|ϝ|Ϝ|ʄ|ɟ|ƒ|Ƒ|ſ|f|F|:regional_indicator_f:\",\n  ].join(\"|\"), // conflicts with T: Ŧ\n  g: [\n    \"ｇ|Ｇ|ﻮ|פֿ|𠂎|🇬|🅶|🅖|🄶|𝚐|𝙶|𝙜|𝙂|𝘨|𝘎|𝗴|𝗚|𝗀|𝖦|𝖌|𝕲|𝕘|𝔾|𝔤|𝔊|𝓰|𝓖|𝒢|𝒈|𝑮|𝑔|𝐺|𝐠|𝐆|ꮐ|ꓖ|ⓖ|Ⓖ|⒢|⅁|ℊ|₲|ḡ\",\n    \"Ḡ|ᶃ|ᵍ|ᴳ|ᘜ|ᏻ|Ᏻ|Ꮹ|Ꮐ|Ꮆ|ງ|ق|ց|ԍ|Ԍ|Б|ʛ|ɢ|ɡ|ɠ|ɓ|ǵ|Ǵ|ǫ|ǧ|Ǧ|Ǥ|ƃ|ģ|Ģ|ġ|Ġ|ğ|Ğ|ĝ|Ĝ|g|G|:regional_indicator_g:\",\n  ].join(\"|\"),\n  h: [\n    \"ｈ|Ｈ|🇭|🅷|🅗|🄷|𝞖|𝝜|𝜢|𝛨|𝚮|𝚑|𝙷|𝙝|𝙃|𝘩|𝘏|𝗵|𝗛|𝗁|𝖧|𝖍|𝕳|𝕙|𝔥|𝓱|𝓗|𝒽|𝒉|𝑯|𝐻|𝐡|𝐇|𐋏|ꮋ|ꓧ|卄|ん|Ⲏ|Ⱨ|ⓗ\",\n    \"Ⓗ|⒣|ℎ|ℍ|ℌ|ℋ𝑖|ℋ|ₕ|ῌ|Ὴ|ᾟ|ᾞ|ᾝ|ᾜ|ᾛ|ᾚ|ᾙ|ᾘ|Ἧ|Ἦ|Ἥ|Ἤ|Ἣ|Ἢ|Ἡ|Ἠ|ẖ|ḫ|Ḫ|ḩ|Ḩ|ḧ|Ḧ|ḥ|Ḥ|ḣ|Ḣ|ᴴ|ᕼ|Ᏺ|Ꮒ|Ꮋ|ዠ|ዞ\",\n    \"հ|ԋ|Ԋ|Ӊ|ӈ|һ|ђ|н|Н|Ћ|Η|Ή|ʱ|ʰ|ʜ|ɧ|ɦ|ɥ|Ƕ|ħ|Ħ|ĥ|Ĥ|h|\\\\#|H|:regional_indicator_h:\",\n  ].join(\"|\"),\n  i: [\n    \"ﾉ|ｉ|Ｉ|！|ﺍ|ﺁ|🇮|🅸|🅘|🄸|𝚒|𝙸|𝙞|𝙄|𝘪|𝘐|𝗶|𝗜|𝗂|𝖨|𝖎|𝕴|𝕚|𝕀|𝔦|𝓲|𝓘|𝒾|𝒊|𝑰|𝑗|𝑖|𝐼|𝐢|𝐈|𐌠|𐌉|𐊊|ꭵ|ꙇ|ꓲ|丨|ⵑ|ⵏ|Ⲓ|ⓘ|Ⓘ|⒤|⍳|∣\",\n    \"ⅼ|ⅰ|Ⅰ|ⅈ|ℹ|ℑ|ℐ|ⁱ|Ὶ|Ῑ|Ῐ|ῗ|ῖ|ῒ|ῑ|ῐ|ὶ|Ἷ|Ἶ|Ἵ|Ἴ|Ἳ|Ἲ|Ἱ|Ἰ|ἷ|ἶ|ἵ|ἴ|ἳ|ἲ|ἱ|ἰ|ị|Ị|ỉ|Ỉ|ḯ|Ḯ|ḭ|Ḭ|ᶤ|ᵢ|ᴵ|ᛁ|ᓰ|Ꮖ|Ꭵ|ར\",\n    \"เ|ߊ|۱|ٱ|١|ا|أ|آ|ו|׀|ӏ|ї|і|І|ϊ|ι|ί|Ι|ΐ|ɪ|ɨ|ǐ|Ǐ|ǃ|Ɨ|ł|ı|İ|į|Į|ĭ|Ĭ|ī|Ī|ĩ|Ĩ|ï|î|í|ì|Ï|Î|Í|Ì|¡|i\",\n    \"\\\\￨|\\\\ǀ|I|:regional_indicator_i:|1|!\",\n  ].join(\"|\"),\n  j: [\n    \"ﾌ|ｊ|Ｊ|ﻝ|🇯|🅹|🅙|🄹|𝚓|𝙹|𝙟|𝙅|𝘫|𝘑|𝗷|𝗝|𝗃|𝖩|𝖏|𝕵|𝕛|𝕁|𝔧|𝔍|𝓳|𝓙|𝒿|𝒥|𝒋|𝑱|𝐽|𝐣|𝐉|ꭻ|Ʝ|ꞁ|ꓙ|ⱼ|ⓙ|Ⓙ|⒥|ⅉ|ᴶ|ᴊ|ᒚ|ᒎ|ᒍ|Ꮰ|Ꭻ|ว\",\n    \"ڶ|ل|ز|נ|ן|ј|Ј|ϳ|Ϳ|ʲ|ʝ|Ɉ|ǰ|ĵ|Ĵ|j|J|:regional_indicator_j:\",\n  ].join(\"|\"),\n  k: [\n    \"ｋ|Ｋ|🇰|🅺|🅚|🄺|𝟆|𝞳|𝞙|𝞌|𝝹|𝝟|𝝒|𝜿|𝜥|𝜘|𝜅|𝛫|𝛞|𝛋|𝚱|𝚔|𝙺|𝙠|𝙆|𝘬|𝘒|𝗸|𝗞|𝗄|𝖪|𝖐|𝕶|𝕜|𝕂|𝔨|𝔎|𝓴|𝓚|𝓀|𝒦|𝒌|𝑲\",\n    \"𝑘|𝐾|𝐤|𝐊|𐒼|ꮶ|Ꝁ|ꓗ|ⲕ|Ⲕ|ⓚ|Ⓚ|⒦|⋊|K|₭|ₖ|ḵ|Ḵ|ḳ|Ḳ|ḱ|Ḱ|ᵏ|ᴷ|ᴋ|ᛕ|ᖽᐸ|Ꮶ|ӄ|Ӄ|Ҡ|ҟ|Ҝ|қ|к|К|Ќ|ϰ|ϗ|κ|Κ|ʞ|ƙ|ĸ\",\n    \"ķ|Ķ|k|K|:regional_indicator_k:\",\n  ].join(\"|\"),\n  l: [\n    \"ﾚ|ｌ|Ｌ|ﺎ|ﺂ|🇱|🅻|🅛|🄻|𝚕|𝙻|𝙡|𝙇|𝘭|𝘓|𝗹|𝗟|𝗅|𝖫|𝖑|𝕷|𝕝|𝕃|𝔩|𝔏|𝓵|𝓛|𝓁|𝒍|𝑳|𝑙|𝐿|𝐥|𝐋|𐑃|𐐛|ꮮ|Ꝉ|ꓡ|ㄥ|し|ⳑ|Ⳑ|Ⱡ|ⓛ|Ⓛ|⒧\",\n    \"Ⅼ|⅃|⅂|ℓ|ℒ|ₗ|ḽ|Ḽ|ḻ|Ḻ|ḹ|Ḹ|ḷ|Ḷ|ᴸ|ᒺ|ᒪ|Ꮮ|Ꮭ|Ꮁ|ᄂ|Ӏ|ˡ|ʟ|ʆ|ʅ|ɭ|ɫ|ƪ|Ɩ|ł|Ł|ŀ|Ŀ|ľ|Ľ|ļ|Ļ|ĺ|Ĺ|l|L|:regional_indicator_l:\",\n  ].join(\"|\"),\n  m: [\n    \"ﾶ|ｍ|Ｍ|🇲|🅼|🅜|🄼|𝞛|𝝡|𝜧|𝛭|𝚳|𝚖|𝙼|𝙢|𝙈|𝘮|𝘔|𝗺|𝗠|𝗆|𝖬|𝖒|𝕸|𝕞|𝕄|𝔪|𝔐|𝓶|𝓜|𝓂|𝒎|𝑴|𝑚|𝑀|𝐦|𝐌|𐌑\",\n    \"𐊰|ꮇ|ꓟ|爪|Ⲙ|ⓜ|Ⓜ|⒨|Ⅿ|ℳ|₥|ₘ|ṃ|Ṃ|ṁ|Ṁ|ḿ|Ḿ|ᵐ|ᴹ|ᴍ|៣|ᛖ|ᘻ|ᗰ|Ꮇ|๓|Ӎ|м|М|ϻ|Ϻ|Μ|ʍ|ɱ|ɯ|m|M|:regional_indicator_m:\",\n  ].join(\"|\"),\n  n: [\n    \"ｎ|Ｎ|🇳|🅽|🅝|🄽|𝞜|𝝢|𝜨|𝛮|𝚴|𝚗|𝙽|𝙣|𝙉|𝘯|𝘕|𝗻|𝗡|𝗇|𝖭|𝖓|𝕹|𝕟|𝔫|𝔑|𝓷|𝓝|𝓃|𝒩|𝒏|𝑵|𝑛|𝑁|𝐧|𝐍|𐑍|𐐥|ꓵ|ꓠ|刀|几\",\n    \"Ⲡ|Ⲛ|ⓝ|Ⓝ|⒩|⋂|∏|ℿ|ℕ|₦|ₙ|ⁿ|ῇ|ῆ|ῄ|ῃ|ῂ|ᾗ|ᾖ|ᾕ|ᾔ|ᾓ|ᾒ|ᾑ|ᾐ|ὴ|ἧ|ἦ|ἥ|ἤ|ἣ|ἢ|ἡ|ἠ|ṋ|Ṋ|ṉ|Ṉ|ṇ|Ṇ|ṅ|Ṅ|ᶰ|ᴺ|ᴎ|ហ\",\n    \"ᘉ|ᑎ|Ꮑ|ቡ|በ|ຖ|ภ|ก|מ|ռ|ո|ղ|Ռ|Ո|ӣ|ѝ|й|и|П|Й|И|Ѝ|Ϟ|η|ή|Π|Ν|ͷ|Ͷ|ɴ|ɳ|ɲ|Ǹ|ƞ|Ɲ|ŋ|ŉ|ň|Ň|ņ|Ņ|ń|Ń|ñ|Ñ|n|N|:regional_indicator_n:\",\n  ].join(\"|\"),\n  o: [\n    \"ｏ|Ｏ|ﻬ|ﻫ|ﻪ|ﻩ|ﮭ|ﮬ|ﮫ|ﮪ|ﮩ|ﮨ|ﮧ|ﮦ|🇴|🅾|🅞|🄾|𝞼|𝞸|𝞞|𝞂|𝝾|𝝤|𝝈|𝝄|𝜪|𝜎|𝜊|𝛰|𝛔|𝛐|𝚶|𝚘|𝙾|𝙤|𝙊|𝘰|𝘖|𝗼|𝗢|𝗈|𝖮|𝖔|𝕺\",\n    \"𝕠|𝕆|𝔬|𝔒|𝓸|𝓞|𝒪|𝒐|𝑶|𝑜|𝑂|𝐨|𝐎|𐓪|𐓃|𐓂|𐐬|𐐄|𐊫|𐊒|ꬽ|Ꙩ|ꓳ|㊉|ㄖ|の|〇|ⵙ|ⵔ|ⲟ|Ⲟ|⨀|✿|☉|ⓞ|Ⓞ|⒪|⍥|⊙|∅|ℴ|ₒ\",\n    \"Ὼ|Ὸ|ᾯ|ᾮ|ᾭ|ᾬ|ᾫ|ᾪ|ᾩ|ᾨ|ὸ|Ὧ|Ὦ|Ὥ|Ὤ|Ὣ|Ὢ|Ὡ|Ὠ|Ὅ|Ὄ|Ὃ|Ὂ|Ὁ|Ὀ|ὅ|ὄ|ὃ|ὂ|ὁ|ὀ|ỡ|Ỡ|ở|Ở|ờ|Ờ|ớ|Ớ|ộ|Ộ|Ỗ|ổ|Ổ|ồ|Ồ|ố|Ố|ỏ|Ỏ\",\n    \"ọ|Ọ|ṓ|Ṓ|ṑ|Ṑ|ṏ|Ṏ|ṍ|Ṍ|ð|ᵒ|ᴼ|ᴑ|ᴏ|ᗝ|ᓍ|Ꮎ|Ꭷ|ዐ|ჿ|၀|ဝ|໐|๐|๏|ට|൦|ഠ|೦|౦|௦|୦|ଠ|૦|੦|০|०|߀|۵|۝|ە|ہ|ھ|٥|ه|ס|օ\",\n    \"Օ|Ө|ӧ|Ӧ|ѻ|о|Ф|О|ό|φ|σ|ο|θ|Ο|Θ|˚|ʘ|ǿ|Ǿ|ǒ|Ǒ|Ʊ|ơ|Ơ|ő|Ő|ŏ|Ŏ|ō|Ō|ø|ö|õ|ô|ó|ò|ð|Ø|Ö|Õ|Ô|Ó|Ò|º|°|o|O|♡|:regional_indicator_o:|0\",\n  ].join(\"|\"),\n  p: [\n    \"ｱ|ｐ|Ｐ|🇵|🅿|🅟|🄿|𝟈|𝞺|𝞠ϱ|𝞠|𝞎|𝞀|𝝦|𝝔|𝝆|𝜬|𝜚|𝜌|𝛲|𝛠|𝛒|𝚸|𝚙|𝙿|𝙥|𝙋|𝘱|𝘗|𝗽|𝗣|𝗉|𝖯|𝖕|𝕡|𝔭|𝓹|𝓟|𝓅|𝒫|𝒑|𝑷|𝑝|𝑃|𝐩|𝐏\",\n    \"𐓄|𐊕|ꮲ|ꓑ|卩|ⲣ|Ⲣ|Ᵽ|ⓟ|Ⓟ|⒫|⍴|ℙ|℘|₱|ₚ|‽|Ῥ|ῥ|ῤ|ṗ|Ṗ|ṕ|Ṕ|ᵖ|ᴾ|ᴩ|ᴘ|ᕵ|ᑭ|Ꮲ|Ꭾ|ק|ք|բ|Ԁ|Ҏ|р|Р|ϸ|Ϸ|ϱ|ρ|Ρ|ƿ|Ƥ|þ|Þ|¶\",\n    \"p|P|:regional_indicator_p:\",\n  ].join(\"|\"),\n  q: [\n    \"ｑ|Ｑ|🇶|🆀|🅠|🅀|𝚚|𝚀|𝙦|𝙌|𝘲|𝘘|𝗾|𝗤|𝗊|𝖰|𝖖|𝕼|𝕢|𝔮|𝔔|𝓺|𝓠|𝓆|𝒬|𝒒|𝑸|𝑞|𝑄|𝐪|𝐐|𐌒|𐊭|ꟼ|Ꝗ|ゐ|ⵕ|ⓠ|Ⓠ|⒬|ℚ|ợ|ᶐ|ᕴ\",\n    \"ᑫ|Ꭴ|๑|۹|ף|զ|գ|ԛ|Ҩ|ϥ|ϙ|Ϙ|Ω|ʠ|ɋ|Ɋ|ǭ|Ǭ|Ǫ|ƍ|q|Q|:regional_indicator_q:\",\n  ].join(\"|\"),\n  r: [\n    \"ｒ|Ｒ|🇷|🆁|🅡|🅁|𝞒|𝝘|𝜞|𝛤|𝚪|𝚛|𝚁|𝙧|𝙍|𝘳|𝘙|𝗿|𝗥|𝗋|𝖱|𝖗|𝕽|𝕣|𝔯|𝓻|𝓡|𝓇|𝒓|𝑹|𝑟|𝑅|𝐫|𝐑|𐒴|ꮢ|ꮁ|ꭱ|ꭈ|ꭇ|ꓣ|尺|ⲅ|Ɽ|ⓡ|Ⓡ|⒭|℞\",\n    \"ℝ|ℜ|ℛ|ṟ|Ṟ|ṝ|Ṝ|ṛ|Ṛ|ṙ|Ṙ|ᵣ|ᴿ|ᴦ|ᴚ|ᴙ|ᚱ|ᖇ|Ꮢ|Ꭱ|འ|ཞ|ર|ր|Ի|я|г|Я|ʳ|ʁ|ʀ|ɿ|ɾ|ɼ|ɹ|Ɍ|Ʀ|ř|Ř|ŗ|Ŗ|ŕ|Ŕ|®|r|R|:regional_indicator_r:\",\n  ].join(\"|\"),\n  s: [\n    \"ｓ|Ｓ|＄|ﮎ|🇸|🆂|🅢|🅂|𝚜|𝚂|𝙨|𝙎|𝘴|𝘚|𝘀|𝗦|𝗌|𝖲|𝖘|𝕾|𝕤|𝕊|𝔰|𝔖|𝓼|𝓢|𝓈|𝒮|𝒔|𝑺|𝑠|𝑆|𝐬|𝐒|𐑈|𐐠|𐊖|ꮪ|ꜱ|ꙅ|Ꙅ|ꓢ|꒚|丂|ⓢ|Ⓢ|⒮|∫|₴\",\n    \"ₛ|ṩ|Ṩ|ṧ|Ṧ|ṥ|Ṥ|ṣ|Ṣ|ṡ|Ṡ|ᴤ|ᔕ|Ꮪ|Ꮥ|Ꭶ|ร|ى|ֆ|Տ|ѕ|Ѕ|ϩ|ˢ|ʃ|ʂ|Ș|ƽ|ƨ|Ƨ|š|Š|ş|Ş|ŝ|Ŝ|ś|Ś|§|s|\\\\$|S|:regional_indicator_s:|5\",\n  ].join(\"|\"),\n  t: [\n    \"ｲ|ｨ|ｔ|Ｔ|🇹|🆃|🅣|🅃|𝞽|𝚝|𝚃|𝙩|𝙏|𝘵|𝘛|𝘁|𝗧|𝗍|𝖳|𝖙|𝕿|𝕥|𝕋|𝔱|𝔗|𝓽|𝓣|𝓉|𝒯|𝒕|𝑻|𝑡|𝑇|𝐭|𝐓|𐌕|𐊱|𐊗|ꭲ|ꓔ|꓄|丅|ㄒ|Ⲧ|Ⲅ|⟙|ⓣ|Ⓣ|⒯\",\n    \"⊥|⊤|ℾ|₮|ₜ|†|ẗ|ṱ|Ṱ|ṯ|Ṯ|ṭ|Ṭ|ṫ|Ṫ|ᵗ|ᵀ|ᴛ|ᖶ|ᒥ|Ꮦ|Ꮏ|Ꮁ|Ꭲ|ኮ|ح|է|Շ|Ի|Ե|ҭ|т|Т|Г|ϯ|Ϯ|τ|π|Τ|Γ|Ͳ|ʈ|ʇ|ɬ|ȶ|ț|Ț|ǂ|Ʈ|Ƭ|ƫ|ƚ\",\n    \"ŧ|ť|Ť|ţ|Ţ|t|T|:regional_indicator_t:|7\",\n  ].join(\"|\"), // conflicts with F: Ŧ\n  u: [\n    \"ｕ|Ｕ|🇺|🆄|🅤|🅄|𝞵|𝝻|𝝁|𝜇|𝛍|𝚞|𝚄|𝙪|𝙐|𝘶|𝘜|𝘂|𝗨|𝗎|𝖴|𝖚|𝖀|𝕦|𝕌|𝔲|𝔘|𝓾|𝓤|𝓊|𝒰|𝒖|𝑼|𝑢|𝑈|𝐮|𝐔|𐓶|𐓎|ꭒ|ꭎ|ꞟ|ꓴ|ㄩ|ひ|ⓤ\",\n    \"Ⓤ|⒰|⋃|∪|∩|℧|ῧ|ῦ|ῢ|ῡ|ῠ|ὺ|ὗ|ὖ|ὕ|ὔ|ὓ|ὒ|ὑ|ὐ|ự|Ự|Ữ|ử|Ử|ừ|Ừ|ứ|Ứ|ủ|Ủ|ụ|Ụ|ṻ|Ṻ|ṹ|Ṹ|ṷ|Ṷ|ṵ|Ṵ|ṳ|Ṳ|ᵾ|ᵤ|ᵘ|ᵁ|ᴜ|ᘴ|ᘮ\",\n    \"ᓑ|ᑘ|ᑌ|ᐡ|Ꮼ|ሆ|ሀ|ย|น|પ|և|ս|մ|Ս|Մ|Ц|ύ|ϋ|υ|μ|ΰ|ʋ|ʊ|Ʉ|Ȕ|ǜ|Ǜ|ǚ|Ǚ|ǘ|Ǘ|ǖ|Ǖ|ǔ|Ǔ|Ʊ|ư|Ư|ų|Ų|ű|Ű|ů|Ů|ŭ|Ŭ|ū|Ū|ũ|Ũ|û\",\n    \"ú|ù|Ü|Û|Ú|Ù|µ|u|U|:regional_indicator_u:\",\n  ].join(\"|\"),\n  v: [\n    \"ｖ|Ｖ|🇻|🆅|🅥|🅅|𝝼|𝝂|𝜈|𝛎|𝚟|𝚅|𝙫|𝙑|𝘷|𝘝|𝘃|𝗩|𝗏|𝖵|𝖛|𝖁|𝕧|𝕍|𝔳|𝔙|𝓿|𝓥|𝓋|𝒱|𝒗|𝑽|𝑣|𝑉|𝐯|𝐕|𐓘|𐒰|𐌡|𐊍|ꮩ|ꓦ|ꓥ|ⴸ|ⴷ|ⱽ|ⓥ|Ⓥ\",\n    \"⒱|⋁|∨|√|ⅴ|Ⅴ|℣|ṿ|Ṿ|ṽ|Ṽ|ᵥ|ᵛ|ᴧ|ᴠ|ᐺ|ᐱ|ᐯ|Ꮩ|Ꮙ|ง|۸|۷|٨|٧|ש|ע|ט|Ѷ|ѵ|Ѵ|Л|ν|Λ|ʌ|ʋ|Ʌ|Ɣ|v|V|:regional_indicator_v:\",\n  ].join(\"|\"),\n  w: [\n    \"ｗ|Ｗ|🇼|🆆|🅦|🅆|𝟉|𝟂|𝞏|𝞈|𝝕|𝝎|𝜛|𝜔|𝜋|𝛡|𝛚|𝛑|𝚠|𝚆|𝙬|𝙒|𝘸|𝘞|𝘄|𝗪|𝗐|𝖶|𝖜|𝖂|𝕨|𝕎|𝔴|𝔚|𝔀|𝓦|𝓌|𝒲|𝒘|𝑾|𝑤\",\n    \"𝑊|𝐰|𝐖|𐓑|ꮃ|ꞷ|ꙍ|ꓪ|山|ⲱ|ⓦ|Ⓦ|⒲|⍵|ℼ|₩|ῷ|ῶ|ῴ|ῳ|ῲ|ẘ|ẉ|Ẉ|ẇ|Ẇ|ẅ|Ẅ|ẃ|Ẃ|ẁ|Ẁ|ᵂ|ᴡ|ᘺ|ᗯ|Ꮿ|Ꮤ|Ꮚ|Ꮗ|Ꮃ|ሠ|ཡ|ຟ|ฬ\",\n    \"ฝ|చ|ա|ԝ|Ԝ|ѡ|Ѡ|ш|Щ|ϖ|ώ|ω|ψ|ʷ|ʍ|ɯ|ŵ|Ŵ|w|W|:regional_indicator_w:\",\n  ].join(\"|\"),\n  x: [\n    \"ﾒ|ｘ|Ｘ|אָ|אַ|🇽|🆇|🅧|🅇|𝟀|𝞆|𝝌|𝜒|𝛘|𝚡|𝚇|𝙭|𝙓|𝘹|𝘟|𝘅|𝗫|𝗑|𝖷|𝖝|𝖃|𝕩|𝕏|𝔵|𝔛|𝔁|𝓧|𝓍|𝒳|𝒙|𝑿|𝑥|𝑋|𝐱|𝐗|𐌢|𐌗|𐊴|𐊐|ꭕ|ꭓ|Ꭓ|ꓫ|꒼\",\n    \"乂|〤|ⵝ|ⲭ|Ⲭ|⨯|⤬|⤫|╳|ⓧ|Ⓧ|⒳|⌧|ⅹ|Ⅹ|ℵ|ₓ|ẍ|Ẍ|ẋ|Ẋ|ᚷ|᙮|᙭|ᕽ|ᕁ|ጀ|ჯ|א|Ӿ|Ӽ|ҳ|х|Х|Ж|χ|Χ|ˣ|ɤ|×|x|X|:regional_indicator_x:\",\n  ].join(\"|\"),\n  y: [\n    \"ﾘ|ｙ|Ｙ|🇾|🆈|🅨|🅈|𝞬|𝝲|𝜸|𝛾|𝛄|𝚢|𝚈|𝙮|𝙔|𝘺|𝘠|𝘆|𝗬|𝗒|𝖸|𝖞|𝖄|𝕪|𝕐|𝔶|𝔜|𝔂|𝓨|𝓎|𝒴|𝒚|𝒀|𝑦|𝑌|𝐲|𝐘|𐊲|ꭚ|ꓬ|ꐯ|ꌦ|ㄚ|Ⲩ|ⓨ|Ⓨ|⒴\",\n    \"⅄|ℽ|Ὺ|Ῡ|Ῠ|Ὗ|Ὕ|Ὓ|Ὑ|ỿ|ỹ|Ỹ|ỷ|Ỷ|ỵ|Ỵ|ỳ|Ỳ|ẙ|ẏ|Ẏ|ᶌ|ᖻ|Ꮍ|Ꭹ|ყ|ฯ|ץ|վ|կ|Ӳ|Ӌ|ұ|ү|Ү|ч|у|У|Ў|ϔ|ϓ|ϒ|γ|Υ|Ύ|ˠ|ʸ|ʏ|ʎ|ɣ|Ɏ|Ƴ\",\n    \"Ÿ|ŷ|Ŷ|ÿ|ý|Ý|¥|y|Y|:regional_indicator_y:\",\n  ].join(\"|\"),\n  z: [\n    \"ｚ|Ｚ|🇿|🆉|🅩|🅉|𝚣|𝚉|𝙯|𝙕|𝘻|𝘡|𝘇|𝗭|𝗓|𝖹|𝖟|𝖅|𝕫|𝔷|𝔃|𝓩|𝓏|𝒵|𝒛|𝒁|𝑧|𝑍|𝐳|𝐙|ꮓ|ꓜ|乙|Ⱬ|☡|ⓩ|Ⓩ|⒵|ℨ|ℤ|ẕ|Ẕ|ẓ|Ẓ|ẑ|Ẑ|ᶻ|ᴢ|ᙆ\",\n    \"ᘔ|Ꮓ|ፚ|ຊ|չ|ζ|Ζ|ʑ|ʐ|ɀ|ȥ|ƹ|ƶ|Ƶ|ž|Ž|ż|Ż|ź|Ź|z|Z|:regional_indicator_z:\",\n  ].join(\"|\"),\n  \" \": \"\\\\s+\", // multiple whitespace -> space\n  \".\": \"．\",\n  \",\": \"，|‘\",\n  \"?\": \"？\",\n};\n\nconst REPLACED_CHARS: Record<string, RegExp> = Array.from(Object.entries(REPLACED_CHARS_PATTERNS)).reduce(\n  (obj, [to, from]) => {\n    obj[to] = new RegExp(from, \"gm\");\n    return obj;\n  },\n  {},\n);\n\nconst NORMAL_CHARS_REGEX = /^[a-z2689:.\\-_+()*&^%><;\"'}{~,]+$/i;\n\nfunction containsOnlyNormalChars(text: string) {\n  return NORMAL_CHARS_REGEX.test(text);\n}\n\n/**\n * Normalizes the input text to only lowercase ASCII letters and special characters\n */\nexport function normalizeText(text: string) {\n  if (!containsOnlyNormalChars(text)) {\n    for (const to in REPLACED_CHARS) {\n      text = text.replace(REPLACED_CHARS[to], to);\n    }\n  }\n\n  return stripMarks(text.toLowerCase());\n}\n"
  },
  {
    "path": "backend/src/utils/parseColor.ts",
    "content": "import _parseColor from \"parse-color\";\n\n// Accepts 100,100,100 and 100 100 100\nconst isRgb = /^(\\d{1,3})\\D+(\\d{1,3})\\D+(\\d{1,3})$/;\n\nconst isPartialHex = /^([0-9a-f]{3}|[0-9a-f]{6})$/i;\n\n/**\n * Parses a color from the input string. The following formats are accepted:\n * - any CSS color format (hex, rgb(), color names, etc.)\n * - rrr, ggg, bbb\n * - rrr ggg bbb\n * @return Parsed color as `[r, g, b]` or `null` if no color could be parsed\n */\nexport function parseColor(input: string): null | [number, number, number] {\n  const rgbMatch = input.match(isRgb);\n  if (rgbMatch) {\n    const r = parseInt(rgbMatch[1], 10);\n    const g = parseInt(rgbMatch[2], 10);\n    const b = parseInt(rgbMatch[3], 10);\n\n    if (r > 255 || g > 255 || b > 255) {\n      return null;\n    }\n\n    return [r, g, b];\n  }\n\n  if (input.match(isPartialHex)) {\n    input = `#${input}`;\n  }\n\n  const cssColorMatch = _parseColor(input);\n  if (cssColorMatch.rgb) {\n    return cssColorMatch.rgb;\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "backend/src/utils/parseCustomId.ts",
    "content": "import { logger } from \"../logger.js\";\n\nconst customIdFormat = /^([^:]+):\\d+:(.*)$/;\n\nexport function parseCustomId(customId: string): { namespace: string; data: any } {\n  const parts = customId.match(customIdFormat);\n  if (!parts) {\n    return {\n      namespace: \"\",\n      data: null,\n    };\n  }\n\n  let parsedData: any;\n  try {\n    parsedData = JSON.parse(parts[2]);\n  } catch (err) {\n    logger.debug(`Error while parsing custom id data (custom id: ${customId}): ${String(err)}`);\n    return {\n      namespace: \"\",\n      data: null,\n    };\n  }\n\n  return {\n    namespace: parts[1],\n    // Skipping timestamp\n    data: parsedData,\n  };\n}\n"
  },
  {
    "path": "backend/src/utils/parseFuzzyTimezone.ts",
    "content": "import escapeStringRegexp from \"escape-string-regexp\";\nimport moment from \"moment-timezone\";\n\nconst normalizeTzName = (str) => str.replace(/[^a-zA-Z0-9+-]/g, \"\").toLowerCase();\n\nconst validTimezones = moment.tz.names();\nconst normalizedTimezoneMap = validTimezones.reduce((map, tz) => {\n  map.set(normalizeTzName(tz), tz);\n  return map;\n}, new Map());\nconst normalizedTimezones = Array.from(normalizedTimezoneMap.keys());\n\nexport function parseFuzzyTimezone(input: string) {\n  const normalizedInput = normalizeTzName(input);\n\n  if (normalizedTimezoneMap.has(normalizedInput)) {\n    return normalizedTimezoneMap.get(normalizedInput);\n  }\n\n  const searchRegex = new RegExp(`.*${escapeStringRegexp(normalizedInput)}.*`);\n  for (const tz of normalizedTimezones) {\n    if (searchRegex.test(tz)) {\n      const result = normalizedTimezoneMap.get(tz);\n      // Ignore Etc/GMT timezones unless explicitly specified, as they have confusing functionality\n      // with the inverted +/- sign\n      if (result.startsWith(\"Etc/GMT\")) continue;\n      return result;\n    }\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "backend/src/utils/permissionNames.ts",
    "content": "import type { PermissionFlagsBits } from \"discord.js\";\nimport { EMPTY_CHAR } from \"../utils.js\";\n\nexport const PERMISSION_NAMES = {\n  AddReactions: \"Add Reactions\",\n  Administrator: \"Administrator\",\n  AttachFiles: \"Attach Files\",\n  BanMembers: \"Ban Members\",\n  ChangeNickname: \"Change Nickname\",\n  Connect: \"Connect\",\n  CreateInstantInvite: \"Create Invite\",\n  CreatePrivateThreads: \"Create Private Threads\",\n  CreatePublicThreads: \"Create Public Threads\",\n  DeafenMembers: \"Deafen Members\",\n  EmbedLinks: \"Embed Links\",\n  KickMembers: \"Kick Members\",\n  ManageChannels: \"Manage Channels\",\n  ManageEmojisAndStickers: \"Manage Emojis and Stickers\",\n  ManageGuild: \"Manage Server\",\n  ManageMessages: \"Manage Messages\",\n  ManageNicknames: \"Manage Nicknames\",\n  ManageRoles: \"Manage Roles\",\n  ManageThreads: \"Manage Threads\",\n  ManageWebhooks: \"Manage Webhooks\",\n  MentionEveryone: `Mention @${EMPTY_CHAR}everyone, @${EMPTY_CHAR}here, and All Roles`,\n  MoveMembers: \"Move Members\",\n  MuteMembers: \"Mute Members\",\n  PrioritySpeaker: \"Priority Speaker\",\n  ReadMessageHistory: \"Read Message History\",\n  RequestToSpeak: \"Request to Speak\",\n  SendMessages: \"Send Messages\",\n  SendMessagesInThreads: \"Send Messages in Threads\",\n  SendTTSMessages: \"Send Text-To-Speech Messages\",\n  Speak: \"Speak\",\n  UseEmbeddedActivities: \"Start Embedded Activities\",\n  Stream: \"Video\",\n  UseApplicationCommands: \"Use Application Commands\",\n  UseExternalEmojis: \"Use External Emoji\",\n  UseExternalStickers: \"Use External Stickers\",\n  UseVAD: \"Use Voice Activity\",\n  ViewAuditLog: \"View Audit Log\",\n  ViewChannel: \"View Channels\",\n  ViewGuildInsights: \"View Guild Insights\",\n  ModerateMembers: \"Moderate Members\",\n  ManageEvents: \"Manage Events\",\n  ManageGuildExpressions: \"Manage Expressions\",\n  SendVoiceMessages: \"Send Voice Messages\",\n  UseExternalSounds: \"Use External Sounds\",\n  UseSoundboard: \"Use Soundboard\",\n  ViewCreatorMonetizationAnalytics: \"View Creator Monetization Analytics\",\n  CreateGuildExpressions: \"Create Guild Expressions\",\n  CreateEvents: \"Create Events\",\n  SendPolls: \"Send Polls\",\n  UseExternalApps: \"Use External Apps\",\n  PinMessages: \"Pin Messages\",\n} as const satisfies Record<keyof typeof PermissionFlagsBits, string>;\n"
  },
  {
    "path": "backend/src/utils/readChannelPermissions.ts",
    "content": "import { PermissionsBitField } from \"discord.js\";\n\n/**\n * Bitmask of permissions required to read messages in a channel\n */\nexport const readChannelPermissions =\n  PermissionsBitField.Flags.ViewChannel | PermissionsBitField.Flags.ReadMessageHistory;\n\n/**\n * Bitmask of permissions required to read messages in a channel (bigint)\n */\nexport const nReadChannelPermissions = BigInt(readChannelPermissions);\n"
  },
  {
    "path": "backend/src/utils/registerEventListenersFromMap.ts",
    "content": "import { EventEmitter } from \"events\";\n\nexport function registerEventListenersFromMap(eventEmitter: EventEmitter, map: Map<string, any>) {\n  for (const [event, listener] of map.entries()) {\n    eventEmitter.on(event, listener);\n  }\n}\n"
  },
  {
    "path": "backend/src/utils/resolveChannelIds.ts",
    "content": "import { CategoryChannel, Channel } from \"discord.js\";\nimport { isDmChannel } from \"./isDmChannel.js\";\nimport { isGuildChannel } from \"./isGuildChannel.js\";\nimport { isThreadChannel } from \"./isThreadChannel.js\";\n\ntype ResolvedChannelIds = {\n  category: string | null;\n  channel: string | null;\n  thread: string | null;\n};\n\nexport function resolveChannelIds(channel: Channel): ResolvedChannelIds {\n  if (isDmChannel(channel)) {\n    return {\n      category: null,\n      channel: channel.id,\n      thread: null,\n    };\n  }\n\n  if (isThreadChannel(channel)) {\n    return {\n      category: channel.parent?.parentId || null,\n      channel: channel.parentId,\n      thread: channel.id,\n    };\n  }\n\n  if (channel instanceof CategoryChannel) {\n    return {\n      category: channel.id,\n      channel: null,\n      thread: null,\n    };\n  }\n\n  if (isGuildChannel(channel)) {\n    return {\n      category: channel.parentId,\n      channel: channel.id,\n      thread: null,\n    };\n  }\n\n  return {\n    category: null,\n    channel: channel.id,\n    thread: null,\n  };\n}\n"
  },
  {
    "path": "backend/src/utils/resolveMessageTarget.ts",
    "content": "import { GuildTextBasedChannel, Snowflake } from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { getChannelIdFromMessageId } from \"../data/getChannelIdFromMessageId.js\";\nimport { isSnowflake } from \"../utils.js\";\n\nconst channelAndMessageIdRegex = /^(\\d+)[-/](\\d+)$/;\nconst messageLinkRegex = /^https:\\/\\/(?:\\w+\\.)?discord(?:app)?\\.com\\/channels\\/\\d+\\/(\\d+)\\/(\\d+)$/i;\n\nexport interface MessageTarget {\n  channel: GuildTextBasedChannel;\n  messageId: string;\n}\n\nexport async function resolveMessageTarget(pluginData: GuildPluginData<any>, value: string) {\n  const result = await (async () => {\n    if (isSnowflake(value)) {\n      const channelId = await getChannelIdFromMessageId(value);\n      if (!channelId) {\n        return null;\n      }\n\n      return {\n        channelId,\n        messageId: value,\n      };\n    }\n\n    const channelAndMessageIdMatch = value.match(channelAndMessageIdRegex);\n    if (channelAndMessageIdMatch) {\n      return {\n        channelId: channelAndMessageIdMatch[1],\n        messageId: channelAndMessageIdMatch[2],\n      };\n    }\n\n    const messageLinkMatch = value.match(messageLinkRegex);\n    if (messageLinkMatch) {\n      return {\n        channelId: messageLinkMatch[1],\n        messageId: messageLinkMatch[2],\n      };\n    }\n  })();\n\n  if (!result) {\n    return null;\n  }\n\n  const channel = pluginData.guild.channels.resolve(result.channelId as Snowflake);\n  if (!channel?.isTextBased()) {\n    return null;\n  }\n\n  return {\n    channel,\n    messageId: result.messageId,\n  };\n}\n"
  },
  {
    "path": "backend/src/utils/rgbToInt.ts",
    "content": "export function rgbToInt(rgb: [number, number, number]) {\n  return (rgb[0] << 16) + (rgb[1] << 8) + rgb[2];\n}\n"
  },
  {
    "path": "backend/src/utils/sendDM.ts",
    "content": "import { User } from \"discord.js\";\nimport { logger } from \"../logger.js\";\nimport { HOURS, createChunkedMessage, isDiscordAPIError } from \"../utils.js\";\nimport { MessageContent } from \"../utils.js\";\nimport Timeout = NodeJS.Timeout;\n\nlet dmsDisabled = false;\nlet dmsDisabledTimeout: Timeout;\n\nfunction disableDMs(duration) {\n  dmsDisabled = true;\n  clearTimeout(dmsDisabledTimeout);\n  dmsDisabledTimeout = setTimeout(() => (dmsDisabled = false), duration);\n}\n\nexport class DMError extends Error {}\n\nconst error20026 = \"The bot cannot currently send DMs\";\n\nexport async function sendDM(\n  user: User,\n  content: MessageContent,\n  source: string,\n) {\n  if (dmsDisabled) {\n    throw new DMError(error20026);\n  }\n\n  logger.debug(`Sending ${source} DM to ${user.id}`);\n\n  try {\n    if (typeof content === \"string\") {\n      await createChunkedMessage(user, content);\n    } else {\n      await user.send(content);\n    }\n  } catch (e) {\n    if (isDiscordAPIError(e) && e.code === 20026) {\n      logger.warn(`Received error code 20026: ${e.message}`);\n      logger.warn(\"Disabling attempts to send DMs for 1 hour\");\n      disableDMs(1 * HOURS);\n      throw new DMError(error20026);\n    }\n    throw e;\n  }\n}"
  },
  {
    "path": "backend/src/utils/snowflakeToTimestamp.ts",
    "content": "import { isValidSnowflake } from \"../utils.js\";\n\n/**\n * @return Unix timestamp in milliseconds\n */\nexport function snowflakeToTimestamp(snowflake: string) {\n  if (!isValidSnowflake(snowflake)) {\n    throw new Error(`Invalid snowflake: ${snowflake}`);\n  }\n\n  // https://discord.com/developers/docs/reference#snowflakes-snowflake-id-format-structure-left-to-right\n  return Number(BigInt(snowflake) >> 22n) + 1_420_070_400_000;\n}\n"
  },
  {
    "path": "backend/src/utils/stripMarkdown.ts",
    "content": "export function stripMarkdown(str) {\n  return str.replace(/[*_|~`]/g, \"\");\n}\n"
  },
  {
    "path": "backend/src/utils/templateSafeObjects.ts",
    "content": "import {\n  Emoji,\n  Guild,\n  GuildBasedChannel,\n  GuildMember,\n  Message,\n  PartialGuildMember,\n  PartialUser,\n  Role,\n  Snowflake,\n  StageInstance,\n  Sticker,\n  StickerFormatType,\n  User,\n} from \"discord.js\";\nimport { GuildPluginData } from \"vety\";\nimport { Case } from \"../data/entities/Case.js\";\nimport {\n  ISavedMessageAttachmentData,\n  ISavedMessageData,\n  ISavedMessageEmbedData,\n  ISavedMessageStickerData,\n  SavedMessage,\n} from \"../data/entities/SavedMessage.js\";\nimport {\n  TemplateSafeValueContainer,\n  TypedTemplateSafeValueContainer,\n  ingestDataIntoTemplateSafeValueContainer,\n} from \"../templateFormatter.js\";\nimport { UnknownUser, renderUsername } from \"../utils.js\";\n\ntype InputProps<T> = Omit<\n  {\n    [K in keyof T]: T[K];\n  },\n  \"_isTemplateSafeValueContainer\"\n>;\n\nexport class TemplateSafeGuild extends TemplateSafeValueContainer {\n  id: Snowflake;\n  name: string;\n\n  constructor(data: InputProps<TemplateSafeGuild>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeUser extends TemplateSafeValueContainer {\n  id: Snowflake | string;\n  username: string;\n  discriminator: string;\n  globalName?: string;\n  mention: string;\n  tag: string;\n  avatarURL: string;\n  bot?: boolean;\n  createdAt?: number;\n  renderedUsername: string;\n\n  constructor(data: InputProps<TemplateSafeUser>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeUnknownUser extends TemplateSafeValueContainer {\n  id: Snowflake;\n  username: string;\n  discriminator: string;\n\n  constructor(data: InputProps<TemplateSafeUnknownUser>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeRole extends TemplateSafeValueContainer {\n  id: Snowflake;\n  name: string;\n  createdAt: number;\n  hexColor: string;\n  hoist: boolean;\n\n  constructor(data: InputProps<TemplateSafeRole>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeMember extends TemplateSafeUser {\n  user: TemplateSafeUser;\n  nick: string;\n  roles: TemplateSafeRole[];\n  joinedAt?: number;\n  guildAvatarURL: string;\n  guildName: string;\n\n  constructor(data: InputProps<TemplateSafeMember>) {\n    super({});\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeUnknownMember extends TemplateSafeUnknownUser {\n  user: TemplateSafeUnknownUser;\n\n  constructor(data: InputProps<TemplateSafeUnknownMember>) {\n    super({});\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeChannel extends TemplateSafeValueContainer {\n  id: Snowflake;\n  name: string;\n  mention: string;\n  parentId?: Snowflake;\n\n  constructor(data: InputProps<TemplateSafeChannel>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeStage extends TemplateSafeValueContainer {\n  channelId: Snowflake;\n  channelMention: string;\n  createdAt: number;\n  discoverable: boolean;\n  topic: string;\n\n  constructor(data: InputProps<TemplateSafeStage>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeEmoji extends TemplateSafeValueContainer {\n  id: Snowflake;\n  name: string;\n  createdAt?: number;\n  animated: boolean;\n  identifier: string;\n  mention: string;\n\n  constructor(data: InputProps<TemplateSafeEmoji>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeSticker extends TemplateSafeValueContainer {\n  id: Snowflake;\n  guildId?: Snowflake;\n  packId?: Snowflake;\n  name: string;\n  description: string;\n  tags: string;\n  format: string;\n  animated: boolean;\n  url: string;\n\n  constructor(data: InputProps<TemplateSafeSticker>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeSavedMessage extends TemplateSafeValueContainer {\n  id: string;\n  guild_id: string;\n  channel_id: string;\n  user_id: string;\n  is_bot: boolean;\n  data: TemplateSafeSavedMessageData;\n\n  constructor(data: InputProps<TemplateSafeSavedMessage>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeSavedMessageData extends TemplateSafeValueContainer {\n  attachments?: Array<TypedTemplateSafeValueContainer<ISavedMessageAttachmentData>>;\n  author: TypedTemplateSafeValueContainer<{\n    username: string;\n    discriminator: string;\n  }>;\n  content: string;\n  embeds?: Array<TypedTemplateSafeValueContainer<ISavedMessageEmbedData>>;\n  stickers?: Array<TypedTemplateSafeValueContainer<ISavedMessageStickerData>>;\n  timestamp: number;\n  reference?: TypedTemplateSafeValueContainer<ISavedMessageData[\"reference\"]>;\n\n  constructor(data: InputProps<TemplateSafeSavedMessageData>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeCase extends TemplateSafeValueContainer {\n  id: number;\n  guild_id: string;\n  case_number: number;\n  user_id: string;\n  user_name: string;\n  mod_id: string | null;\n  mod_name: string | null;\n  type: number;\n  audit_log_id: string | null;\n  created_at: string;\n  is_hidden: boolean;\n  pp_id: string | null;\n  pp_name: string | null;\n  log_message_id: string | null;\n\n  constructor(data: InputProps<TemplateSafeCase>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\nexport class TemplateSafeMessage extends TemplateSafeValueContainer {\n  id: string;\n  content: string;\n  author: TemplateSafeUser;\n  channel: TemplateSafeChannel;\n\n  constructor(data: InputProps<TemplateSafeMessage>) {\n    super();\n    ingestDataIntoTemplateSafeValueContainer(this, data);\n  }\n}\n\n// ===================\n// CONVERTER FUNCTIONS\n// ===================\n\nexport function guildToTemplateSafeGuild(guild: Guild): TemplateSafeGuild {\n  return new TemplateSafeGuild({\n    id: guild.id,\n    name: guild.name,\n  });\n}\n\nexport function userToTemplateSafeUser(user: User | UnknownUser | PartialUser): TemplateSafeUser {\n  if (user instanceof User) {\n    return new TemplateSafeUser({\n      id: user.id,\n      username: user.username,\n      discriminator: user.discriminator,\n      globalName: user.globalName,\n      mention: `<@${user.id}>`,\n      tag: user.tag,\n      avatarURL: user.displayAvatarURL?.() || \"\",\n      bot: user.bot,\n      createdAt: user.createdTimestamp,\n      renderedUsername: renderUsername(user),\n    });\n  }\n\n  return new TemplateSafeUser({\n    id: user.id,\n    username: user.username || \"Unknown\",\n    discriminator: user.discriminator || \"0000\",\n    mention: `<@${user.id}>`,\n    tag: user.tag || \"Unknown#0000\",\n    renderedUsername: user.tag || \"Unknown\",\n  });\n}\n\nexport function roleToTemplateSafeRole(role: Role): TemplateSafeRole {\n  return new TemplateSafeRole({\n    id: role.id,\n    name: role.name,\n    createdAt: role.createdTimestamp,\n    hexColor: role.hexColor,\n    hoist: role.hoist,\n  });\n}\n\nexport function memberToTemplateSafeMember(member: GuildMember | PartialGuildMember): TemplateSafeMember {\n  const templateSafeUser = userToTemplateSafeUser(member.user!);\n\n  return new TemplateSafeMember({\n    ...templateSafeUser,\n    user: templateSafeUser,\n    nick: member.nickname ?? \"*None*\",\n    roles: [...member.roles.cache.mapValues((r) => roleToTemplateSafeRole(r)).values()],\n    joinedAt: member.joinedTimestamp ?? undefined,\n    guildAvatarURL: member.displayAvatarURL(),\n    guildName: member.guild.name,\n  });\n}\n\nexport function channelToTemplateSafeChannel(channel: GuildBasedChannel): TemplateSafeChannel {\n  return new TemplateSafeChannel({\n    id: channel.id,\n    name: channel.name,\n    mention: `<#${channel.id}>`,\n    parentId: channel.parentId ?? undefined,\n  });\n}\n\nexport function stageToTemplateSafeStage(stage: StageInstance): TemplateSafeStage {\n  return new TemplateSafeStage({\n    channelId: stage.channelId,\n    channelMention: `<#${stage.channelId}>`,\n    createdAt: stage.createdTimestamp,\n    discoverable: !stage.discoverableDisabled,\n    topic: stage.topic,\n  });\n}\n\nexport function emojiToTemplateSafeEmoji(emoji: Emoji): TemplateSafeEmoji {\n  return new TemplateSafeEmoji({\n    id: emoji.id!,\n    name: emoji.name!,\n    createdAt: emoji.createdTimestamp ?? undefined,\n    animated: emoji.animated ?? false,\n    identifier: emoji.identifier,\n    mention: emoji.animated ? `<a:${emoji.name}:${emoji.id}>` : `<:${emoji.name}:${emoji.id}>`,\n  });\n}\n\nexport function stickerToTemplateSafeSticker(sticker: Sticker): TemplateSafeSticker {\n  return new TemplateSafeSticker({\n    id: sticker.id,\n    guildId: sticker.guildId ?? undefined,\n    packId: sticker.packId ?? undefined,\n    name: sticker.name,\n    description: sticker.description ?? \"\",\n    tags: sticker.tags ?? \"\",\n    format: sticker.format,\n    animated: sticker.format === StickerFormatType.PNG ? false : true,\n    url: sticker.url,\n  });\n}\n\nexport function savedMessageToTemplateSafeSavedMessage(savedMessage: SavedMessage): TemplateSafeSavedMessage {\n  return new TemplateSafeSavedMessage({\n    id: savedMessage.id,\n    channel_id: savedMessage.channel_id,\n    guild_id: savedMessage.guild_id,\n    is_bot: savedMessage.is_bot,\n    user_id: savedMessage.user_id,\n\n    data: new TemplateSafeSavedMessageData({\n      attachments: (savedMessage.data.attachments ?? []).map(\n        (att) =>\n          new TemplateSafeValueContainer({\n            id: att.id,\n            contentType: att.contentType,\n            name: att.name,\n            proxyURL: att.proxyURL,\n            size: att.size,\n            spoiler: att.spoiler,\n            url: att.url,\n            width: att.width,\n          }) as TypedTemplateSafeValueContainer<ISavedMessageAttachmentData>,\n      ),\n\n      author: new TemplateSafeValueContainer({\n        username: savedMessage.data.author.username,\n        discriminator: savedMessage.data.author.discriminator,\n      }) as TypedTemplateSafeValueContainer<ISavedMessageData[\"author\"]>,\n\n      content: savedMessage.data.content,\n\n      embeds: (savedMessage.data.embeds ?? []).map(\n        (embed) =>\n          new TemplateSafeValueContainer({\n            title: embed.title,\n            description: embed.description,\n            url: embed.url,\n            timestamp: embed.timestamp,\n            color: embed.color,\n\n            fields: (embed.fields ?? []).map(\n              (field) =>\n                new TemplateSafeValueContainer({\n                  name: field.name,\n                  value: field.value,\n                  inline: field.inline,\n                }),\n            ),\n\n            author: embed.author\n              ? new TemplateSafeValueContainer({\n                  name: embed.author?.name,\n                  url: embed.author?.url,\n                  iconURL: embed.author?.iconURL,\n                  proxyIconURL: embed.author?.proxyIconURL,\n                })\n              : undefined,\n\n            thumbnail: embed.thumbnail\n              ? new TemplateSafeValueContainer({\n                  url: embed.thumbnail?.url,\n                  proxyURL: embed.thumbnail?.url,\n                  height: embed.thumbnail?.height,\n                  width: embed.thumbnail?.width,\n                })\n              : undefined,\n\n            image: embed.image\n              ? new TemplateSafeValueContainer({\n                  url: embed.image?.url,\n                  proxyURL: embed.image?.url,\n                  height: embed.image?.height,\n                  width: embed.image?.width,\n                })\n              : undefined,\n\n            video: embed.video\n              ? new TemplateSafeValueContainer({\n                  url: embed.video?.url,\n                  proxyURL: embed.video?.url,\n                  height: embed.video?.height,\n                  width: embed.video?.width,\n                })\n              : undefined,\n\n            footer: embed.footer\n              ? new TemplateSafeValueContainer({\n                  text: embed.footer.text,\n                  iconURL: embed.footer.iconURL,\n                  proxyIconURL: embed.footer.proxyIconURL,\n                })\n              : undefined,\n          }) as TypedTemplateSafeValueContainer<ISavedMessageEmbedData>,\n      ),\n\n      stickers: (savedMessage.data.stickers ?? []).map(\n        (sticker) =>\n          new TemplateSafeValueContainer({\n            format: sticker.format,\n            guildId: sticker.guildId,\n            id: sticker.id,\n            name: sticker.name,\n            description: sticker.description,\n            available: sticker.available,\n            type: sticker.type,\n          }) as TypedTemplateSafeValueContainer<ISavedMessageStickerData>,\n      ),\n\n      timestamp: savedMessage.data.timestamp,\n\n      reference: savedMessage.data.reference\n        ? (new TemplateSafeValueContainer({\n            messageId: savedMessage.data.reference.messageId ?? null,\n            channelId: savedMessage.data.reference.channelId ?? null,\n            guildId: savedMessage.data.reference.guildId ?? null,\n          }) as TypedTemplateSafeValueContainer<ISavedMessageData[\"reference\"]>)\n        : undefined,\n    }),\n  });\n}\n\nexport function caseToTemplateSafeCase(theCase: Case): TemplateSafeCase {\n  return new TemplateSafeCase({\n    id: theCase.id,\n    guild_id: theCase.guild_id,\n    case_number: theCase.case_number,\n    user_id: theCase.user_id,\n    user_name: theCase.user_name,\n    mod_id: theCase.mod_id,\n    mod_name: theCase.mod_name,\n    type: theCase.type,\n    audit_log_id: theCase.audit_log_id,\n    created_at: theCase.created_at,\n    is_hidden: theCase.is_hidden,\n    pp_id: theCase.pp_id,\n    pp_name: theCase.pp_name,\n    log_message_id: theCase.log_message_id,\n  });\n}\n\nexport function messageToTemplateSafeMessage(message: Message): TemplateSafeMessage {\n  return new TemplateSafeMessage({\n    id: message.id,\n    content: message.content,\n    author: userToTemplateSafeUser(message.author),\n    channel: channelToTemplateSafeChannel(message.channel as GuildBasedChannel),\n  });\n}\n\nexport function getTemplateSafeMemberLevel(pluginData: GuildPluginData<any>, member: TemplateSafeMember): number {\n  if (member.id === pluginData.guild.ownerId) {\n    return 99999;\n  }\n\n  const levels = pluginData.fullConfig.levels ?? {};\n  for (const [id, level] of Object.entries(levels)) {\n    if (member.id === id || member.roles?.find((r) => r.id === id)) {\n      return level as number;\n    }\n  }\n\n  return 0;\n}\n"
  },
  {
    "path": "backend/src/utils/typeUtils.ts",
    "content": "// From https://stackoverflow.com/a/56370310/316944\nexport type Tail<T extends any[]> = ((...t: T) => void) extends (h: any, ...r: infer R) => void ? R : never;\n\nexport declare type WithRequiredProps<T, K extends keyof T> = T & {\n  // https://mariusschulz.com/blog/mapped-type-modifiers-in-typescript#removing-the-mapped-type-modifier\n  [PK in K]-?: Exclude<T[K], null>;\n};\n\n// https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/\nexport type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;\n\nexport type Awaitable<T = unknown> = T | Promise<T>;\n\nexport type DeepMutable<T> = {\n  -readonly [P in keyof T]: DeepMutable<T[P]>;\n};\n\n// From https://stackoverflow.com/a/70262876/316944\nexport declare abstract class As<Tag extends keyof never> {\n  private static readonly $as$: unique symbol;\n  private [As.$as$]: Record<Tag, true>;\n}\n\nexport type Brand<T, B extends keyof never> = T & As<B>;\n"
  },
  {
    "path": "backend/src/utils/unregisterEventListenersFromMap.ts",
    "content": "import { EventEmitter } from \"events\";\n\nexport function unregisterEventListenersFromMap(eventEmitter: EventEmitter, map: Map<string, any>) {\n  for (const [event, listener] of map.entries()) {\n    eventEmitter.off(event, listener);\n  }\n}\n"
  },
  {
    "path": "backend/src/utils/validateNoObjectAliases.test.ts",
    "content": "import test from \"ava\";\nimport { ObjectAliasError, validateNoObjectAliases } from \"./validateNoObjectAliases.js\";\n\ntest(\"validateNoObjectAliases() disallows object aliases at top level\", (t) => {\n  const obj: any = {\n    objectRef: {\n      foo: \"bar\",\n    },\n  };\n  obj.otherProp = obj.objectRef;\n\n  t.throws(() => validateNoObjectAliases(obj), { instanceOf: ObjectAliasError });\n});\n\ntest(\"validateNoObjectAliases() disallows aliases to nested objects\", (t) => {\n  const obj: any = {\n    nested: {\n      objectRef: {\n        foo: \"bar\",\n      },\n    },\n  };\n  obj.otherProp = obj.nested.objectRef;\n\n  t.throws(() => validateNoObjectAliases(obj), { instanceOf: ObjectAliasError });\n});\n\ntest(\"validateNoObjectAliases() disallows nested object aliases\", (t) => {\n  const obj: any = {\n    nested: {\n      objectRef: {\n        foo: \"bar\",\n      },\n    },\n  };\n  obj.otherProp = {\n    alsoNested: {\n      ref: obj.nested.objectRef,\n    },\n  };\n\n  t.throws(() => validateNoObjectAliases(obj), { instanceOf: ObjectAliasError });\n});\n"
  },
  {
    "path": "backend/src/utils/validateNoObjectAliases.ts",
    "content": "const scalarTypes = [\"string\", \"number\", \"boolean\", \"bigint\"];\n\nexport class ObjectAliasError extends Error {}\n\n/**\n * Removes object aliases/anchors from a loaded YAML object\n */\nexport function validateNoObjectAliases<T extends object>(obj: T, seen?: WeakSet<any>): void {\n  if (!seen) {\n    seen = new WeakSet();\n  }\n\n  for (const [, value] of Object.entries(obj)) {\n    if (value == null || scalarTypes.includes(typeof value)) {\n      continue;\n    }\n\n    if (seen.has(value)) {\n      throw new ObjectAliasError(\"Object aliases are not allowed\");\n    }\n\n    validateNoObjectAliases(value, seen);\n    seen.add(value);\n  }\n}\n"
  },
  {
    "path": "backend/src/utils/waitForInteraction.ts",
    "content": "import {\n  ActionRowBuilder,\n  ButtonBuilder,\n  ButtonStyle,\n  MessageActionRowComponentBuilder,\n  MessageComponentInteraction,\n  MessageCreateOptions,\n} from \"discord.js\";\nimport moment from \"moment-timezone\";\nimport { v4 as uuidv4 } from \"uuid\";\nimport { GenericCommandSource, isContextInteraction, sendContextResponse } from \"../pluginUtils.js\";\nimport { noop, createDisabledButtonRow } from \"../utils.js\";\n\nexport async function waitForButtonConfirm(\n  context: GenericCommandSource,\n  toPost: Omit<MessageCreateOptions, \"flags\">,\n  options?: WaitForOptions,\n): Promise<boolean> {\n  return new Promise(async (resolve) => {\n    const contextIsInteraction = isContextInteraction(context);\n    const idMod = `${context.id}-${moment.utc().valueOf()}`;\n    const row = new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents([\n      new ButtonBuilder()\n        .setStyle(ButtonStyle.Success)\n        .setLabel(options?.confirmText || \"Confirm\")\n        .setCustomId(`confirmButton:${idMod}:${uuidv4()}`),\n      new ButtonBuilder()\n        .setStyle(ButtonStyle.Danger)\n        .setLabel(options?.cancelText || \"Cancel\")\n        .setCustomId(`cancelButton:${idMod}:${uuidv4()}`),\n    ]);\n    const message = await sendContextResponse(context, { ...toPost, components: [row] }, true);\n    const collector = message.createMessageComponentCollector({ time: 10000 });\n\n    collector.on(\"collect\", (interaction: MessageComponentInteraction) => {\n      if (options?.restrictToId && options.restrictToId !== interaction.user.id) {\n        interaction\n          .reply({ content: `You are not permitted to use these buttons.`, ephemeral: true })\n          .catch(noop);\n      } else if (interaction.customId.startsWith(`confirmButton:${idMod}:`)) {\n        if (!contextIsInteraction) {\n          message.delete().catch(noop);\n        } else {\n          interaction.update({ components: [createDisabledButtonRow(row)] }).catch(noop);\n        }\n        resolve(true);\n      } else if (interaction.customId.startsWith(`cancelButton:${idMod}:`)) {\n        if (!contextIsInteraction) {\n          message.delete().catch(noop);\n        } else {\n          interaction.update({ components: [createDisabledButtonRow(row)] }).catch(noop);\n        }\n        resolve(false);\n      }\n    });\n\n    collector.on(\"end\", () => {\n      if (!contextIsInteraction) {\n        if (message.deletable) message.delete().catch(noop);\n      } else {\n        message.edit({ components: [createDisabledButtonRow(row)] }).catch(noop);\n      }\n      resolve(false);\n    });\n  });\n}\n\nexport interface WaitForOptions {\n  restrictToId?: string;\n  confirmText?: string;\n  cancelText?: string;\n}\n"
  },
  {
    "path": "backend/src/utils/zColor.ts",
    "content": "import { z } from \"zod\";\nimport { parseColor } from \"./parseColor.js\";\nimport { rgbToInt } from \"./rgbToInt.js\";\n\nexport const zColor = z.string().transform((val, ctx) => {\n  const parsedColor = parseColor(val);\n  if (parsedColor == null) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: \"Invalid color\",\n    });\n    return z.NEVER;\n  }\n  return rgbToInt(parsedColor);\n});\n"
  },
  {
    "path": "backend/src/utils/zValidTimezone.ts",
    "content": "import { ZodString } from \"zod\";\nimport { isValidTimezone } from \"./isValidTimezone.js\";\n\nexport function zValidTimezone<Z extends ZodString>(z: Z) {\n  return z.refine((val) => isValidTimezone(val), {\n    message: \"Invalid timezone\",\n  });\n}\n"
  },
  {
    "path": "backend/src/utils/zodDeepPartial.ts",
    "content": "/*\n   Modified version of https://gist.github.com/jaens/7e15ae1984bb338c86eb5e452dee3010\n   Original version's license:\n\n   Copyright 2024, Jaen - https://github.com/jaens\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n*/\n\nimport { z } from \"zod\";\nimport { $ZodRecordKey, $ZodType } from \"zod/v4/core\";\n\nconst RESOLVING = Symbol(\"mapOnSchema/resolving\");\n\nexport function mapOnSchema<T extends $ZodType, TResult extends $ZodType>(\n  schema: T,\n  fn: (schema: $ZodType) => TResult,\n): TResult;\n\n/**\n * Applies {@link fn} to each element of the schema recursively, replacing every schema with its return value.\n * The rewriting is applied bottom-up (ie. {@link fn} will get called on \"children\" first).\n */\nexport function mapOnSchema(schema: $ZodType, fn: (schema: $ZodType) => $ZodType): $ZodType {\n  // Cache results to support recursive schemas\n  const results = new Map<$ZodType, $ZodType | typeof RESOLVING>();\n\n  function mapElement(s: $ZodType) {\n    const value = results.get(s);\n    if (value === RESOLVING) {\n      throw new Error(\"Recursive schema access detected\");\n    } else if (value !== undefined) {\n      return value;\n    }\n\n    results.set(s, RESOLVING);\n    const result = mapOnSchema(s, fn);\n    results.set(s, result);\n    return result;\n  }\n\n  function mapInner() {\n    if (schema instanceof z.ZodObject) {\n      const newShape: Record<string, $ZodType> = {};\n      for (const [key, value] of Object.entries(schema.shape)) {\n        newShape[key] = mapElement(value);\n      }\n\n      return new z.ZodObject({\n        ...schema.def,\n        shape: newShape,\n      });\n    } else if (schema instanceof z.ZodArray) {\n      return new z.ZodArray({\n        ...schema.def,\n        type: \"array\",\n        element: mapElement(schema.def.element),\n      });\n    } else if (schema instanceof z.ZodMap) {\n      return new z.ZodMap({\n        ...schema.def,\n        keyType: mapElement(schema.def.keyType),\n        valueType: mapElement(schema.def.valueType),\n      });\n    } else if (schema instanceof z.ZodSet) {\n      return new z.ZodSet({\n        ...schema.def,\n        valueType: mapElement(schema.def.valueType),\n      });\n    } else if (schema instanceof z.ZodOptional) {\n      return new z.ZodOptional({\n        ...schema.def,\n        innerType: mapElement(schema.def.innerType),\n      });\n    } else if (schema instanceof z.ZodNullable) {\n      return new z.ZodNullable({\n        ...schema.def,\n        innerType: mapElement(schema.def.innerType),\n      });\n    } else if (schema instanceof z.ZodDefault) {\n      return new z.ZodDefault({\n        ...schema.def,\n        innerType: mapElement(schema.def.innerType),\n      });\n    } else if (schema instanceof z.ZodReadonly) {\n      return new z.ZodReadonly({\n        ...schema.def,\n        innerType: mapElement(schema.def.innerType),\n      });\n    } else if (schema instanceof z.ZodLazy) {\n      return new z.ZodLazy({\n        ...schema.def,\n        // NB: This leaks `fn` into the schema, but there is no other way to support recursive schemas\n        getter: () => mapElement(schema._def.getter()),\n      });\n    } else if (schema instanceof z.ZodPromise) {\n      return new z.ZodPromise({\n        ...schema.def,\n        innerType: mapElement(schema.def.innerType),\n      });\n    } else if (schema instanceof z.ZodCatch) {\n      return new z.ZodCatch({\n        ...schema.def,\n        innerType: mapElement(schema._def.innerType),\n      });\n    } else if (schema instanceof z.ZodTuple) {\n      return new z.ZodTuple({\n        ...schema.def,\n        items: schema.def.items.map((item: $ZodType) => mapElement(item)),\n        rest: schema.def.rest && mapElement(schema.def.rest),\n      });\n    } else if (schema instanceof z.ZodDiscriminatedUnion) {\n      return new z.ZodDiscriminatedUnion({\n        ...schema.def,\n        options: schema.options.map((option) => mapOnSchema(option, fn)),\n      });\n    } else if (schema instanceof z.ZodUnion) {\n      return new z.ZodUnion({\n        ...schema.def,\n        options: schema.options.map((option) => mapOnSchema(option, fn)),\n      });\n    } else if (schema instanceof z.ZodIntersection) {\n      return new z.ZodIntersection({\n        ...schema.def,\n        right: mapElement(schema.def.right),\n        left: mapElement(schema.def.left),\n      });\n    } else if (schema instanceof z.ZodRecord) {\n      return new z.ZodRecord({\n        ...schema.def,\n        keyType: mapElement(schema.def.keyType) as $ZodRecordKey,\n        valueType: mapElement(schema.def.valueType),\n      });\n    } else {\n      return schema;\n    }\n  }\n\n  return fn(mapInner());\n}\n\nexport function deepPartial<T extends z.ZodType>(schema: T): T {\n  return mapOnSchema(schema, (s) => (s instanceof z.ZodObject ? s.partial() : s)) as T;\n}\n\n/** Make all object schemas \"strict\" (ie. fail on unknown keys), except if they are marked as `.passthrough()` */\nexport function deepStrict<T extends z.ZodType>(schema: T): T {\n  return mapOnSchema(schema, (s) =>\n    s instanceof z.ZodObject /* && s.def.unknownKeys !== \"passthrough\" */ ? s.strict() : s,\n  ) as T;\n}\n\nexport function deepStrictAll<T extends z.ZodType>(schema: T): T {\n  return mapOnSchema(schema, (s) => (s instanceof z.ZodObject ? s.strict() : s)) as T;\n}\n"
  },
  {
    "path": "backend/src/utils.test.ts",
    "content": "import test from \"ava\";\nimport { z } from \"zod\";\nimport { convertDelayStringToMS, convertMSToDelayString, getUrlsInString, zAllowedMentions } from \"./utils.js\";\nimport { ErisAllowedMentionFormat } from \"./utils/erisAllowedMentionsToDjsMentionOptions.js\";\n\ntype AssertEquals<TActual, TExpected> = TActual extends TExpected ? true : false;\n\ntest(\"getUrlsInString(): detects full links\", (t) => {\n  const urls = getUrlsInString(\"foo https://google.com/ bar\");\n  t.is(urls.length, 1);\n  t.is(urls[0].hostname, \"google.com\");\n});\n\ntest(\"getUrlsInString(): detects partial links\", (t) => {\n  const urls = getUrlsInString(\"foo google.com bar\");\n  t.is(urls.length, 1);\n  t.is(urls[0].hostname, \"google.com\");\n});\n\ntest(\"getUrlsInString(): detects subdomains\", (t) => {\n  const urls = getUrlsInString(\"foo photos.google.com bar\");\n  t.is(urls.length, 1);\n  t.is(urls[0].hostname, \"photos.google.com\");\n});\n\ntest(\"delay strings: basic support\", (t) => {\n  const delayString = \"2w4d7h32m17s\";\n  const expected = 1_582_337_000;\n  t.is(convertDelayStringToMS(delayString), expected);\n});\n\ntest(\"delay strings: default unit (minutes)\", (t) => {\n  t.is(convertDelayStringToMS(\"10\"), 10 * 60 * 1000);\n});\n\ntest(\"delay strings: custom default unit\", (t) => {\n  t.is(convertDelayStringToMS(\"10\", \"s\"), 10 * 1000);\n});\n\ntest(\"delay strings: reverse conversion\", (t) => {\n  const ms = 1_582_337_020;\n  const expected = \"2w4d7h32m17s20x\";\n  t.is(convertMSToDelayString(ms), expected);\n});\n\ntest(\"delay strings: reverse conversion (conservative)\", (t) => {\n  const ms = 1_209_600_000;\n  const expected = \"2w\";\n  t.is(convertMSToDelayString(ms), expected);\n});\n\ntest(\"tAllowedMentions matches Eris's AllowedMentions\", (t) => {\n  type TAllowedMentions = z.infer<typeof zAllowedMentions>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  const typeTest: AssertEquals<TAllowedMentions, ErisAllowedMentionFormat> = true;\n  t.pass();\n});\n"
  },
  {
    "path": "backend/src/utils.ts",
    "content": "import {\n  ActionRowBuilder,\n  APIEmbed,\n  ButtonBuilder,\n  ChannelType,\n  Client,\n  DiscordAPIError,\n  DiscordjsTypeError,\n  EmbedData,\n  EmbedType,\n  Emoji,\n  escapeCodeBlock,\n  Guild,\n  GuildBasedChannel,\n  GuildChannel,\n  GuildMember,\n  GuildTextBasedChannel,\n  Invite,\n  InviteGuild,\n  InviteType,\n  LimitedCollection,\n  Message,\n  MessageActionRowComponentBuilder,\n  MessageCreateOptions,\n  MessageMentionOptions,\n  PartialGroupDMChannel,\n  PartialMessage,\n  RoleResolvable,\n  SendableChannels,\n  Sticker,\n  User,\n} from \"discord.js\";\nimport emojiRegex from \"emoji-regex\";\nimport fs from \"fs\";\nimport https from \"https\";\nimport { isEqual } from \"lodash-es\";\nimport { performance } from \"perf_hooks\";\nimport tlds from \"tlds\" with { type: \"json\" };\nimport tmp from \"tmp\";\nimport { URL } from \"url\";\nimport { z, ZodError, ZodPipe, ZodRecord, ZodString, ZodTransform } from \"zod\";\nimport { ISavedMessageAttachmentData, SavedMessage } from \"./data/entities/SavedMessage.js\";\nimport { delayStringMultipliers, humanizeDuration } from \"./humanizeDuration.js\";\nimport { getProfiler } from \"./profiler.js\";\nimport { SimpleCache } from \"./SimpleCache.js\";\nimport { sendDM } from \"./utils/sendDM.js\";\nimport { Brand } from \"./utils/typeUtils.js\";\nimport { waitForButtonConfirm } from \"./utils/waitForInteraction.js\";\nimport { GenericCommandSource } from \"./pluginUtils.js\";\nimport { getOrFetchUser } from \"./utils/getOrFetchUser.js\";\nimport { incrementDebugCounter } from \"./debugCounters.js\";\n\nconst fsp = fs.promises;\n\nexport const MS = 1;\nexport const SECONDS = 1000 * MS;\nexport const MINUTES = 60 * SECONDS;\nexport const HOURS = 60 * MINUTES;\nexport const DAYS = 24 * HOURS;\nexport const WEEKS = 7 * DAYS;\nexport const YEARS = (365 + 1 / 4 - 1 / 100 + 1 / 400) * DAYS;\nexport const MONTHS = YEARS / 12;\n\nexport const EMPTY_CHAR = \"\\u200b\";\n\n// https://discord.com/developers/docs/reference#snowflakes\nexport const MIN_SNOWFLAKE = 0b000000000000000000000000000000000000000000_00001_00001_000000000001;\n// 0b111111111111111111111111111111111111111111_11111_11111_111111111111 without _ which BigInt doesn't support\nexport const MAX_SNOWFLAKE = BigInt(\"0b1111111111111111111111111111111111111111111111111111111111111111\");\n\nconst snowflakePattern = /^[1-9]\\d+$/;\nexport function isValidSnowflake(str: string) {\n  if (!str.match(snowflakePattern)) return false;\n  if (parseInt(str, 10) < MIN_SNOWFLAKE) return false;\n  if (BigInt(str) > MAX_SNOWFLAKE) return false;\n  return true;\n}\n\nexport const DISCORD_HTTP_ERROR_NAME = \"DiscordHTTPError\";\nexport const DISCORD_REST_ERROR_NAME = \"DiscordAPIError\";\n\nexport function isDiscordHTTPError(err: Error | string) {\n  return typeof err === \"object\" && err.constructor?.name === DISCORD_HTTP_ERROR_NAME;\n}\n\nexport function isDiscordAPIError(err: Error | string): err is DiscordAPIError {\n  return err instanceof DiscordAPIError;\n}\n\nexport function isDiscordJsTypeError(err: unknown): err is DiscordjsTypeError {\n  return err instanceof DiscordjsTypeError;\n}\n\n// null | undefined -> undefined\nexport function zNullishToUndefined<T extends z.ZodTypeAny>(\n  type: T,\n): ZodPipe<T, ZodTransform<NonNullable<z.output<T>> | undefined>> {\n  return type.transform((v) => v ?? undefined);\n}\n\nexport function getScalarDifference<T extends object>(\n  base: T,\n  object: T,\n  ignoreKeys: string[] = [],\n): Map<string, { was: any; is: any }> {\n  base = stripObjectToScalars(base) as T;\n  object = stripObjectToScalars(object) as T;\n  const diff = new Map<string, { was: any; is: any }>();\n\n  for (const [key, value] of Object.entries(object)) {\n    if (!isEqual(value, base[key]) && !ignoreKeys.includes(key)) {\n      diff.set(key, { was: base[key], is: value });\n    }\n  }\n\n  return diff;\n}\n\n// This is a stupid, messy solution that is not extendable at all.\n// If anyone plans on adding anything to this, they should rewrite this first.\n// I just want to get this done and this works for now :)\nexport function prettyDifference(diff: Map<string, { was: any; is: any }>): Map<string, { was: any; is: any }> {\n  const toReturn = new Map<string, { was: any; is: any }>();\n\n  for (let [key, difference] of diff) {\n    if (key === \"rateLimitPerUser\") {\n      difference.is = humanizeDuration(difference.is * 1000);\n      difference.was = humanizeDuration(difference.was * 1000);\n      key = \"slowmode\";\n    }\n\n    toReturn.set(key, { was: difference.was, is: difference.is });\n  }\n\n  return toReturn;\n}\n\nexport function differenceToString(diff: Map<string, { was: any; is: any }>): string {\n  let toReturn = \"\";\n  diff = prettyDifference(diff);\n  for (const [key, difference] of diff) {\n    toReturn += `**${key[0].toUpperCase() + key.slice(1)}**: \\`${difference.was}\\` ➜ \\`${difference.is}\\`\\n`;\n  }\n  return toReturn;\n}\n\n// https://stackoverflow.com/a/49262929/316944\nexport type Not<T, E> = T & Exclude<T, E>;\n\nexport function nonNullish<V>(v: V): v is NonNullable<V> {\n  return v != null;\n}\n\nexport type GuildInvite = Invite & { guild: InviteGuild | Guild };\nexport type GroupDMInvite = Invite & {\n  channel: PartialGroupDMChannel;\n};\n\nexport function zBoundedCharacters(min: number, max: number) {\n  return z.string().refine(\n    (str) => {\n      const len = [...str].length; // Unicode aware character split\n      return len >= min && len <= max;\n    },\n    {\n      message: `String must be between ${min} and ${max} characters long`,\n    },\n  );\n}\n\nexport const zSnowflake = z.string().refine((str) => isSnowflake(str), {\n  message: \"Invalid snowflake ID\",\n});\n\nconst regexWithFlags = /^\\/(.*?)\\/([i]*)$/;\n\nexport class InvalidRegexError extends Error {}\n\n/**\n * This function supports two input syntaxes for regexes: /<pattern>/<flags> and just <pattern>\n */\nexport function inputPatternToRegExp(pattern: string) {\n  const advancedSyntaxMatch = pattern.match(regexWithFlags);\n  const [finalPattern, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [pattern, \"\"];\n  try {\n    return new RegExp(finalPattern, flags);\n  } catch (e) {\n    throw new InvalidRegexError(e.message);\n  }\n}\n\nexport function zRegex<T extends ZodString>(zStr: T) {\n  return zStr.refine((str) => {\n    try {\n      inputPatternToRegExp(str);\n      return true;\n    } catch (err) {\n      if (err instanceof InvalidRegexError) {\n        return false;\n      }\n      throw err;\n    }\n  });\n}\n\nexport const zEmbedInput = z\n  .strictObject({\n    title: z.string().optional(),\n    description: z.string().optional(),\n    url: z.string().optional(),\n    timestamp: z.string().optional(),\n    color: z.number().optional(),\n\n    footer: z.optional(\n      z.object({\n        text: z.string(),\n        icon_url: z.string().optional(),\n      }),\n    ),\n\n    image: z.optional(\n      z.object({\n        url: z.string().optional(),\n        width: z.number().optional(),\n        height: z.number().optional(),\n      }),\n    ),\n\n    thumbnail: z.optional(\n      z.object({\n        url: z.string().optional(),\n        width: z.number().optional(),\n        height: z.number().optional(),\n      }),\n    ),\n\n    video: z.optional(\n      z.object({\n        url: z.string().optional(),\n        width: z.number().optional(),\n        height: z.number().optional(),\n      }),\n    ),\n\n    provider: z.optional(\n      z.object({\n        name: z.string(),\n        url: z.string().optional(),\n      }),\n    ),\n\n    fields: z.optional(\n      z.array(\n        z.object({\n          name: z.string().optional(),\n          value: z.string().optional(),\n          inline: z.boolean().optional(),\n        }),\n      ),\n    ),\n\n    author: z\n      .optional(\n        z.object({\n          name: z.string(),\n          url: z.string().optional(),\n          width: z.number().optional(),\n          height: z.number().optional(),\n        }),\n      )\n      .nullable(),\n  })\n  .meta({\n    id: \"embedInput\",\n  });\n\nexport type EmbedWith<T extends keyof APIEmbed> = APIEmbed & Pick<Required<APIEmbed>, T>;\n\nexport const zStrictMessageContent = z\n  .strictObject({\n    content: z.string().optional(),\n    tts: z.boolean().optional(),\n    embeds: z.union([z.array(zEmbedInput), zEmbedInput]).optional(),\n    embed: zEmbedInput.optional(),\n  })\n  .transform((data) => {\n    if (data.embed) {\n      data.embeds = [data.embed];\n      delete data.embed;\n    }\n    if (data.embeds && !Array.isArray(data.embeds)) {\n      data.embeds = [data.embeds];\n    }\n    return data as StrictMessageContent;\n  })\n  .meta({\n    id: \"strictMessageContent\",\n  });\n\nexport type ZStrictMessageContent = z.infer<typeof zStrictMessageContent>;\n\nexport type StrictMessageContent = {\n  content?: string;\n  tts?: boolean;\n  embeds?: APIEmbed[];\n};\n\nexport type MessageContent = string | StrictMessageContent;\nexport const zMessageContent = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]);\n\nexport function validateAndParseMessageContent(input: unknown): StrictMessageContent {\n  if (input == null) {\n    return {};\n  }\n\n  if (typeof input !== \"object\") {\n    return { content: String(input) };\n  }\n\n  // Migrate embed -> embeds\n  if ((input as any).embed) {\n    (input as any).embeds = [(input as any).embed];\n    delete (input as any).embed;\n  }\n\n  dropNullValuesRecursively(input);\n\n  try {\n    return zStrictMessageContent.parse(input) as unknown as StrictMessageContent;\n  } catch (err) {\n    if (err instanceof ZodError) {\n      // TODO: Allow error to be thrown and handle at use location\n      return {};\n    }\n\n    throw err;\n  }\n}\n\nfunction dropNullValuesRecursively(obj: any) {\n  if (obj == null) {\n    return;\n  }\n\n  if (Array.isArray(obj)) {\n    for (const item of obj) {\n      dropNullValuesRecursively(item);\n    }\n  }\n\n  if (typeof obj !== \"object\") {\n    return;\n  }\n\n  for (const [key, value] of Object.entries(obj)) {\n    if (value == null) {\n      delete obj[key];\n      continue;\n    }\n\n    dropNullValuesRecursively(value);\n  }\n}\n\n/**\n * Mirrors AllowedMentions from Eris\n */\nexport const zAllowedMentions = z.strictObject({\n  everyone: zNullishToUndefined(z.boolean().nullable().optional()),\n  users: zNullishToUndefined(\n    z\n      .union([z.boolean(), z.array(z.string())])\n      .nullable()\n      .optional(),\n  ),\n  roles: zNullishToUndefined(\n    z\n      .union([z.boolean(), z.array(z.string())])\n      .nullable()\n      .optional(),\n  ),\n  replied_user: zNullishToUndefined(z.boolean().nullable().optional()),\n});\n\nexport function dropPropertiesByName(obj, propName) {\n  if (Object.hasOwn(obj, propName)) {\n    delete obj[propName];\n  }\n  for (const value of Object.values(obj)) {\n    if (typeof value === \"object\" && value !== null && !Array.isArray(value)) {\n      dropPropertiesByName(value, propName);\n    }\n  }\n}\n\nexport function zBoundedRecord<TRecord extends ZodRecord<any, any>>(\n  record: TRecord,\n  minKeys: number,\n  maxKeys: number,\n): TRecord {\n  return record.refine(\n    (data) => {\n      const len = Object.keys(data).length;\n      return len >= minKeys && len <= maxKeys;\n    },\n    {\n      message: `Object must have ${minKeys}-${maxKeys} keys`,\n    },\n  );\n}\n\nexport const zDelayString = z\n  .string()\n  .max(32)\n  .refine((str) => convertDelayStringToMS(str) !== null, {\n    message: \"Invalid delay string\",\n  });\n\n// To avoid running into issues with the JS max date vaLue, we cap maximum delay strings *far* below that.\n// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#The_ECMAScript_epoch_and_timestamps\nconst MAX_DELAY_STRING_AMOUNT = 100 * 365 * DAYS;\n\n/**\n * Turns a \"delay string\" such as \"1h30m\" to milliseconds\n */\nexport function convertDelayStringToMS(str, defaultUnit = \"m\"): number | null {\n  const regex = /^([0-9]+)\\s*((?:mo?)|[ywdhs])?[a-z]*\\s*/;\n  let match;\n  let ms = 0;\n\n  str = str.trim();\n\n  // tslint:disable-next-line\n  while (str !== \"\" && (match = str.match(regex)) !== null) {\n    ms += match[1] * ((match[2] && delayStringMultipliers[match[2]]) || delayStringMultipliers[defaultUnit]);\n    str = str.slice(match[0].length);\n  }\n\n  // Invalid delay string\n  if (str !== \"\") {\n    return null;\n  }\n\n  if (ms > MAX_DELAY_STRING_AMOUNT) {\n    return null;\n  }\n\n  return ms;\n}\n\nexport function convertMSToDelayString(ms: number): string {\n  let result = \"\";\n  let remaining = ms;\n  for (const [abbr, multiplier] of Object.entries(delayStringMultipliers)) {\n    if (multiplier <= remaining) {\n      const amount = Math.floor(remaining / multiplier);\n      result += `${amount}${abbr}`;\n      remaining -= amount * multiplier;\n    }\n\n    if (remaining === 0) break;\n  }\n  return result;\n}\n\nexport function successMessage(str: string, emoji = \"<:zep_check:906897402101891093>\") {\n  return emoji ? `${emoji} ${str}` : str;\n}\n\nexport function errorMessage(str, emoji = \"⚠\") {\n  return emoji ? `${emoji} ${str}` : str;\n}\n\nexport function get(obj, path, def?): any {\n  let cursor = obj;\n  const pathParts = path\n    .split(\".\")\n    .map((s) => s.trim())\n    .filter((s) => s !== \"\");\n  for (const part of pathParts) {\n    // hasOwn check here is necessary to prevent prototype traversal in tags\n    if (!Object.hasOwn(cursor, part)) return def;\n    cursor = cursor[part];\n    if (cursor === undefined) return def;\n    if (cursor == null) return null;\n  }\n  return cursor;\n}\n\nexport function has(obj, path): boolean {\n  return get(obj, path) !== undefined;\n}\n\nexport function stripObjectToScalars(obj, includedNested: string[] = []) {\n  const result = Array.isArray(obj) ? [] : {};\n\n  for (const key in obj) {\n    if (\n      obj[key] == null ||\n      typeof obj[key] === \"string\" ||\n      typeof obj[key] === \"number\" ||\n      typeof obj[key] === \"boolean\"\n    ) {\n      result[key] = obj[key];\n    } else if (typeof obj[key] === \"object\") {\n      const prefix = `${key}.`;\n      const nestedNested = includedNested\n        .filter((p) => p === key || p.startsWith(prefix))\n        .map((p) => (p === key ? p : p.slice(prefix.length)));\n\n      if (nestedNested.length) {\n        result[key] = stripObjectToScalars(obj[key], nestedNested);\n      }\n    }\n  }\n\n  return result;\n}\n\nexport const snowflakeRegex = /[1-9][0-9]{5,19}/;\n\nexport type Snowflake = Brand<string, \"Snowflake\">;\n\nconst isSnowflakeRegex = new RegExp(`^${snowflakeRegex.source}$`);\nexport function isSnowflake(v: unknown): v is Snowflake {\n  return typeof v === \"string\" && isSnowflakeRegex.test(v);\n}\n\nexport function sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => {\n    setTimeout(resolve, ms);\n  });\n}\n\nconst realLinkRegex = /https?:\\/\\/\\S+/; // http://anything or https://anything\nconst plainLinkRegex = /((?!https?:\\/\\/)\\S)+\\.\\S+/; // anything.anything, without http:// or https:// preceding it\n// Both of the above, with precedence on the first one\nconst urlRegex = new RegExp(`(${realLinkRegex.source}|${plainLinkRegex.source})`, \"g\");\nconst protocolRegex = /^[a-z]+:\\/\\//;\n\ninterface MatchedURL extends URL {\n  input: string;\n}\n\nexport function getUrlsInString(str: string, onlyUnique = false): MatchedURL[] {\n  let matches = [...(str.match(urlRegex) ?? [])];\n  if (onlyUnique) {\n    matches = unique(matches);\n  }\n\n  return matches.reduce<MatchedURL[]>((urls, match) => {\n    const withProtocol = protocolRegex.test(match) ? match : `https://${match}`;\n\n    let matchUrl: MatchedURL;\n    try {\n      matchUrl = new URL(withProtocol) as MatchedURL;\n      matchUrl.input = match;\n    } catch {\n      return urls;\n    }\n\n    let hostname = matchUrl.hostname.toLowerCase();\n\n    if (hostname.length > 3) {\n      hostname = hostname.replace(/[^a-z]+$/, \"\");\n    }\n\n    const hostnameParts = hostname.split(\".\");\n    const tld = hostnameParts[hostnameParts.length - 1];\n    if (tlds.includes(tld)) {\n      urls.push(matchUrl);\n    }\n\n    return urls;\n  }, []);\n}\n\nexport function parseInviteCodeInput(str: string): string {\n  const parsedInviteCodes = getInviteCodesInString(str);\n  if (parsedInviteCodes.length) {\n    return parsedInviteCodes[0];\n  }\n\n  return str;\n}\n\nexport function isNotNull<T>(value: T): value is Exclude<T, null | undefined> {\n  return value != null;\n}\n\n// discord.com/invite/<code>\n// discordapp.com/invite/<code>\n// discord.gg/invite/<code>\n// discord.gg/<code>\n// discord.com/friend-invite/<code>\nconst quickInviteDetection =\n  /discord(?:app)?\\.com\\/(?:friend-)?invite\\/([a-z0-9-]+)|discord\\.gg\\/(?:\\S+\\/)?([a-z0-9-]+)/gi;\n\nconst isInviteHostRegex = /(?:^|\\.)(?:discord.gg|discord.com|discordapp.com)$/i;\nconst longInvitePathRegex = /^\\/(?:friend-)?invite\\/([a-z0-9-]+)$/i;\n\nexport function getInviteCodesInString(str: string): string[] {\n  const inviteCodes: string[] = [];\n\n  // Clean up markdown\n  str = str.replace(/[|*_~]/g, \"\");\n\n  // Quick detection\n  const quickDetectionMatch = str.matchAll(quickInviteDetection);\n  if (quickDetectionMatch) {\n    inviteCodes.push(...[...quickDetectionMatch].map((m) => m[1] || m[2]));\n  }\n\n  // Deep detection via URL parsing\n  const linksInString = getUrlsInString(str, true);\n  const potentialInviteLinks = linksInString.filter((url) => isInviteHostRegex.test(url.hostname));\n  const withNormalizedPaths = potentialInviteLinks.map((url) => {\n    url.pathname = url.pathname.replace(/\\/{2,}/g, \"/\").replace(/\\/+$/g, \"\");\n    return url;\n  });\n\n  const codesFromInviteLinks = withNormalizedPaths\n    .map((url) => {\n      // discord.gg/[anything/]<code>\n      if (url.hostname === \"discord.gg\") {\n        const parts = url.pathname.split(\"/\").filter(Boolean);\n        return parts[parts.length - 1];\n      }\n\n      // discord.com/invite/<code>[/anything]\n      // discordapp.com/invite/<code>[/anything]\n      // discord.com/friend-invite/<code>[/anything]\n      // discordapp.com/friend-invite/<code>[/anything]\n      const longInviteMatch = url.pathname.match(longInvitePathRegex);\n      if (longInviteMatch) {\n        return longInviteMatch[1];\n      }\n\n      return null;\n    })\n    .filter(Boolean) as string[];\n\n  inviteCodes.push(...codesFromInviteLinks);\n\n  return unique(inviteCodes);\n}\n\nexport const unicodeEmojiRegex = emojiRegex();\nexport const customEmojiRegex = /<a?:(.*?):(\\d+)>/;\n\nconst matchAllEmojiRegex = new RegExp(`(${unicodeEmojiRegex.source})|(${customEmojiRegex.source})`, \"g\");\n\nexport function getEmojiInString(str: string): string[] {\n  return str.match(matchAllEmojiRegex) || [];\n}\n\nexport function isEmoji(str: string): boolean {\n  return str.match(`^(${unicodeEmojiRegex.source})|(${customEmojiRegex.source})$`) !== null;\n}\n\nexport function isUnicodeEmoji(str: string): boolean {\n  return str.match(`^${unicodeEmojiRegex.source}$`) !== null;\n}\n\nexport function trimLines(str: string) {\n  return str\n    .trim()\n    .split(\"\\n\")\n    .map((l) => l.trim())\n    .join(\"\\n\")\n    .trim();\n}\n\nexport function trimEmptyLines(str: string) {\n  return str\n    .split(\"\\n\")\n    .filter((l) => l.trim() !== \"\")\n    .join(\"\\n\");\n}\n\nexport function asSingleLine(str: string) {\n  return trimLines(str).replace(/\\n/g, \" \");\n}\n\nexport function trimEmptyStartEndLines(str: string) {\n  const lines = str.split(\"\\n\");\n  let emptyLinesAtStart = 0;\n  let emptyLinesAtEnd = 0;\n\n  for (const line of lines) {\n    if (line.match(/^\\s*$/)) {\n      emptyLinesAtStart++;\n    } else {\n      break;\n    }\n  }\n\n  for (let i = lines.length - 1; i > 0; i--) {\n    if (lines[i].match(/^\\s*$/)) {\n      emptyLinesAtEnd++;\n    } else {\n      break;\n    }\n  }\n\n  return lines.slice(emptyLinesAtStart, emptyLinesAtEnd ? -1 * emptyLinesAtEnd : undefined).join(\"\\n\");\n}\n\nexport function trimIndents(str: string, indentLength: number) {\n  const regex = new RegExp(`^\\\\s{0,${indentLength}}`, \"g\");\n  return str\n    .split(\"\\n\")\n    .map((line) => line.replace(regex, \"\"))\n    .join(\"\\n\");\n}\n\nexport function indentLine(str: string, indentLength: number) {\n  return \" \".repeat(indentLength) + str;\n}\n\nexport function indentLines(str: string, indentLength: number) {\n  return str\n    .split(\"\\n\")\n    .map((line) => indentLine(line, indentLength))\n    .join(\"\\n\");\n}\n\nexport const emptyEmbedValue = \"\\u200b\";\nexport const preEmbedPadding = emptyEmbedValue + \"\\n\";\nexport const embedPadding = \"\\n\" + emptyEmbedValue;\n\nexport const userMentionRegex = /<@!?([0-9]+)>/g;\nexport const roleMentionRegex = /<@&([0-9]+)>/g;\nexport const channelMentionRegex = /<#([0-9]+)>/g;\n\nexport function getUserMentions(str: string) {\n  const regex = new RegExp(userMentionRegex.source, \"g\");\n  const userIds: string[] = [];\n  let match;\n\n  // tslint:disable-next-line\n  while ((match = regex.exec(str)) !== null) {\n    userIds.push(match[1]);\n  }\n\n  return userIds;\n}\n\nexport function getRoleMentions(str: string) {\n  const regex = new RegExp(roleMentionRegex.source, \"g\");\n  const roleIds: string[] = [];\n  let match;\n\n  // tslint:disable-next-line\n  while ((match = regex.exec(str)) !== null) {\n    roleIds.push(match[1]);\n  }\n\n  return roleIds;\n}\n\n/**\n * Disable link previews in the given string by wrapping links in < >\n */\nexport function disableLinkPreviews(str: string): string {\n  return str.replace(/(?<!<)(https?:\\/\\/\\S+)/gi, \"<$1>\");\n}\n\nexport function deactivateMentions(content: string): string {\n  return content.replace(/@/g, \"@\\u200b\");\n}\n\nexport function useMediaUrls(content: string): string {\n  return content.replace(/cdn\\.discord(app)?\\.com/g, \"media.discordapp.net\");\n}\n\nexport function chunkArray<T>(arr: T[], chunkSize): T[][] {\n  const chunks: T[][] = [];\n  let currentChunk: T[] = [];\n\n  for (let i = 0; i < arr.length; i++) {\n    currentChunk.push(arr[i]);\n    if ((i !== 0 && (i + 1) % chunkSize === 0) || i === arr.length - 1) {\n      chunks.push(currentChunk);\n      currentChunk = [];\n    }\n  }\n\n  return chunks;\n}\n\nexport function chunkLines(str: string, maxChunkLength = 2000): string[] {\n  if (str.length < maxChunkLength) {\n    return [str];\n  }\n\n  const chunks: string[] = [];\n\n  while (str.length) {\n    if (str.length <= maxChunkLength) {\n      chunks.push(str);\n      break;\n    }\n\n    const slice = str.slice(0, maxChunkLength);\n\n    const lastLineBreakIndex = slice.lastIndexOf(\"\\n\");\n    if (lastLineBreakIndex === -1) {\n      chunks.push(str.slice(0, maxChunkLength));\n      str = str.slice(maxChunkLength);\n    } else {\n      chunks.push(str.slice(0, lastLineBreakIndex));\n      str = str.slice(lastLineBreakIndex + 1);\n    }\n  }\n\n  return chunks;\n}\n\n/**\n * Chunks a long message to multiple smaller messages, retaining leading and trailing line breaks, open code blocks, etc.\n *\n * Default maxChunkLength is 1990, a bit under the message length limit of 2000, so we have space to add code block\n * shenanigans to the start/end when needed. Take this into account when choosing a custom maxChunkLength as well.\n */\nexport function chunkMessageLines(str: string, maxChunkLength = 1990): string[] {\n  const chunks = chunkLines(str, maxChunkLength);\n  let openCodeBlock = false;\n\n  return chunks.map((chunk) => {\n    // If the chunk starts with a newline, add an invisible unicode char so Discord doesn't strip it away\n    if (chunk[0] === \"\\n\") chunk = \"\\u200b\" + chunk;\n    // If the chunk ends with a newline, add an invisible unicode char so Discord doesn't strip it away\n    if (chunk[chunk.length - 1] === \"\\n\") chunk = chunk + \"\\u200b\";\n    // If the previous chunk had an open code block, open it here again\n    if (openCodeBlock) {\n      openCodeBlock = false;\n      if (chunk.startsWith(\"```\")) {\n        // Edge case: chunk starts with a code block delimiter, e.g. the previous chunk and this one were split right before the end of a code block\n        // Fix: just strip the code block delimiter away from here, we don't need it anymore\n        chunk = chunk.slice(3);\n      } else {\n        chunk = \"```\" + chunk;\n      }\n    }\n    // If the chunk has an open code block, close it and open it again in the next chunk\n    const codeBlockDelimiters = chunk.match(/```/g);\n    if (codeBlockDelimiters && codeBlockDelimiters.length % 2 !== 0) {\n      chunk += \"```\";\n      openCodeBlock = true;\n    }\n\n    return chunk;\n  });\n}\n\nexport async function createChunkedMessage(\n  channel: SendableChannels | User,\n  messageText: string,\n  allowedMentions?: MessageMentionOptions,\n) {\n  const chunks = chunkMessageLines(messageText);\n  for (const chunk of chunks) {\n    await channel.send({ content: chunk, allowedMentions });\n  }\n}\n\n/**\n * Downloads the file from the given URL to a temporary file, with retry support\n */\nexport function downloadFile(attachmentUrl: string, retries = 3): Promise<{ path: string; deleteFn: () => void }> {\n  return new Promise((resolve) => {\n    tmp.file((err, path, fd, deleteFn) => {\n      if (err) throw err;\n\n      const writeStream = fs.createWriteStream(path);\n\n      https\n        .get(attachmentUrl, (res) => {\n          res.pipe(writeStream);\n          writeStream.on(\"finish\", () => {\n            writeStream.end();\n            resolve({\n              path,\n              deleteFn,\n            });\n          });\n        })\n        .on(\"error\", (httpsErr) => {\n          fsp.unlink(path);\n\n          if (retries === 0) {\n            throw httpsErr;\n          } else {\n            console.warn(\"File download failed, retrying. Error given:\", httpsErr.message); // tslint:disable-line\n            resolve(downloadFile(attachmentUrl, retries - 1));\n          }\n        });\n    });\n  });\n}\n\ntype ItemWithRanking<T> = [T, number];\nexport function simpleClosestStringMatch(searchStr: string, haystack: string[]): string | null;\nexport function simpleClosestStringMatch<T extends Not<any, string>>(\n  searchStr,\n  haystack: T[],\n  getter: (item: T) => string,\n): T | null;\nexport function simpleClosestStringMatch(searchStr, haystack, getter?) {\n  const normalizedSearchStr = searchStr.toLowerCase();\n\n  // See if any haystack item contains a part of the search string\n  const itemsWithRankings: Array<ItemWithRanking<any>> = haystack.map((item) => {\n    const itemStr: string = getter ? getter(item) : item;\n    const normalizedItemStr = itemStr.toLowerCase();\n\n    let i = 0;\n    do {\n      if (!normalizedItemStr.includes(normalizedSearchStr.slice(0, i + 1))) break;\n      i++;\n    } while (i < normalizedSearchStr.length);\n\n    if (i > 0 && normalizedItemStr.startsWith(normalizedSearchStr.slice(0, i))) {\n      // Slightly prioritize items that *start* with the search string\n      i += 0.5;\n    }\n\n    return [item, i] as ItemWithRanking<any>;\n  });\n\n  // Sort by best match\n  itemsWithRankings.sort((a, b) => {\n    return a[1] > b[1] ? -1 : 1;\n  });\n\n  if (itemsWithRankings[0][1] === 0) {\n    return null;\n  }\n\n  return itemsWithRankings[0][0];\n}\n\ntype sorterDirection = \"ASC\" | \"DESC\";\ntype sorterGetterFn = (any) => any;\ntype sorterGetterFnWithDirection = [sorterGetterFn, sorterDirection];\ntype sorterGetterResolvable = string | sorterGetterFn;\ntype sorterGetterResolvableWithDirection = [sorterGetterResolvable, sorterDirection];\ntype sorterFn = (a: any, b: any) => number;\n\nfunction resolveGetter(getter: sorterGetterResolvable): sorterGetterFn {\n  if (typeof getter === \"string\") {\n    return (obj) => obj[getter];\n  }\n\n  return getter;\n}\n\nexport function multiSorter(getters: Array<sorterGetterResolvable | sorterGetterResolvableWithDirection>): sorterFn {\n  const resolvedGetters: sorterGetterFnWithDirection[] = getters.map((getter) => {\n    if (Array.isArray(getter)) {\n      return [resolveGetter(getter[0]), getter[1]] as sorterGetterFnWithDirection;\n    } else {\n      return [resolveGetter(getter), \"ASC\"] as sorterGetterFnWithDirection;\n    }\n  });\n\n  return (a, b) => {\n    for (const getter of resolvedGetters) {\n      const aVal = getter[0](a);\n      const bVal = getter[0](b);\n      if (aVal > bVal) return getter[1] === \"ASC\" ? 1 : -1;\n      if (aVal < bVal) return getter[1] === \"ASC\" ? -1 : 1;\n    }\n\n    return 0;\n  };\n}\n\nexport function sorter(getter: sorterGetterResolvable, direction: sorterDirection = \"ASC\"): sorterFn {\n  return multiSorter([[getter, direction]]);\n}\n\nexport function noop() {\n  // IT'S LITERALLY NOTHING\n}\n\nexport type CustomEmoji = {\n  id: string;\n} & Emoji;\n\nexport type UserNotificationMethod = { type: \"dm\" } | { type: \"channel\"; channel: GuildTextBasedChannel };\n\nexport const disableUserNotificationStrings = [\"no\", \"none\", \"off\"];\n\nexport interface UserNotificationResult {\n  method: UserNotificationMethod | null;\n  success: boolean;\n  text?: string;\n}\n\nexport function createUserNotificationError(text: string): UserNotificationResult {\n  return {\n    method: null,\n    success: false,\n    text,\n  };\n}\n\n/**\n * Attempts to notify the user using one of the specified methods. Only the first one that succeeds will be used.\n * @param methods List of methods to try, in priority order\n */\nexport async function notifyUser(\n  user: User,\n  body: string,\n  methods: UserNotificationMethod[],\n): Promise<UserNotificationResult> {\n  if (methods.length === 0) {\n    return { method: null, success: true };\n  }\n\n  let lastError: Error | null = null;\n\n  for (const method of methods) {\n    if (method.type === \"dm\") {\n      try {\n        await sendDM(user, body, \"mod action notification\");\n        return {\n          method,\n          success: true,\n          text: \"user notified with a direct message\",\n        };\n      } catch (e) {\n        lastError = e;\n      }\n    } else if (method.type === \"channel\") {\n      try {\n        await method.channel.send({\n          content: `<@!${user.id}> ${body}`,\n          allowedMentions: { users: [user.id] },\n        });\n        return {\n          method,\n          success: true,\n          text: `user notified in <#${method.channel.id}>`,\n        };\n      } catch (e) {\n        lastError = e;\n      }\n    }\n  }\n\n  const errorText = lastError ? `failed to message user: ${lastError.message}` : `failed to message user`;\n\n  return {\n    method: null,\n    success: false,\n    text: errorText,\n  };\n}\n\nexport function ucfirst(str) {\n  if (typeof str !== \"string\" || str === \"\") return str;\n  return str[0].toUpperCase() + str.slice(1);\n}\n\nexport class UnknownUser {\n  public id: string;\n  public username = \"Unknown\";\n  public discriminator = \"0000\";\n  public tag = \"Unknown#0000\";\n\n  constructor(props = {}) {\n    for (const key in props) {\n      this[key] = props[key];\n    }\n  }\n}\n\nexport function isObjectLiteral(obj) {\n  let deepestPrototype = obj;\n  while (Object.getPrototypeOf(deepestPrototype) != null) {\n    deepestPrototype = Object.getPrototypeOf(deepestPrototype);\n  }\n  return Object.getPrototypeOf(obj) === deepestPrototype;\n}\n\nconst keyMods = [\"+\", \"-\", \"=\"];\nexport function deepKeyIntersect(obj, keyReference) {\n  const result = {};\n  for (let [key, value] of Object.entries(obj)) {\n    if (!Object.hasOwn(keyReference, key)) {\n      // Temporary solution so we don't erase keys with modifiers\n      // Modifiers will be removed soon(tm) so we can remove this when that happens as well\n      let found = false;\n      for (const mod of keyMods) {\n        if (Object.hasOwn(keyReference, mod + key)) {\n          key = mod + key;\n          found = true;\n          break;\n        }\n      }\n      if (!found) continue;\n    }\n\n    if (Array.isArray(value)) {\n      // Also temp (because modifier shenanigans)\n      result[key] = keyReference[key];\n    } else if (\n      value != null &&\n      typeof value === \"object\" &&\n      typeof keyReference[key] === \"object\" &&\n      isObjectLiteral(value)\n    ) {\n      result[key] = deepKeyIntersect(value, keyReference[key]);\n    } else {\n      result[key] = value;\n    }\n  }\n  return result;\n}\n\nconst unknownUsers = new Set();\nconst unknownMembers = new Set();\n\nexport function resolveUserId(bot: Client, value: string) {\n  if (value == null) {\n    return null;\n  }\n\n  // Just a user ID?\n  if (isValidSnowflake(value)) {\n    return value;\n  }\n\n  // A user mention?\n  const mentionMatch = value.match(/^<@!?(\\d+)>$/);\n  if (mentionMatch) {\n    return mentionMatch[1];\n  }\n\n  // a username\n  const usernameMatch = value.match(/^@?(\\S{3,})$/);\n  if (usernameMatch) {\n    const profiler = getProfiler();\n    const start = performance.now();\n    const user = bot.users.cache.find((u) => u.tag === usernameMatch[1]);\n    profiler?.addDataPoint(\"utils:resolveUserId:usernameMatch\", performance.now() - start);\n    if (user) {\n      return user.id;\n    }\n  }\n\n  return null;\n}\n\n/**\n * Finds a matching User for the passed user id, user mention, or full username (with discriminator).\n * If a user is not found, returns an UnknownUser instead.\n */\nexport function getUser(client: Client, userResolvable: string): User | UnknownUser {\n  const id = resolveUserId(client, userResolvable);\n  return id ? client.users.resolve(id as Snowflake) || new UnknownUser({ id }) : new UnknownUser();\n}\n\n/**\n * Resolves a User from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.\n * If the user is not found in the cache, it's fetched from the API.\n */\nexport async function resolveUser(bot: Client, value: unknown, context?: string): Promise<User | UnknownUser> {\n  if (typeof value !== \"string\") {\n    return new UnknownUser();\n  }\n\n  const userId = resolveUserId(bot, value);\n  if (!userId) {\n    return new UnknownUser();\n  }\n\n  incrementDebugCounter(`resolveUser:${context ?? \"unknown\"}`);\n  return (await getOrFetchUser(bot, userId)) ?? new UnknownUser();\n}\n\n/**\n * Resolves a guild Member from the passed user id, user mention, or full username (with discriminator).\n * If the member is not found in the cache, it's fetched from the API.\n */\nexport async function resolveMember(\n  bot: Client,\n  guild: Guild,\n  value: string,\n  fresh = false,\n): Promise<GuildMember | null> {\n  const userId = resolveUserId(bot, value);\n  if (!userId) return null;\n\n  // If we have the member cached, return that directly\n  if (guild.members.cache.has(userId as Snowflake) && !fresh) {\n    return guild.members.cache.get(userId as Snowflake) || null;\n  }\n\n  // We don't want to spam the API by trying to fetch unknown members again and again,\n  // so we cache the fact that they're \"unknown\" for a while\n  const unknownKey = `${guild.id}-${userId}`;\n  if (unknownMembers.has(unknownKey)) {\n    return null;\n  }\n\n  const freshMember = await guild.members.fetch({ user: userId as Snowflake, force: true }).catch(noop);\n  if (freshMember) {\n    // freshMember.id = userId; // I dont even know why this is here -Dark\n    return freshMember;\n  }\n\n  unknownMembers.add(unknownKey);\n  setTimeout(() => unknownMembers.delete(unknownKey), 15 * MINUTES);\n\n  return null;\n}\n\n/**\n * Resolves a role from the passed role ID, role mention, or role name.\n * In the event of duplicate role names, this function will return the first one it comes across.\n *\n * FIXME: Define \"first one it comes across\" better\n */\nexport async function resolveRoleId(bot: Client, guildId: string, value: string) {\n  if (value == null) {\n    return null;\n  }\n\n  // Role mention\n  const mentionMatch = value.match(/^<@&?(\\d+)>$/);\n  if (mentionMatch) {\n    return mentionMatch[1];\n  }\n\n  // Role name\n  const roleList = (await bot.guilds.fetch(guildId as Snowflake)).roles.cache;\n  const role = roleList.filter((x) => x.name.toLocaleLowerCase() === value.toLocaleLowerCase());\n  if (role.size >= 1) {\n    return role.firstKey();\n  }\n\n  // Role ID\n  const idMatch = value.match(/^\\d+$/);\n  if (idMatch) {\n    return value;\n  }\n  return null;\n}\n\nexport class UnknownRole {\n  public id: string;\n  public name: string;\n\n  constructor(props = {}) {\n    for (const key in props) {\n      this[key] = props[key];\n    }\n  }\n}\n\nexport function resolveRole(guild: Guild, roleResolvable: RoleResolvable) {\n  const roleId = guild.roles.resolveId(roleResolvable);\n  return guild.roles.resolve(roleId) ?? new UnknownRole({ id: roleId, name: roleId });\n}\n\nconst inviteCache = new SimpleCache<Promise<Invite | null>>(10 * MINUTES, 200);\n\ntype ResolveInviteReturnType = Promise<Invite | null>;\nexport async function resolveInvite<T extends boolean>(\n  client: Client,\n  code: string,\n  withCounts?: T,\n): ResolveInviteReturnType {\n  const key = `${code}:${withCounts ? 1 : 0}`;\n\n  if (inviteCache.has(key)) {\n    return inviteCache.get(key) as ResolveInviteReturnType;\n  }\n\n  const promise = client.fetchInvite(code).catch(() => null);\n  inviteCache.set(key, promise);\n\n  return promise as ResolveInviteReturnType;\n}\n\nconst internalStickerCache: LimitedCollection<Snowflake, Sticker> = new LimitedCollection({ maxSize: 500 });\n\nexport async function resolveStickerId(bot: Client, id: Snowflake): Promise<Sticker | null> {\n  const cachedSticker = internalStickerCache.get(id);\n  if (cachedSticker) return cachedSticker;\n\n  const fetchedSticker = await bot.fetchSticker(id).catch(() => null);\n  if (fetchedSticker) {\n    internalStickerCache.set(id, fetchedSticker);\n  }\n\n  return fetchedSticker;\n}\n\nexport async function confirm(\n  context: GenericCommandSource,\n  userId: string,\n  content: MessageCreateOptions,\n): Promise<boolean> {\n  return waitForButtonConfirm(context, content, { restrictToId: userId });\n}\n\nexport function createDisabledButtonRow(\n  row: ActionRowBuilder<MessageActionRowComponentBuilder>\n): ActionRowBuilder<MessageActionRowComponentBuilder> {\n  const newRow = new ActionRowBuilder<MessageActionRowComponentBuilder>();\n  for (const component of row.components) {\n    if (component instanceof ButtonBuilder) {\n      newRow.addComponents(\n        ButtonBuilder.from(component).setDisabled(true)\n      );\n    }\n  }\n  return newRow;\n}\n\nexport function messageSummary(msg: SavedMessage) {\n  // Regular text content\n  let result = \"```\\n\" + (msg.data.content ? escapeCodeBlock(msg.data.content) : \"<no text content>\") + \"```\";\n\n  // Rich embed\n  const richEmbed = (msg.data.embeds || []).find((e) => (e as EmbedData).type === EmbedType.Rich);\n  if (richEmbed) result += \"Embed:```\" + escapeCodeBlock(JSON.stringify(richEmbed)) + \"```\";\n\n  // Attachments\n  if (msg.data.attachments && msg.data.attachments.length) {\n    result +=\n      \"Attachments:\\n\" +\n      msg.data.attachments.map((a: ISavedMessageAttachmentData) => disableLinkPreviews(a.url)).join(\"\\n\") +\n      \"\\n\";\n  }\n\n  return result;\n}\n\nexport function verboseUserMention(user: User | UnknownUser): string {\n  if (user.id == null) {\n    return `**${renderUsername(user.username, user.discriminator)}**`;\n  }\n\n  return `<@!${user.id}> (**${renderUsername(user.username, user.discriminator)}**, \\`${user.id}\\`)`;\n}\n\nexport function verboseUserName(user: User | UnknownUser): string {\n  if (user.id == null) {\n    return `**${renderUsername(user.username, user.discriminator)}**`;\n  }\n\n  return `**${renderUsername(user.username, user.discriminator)}** (\\`${user.id}\\`)`;\n}\n\nexport function verboseChannelMention(channel: GuildBasedChannel): string {\n  const plainTextName =\n    channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice\n      ? channel.name\n      : `#${channel.name}`;\n  return `<#${channel.id}> (**${plainTextName}**, \\`${channel.id}\\`)`;\n}\n\nexport function messageLink(message: Message): string;\nexport function messageLink(guildId: string, channelId: string, messageId: string): string;\nexport function messageLink(guildIdOrMessage: string | Message | null, channelId?: string, messageId?: string): string {\n  let guildId;\n  if (guildIdOrMessage == null) {\n    // Full arguments without a guild id -> DM/Group chat\n    guildId = \"@me\";\n  } else if (guildIdOrMessage instanceof Message) {\n    // Message object as the only argument\n    guildId = (guildIdOrMessage.channel as GuildChannel).guild?.id ?? \"@me\";\n    channelId = guildIdOrMessage.channel.id;\n    messageId = guildIdOrMessage.id;\n  } else {\n    // Full arguments with all IDs\n    guildId = guildIdOrMessage;\n  }\n\n  return `https://discord.com/channels/${guildId}/${channelId}/${messageId}`;\n}\n\nexport function isValidEmbed(embed: any): boolean {\n  return zEmbedInput.safeParse(embed).success;\n}\n\nconst formatter = new Intl.NumberFormat(\"en-US\");\nexport function formatNumber(numberToFormat: number): string {\n  return formatter.format(numberToFormat);\n}\n\ninterface IMemoizedItem {\n  createdAt: number;\n  value: any;\n}\n\nconst memoizeCache: Map<any, IMemoizedItem> = new Map();\nexport function memoize<T>(fn: () => T, key?, time?): T {\n  const realKey = key ?? fn;\n\n  if (memoizeCache.has(realKey)) {\n    const memoizedItem = memoizeCache.get(realKey)!;\n    if (!time || memoizedItem.createdAt > Date.now() - time) {\n      return memoizedItem.value;\n    }\n\n    memoizeCache.delete(realKey);\n  }\n\n  const value = fn();\n  memoizeCache.set(realKey, {\n    createdAt: Date.now(),\n    value,\n  });\n\n  return value;\n}\n\nexport function lazyMemoize<T extends () => unknown>(fn: T, key?: string, time?: number): T {\n  return (() => {\n    return memoize(fn, key, time);\n  }) as T;\n}\n\ntype RecursiveRenderFn = (str: string) => string | Promise<string>;\n\nexport async function renderRecursively(value, fn: RecursiveRenderFn) {\n  if (Array.isArray(value)) {\n    const result: any[] = [];\n    for (const item of value) {\n      result.push(await renderRecursively(item, fn));\n    }\n    return result;\n  } else if (value === null) {\n    return null;\n  } else if (typeof value === \"object\") {\n    const result = {};\n    for (const [prop, _value] of Object.entries(value)) {\n      result[prop] = await renderRecursively(_value, fn);\n    }\n    return result;\n  } else if (typeof value === \"string\") {\n    return fn(value);\n  }\n\n  return value;\n}\n\nexport function isValidEmoji(emoji: string): boolean {\n  return isUnicodeEmoji(emoji) || isSnowflake(emoji);\n}\n\nexport function canUseEmoji(client: Client, emoji: string): boolean {\n  if (isUnicodeEmoji(emoji)) {\n    return true;\n  } else if (isSnowflake(emoji)) {\n    for (const guild of client.guilds.cache) {\n      if (guild[1].emojis.cache.some((e) => (e as any).id === emoji)) {\n        return true;\n      }\n    }\n  } else {\n    throw new Error(`Invalid emoji ${emoji}`);\n  }\n\n  return false;\n}\n\n/**\n * Trims any empty lines from the beginning and end of the given string\n * and indents matching the first line's indent\n */\nexport function trimMultilineString(str) {\n  const emptyLinesTrimmed = trimEmptyStartEndLines(str);\n  const lines = emptyLinesTrimmed.split(\"\\n\");\n  const firstLineIndentation = (lines[0].match(/^ +/g) || [\"\"])[0].length;\n  return trimIndents(emptyLinesTrimmed, firstLineIndentation);\n}\nexport const trimPluginDescription = trimMultilineString;\n\nexport function isFullMessage(msg: Message | PartialMessage): msg is Message {\n  return (msg as Message).createdAt != null;\n}\n\nexport function isGuildInvite(invite: Invite): invite is GuildInvite {\n  return invite.type === InviteType.Guild;\n}\n\nexport function isGroupDMInvite(invite: Invite): invite is GroupDMInvite {\n  return invite.type === InviteType.GroupDM;\n}\n\nexport function inviteHasCounts(invite: Invite): invite is Invite & { memberCount: number; presenceCount: number } {\n  return invite.memberCount != null;\n}\n\nexport function asyncMap<T, R>(arr: T[], fn: (item: T) => Promise<R>): Promise<R[]> {\n  return Promise.all(arr.map((item) => fn(item)));\n}\n\nexport function unique<T>(arr: T[]): T[] {\n  return Array.from(new Set(arr));\n}\n\n// From https://github.com/microsoft/TypeScript/pull/29955#issuecomment-470062531\nexport function isTruthy<T>(value: T): value is Exclude<T, false | null | undefined | \"\" | 0> {\n  return Boolean(value);\n}\n\nexport const DBDateFormat = \"YYYY-MM-DD HH:mm:ss\";\n\nexport function renderUsername(memberOrUser: GuildMember | UnknownUser | User): string;\nexport function renderUsername(username: string, discriminator: string): string;\nexport function renderUsername(username: string | User | GuildMember | UnknownUser, discriminator?: string): string {\n  if (username instanceof GuildMember) return username.user.tag;\n  if (username instanceof User || username instanceof UnknownUser) return username.tag;\n  if (discriminator === \"0\") {\n    return username;\n  }\n  return `${username}#${discriminator}`;\n}\n\nexport function renderUserUsername(user: User | UnknownUser): string {\n  return renderUsername(user.username, user.discriminator);\n}\n\ntype Entries<T> = Array<\n  {\n    [Key in keyof T]-?: [Key, T[Key]];\n  }[keyof T]\n>;\n\nexport function entries<T extends object>(object: T) {\n  return Object.entries(object) as Entries<T>;\n}\n\nexport function keys<T extends object>(object: T) {\n  return Object.keys(object) as Array<keyof T>;\n}\n\nexport function values<T extends object>(object: T) {\n  return Object.values(object) as Array<T[keyof T]>;\n}\n"
  },
  {
    "path": "backend/src/validateActiveConfigs.ts",
    "content": "import { YAMLException } from \"js-yaml\";\nimport { validateGuildConfig } from \"./configValidator.js\";\nimport { Configs } from \"./data/Configs.js\";\nimport { connect, disconnect } from \"./data/db.js\";\nimport { loadYamlSafely } from \"./utils/loadYamlSafely.js\";\nimport { ObjectAliasError } from \"./utils/validateNoObjectAliases.js\";\n\nfunction writeError(key: string, error: string) {\n  const indented = error\n    .split(\"\\n\")\n    .map((s) => \" \".repeat(64) + s)\n    .join(\"\\n\");\n  const prefix = `Invalid config ${key}:`;\n  const prefixed = prefix + indented.slice(prefix.length);\n  console.log(prefixed + \"\\n\\n\");\n}\n\nconnect().then(async () => {\n  const configs = new Configs();\n  const activeConfigs = await configs.getActive();\n  for (const config of activeConfigs) {\n    if (config.key === \"global\") {\n      continue;\n    }\n\n    let parsed: unknown;\n    try {\n      parsed = loadYamlSafely(config.config);\n    } catch (err) {\n      if (err instanceof ObjectAliasError) {\n        writeError(config.key, err.message);\n        continue;\n      }\n      if (err instanceof YAMLException) {\n        writeError(config.key, `invalid YAML: ${err.message}`);\n        continue;\n      }\n      throw err;\n    }\n\n    const errors = await validateGuildConfig(parsed);\n    if (errors) {\n      writeError(config.key, errors);\n    }\n  }\n\n  await disconnect();\n  process.exit(0);\n});\n"
  },
  {
    "path": "backend/start-dev.js",
    "content": "/**\n * This file starts the bot and api processes in tandem.\n * Used with tsc-watch for restarting on watch.\n */\n\nimport childProcess from \"node:child_process\";\n\nchildProcess.spawn(\"pnpm\", [\"run\", \"start-bot-dev\"], {\n  stdio: [process.stdin, process.stdout, process.stderr],\n});\n\nchildProcess.spawn(\"pnpm\", [\"run\", \"start-api-dev\"], {\n  stdio: [process.stdin, process.stdout, process.stderr],\n});\n"
  },
  {
    "path": "backend/tsconfig.json",
    "content": "{\n  \"extends\": \"../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"moduleResolution\": \"NodeNext\",\n    \"module\": \"NodeNext\",\n    \"baseUrl\": \"./src\",\n    \"rootDir\": \"./src\",\n    \"outDir\": \"./dist\",\n    \"composite\": true\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.json\"],\n  \"references\": [\n    {\n      \"path\": \"../shared/tsconfig.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "build-image.sh",
    "content": "docker build \\\n  -t dragory/zeppelin \\\n  --build-arg COMMIT_HASH=$(git rev-parse HEAD) \\\n  --build-arg BUILD_TIME=$(date -u +\"%Y-%m-%dT%H:%M:%SZ\") \\\n  .\n"
  },
  {
    "path": "config-checker/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "config-checker/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"darkreader-lock\">\n    <link rel=\"stylesheet\" href=\"/src/style.css\">\n    <title>Zeppelin config checker</title>\n  </head>\n  <body>\n    <div class=\"wrap\">\n      <div class=\"section\" style=\"flex: 1 1 auto\">\n        <div class=\"title\">\n          <h1>Config</h1>\n        </div>\n        <div class=\"content\">\n          <div class=\"editor-wrap\">\n            <div id=\"editor\"></div>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"section\" style=\"flex: 0 0 min(300px, 40vh)\">\n        <div class=\"title\">\n          <h1>Errors</h1>\n        </div>\n        <div class=\"content\">\n          <div class=\"errors-wrap\">\n            <div id=\"errors\"></div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "config-checker/package.json",
    "content": "{\n  \"name\": \"config-checker\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite --host 127.0.0.1\",\n    \"build\": \"tsc && vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"devDependencies\": {\n    \"typescript\": \"~5.8.3\",\n    \"vite\": \"^6.3.5\"\n  },\n  \"dependencies\": {\n    \"monaco-editor\": \"^0.52.2\",\n    \"monaco-yaml\": \"^5.4.0\"\n  }\n}\n"
  },
  {
    "path": "config-checker/public/config-schema.json",
    "content": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"prefix\": {\n      \"type\": \"string\"\n    },\n    \"levels\": {\n      \"type\": \"object\",\n      \"propertyNames\": {\n        \"type\": \"string\"\n      },\n      \"additionalProperties\": {\n        \"type\": \"number\"\n      }\n    },\n    \"plugins\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"auto_delete\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"enabled\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"delay\": {\n                  \"default\": \"5s\",\n                  \"type\": \"string\",\n                  \"maxLength\": 32\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"enabled\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"delay\": {\n                        \"default\": \"5s\",\n                        \"type\": \"string\",\n                        \"maxLength\": 32\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"automod\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"rules\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"propertyNames\": {\n                    \"type\": \"string\",\n                    \"maxLength\": 100\n                  },\n                  \"additionalProperties\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"enabled\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"pretty_name\": {\n                        \"type\": \"string\"\n                      },\n                      \"presets\": {\n                        \"default\": [],\n                        \"maxItems\": 25,\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"string\",\n                          \"maxLength\": 100\n                        }\n                      },\n                      \"affects_bots\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"affects_self\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"cooldown\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\",\n                            \"maxLength\": 32\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"allow_further_rules\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"triggers\": {\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"any_message\": {\n                              \"type\": \"object\",\n                              \"properties\": {},\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"match_words\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"words\": {\n                                  \"maxItems\": 1024,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 2000\n                                  }\n                                },\n                                \"case_sensitive\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"only_full_words\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"normalize\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"loose_matching\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"loose_matching_threshold\": {\n                                  \"default\": 1,\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"strip_markdown\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_messages\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_embeds\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_visible_names\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_usernames\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_nicknames\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_custom_status\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [\n                                \"words\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"match_regex\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"patterns\": {\n                                  \"maxItems\": 512,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 2000\n                                  }\n                                },\n                                \"case_sensitive\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"normalize\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"strip_markdown\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_messages\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_embeds\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_visible_names\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_usernames\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_nicknames\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_custom_status\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [\n                                \"patterns\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"match_invites\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"include_guilds\": {\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                \"exclude_guilds\": {\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                \"include_invite_codes\": {\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  }\n                                },\n                                \"exclude_invite_codes\": {\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  }\n                                },\n                                \"include_custom_invite_codes\": {\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  }\n                                },\n                                \"exclude_custom_invite_codes\": {\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  }\n                                },\n                                \"allow_group_dm_invites\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_messages\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_embeds\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_visible_names\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_usernames\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_nicknames\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_custom_status\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"match_links\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"include_domains\": {\n                                  \"maxItems\": 700,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 255\n                                  }\n                                },\n                                \"exclude_domains\": {\n                                  \"maxItems\": 700,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 255\n                                  }\n                                },\n                                \"include_subdomains\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"include_words\": {\n                                  \"maxItems\": 700,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 2000\n                                  }\n                                },\n                                \"exclude_words\": {\n                                  \"maxItems\": 700,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 2000\n                                  }\n                                },\n                                \"include_regex\": {\n                                  \"maxItems\": 512,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 2000\n                                  }\n                                },\n                                \"exclude_regex\": {\n                                  \"maxItems\": 512,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 2000\n                                  }\n                                },\n                                \"phisherman\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"include_suspected\": {\n                                      \"type\": \"boolean\"\n                                    },\n                                    \"include_verified\": {\n                                      \"type\": \"boolean\"\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"include_malicious\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"only_real_links\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_messages\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_embeds\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_visible_names\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_usernames\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_nicknames\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"match_custom_status\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"match_attachment_type\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"whitelist_enabled\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"filetype_whitelist\": {\n                                  \"default\": [],\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  }\n                                },\n                                \"blacklist_enabled\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"filetype_blacklist\": {\n                                  \"default\": [],\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  }\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"match_mime_type\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"whitelist_enabled\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"mime_type_whitelist\": {\n                                  \"default\": [],\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  }\n                                },\n                                \"blacklist_enabled\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"mime_type_blacklist\": {\n                                  \"default\": [],\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  }\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"member_join\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"only_new\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"new_threshold\": {\n                                  \"default\": \"1h\",\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"member_leave\": {\n                              \"type\": \"object\",\n                              \"properties\": {},\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"role_added\": {\n                              \"default\": [],\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"string\"\n                                },\n                                {\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                }\n                              ]\n                            },\n                            \"role_removed\": {\n                              \"default\": [],\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"string\"\n                                },\n                                {\n                                  \"maxItems\": 255,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                }\n                              ]\n                            },\n                            \"message_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                },\n                                \"per_channel\": {\n                                  \"default\": false,\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"boolean\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"mention_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                },\n                                \"per_channel\": {\n                                  \"default\": false,\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"boolean\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"link_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                },\n                                \"per_channel\": {\n                                  \"default\": false,\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"boolean\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"attachment_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                },\n                                \"per_channel\": {\n                                  \"default\": false,\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"boolean\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"emoji_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                },\n                                \"per_channel\": {\n                                  \"default\": false,\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"boolean\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"line_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                },\n                                \"per_channel\": {\n                                  \"default\": false,\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"boolean\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"character_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                },\n                                \"per_channel\": {\n                                  \"default\": false,\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"boolean\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"member_join_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"sticker_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                },\n                                \"per_channel\": {\n                                  \"default\": false,\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"boolean\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"thread_create_spam\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"amount\": {\n                                  \"type\": \"integer\",\n                                  \"minimum\": -9007199254740991,\n                                  \"maximum\": 9007199254740991\n                                },\n                                \"within\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                }\n                              },\n                              \"required\": [\n                                \"amount\",\n                                \"within\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"counter_trigger\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"counter\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 100\n                                },\n                                \"trigger\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 100\n                                },\n                                \"reverse\": {\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [\n                                \"counter\",\n                                \"trigger\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"note\": {\n                              \"type\": \"object\",\n                              \"properties\": {},\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"warn\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"manual\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"automatic\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"mute\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"manual\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"automatic\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"unmute\": {\n                              \"type\": \"object\",\n                              \"properties\": {},\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"kick\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"manual\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"automatic\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"ban\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"manual\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"automatic\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"unban\": {\n                              \"type\": \"object\",\n                              \"properties\": {},\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"antiraid_level\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"level\": {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\",\n                                      \"maxLength\": 100\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                },\n                                \"only_on_change\": {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"boolean\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"level\",\n                                \"only_on_change\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            \"thread_create\": {\n                              \"type\": \"object\",\n                              \"properties\": {},\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"thread_delete\": {\n                              \"type\": \"object\",\n                              \"properties\": {},\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"thread_archive\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"locked\": {\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"thread_unarchive\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"locked\": {\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      },\n                      \"actions\": {\n                        \"type\": \"object\",\n                        \"properties\": {\n                          \"clean\": {\n                            \"default\": false,\n                            \"type\": \"boolean\"\n                          },\n                          \"warn\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"reason\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"notify\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"anyOf\": [\n                                      {\n                                        \"const\": \"dm\"\n                                      },\n                                      {\n                                        \"const\": \"channel\"\n                                      }\n                                    ]\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"notifyChannel\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"postInCaseLog\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"hide_case\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          \"mute\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"reason\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"duration\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"notify\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"anyOf\": [\n                                      {\n                                        \"const\": \"dm\"\n                                      },\n                                      {\n                                        \"const\": \"channel\"\n                                      }\n                                    ]\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"notifyChannel\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\"\n                                        }\n                                      }\n                                    ]\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\"\n                                        }\n                                      }\n                                    ]\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"postInCaseLog\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"hide_case\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          \"kick\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"reason\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"notify\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"anyOf\": [\n                                      {\n                                        \"const\": \"dm\"\n                                      },\n                                      {\n                                        \"const\": \"channel\"\n                                      }\n                                    ]\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"notifyChannel\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"postInCaseLog\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"hide_case\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          \"ban\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"reason\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"duration\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"notify\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"anyOf\": [\n                                      {\n                                        \"const\": \"dm\"\n                                      },\n                                      {\n                                        \"const\": \"channel\"\n                                      }\n                                    ]\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"notifyChannel\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"deleteMessageDays\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"postInCaseLog\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"hide_case\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          \"alert\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"channel\": {\n                                \"type\": \"string\"\n                              },\n                              \"text\": {\n                                \"type\": \"string\"\n                              },\n                              \"allowed_mentions\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"everyone\": {\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      },\n                                      \"users\": {\n                                        \"anyOf\": [\n                                          {\n                                            \"anyOf\": [\n                                              {\n                                                \"type\": \"boolean\"\n                                              },\n                                              {\n                                                \"type\": \"array\",\n                                                \"items\": {\n                                                  \"type\": \"string\"\n                                                }\n                                              }\n                                            ]\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      },\n                                      \"roles\": {\n                                        \"anyOf\": [\n                                          {\n                                            \"anyOf\": [\n                                              {\n                                                \"type\": \"boolean\"\n                                              },\n                                              {\n                                                \"type\": \"array\",\n                                                \"items\": {\n                                                  \"type\": \"string\"\n                                                }\n                                              }\n                                            ]\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      },\n                                      \"replied_user\": {\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"required\": [\n                              \"channel\",\n                              \"text\"\n                            ]\n                          },\n                          \"change_nickname\": {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                  \"name\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                \"required\": [\n                                  \"name\"\n                                ],\n                                \"additionalProperties\": false\n                              }\n                            ]\n                          },\n                          \"log\": {\n                            \"default\": true,\n                            \"type\": \"boolean\"\n                          },\n                          \"add_roles\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          \"remove_roles\": {\n                            \"default\": [],\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          \"set_antiraid_level\": {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"null\"\n                              }\n                            ]\n                          },\n                          \"reply\": {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                  \"text\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"string\"\n                                      },\n                                      {\n                                        \"$ref\": \"#/$defs/strictMessageContent\"\n                                      }\n                                    ]\n                                  },\n                                  \"auto_delete\": {\n                                    \"default\": null,\n                                    \"anyOf\": [\n                                      {\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"string\",\n                                            \"maxLength\": 32\n                                          },\n                                          {\n                                            \"type\": \"number\"\n                                          }\n                                        ]\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"inline\": {\n                                    \"default\": false,\n                                    \"type\": \"boolean\"\n                                  }\n                                },\n                                \"required\": [\n                                  \"text\"\n                                ],\n                                \"additionalProperties\": false\n                              }\n                            ]\n                          },\n                          \"add_to_counter\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"counter\": {\n                                \"type\": \"string\"\n                              },\n                              \"amount\": {\n                                \"type\": \"number\"\n                              }\n                            },\n                            \"required\": [\n                              \"counter\",\n                              \"amount\"\n                            ]\n                          },\n                          \"set_counter\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"counter\": {\n                                \"type\": \"string\"\n                              },\n                              \"value\": {\n                                \"type\": \"number\",\n                                \"minimum\": 0,\n                                \"maximum\": 2147483647\n                              }\n                            },\n                            \"required\": [\n                              \"counter\",\n                              \"value\"\n                            ],\n                            \"additionalProperties\": false\n                          },\n                          \"set_slowmode\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"channels\": {\n                                \"default\": [],\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"duration\": {\n                                \"default\": \"10s\",\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          \"start_thread\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"name\": {\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"auto_archive\": {\n                                \"type\": \"string\",\n                                \"maxLength\": 32\n                              },\n                              \"private\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"slowmode\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\",\n                                    \"maxLength\": 32\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"limit_per_channel\": {\n                                \"default\": 5,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"required\": [\n                              \"name\",\n                              \"auto_archive\"\n                            ],\n                            \"additionalProperties\": false\n                          },\n                          \"archive_thread\": {\n                            \"type\": \"object\",\n                            \"properties\": {},\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          \"change_perms\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"target\": {\n                                \"type\": \"string\"\n                              },\n                              \"channel\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"perms\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                  \"CreateInstantInvite\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"KickMembers\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"BanMembers\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"Administrator\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageChannels\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageGuild\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"AddReactions\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ViewAuditLog\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"PrioritySpeaker\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"Stream\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ViewChannel\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"SendMessages\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"SendTTSMessages\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageMessages\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"EmbedLinks\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"AttachFiles\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ReadMessageHistory\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MentionEveryone\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"UseExternalEmojis\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ViewGuildInsights\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"Connect\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"Speak\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MuteMembers\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"DeafenMembers\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MoveMembers\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"UseVAD\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ChangeNickname\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageNicknames\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageRoles\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageWebhooks\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageEmojisAndStickers\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageGuildExpressions\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"UseApplicationCommands\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"RequestToSpeak\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageEvents\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ManageThreads\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"CreatePublicThreads\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"CreatePrivateThreads\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"UseExternalStickers\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"SendMessagesInThreads\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"UseEmbeddedActivities\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ModerateMembers\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ViewCreatorMonetizationAnalytics\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"UseSoundboard\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"CreateGuildExpressions\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"CreateEvents\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"UseExternalSounds\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"SendVoiceMessages\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"SendPolls\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"UseExternalApps\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"CREATE_INSTANT_INVITE\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"KICK_MEMBERS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"BAN_MEMBERS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ADMINISTRATOR\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MANAGE_CHANNELS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MANAGE_GUILD\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ADD_REACTIONS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"VIEW_AUDIT_LOG\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"PRIORITY_SPEAKER\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"STREAM\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"VIEW_CHANNEL\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"SEND_MESSAGES\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"SEND_TTSMESSAGES\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MANAGE_MESSAGES\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"EMBED_LINKS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"ATTACH_FILES\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"READ_MESSAGE_HISTORY\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MENTION_EVERYONE\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"USE_EXTERNAL_EMOJIS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"VIEW_GUILD_INSIGHTS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"CONNECT\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"SPEAK\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MUTE_MEMBERS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"DEAFEN_MEMBERS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MOVE_MEMBERS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"USE_VAD\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"CHANGE_NICKNAME\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MANAGE_NICKNAMES\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MANAGE_ROLES\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MANAGE_WEBHOOKS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MANAGE_EMOJIS_AND_STICKERS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"USE_APPLICATION_COMMANDS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"REQUEST_TO_SPEAK\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MANAGE_EVENTS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MANAGE_THREADS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"CREATE_PUBLIC_THREADS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"CREATE_PRIVATE_THREADS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"USE_EXTERNAL_STICKERS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"SEND_MESSAGES_IN_THREADS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"USE_EMBEDDED_ACTIVITIES\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"MODERATE_MEMBERS\": {\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"boolean\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  }\n                                },\n                                \"required\": [],\n                                \"additionalProperties\": false\n                              }\n                            },\n                            \"required\": [\n                              \"target\",\n                              \"perms\"\n                            ],\n                            \"additionalProperties\": false\n                          },\n                          \"pause_invites\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"paused\": {\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [\n                              \"paused\"\n                            ],\n                            \"additionalProperties\": false\n                          }\n                        },\n                        \"required\": [],\n                        \"additionalProperties\": false\n                      }\n                    },\n                    \"required\": [\n                      \"triggers\",\n                      \"actions\"\n                    ],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"antiraid_levels\": {\n                  \"default\": [\n                    \"low\",\n                    \"medium\",\n                    \"high\"\n                  ],\n                  \"maxItems\": 10,\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\",\n                    \"maxLength\": 100\n                  }\n                },\n                \"can_set_antiraid\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_view_antiraid\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"rules\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\",\n                          \"maxLength\": 100\n                        },\n                        \"additionalProperties\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"enabled\": {\n                              \"default\": true,\n                              \"type\": \"boolean\"\n                            },\n                            \"pretty_name\": {\n                              \"type\": \"string\"\n                            },\n                            \"presets\": {\n                              \"default\": [],\n                              \"maxItems\": 25,\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\",\n                                \"maxLength\": 100\n                              }\n                            },\n                            \"affects_bots\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"affects_self\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"cooldown\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"allow_further_rules\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"triggers\": {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                  \"any_message\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {},\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"match_words\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"words\": {\n                                        \"maxItems\": 1024,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 2000\n                                        }\n                                      },\n                                      \"case_sensitive\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"only_full_words\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"normalize\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"loose_matching\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"loose_matching_threshold\": {\n                                        \"default\": 1,\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"strip_markdown\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_messages\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_embeds\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_visible_names\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_usernames\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_nicknames\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_custom_status\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"match_regex\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"patterns\": {\n                                        \"maxItems\": 512,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 2000\n                                        }\n                                      },\n                                      \"case_sensitive\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"normalize\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"strip_markdown\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_messages\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_embeds\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_visible_names\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_usernames\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_nicknames\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_custom_status\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"match_invites\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"include_guilds\": {\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\"\n                                        }\n                                      },\n                                      \"exclude_guilds\": {\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\"\n                                        }\n                                      },\n                                      \"include_invite_codes\": {\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        }\n                                      },\n                                      \"exclude_invite_codes\": {\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        }\n                                      },\n                                      \"include_custom_invite_codes\": {\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        }\n                                      },\n                                      \"exclude_custom_invite_codes\": {\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        }\n                                      },\n                                      \"allow_group_dm_invites\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_messages\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_embeds\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_visible_names\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_usernames\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_nicknames\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_custom_status\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"match_links\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"include_domains\": {\n                                        \"maxItems\": 700,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 255\n                                        }\n                                      },\n                                      \"exclude_domains\": {\n                                        \"maxItems\": 700,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 255\n                                        }\n                                      },\n                                      \"include_subdomains\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"include_words\": {\n                                        \"maxItems\": 700,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 2000\n                                        }\n                                      },\n                                      \"exclude_words\": {\n                                        \"maxItems\": 700,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 2000\n                                        }\n                                      },\n                                      \"include_regex\": {\n                                        \"maxItems\": 512,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 2000\n                                        }\n                                      },\n                                      \"exclude_regex\": {\n                                        \"maxItems\": 512,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 2000\n                                        }\n                                      },\n                                      \"phisherman\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                          \"include_suspected\": {\n                                            \"type\": \"boolean\"\n                                          },\n                                          \"include_verified\": {\n                                            \"type\": \"boolean\"\n                                          }\n                                        },\n                                        \"required\": [],\n                                        \"additionalProperties\": false\n                                      },\n                                      \"include_malicious\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"only_real_links\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_messages\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_embeds\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_visible_names\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_usernames\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_nicknames\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"match_custom_status\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"match_attachment_type\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"whitelist_enabled\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"filetype_whitelist\": {\n                                        \"default\": [],\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        }\n                                      },\n                                      \"blacklist_enabled\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"filetype_blacklist\": {\n                                        \"default\": [],\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        }\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"match_mime_type\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"whitelist_enabled\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"mime_type_whitelist\": {\n                                        \"default\": [],\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        }\n                                      },\n                                      \"blacklist_enabled\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"mime_type_blacklist\": {\n                                        \"default\": [],\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        }\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"member_join\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"only_new\": {\n                                        \"default\": false,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"new_threshold\": {\n                                        \"default\": \"1h\",\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"member_leave\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {},\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"role_added\": {\n                                    \"default\": [],\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"string\"\n                                      },\n                                      {\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\"\n                                        }\n                                      }\n                                    ]\n                                  },\n                                  \"role_removed\": {\n                                    \"default\": [],\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"string\"\n                                      },\n                                      {\n                                        \"maxItems\": 255,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"string\"\n                                        }\n                                      }\n                                    ]\n                                  },\n                                  \"message_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      },\n                                      \"per_channel\": {\n                                        \"default\": false,\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"mention_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      },\n                                      \"per_channel\": {\n                                        \"default\": false,\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"link_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      },\n                                      \"per_channel\": {\n                                        \"default\": false,\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"attachment_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      },\n                                      \"per_channel\": {\n                                        \"default\": false,\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"emoji_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      },\n                                      \"per_channel\": {\n                                        \"default\": false,\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"line_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      },\n                                      \"per_channel\": {\n                                        \"default\": false,\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"character_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      },\n                                      \"per_channel\": {\n                                        \"default\": false,\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"member_join_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"sticker_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      },\n                                      \"per_channel\": {\n                                        \"default\": false,\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"thread_create_spam\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"amount\": {\n                                        \"type\": \"integer\",\n                                        \"minimum\": -9007199254740991,\n                                        \"maximum\": 9007199254740991\n                                      },\n                                      \"within\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"counter_trigger\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"counter\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 100\n                                      },\n                                      \"trigger\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 100\n                                      },\n                                      \"reverse\": {\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"note\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {},\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"warn\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"manual\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"automatic\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"mute\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"manual\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"automatic\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"unmute\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {},\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"kick\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"manual\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"automatic\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"ban\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"manual\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      },\n                                      \"automatic\": {\n                                        \"default\": true,\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"unban\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {},\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"antiraid_level\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"level\": {\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"string\",\n                                            \"maxLength\": 100\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      },\n                                      \"only_on_change\": {\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"boolean\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"thread_create\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {},\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"thread_delete\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {},\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"thread_archive\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"locked\": {\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  \"thread_unarchive\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"locked\": {\n                                        \"type\": \"boolean\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  }\n                                },\n                                \"required\": [],\n                                \"additionalProperties\": false\n                              }\n                            },\n                            \"actions\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"clean\": {\n                                  \"default\": false,\n                                  \"type\": \"boolean\"\n                                },\n                                \"warn\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"reason\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"notify\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"anyOf\": [\n                                            {\n                                              \"const\": \"dm\"\n                                            },\n                                            {\n                                              \"const\": \"channel\"\n                                            }\n                                          ]\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"notifyChannel\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"postInCaseLog\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"boolean\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"hide_case\": {\n                                      \"default\": false,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"boolean\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"mute\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"reason\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"duration\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"notify\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"anyOf\": [\n                                            {\n                                              \"const\": \"dm\"\n                                            },\n                                            {\n                                              \"const\": \"channel\"\n                                            }\n                                          ]\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"notifyChannel\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"remove_roles_on_mute\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"array\",\n                                              \"items\": {\n                                                \"type\": \"string\"\n                                              }\n                                            }\n                                          ]\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"restore_roles_on_mute\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"array\",\n                                              \"items\": {\n                                                \"type\": \"string\"\n                                              }\n                                            }\n                                          ]\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"postInCaseLog\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"boolean\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"hide_case\": {\n                                      \"default\": false,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"boolean\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"kick\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"reason\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"notify\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"anyOf\": [\n                                            {\n                                              \"const\": \"dm\"\n                                            },\n                                            {\n                                              \"const\": \"channel\"\n                                            }\n                                          ]\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"notifyChannel\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"postInCaseLog\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"boolean\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"hide_case\": {\n                                      \"default\": false,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"boolean\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"ban\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"reason\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"duration\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"notify\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"anyOf\": [\n                                            {\n                                              \"const\": \"dm\"\n                                            },\n                                            {\n                                              \"const\": \"channel\"\n                                            }\n                                          ]\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"notifyChannel\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"deleteMessageDays\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"number\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"postInCaseLog\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"boolean\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"hide_case\": {\n                                      \"default\": false,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"boolean\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"alert\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"channel\": {\n                                      \"type\": \"string\"\n                                    },\n                                    \"text\": {\n                                      \"type\": \"string\"\n                                    },\n                                    \"allowed_mentions\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"object\",\n                                          \"properties\": {\n                                            \"everyone\": {\n                                              \"anyOf\": [\n                                                {\n                                                  \"type\": \"boolean\"\n                                                },\n                                                {\n                                                  \"type\": \"null\"\n                                                }\n                                              ]\n                                            },\n                                            \"users\": {\n                                              \"anyOf\": [\n                                                {\n                                                  \"anyOf\": [\n                                                    {\n                                                      \"type\": \"boolean\"\n                                                    },\n                                                    {\n                                                      \"type\": \"array\",\n                                                      \"items\": {\n                                                        \"type\": \"string\"\n                                                      }\n                                                    }\n                                                  ]\n                                                },\n                                                {\n                                                  \"type\": \"null\"\n                                                }\n                                              ]\n                                            },\n                                            \"roles\": {\n                                              \"anyOf\": [\n                                                {\n                                                  \"anyOf\": [\n                                                    {\n                                                      \"type\": \"boolean\"\n                                                    },\n                                                    {\n                                                      \"type\": \"array\",\n                                                      \"items\": {\n                                                        \"type\": \"string\"\n                                                      }\n                                                    }\n                                                  ]\n                                                },\n                                                {\n                                                  \"type\": \"null\"\n                                                }\n                                              ]\n                                            },\n                                            \"replied_user\": {\n                                              \"anyOf\": [\n                                                {\n                                                  \"type\": \"boolean\"\n                                                },\n                                                {\n                                                  \"type\": \"null\"\n                                                }\n                                              ]\n                                            }\n                                          },\n                                          \"required\": [],\n                                          \"additionalProperties\": false\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    }\n                                  },\n                                  \"required\": []\n                                },\n                                \"change_nickname\": {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"type\": \"object\",\n                                      \"properties\": {\n                                        \"name\": {\n                                          \"type\": \"string\"\n                                        }\n                                      },\n                                      \"required\": [],\n                                      \"additionalProperties\": false\n                                    }\n                                  ]\n                                },\n                                \"log\": {\n                                  \"default\": true,\n                                  \"type\": \"boolean\"\n                                },\n                                \"add_roles\": {\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                \"remove_roles\": {\n                                  \"default\": [],\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                \"set_antiraid_level\": {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                },\n                                \"reply\": {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"type\": \"object\",\n                                      \"properties\": {\n                                        \"text\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"string\"\n                                            },\n                                            {\n                                              \"$ref\": \"#/$defs/strictMessageContent\"\n                                            }\n                                          ]\n                                        },\n                                        \"auto_delete\": {\n                                          \"default\": null,\n                                          \"anyOf\": [\n                                            {\n                                              \"anyOf\": [\n                                                {\n                                                  \"type\": \"string\",\n                                                  \"maxLength\": 32\n                                                },\n                                                {\n                                                  \"type\": \"number\"\n                                                }\n                                              ]\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"inline\": {\n                                          \"default\": false,\n                                          \"type\": \"boolean\"\n                                        }\n                                      },\n                                      \"required\": [],\n                                      \"additionalProperties\": false\n                                    }\n                                  ]\n                                },\n                                \"add_to_counter\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"counter\": {\n                                      \"type\": \"string\"\n                                    },\n                                    \"amount\": {\n                                      \"type\": \"number\"\n                                    }\n                                  },\n                                  \"required\": []\n                                },\n                                \"set_counter\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"counter\": {\n                                      \"type\": \"string\"\n                                    },\n                                    \"value\": {\n                                      \"type\": \"number\",\n                                      \"minimum\": 0,\n                                      \"maximum\": 2147483647\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"set_slowmode\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"channels\": {\n                                      \"default\": [],\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"array\",\n                                          \"items\": {\n                                            \"type\": \"string\"\n                                          }\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"duration\": {\n                                      \"default\": \"10s\",\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"start_thread\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"name\": {\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"auto_archive\": {\n                                      \"type\": \"string\",\n                                      \"maxLength\": 32\n                                    },\n                                    \"private\": {\n                                      \"default\": false,\n                                      \"type\": \"boolean\"\n                                    },\n                                    \"slowmode\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\",\n                                          \"maxLength\": 32\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"limit_per_channel\": {\n                                      \"default\": 5,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"number\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"archive_thread\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {},\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"change_perms\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"target\": {\n                                      \"type\": \"string\"\n                                    },\n                                    \"channel\": {\n                                      \"default\": null,\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"type\": \"null\"\n                                        }\n                                      ]\n                                    },\n                                    \"perms\": {\n                                      \"type\": \"object\",\n                                      \"properties\": {\n                                        \"CreateInstantInvite\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"KickMembers\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"BanMembers\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"Administrator\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageChannels\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageGuild\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"AddReactions\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ViewAuditLog\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"PrioritySpeaker\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"Stream\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ViewChannel\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"SendMessages\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"SendTTSMessages\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageMessages\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"EmbedLinks\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"AttachFiles\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ReadMessageHistory\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MentionEveryone\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"UseExternalEmojis\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ViewGuildInsights\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"Connect\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"Speak\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MuteMembers\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"DeafenMembers\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MoveMembers\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"UseVAD\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ChangeNickname\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageNicknames\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageRoles\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageWebhooks\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageEmojisAndStickers\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageGuildExpressions\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"UseApplicationCommands\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"RequestToSpeak\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageEvents\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ManageThreads\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"CreatePublicThreads\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"CreatePrivateThreads\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"UseExternalStickers\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"SendMessagesInThreads\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"UseEmbeddedActivities\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ModerateMembers\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ViewCreatorMonetizationAnalytics\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"UseSoundboard\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"CreateGuildExpressions\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"CreateEvents\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"UseExternalSounds\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"SendVoiceMessages\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"SendPolls\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"UseExternalApps\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"CREATE_INSTANT_INVITE\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"KICK_MEMBERS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"BAN_MEMBERS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ADMINISTRATOR\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MANAGE_CHANNELS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MANAGE_GUILD\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ADD_REACTIONS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"VIEW_AUDIT_LOG\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"PRIORITY_SPEAKER\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"STREAM\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"VIEW_CHANNEL\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"SEND_MESSAGES\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"SEND_TTSMESSAGES\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MANAGE_MESSAGES\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"EMBED_LINKS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"ATTACH_FILES\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"READ_MESSAGE_HISTORY\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MENTION_EVERYONE\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"USE_EXTERNAL_EMOJIS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"VIEW_GUILD_INSIGHTS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"CONNECT\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"SPEAK\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MUTE_MEMBERS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"DEAFEN_MEMBERS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MOVE_MEMBERS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"USE_VAD\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"CHANGE_NICKNAME\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MANAGE_NICKNAMES\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MANAGE_ROLES\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MANAGE_WEBHOOKS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MANAGE_EMOJIS_AND_STICKERS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"USE_APPLICATION_COMMANDS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"REQUEST_TO_SPEAK\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MANAGE_EVENTS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MANAGE_THREADS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"CREATE_PUBLIC_THREADS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"CREATE_PRIVATE_THREADS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"USE_EXTERNAL_STICKERS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"SEND_MESSAGES_IN_THREADS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"USE_EMBEDDED_ACTIVITIES\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        },\n                                        \"MODERATE_MEMBERS\": {\n                                          \"anyOf\": [\n                                            {\n                                              \"type\": \"boolean\"\n                                            },\n                                            {\n                                              \"type\": \"null\"\n                                            }\n                                          ]\n                                        }\n                                      },\n                                      \"required\": [],\n                                      \"additionalProperties\": false\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                \"pause_invites\": {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"paused\": {\n                                      \"type\": \"boolean\"\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      },\n                      \"antiraid_levels\": {\n                        \"default\": [\n                          \"low\",\n                          \"medium\",\n                          \"high\"\n                        ],\n                        \"maxItems\": 10,\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"string\",\n                          \"maxLength\": 100\n                        }\n                      },\n                      \"can_set_antiraid\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_view_antiraid\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"auto_reactions\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_manage\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_manage\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"cases\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"log_automatic_actions\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                },\n                \"case_log_channel\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"show_relative_times\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                },\n                \"relative_time_cutoff\": {\n                  \"default\": \"1w\",\n                  \"type\": \"string\",\n                  \"maxLength\": 32\n                },\n                \"case_colors\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"ban\": {\n                          \"type\": \"string\"\n                        },\n                        \"unban\": {\n                          \"type\": \"string\"\n                        },\n                        \"note\": {\n                          \"type\": \"string\"\n                        },\n                        \"warn\": {\n                          \"type\": \"string\"\n                        },\n                        \"kick\": {\n                          \"type\": \"string\"\n                        },\n                        \"mute\": {\n                          \"type\": \"string\"\n                        },\n                        \"unmute\": {\n                          \"type\": \"string\"\n                        },\n                        \"deleted\": {\n                          \"type\": \"string\"\n                        },\n                        \"softban\": {\n                          \"type\": \"string\"\n                        }\n                      },\n                      \"required\": [],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"case_icons\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"ban\": {\n                          \"type\": \"string\"\n                        },\n                        \"unban\": {\n                          \"type\": \"string\"\n                        },\n                        \"note\": {\n                          \"type\": \"string\"\n                        },\n                        \"warn\": {\n                          \"type\": \"string\"\n                        },\n                        \"kick\": {\n                          \"type\": \"string\"\n                        },\n                        \"mute\": {\n                          \"type\": \"string\"\n                        },\n                        \"unmute\": {\n                          \"type\": \"string\"\n                        },\n                        \"deleted\": {\n                          \"type\": \"string\"\n                        },\n                        \"softban\": {\n                          \"type\": \"string\"\n                        }\n                      },\n                      \"required\": [],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"log_automatic_actions\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"case_log_channel\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"show_relative_times\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"relative_time_cutoff\": {\n                        \"default\": \"1w\",\n                        \"type\": \"string\",\n                        \"maxLength\": 32\n                      },\n                      \"case_colors\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"ban\": {\n                                \"type\": \"string\"\n                              },\n                              \"unban\": {\n                                \"type\": \"string\"\n                              },\n                              \"note\": {\n                                \"type\": \"string\"\n                              },\n                              \"warn\": {\n                                \"type\": \"string\"\n                              },\n                              \"kick\": {\n                                \"type\": \"string\"\n                              },\n                              \"mute\": {\n                                \"type\": \"string\"\n                              },\n                              \"unmute\": {\n                                \"type\": \"string\"\n                              },\n                              \"deleted\": {\n                                \"type\": \"string\"\n                              },\n                              \"softban\": {\n                                \"type\": \"string\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"case_icons\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"ban\": {\n                                \"type\": \"string\"\n                              },\n                              \"unban\": {\n                                \"type\": \"string\"\n                              },\n                              \"note\": {\n                                \"type\": \"string\"\n                              },\n                              \"warn\": {\n                                \"type\": \"string\"\n                              },\n                              \"kick\": {\n                                \"type\": \"string\"\n                              },\n                              \"mute\": {\n                                \"type\": \"string\"\n                              },\n                              \"unmute\": {\n                                \"type\": \"string\"\n                              },\n                              \"deleted\": {\n                                \"type\": \"string\"\n                              },\n                              \"softban\": {\n                                \"type\": \"string\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"censor\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"filter_zalgo\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"filter_invites\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"invite_guild_whitelist\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"invite_guild_blacklist\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"invite_code_whitelist\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"invite_code_blacklist\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"allow_group_dm_invites\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"filter_domains\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"domain_whitelist\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"domain_blacklist\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"blocked_tokens\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"blocked_words\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"blocked_regex\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\",\n                        \"maxLength\": 1000\n                      }\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"filter_zalgo\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"filter_invites\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"invite_guild_whitelist\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"invite_guild_blacklist\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"invite_code_whitelist\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"invite_code_blacklist\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"allow_group_dm_invites\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"filter_domains\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"domain_whitelist\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"domain_blacklist\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"blocked_tokens\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"blocked_words\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"blocked_regex\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\",\n                              \"maxLength\": 1000\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"companion_channels\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"entries\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"propertyNames\": {\n                    \"type\": \"string\"\n                  },\n                  \"additionalProperties\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"voice_channel_ids\": {\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"string\"\n                        }\n                      },\n                      \"text_channel_ids\": {\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"string\"\n                        }\n                      },\n                      \"permissions\": {\n                        \"type\": \"number\"\n                      },\n                      \"enabled\": {\n                        \"default\": true,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [\n                      \"voice_channel_ids\",\n                      \"text_channel_ids\",\n                      \"permissions\"\n                    ],\n                    \"additionalProperties\": false\n                  }\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"entries\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"voice_channel_ids\": {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            },\n                            \"text_channel_ids\": {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            },\n                            \"permissions\": {\n                              \"type\": \"number\"\n                            },\n                            \"enabled\": {\n                              \"default\": true,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"boolean\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"context_menu\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_use\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_open_mod_menu\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_use\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_open_mod_menu\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"counters\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"counters\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"propertyNames\": {\n                    \"type\": \"string\"\n                  },\n                  \"additionalProperties\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"pretty_name\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"per_channel\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"per_user\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"initial_value\": {\n                        \"default\": 0,\n                        \"type\": \"number\",\n                        \"minimum\": 0,\n                        \"maximum\": 2147483647\n                      },\n                      \"triggers\": {\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"anyOf\": [\n                            {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"pretty_name\": {\n                                  \"default\": null,\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"type\": \"null\"\n                                    }\n                                  ]\n                                },\n                                \"condition\": {\n                                  \"type\": \"string\"\n                                },\n                                \"reverse_condition\": {\n                                  \"type\": \"string\"\n                                }\n                              },\n                              \"required\": [\n                                \"condition\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            {\n                              \"type\": \"string\"\n                            }\n                          ]\n                        }\n                      },\n                      \"decay\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"amount\": {\n                                \"type\": \"number\"\n                              },\n                              \"every\": {\n                                \"type\": \"string\",\n                                \"maxLength\": 32\n                              }\n                            },\n                            \"required\": [\n                              \"amount\",\n                              \"every\"\n                            ],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"can_view\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"can_edit\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"can_reset_all\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [\n                      \"triggers\"\n                    ],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"can_view\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_edit\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_reset_all\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"counters\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"pretty_name\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"string\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"per_channel\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"per_user\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"initial_value\": {\n                              \"default\": 0,\n                              \"type\": \"number\",\n                              \"minimum\": 0,\n                              \"maximum\": 2147483647\n                            },\n                            \"triggers\": {\n                              \"type\": \"object\",\n                              \"propertyNames\": {\n                                \"type\": \"string\"\n                              },\n                              \"additionalProperties\": {\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"pretty_name\": {\n                                        \"default\": null,\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"string\"\n                                          },\n                                          {\n                                            \"type\": \"null\"\n                                          }\n                                        ]\n                                      },\n                                      \"condition\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"reverse_condition\": {\n                                        \"type\": \"string\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  {\n                                    \"type\": \"string\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"decay\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"amount\": {\n                                      \"type\": \"number\"\n                                    },\n                                    \"every\": {\n                                      \"type\": \"string\",\n                                      \"maxLength\": 32\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"can_view\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"boolean\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"can_edit\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"boolean\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"can_reset_all\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"boolean\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      },\n                      \"can_view\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_edit\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_reset_all\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"custom_events\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"events\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"propertyNames\": {\n                    \"type\": \"string\"\n                  },\n                  \"additionalProperties\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"name\": {\n                        \"type\": \"string\"\n                      },\n                      \"trigger\": {\n                        \"type\": \"object\",\n                        \"properties\": {\n                          \"type\": {\n                            \"const\": \"command\"\n                          },\n                          \"name\": {\n                            \"type\": \"string\"\n                          },\n                          \"params\": {\n                            \"type\": \"string\"\n                          },\n                          \"can_use\": {\n                            \"type\": \"boolean\"\n                          }\n                        },\n                        \"required\": [\n                          \"type\",\n                          \"name\",\n                          \"params\",\n                          \"can_use\"\n                        ],\n                        \"additionalProperties\": false\n                      },\n                      \"actions\": {\n                        \"maxItems\": 10,\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"anyOf\": [\n                            {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"type\": {\n                                  \"const\": \"add_role\"\n                                },\n                                \"target\": {\n                                  \"type\": \"string\"\n                                },\n                                \"role\": {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"type\": \"array\",\n                                      \"items\": {\n                                        \"type\": \"string\"\n                                      }\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [\n                                \"type\",\n                                \"target\",\n                                \"role\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"type\": {\n                                  \"const\": \"create_case\"\n                                },\n                                \"case_type\": {\n                                  \"type\": \"string\"\n                                },\n                                \"mod\": {\n                                  \"type\": \"string\"\n                                },\n                                \"target\": {\n                                  \"type\": \"string\"\n                                },\n                                \"reason\": {\n                                  \"type\": \"string\"\n                                }\n                              },\n                              \"required\": [\n                                \"type\",\n                                \"case_type\",\n                                \"mod\",\n                                \"target\",\n                                \"reason\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"type\": {\n                                  \"const\": \"move_to_vc\"\n                                },\n                                \"target\": {\n                                  \"type\": \"string\"\n                                },\n                                \"channel\": {\n                                  \"type\": \"string\"\n                                }\n                              },\n                              \"required\": [\n                                \"type\",\n                                \"target\",\n                                \"channel\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"type\": {\n                                  \"const\": \"message\"\n                                },\n                                \"channel\": {\n                                  \"type\": \"string\"\n                                },\n                                \"content\": {\n                                  \"type\": \"string\"\n                                }\n                              },\n                              \"required\": [\n                                \"type\",\n                                \"channel\",\n                                \"content\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"type\": {\n                                  \"const\": \"make_role_mentionable\"\n                                },\n                                \"role\": {\n                                  \"type\": \"string\"\n                                },\n                                \"timeout\": {\n                                  \"type\": \"string\",\n                                  \"maxLength\": 32\n                                }\n                              },\n                              \"required\": [\n                                \"type\",\n                                \"role\",\n                                \"timeout\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"type\": {\n                                  \"const\": \"make_role_unmentionable\"\n                                },\n                                \"role\": {\n                                  \"type\": \"string\"\n                                }\n                              },\n                              \"required\": [\n                                \"type\",\n                                \"role\"\n                              ],\n                              \"additionalProperties\": false\n                            },\n                            {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"type\": {\n                                  \"const\": \"set_channel_permission_overrides\"\n                                },\n                                \"channel\": {\n                                  \"type\": \"string\"\n                                },\n                                \"overrides\": {\n                                  \"maxItems\": 15,\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"type\": {\n                                        \"anyOf\": [\n                                          {\n                                            \"const\": \"member\"\n                                          },\n                                          {\n                                            \"const\": \"role\"\n                                          }\n                                        ]\n                                      },\n                                      \"id\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"allow\": {\n                                        \"type\": \"number\"\n                                      },\n                                      \"deny\": {\n                                        \"type\": \"number\"\n                                      }\n                                    },\n                                    \"required\": [\n                                      \"type\",\n                                      \"id\",\n                                      \"allow\",\n                                      \"deny\"\n                                    ],\n                                    \"additionalProperties\": false\n                                  }\n                                }\n                              },\n                              \"required\": [\n                                \"type\",\n                                \"channel\",\n                                \"overrides\"\n                              ],\n                              \"additionalProperties\": false\n                            }\n                          ]\n                        }\n                      }\n                    },\n                    \"required\": [\n                      \"name\",\n                      \"trigger\",\n                      \"actions\"\n                    ],\n                    \"additionalProperties\": false\n                  }\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"events\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"name\": {\n                              \"type\": \"string\"\n                            },\n                            \"trigger\": {\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"type\": {\n                                  \"const\": \"command\"\n                                },\n                                \"name\": {\n                                  \"type\": \"string\"\n                                },\n                                \"params\": {\n                                  \"type\": \"string\"\n                                },\n                                \"can_use\": {\n                                  \"type\": \"boolean\"\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"actions\": {\n                              \"maxItems\": 10,\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"type\": {\n                                        \"const\": \"add_role\"\n                                      },\n                                      \"target\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"role\": {\n                                        \"anyOf\": [\n                                          {\n                                            \"type\": \"string\"\n                                          },\n                                          {\n                                            \"type\": \"array\",\n                                            \"items\": {\n                                              \"type\": \"string\"\n                                            }\n                                          }\n                                        ]\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"type\": {\n                                        \"const\": \"create_case\"\n                                      },\n                                      \"case_type\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"mod\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"target\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"reason\": {\n                                        \"type\": \"string\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"type\": {\n                                        \"const\": \"move_to_vc\"\n                                      },\n                                      \"target\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"channel\": {\n                                        \"type\": \"string\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"type\": {\n                                        \"const\": \"message\"\n                                      },\n                                      \"channel\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"content\": {\n                                        \"type\": \"string\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"type\": {\n                                        \"const\": \"make_role_mentionable\"\n                                      },\n                                      \"role\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"timeout\": {\n                                        \"type\": \"string\",\n                                        \"maxLength\": 32\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"type\": {\n                                        \"const\": \"make_role_unmentionable\"\n                                      },\n                                      \"role\": {\n                                        \"type\": \"string\"\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  },\n                                  {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                      \"type\": {\n                                        \"const\": \"set_channel_permission_overrides\"\n                                      },\n                                      \"channel\": {\n                                        \"type\": \"string\"\n                                      },\n                                      \"overrides\": {\n                                        \"maxItems\": 15,\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                          \"type\": \"object\",\n                                          \"properties\": {\n                                            \"type\": {\n                                              \"anyOf\": [\n                                                {\n                                                  \"const\": \"member\"\n                                                },\n                                                {\n                                                  \"const\": \"role\"\n                                                }\n                                              ]\n                                            },\n                                            \"id\": {\n                                              \"type\": \"string\"\n                                            },\n                                            \"allow\": {\n                                              \"type\": \"number\"\n                                            },\n                                            \"deny\": {\n                                              \"type\": \"number\"\n                                            }\n                                          },\n                                          \"required\": [],\n                                          \"additionalProperties\": false\n                                        }\n                                      }\n                                    },\n                                    \"required\": [],\n                                    \"additionalProperties\": false\n                                  }\n                                ]\n                              }\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"guild_info_saver\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {},\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {},\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"internal_poster\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"default\": {},\n              \"type\": \"object\",\n              \"properties\": {},\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"default\": {},\n                    \"type\": \"object\",\n                    \"properties\": {},\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"locate_user\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_where\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_alert\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_where\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_alert\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"logs\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"channels\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"propertyNames\": {\n                    \"type\": \"string\"\n                  },\n                  \"additionalProperties\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"include\": {\n                        \"default\": [],\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"string\"\n                        }\n                      },\n                      \"exclude\": {\n                        \"default\": [],\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"string\"\n                        }\n                      },\n                      \"batched\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"batch_time\": {\n                        \"default\": 1000,\n                        \"type\": \"number\",\n                        \"minimum\": 250,\n                        \"maximum\": 5000\n                      },\n                      \"excluded_users\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"excluded_message_regexes\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"excluded_channels\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"excluded_categories\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"excluded_threads\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"exclude_bots\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"excluded_roles\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"format\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"properties\": {\n                          \"MEMBER_WARN\": {\n                            \"default\": \"{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_MUTE\": {\n                            \"default\": \"{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_UNMUTE\": {\n                            \"default\": \"{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_MUTE_EXPIRED\": {\n                            \"default\": \"{timestamp} 🔊 {userMention(member)}'s mute expired\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_KICK\": {\n                            \"default\": \"{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_BAN\": {\n                            \"default\": \"{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_UNBAN\": {\n                            \"default\": \"{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_FORCEBAN\": {\n                            \"default\": \"{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_SOFTBAN\": {\n                            \"default\": \"{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_JOIN\": {\n                            \"default\": \"{timestamp} 📥 {new} {userMention(member)} joined (created <t:{account_age_ts}:R>)\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_LEAVE\": {\n                            \"default\": \"{timestamp} 📤 {userMention(member)} left the server\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_ROLE_ADD\": {\n                            \"default\": \"{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_ROLE_REMOVE\": {\n                            \"default\": \"{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_NICK_CHANGE\": {\n                            \"default\": \"{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_USERNAME_CHANGE\": {\n                            \"default\": \"{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_RESTORE\": {\n                            \"default\": \"{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CHANNEL_CREATE\": {\n                            \"default\": \"{timestamp} 🖊 Channel {channelMention(channel)} was created\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CHANNEL_DELETE\": {\n                            \"default\": \"{timestamp} 🗑 Channel {channelMention(channel)} was deleted\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CHANNEL_UPDATE\": {\n                            \"default\": \"{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"THREAD_CREATE\": {\n                            \"default\": \"{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"THREAD_DELETE\": {\n                            \"default\": \"{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"THREAD_UPDATE\": {\n                            \"default\": \"{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"ROLE_CREATE\": {\n                            \"default\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"ROLE_DELETE\": {\n                            \"default\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"ROLE_UPDATE\": {\n                            \"default\": \"{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_EDIT\": {\n                            \"default\": \"{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_DELETE\": {\n                            \"default\": \"{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_DELETE_BULK\": {\n                            \"default\": \"{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_DELETE_BARE\": {\n                            \"default\": \"{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_JOIN\": {\n                            \"default\": \"{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_LEAVE\": {\n                            \"default\": \"{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_MOVE\": {\n                            \"default\": \"{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STAGE_INSTANCE_CREATE\": {\n                            \"default\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STAGE_INSTANCE_DELETE\": {\n                            \"default\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STAGE_INSTANCE_UPDATE\": {\n                            \"default\": \"{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"EMOJI_CREATE\": {\n                            \"default\": \"{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"EMOJI_DELETE\": {\n                            \"default\": \"{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"EMOJI_UPDATE\": {\n                            \"default\": \"{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STICKER_CREATE\": {\n                            \"default\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STICKER_DELETE\": {\n                            \"default\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STICKER_UPDATE\": {\n                            \"default\": \"{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"COMMAND\": {\n                            \"default\": \"{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\\n`{command}`\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_SPAM_DETECTED\": {\n                            \"default\": \"{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\\n{archiveUrl}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CENSOR\": {\n                            \"default\": \"{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\\n```{messageText}```\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CLEAN\": {\n                            \"default\": \"{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\\n{archiveUrl}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CASE_CREATE\": {\n                            \"default\": \"{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MASSUNBAN\": {\n                            \"default\": \"{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MASSBAN\": {\n                            \"default\": \"{timestamp} ⚒ {userMention(mod)} massbanned {count} users\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MASSMUTE\": {\n                            \"default\": \"{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_TIMED_MUTE\": {\n                            \"default\": \"{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_TIMED_UNMUTE\": {\n                            \"default\": \"{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_TIMED_BAN\": {\n                            \"default\": \"{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_TIMED_UNBAN\": {\n                            \"default\": \"{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_JOIN_WITH_PRIOR_RECORDS\": {\n                            \"default\": \"{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\\n{recentCaseSummary}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"OTHER_SPAM_DETECTED\": {\n                            \"default\": \"{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_ROLE_CHANGES\": {\n                            \"default\": \"{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_FORCE_MOVE\": {\n                            \"default\": \"{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_FORCE_DISCONNECT\": {\n                            \"default\": \"{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CASE_UPDATE\": {\n                            \"default\": \"{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\\n```{note}```\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_MUTE_REJOIN\": {\n                            \"default\": \"{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"SCHEDULED_MESSAGE\": {\n                            \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"POSTED_SCHEDULED_MESSAGE\": {\n                            \"default\": \"{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"BOT_ALERT\": {\n                            \"default\": \"{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"AUTOMOD_ACTION\": {\n                            \"default\": \"{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\\n{matchSummary}\\nActions taken: **{actionsTaken}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"SCHEDULED_REPEATED_MESSAGE\": {\n                            \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"REPEATED_MESSAGE\": {\n                            \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_DELETE_AUTO\": {\n                            \"default\": \"{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"SET_ANTIRAID_USER\": {\n                            \"default\": \"{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"SET_ANTIRAID_AUTO\": {\n                            \"default\": \"{timestamp} ⚔ Anti-raid automatically set to **{level}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_NOTE\": {\n                            \"default\": \"{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CASE_DELETE\": {\n                            \"default\": \"{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"DM_FAILED\": {\n                            \"default\": \"{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          }\n                        },\n                        \"required\": [],\n                        \"additionalProperties\": false\n                      },\n                      \"timestamp_format\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"include_embed_timestamp\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"format\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"MEMBER_WARN\": {\n                      \"default\": \"{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_MUTE\": {\n                      \"default\": \"{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_UNMUTE\": {\n                      \"default\": \"{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_MUTE_EXPIRED\": {\n                      \"default\": \"{timestamp} 🔊 {userMention(member)}'s mute expired\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_KICK\": {\n                      \"default\": \"{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_BAN\": {\n                      \"default\": \"{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_UNBAN\": {\n                      \"default\": \"{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_FORCEBAN\": {\n                      \"default\": \"{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_SOFTBAN\": {\n                      \"default\": \"{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_JOIN\": {\n                      \"default\": \"{timestamp} 📥 {new} {userMention(member)} joined (created <t:{account_age_ts}:R>)\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_LEAVE\": {\n                      \"default\": \"{timestamp} 📤 {userMention(member)} left the server\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_ROLE_ADD\": {\n                      \"default\": \"{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_ROLE_REMOVE\": {\n                      \"default\": \"{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_NICK_CHANGE\": {\n                      \"default\": \"{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_USERNAME_CHANGE\": {\n                      \"default\": \"{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_RESTORE\": {\n                      \"default\": \"{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"CHANNEL_CREATE\": {\n                      \"default\": \"{timestamp} 🖊 Channel {channelMention(channel)} was created\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"CHANNEL_DELETE\": {\n                      \"default\": \"{timestamp} 🗑 Channel {channelMention(channel)} was deleted\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"CHANNEL_UPDATE\": {\n                      \"default\": \"{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\\n{differenceString}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"THREAD_CREATE\": {\n                      \"default\": \"{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"THREAD_DELETE\": {\n                      \"default\": \"{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"THREAD_UPDATE\": {\n                      \"default\": \"{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\\n{differenceString}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"ROLE_CREATE\": {\n                      \"default\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"ROLE_DELETE\": {\n                      \"default\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"ROLE_UPDATE\": {\n                      \"default\": \"{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\\n{differenceString}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MESSAGE_EDIT\": {\n                      \"default\": \"{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MESSAGE_DELETE\": {\n                      \"default\": \"{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MESSAGE_DELETE_BULK\": {\n                      \"default\": \"{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MESSAGE_DELETE_BARE\": {\n                      \"default\": \"{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"VOICE_CHANNEL_JOIN\": {\n                      \"default\": \"{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"VOICE_CHANNEL_LEAVE\": {\n                      \"default\": \"{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"VOICE_CHANNEL_MOVE\": {\n                      \"default\": \"{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"STAGE_INSTANCE_CREATE\": {\n                      \"default\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"STAGE_INSTANCE_DELETE\": {\n                      \"default\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"STAGE_INSTANCE_UPDATE\": {\n                      \"default\": \"{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\\n{differenceString}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"EMOJI_CREATE\": {\n                      \"default\": \"{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"EMOJI_DELETE\": {\n                      \"default\": \"{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"EMOJI_UPDATE\": {\n                      \"default\": \"{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\\n{differenceString}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"STICKER_CREATE\": {\n                      \"default\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"STICKER_DELETE\": {\n                      \"default\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"STICKER_UPDATE\": {\n                      \"default\": \"{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\\n{differenceString}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"COMMAND\": {\n                      \"default\": \"{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\\n`{command}`\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MESSAGE_SPAM_DETECTED\": {\n                      \"default\": \"{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\\n{archiveUrl}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"CENSOR\": {\n                      \"default\": \"{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\\n```{messageText}```\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"CLEAN\": {\n                      \"default\": \"{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\\n{archiveUrl}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"CASE_CREATE\": {\n                      \"default\": \"{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MASSUNBAN\": {\n                      \"default\": \"{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MASSBAN\": {\n                      \"default\": \"{timestamp} ⚒ {userMention(mod)} massbanned {count} users\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MASSMUTE\": {\n                      \"default\": \"{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_TIMED_MUTE\": {\n                      \"default\": \"{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_TIMED_UNMUTE\": {\n                      \"default\": \"{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_TIMED_BAN\": {\n                      \"default\": \"{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_TIMED_UNBAN\": {\n                      \"default\": \"{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_JOIN_WITH_PRIOR_RECORDS\": {\n                      \"default\": \"{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\\n{recentCaseSummary}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"OTHER_SPAM_DETECTED\": {\n                      \"default\": \"{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_ROLE_CHANGES\": {\n                      \"default\": \"{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"VOICE_CHANNEL_FORCE_MOVE\": {\n                      \"default\": \"{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"VOICE_CHANNEL_FORCE_DISCONNECT\": {\n                      \"default\": \"{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"CASE_UPDATE\": {\n                      \"default\": \"{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\\n```{note}```\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_MUTE_REJOIN\": {\n                      \"default\": \"{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"SCHEDULED_MESSAGE\": {\n                      \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"POSTED_SCHEDULED_MESSAGE\": {\n                      \"default\": \"{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"BOT_ALERT\": {\n                      \"default\": \"{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"AUTOMOD_ACTION\": {\n                      \"default\": \"{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\\n{matchSummary}\\nActions taken: **{actionsTaken}**\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"SCHEDULED_REPEATED_MESSAGE\": {\n                      \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"REPEATED_MESSAGE\": {\n                      \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MESSAGE_DELETE_AUTO\": {\n                      \"default\": \"{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"SET_ANTIRAID_USER\": {\n                      \"default\": \"{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"SET_ANTIRAID_AUTO\": {\n                      \"default\": \"{timestamp} ⚔ Anti-raid automatically set to **{level}**\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"MEMBER_NOTE\": {\n                      \"default\": \"{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"CASE_DELETE\": {\n                      \"default\": \"{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    },\n                    \"DM_FAILED\": {\n                      \"default\": \"{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"$ref\": \"#/$defs/strictMessageContent\"\n                        }\n                      ]\n                    }\n                  },\n                  \"required\": [],\n                  \"additionalProperties\": false\n                },\n                \"ping_user\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                },\n                \"allow_user_mentions\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"timestamp_format\": {\n                  \"default\": \"[<t:]X[>]\",\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"include_embed_timestamp\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"channels\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"include\": {\n                              \"default\": [],\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            },\n                            \"exclude\": {\n                              \"default\": [],\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            },\n                            \"batched\": {\n                              \"default\": true,\n                              \"type\": \"boolean\"\n                            },\n                            \"batch_time\": {\n                              \"default\": 1000,\n                              \"type\": \"number\",\n                              \"minimum\": 250,\n                              \"maximum\": 5000\n                            },\n                            \"excluded_users\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"excluded_message_regexes\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"excluded_channels\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"excluded_categories\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"excluded_threads\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"exclude_bots\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"excluded_roles\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"array\",\n                                  \"items\": {\n                                    \"type\": \"string\"\n                                  }\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"format\": {\n                              \"default\": {},\n                              \"type\": \"object\",\n                              \"properties\": {\n                                \"MEMBER_WARN\": {\n                                  \"default\": \"{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_MUTE\": {\n                                  \"default\": \"{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_UNMUTE\": {\n                                  \"default\": \"{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_MUTE_EXPIRED\": {\n                                  \"default\": \"{timestamp} 🔊 {userMention(member)}'s mute expired\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_KICK\": {\n                                  \"default\": \"{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_BAN\": {\n                                  \"default\": \"{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_UNBAN\": {\n                                  \"default\": \"{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_FORCEBAN\": {\n                                  \"default\": \"{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_SOFTBAN\": {\n                                  \"default\": \"{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_JOIN\": {\n                                  \"default\": \"{timestamp} 📥 {new} {userMention(member)} joined (created <t:{account_age_ts}:R>)\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_LEAVE\": {\n                                  \"default\": \"{timestamp} 📤 {userMention(member)} left the server\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_ROLE_ADD\": {\n                                  \"default\": \"{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_ROLE_REMOVE\": {\n                                  \"default\": \"{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_NICK_CHANGE\": {\n                                  \"default\": \"{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_USERNAME_CHANGE\": {\n                                  \"default\": \"{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_RESTORE\": {\n                                  \"default\": \"{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"CHANNEL_CREATE\": {\n                                  \"default\": \"{timestamp} 🖊 Channel {channelMention(channel)} was created\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"CHANNEL_DELETE\": {\n                                  \"default\": \"{timestamp} 🗑 Channel {channelMention(channel)} was deleted\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"CHANNEL_UPDATE\": {\n                                  \"default\": \"{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\\n{differenceString}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"THREAD_CREATE\": {\n                                  \"default\": \"{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"THREAD_DELETE\": {\n                                  \"default\": \"{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"THREAD_UPDATE\": {\n                                  \"default\": \"{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\\n{differenceString}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"ROLE_CREATE\": {\n                                  \"default\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"ROLE_DELETE\": {\n                                  \"default\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"ROLE_UPDATE\": {\n                                  \"default\": \"{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\\n{differenceString}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MESSAGE_EDIT\": {\n                                  \"default\": \"{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MESSAGE_DELETE\": {\n                                  \"default\": \"{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MESSAGE_DELETE_BULK\": {\n                                  \"default\": \"{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MESSAGE_DELETE_BARE\": {\n                                  \"default\": \"{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"VOICE_CHANNEL_JOIN\": {\n                                  \"default\": \"{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"VOICE_CHANNEL_LEAVE\": {\n                                  \"default\": \"{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"VOICE_CHANNEL_MOVE\": {\n                                  \"default\": \"{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"STAGE_INSTANCE_CREATE\": {\n                                  \"default\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"STAGE_INSTANCE_DELETE\": {\n                                  \"default\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"STAGE_INSTANCE_UPDATE\": {\n                                  \"default\": \"{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\\n{differenceString}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"EMOJI_CREATE\": {\n                                  \"default\": \"{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"EMOJI_DELETE\": {\n                                  \"default\": \"{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"EMOJI_UPDATE\": {\n                                  \"default\": \"{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\\n{differenceString}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"STICKER_CREATE\": {\n                                  \"default\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"STICKER_DELETE\": {\n                                  \"default\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"STICKER_UPDATE\": {\n                                  \"default\": \"{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\\n{differenceString}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"COMMAND\": {\n                                  \"default\": \"{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\\n`{command}`\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MESSAGE_SPAM_DETECTED\": {\n                                  \"default\": \"{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\\n{archiveUrl}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"CENSOR\": {\n                                  \"default\": \"{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\\n```{messageText}```\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"CLEAN\": {\n                                  \"default\": \"{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\\n{archiveUrl}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"CASE_CREATE\": {\n                                  \"default\": \"{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MASSUNBAN\": {\n                                  \"default\": \"{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MASSBAN\": {\n                                  \"default\": \"{timestamp} ⚒ {userMention(mod)} massbanned {count} users\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MASSMUTE\": {\n                                  \"default\": \"{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_TIMED_MUTE\": {\n                                  \"default\": \"{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_TIMED_UNMUTE\": {\n                                  \"default\": \"{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_TIMED_BAN\": {\n                                  \"default\": \"{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_TIMED_UNBAN\": {\n                                  \"default\": \"{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_JOIN_WITH_PRIOR_RECORDS\": {\n                                  \"default\": \"{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\\n{recentCaseSummary}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"OTHER_SPAM_DETECTED\": {\n                                  \"default\": \"{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_ROLE_CHANGES\": {\n                                  \"default\": \"{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"VOICE_CHANNEL_FORCE_MOVE\": {\n                                  \"default\": \"{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"VOICE_CHANNEL_FORCE_DISCONNECT\": {\n                                  \"default\": \"{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"CASE_UPDATE\": {\n                                  \"default\": \"{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\\n```{note}```\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_MUTE_REJOIN\": {\n                                  \"default\": \"{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"SCHEDULED_MESSAGE\": {\n                                  \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"POSTED_SCHEDULED_MESSAGE\": {\n                                  \"default\": \"{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"BOT_ALERT\": {\n                                  \"default\": \"{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"AUTOMOD_ACTION\": {\n                                  \"default\": \"{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\\n{matchSummary}\\nActions taken: **{actionsTaken}**\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"SCHEDULED_REPEATED_MESSAGE\": {\n                                  \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"REPEATED_MESSAGE\": {\n                                  \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MESSAGE_DELETE_AUTO\": {\n                                  \"default\": \"{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"SET_ANTIRAID_USER\": {\n                                  \"default\": \"{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"SET_ANTIRAID_AUTO\": {\n                                  \"default\": \"{timestamp} ⚔ Anti-raid automatically set to **{level}**\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"MEMBER_NOTE\": {\n                                  \"default\": \"{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"CASE_DELETE\": {\n                                  \"default\": \"{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                },\n                                \"DM_FAILED\": {\n                                  \"default\": \"{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}\",\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"$ref\": \"#/$defs/strictMessageContent\"\n                                    }\n                                  ]\n                                }\n                              },\n                              \"required\": [],\n                              \"additionalProperties\": false\n                            },\n                            \"timestamp_format\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"string\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"include_embed_timestamp\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"boolean\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      },\n                      \"format\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"properties\": {\n                          \"MEMBER_WARN\": {\n                            \"default\": \"{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_MUTE\": {\n                            \"default\": \"{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_UNMUTE\": {\n                            \"default\": \"{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_MUTE_EXPIRED\": {\n                            \"default\": \"{timestamp} 🔊 {userMention(member)}'s mute expired\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_KICK\": {\n                            \"default\": \"{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_BAN\": {\n                            \"default\": \"{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_UNBAN\": {\n                            \"default\": \"{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_FORCEBAN\": {\n                            \"default\": \"{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_SOFTBAN\": {\n                            \"default\": \"{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_JOIN\": {\n                            \"default\": \"{timestamp} 📥 {new} {userMention(member)} joined (created <t:{account_age_ts}:R>)\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_LEAVE\": {\n                            \"default\": \"{timestamp} 📤 {userMention(member)} left the server\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_ROLE_ADD\": {\n                            \"default\": \"{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_ROLE_REMOVE\": {\n                            \"default\": \"{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_NICK_CHANGE\": {\n                            \"default\": \"{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_USERNAME_CHANGE\": {\n                            \"default\": \"{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_RESTORE\": {\n                            \"default\": \"{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CHANNEL_CREATE\": {\n                            \"default\": \"{timestamp} 🖊 Channel {channelMention(channel)} was created\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CHANNEL_DELETE\": {\n                            \"default\": \"{timestamp} 🗑 Channel {channelMention(channel)} was deleted\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CHANNEL_UPDATE\": {\n                            \"default\": \"{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"THREAD_CREATE\": {\n                            \"default\": \"{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"THREAD_DELETE\": {\n                            \"default\": \"{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"THREAD_UPDATE\": {\n                            \"default\": \"{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"ROLE_CREATE\": {\n                            \"default\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"ROLE_DELETE\": {\n                            \"default\": \"{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"ROLE_UPDATE\": {\n                            \"default\": \"{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_EDIT\": {\n                            \"default\": \"{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_DELETE\": {\n                            \"default\": \"{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_DELETE_BULK\": {\n                            \"default\": \"{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_DELETE_BARE\": {\n                            \"default\": \"{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_JOIN\": {\n                            \"default\": \"{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_LEAVE\": {\n                            \"default\": \"{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_MOVE\": {\n                            \"default\": \"{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STAGE_INSTANCE_CREATE\": {\n                            \"default\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STAGE_INSTANCE_DELETE\": {\n                            \"default\": \"{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STAGE_INSTANCE_UPDATE\": {\n                            \"default\": \"{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"EMOJI_CREATE\": {\n                            \"default\": \"{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"EMOJI_DELETE\": {\n                            \"default\": \"{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"EMOJI_UPDATE\": {\n                            \"default\": \"{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STICKER_CREATE\": {\n                            \"default\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STICKER_DELETE\": {\n                            \"default\": \"{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"STICKER_UPDATE\": {\n                            \"default\": \"{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\\n{differenceString}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"COMMAND\": {\n                            \"default\": \"{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\\n`{command}`\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_SPAM_DETECTED\": {\n                            \"default\": \"{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\\n{archiveUrl}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CENSOR\": {\n                            \"default\": \"{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\\n```{messageText}```\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CLEAN\": {\n                            \"default\": \"{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\\n{archiveUrl}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CASE_CREATE\": {\n                            \"default\": \"{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MASSUNBAN\": {\n                            \"default\": \"{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MASSBAN\": {\n                            \"default\": \"{timestamp} ⚒ {userMention(mod)} massbanned {count} users\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MASSMUTE\": {\n                            \"default\": \"{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_TIMED_MUTE\": {\n                            \"default\": \"{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_TIMED_UNMUTE\": {\n                            \"default\": \"{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_TIMED_BAN\": {\n                            \"default\": \"{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_TIMED_UNBAN\": {\n                            \"default\": \"{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_JOIN_WITH_PRIOR_RECORDS\": {\n                            \"default\": \"{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\\n{recentCaseSummary}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"OTHER_SPAM_DETECTED\": {\n                            \"default\": \"{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_ROLE_CHANGES\": {\n                            \"default\": \"{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_FORCE_MOVE\": {\n                            \"default\": \"{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"VOICE_CHANNEL_FORCE_DISCONNECT\": {\n                            \"default\": \"{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CASE_UPDATE\": {\n                            \"default\": \"{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\\n```{note}```\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_MUTE_REJOIN\": {\n                            \"default\": \"{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"SCHEDULED_MESSAGE\": {\n                            \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"POSTED_SCHEDULED_MESSAGE\": {\n                            \"default\": \"{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"BOT_ALERT\": {\n                            \"default\": \"{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"AUTOMOD_ACTION\": {\n                            \"default\": \"{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\\n{matchSummary}\\nActions taken: **{actionsTaken}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"SCHEDULED_REPEATED_MESSAGE\": {\n                            \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"REPEATED_MESSAGE\": {\n                            \"default\": \"{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MESSAGE_DELETE_AUTO\": {\n                            \"default\": \"{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"SET_ANTIRAID_USER\": {\n                            \"default\": \"{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"SET_ANTIRAID_AUTO\": {\n                            \"default\": \"{timestamp} ⚔ Anti-raid automatically set to **{level}**\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"MEMBER_NOTE\": {\n                            \"default\": \"{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"CASE_DELETE\": {\n                            \"default\": \"{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          },\n                          \"DM_FAILED\": {\n                            \"default\": \"{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}\",\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"$ref\": \"#/$defs/strictMessageContent\"\n                              }\n                            ]\n                          }\n                        },\n                        \"required\": [],\n                        \"additionalProperties\": false\n                      },\n                      \"ping_user\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"allow_user_mentions\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"timestamp_format\": {\n                        \"default\": \"[<t:]X[>]\",\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"include_embed_timestamp\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"message_saver\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_manage\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_manage\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"mod_actions\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"dm_on_warn\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                },\n                \"dm_on_kick\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"dm_on_ban\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"message_on_warn\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"message_on_kick\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"message_on_ban\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"message_channel\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"warn_message\": {\n                  \"default\": \"You have received a warning on the {guildName} server: {reason}\",\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"kick_message\": {\n                  \"default\": \"You have been kicked from the {guildName} server. Reason given: {reason}\",\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"ban_message\": {\n                  \"default\": \"You have been banned from the {guildName} server. Reason given: {reason}\",\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"tempban_message\": {\n                  \"default\": \"You have been banned from the {guildName} server for {banTime}. Reason given: {reason}\",\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"alert_on_rejoin\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"alert_channel\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"warn_notify_enabled\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"warn_notify_threshold\": {\n                  \"default\": 5,\n                  \"type\": \"number\"\n                },\n                \"warn_notify_message\": {\n                  \"default\": \"The user already has **{priorWarnings}** warnings!\\n Please check their prior cases and assess whether or not to warn anyways.\\n Proceed with the warning?\",\n                  \"type\": \"string\"\n                },\n                \"ban_delete_message_days\": {\n                  \"default\": 1,\n                  \"type\": \"number\"\n                },\n                \"attachment_link_reaction\": {\n                  \"default\": \"warn\",\n                  \"anyOf\": [\n                    {\n                      \"anyOf\": [\n                        {\n                          \"const\": \"none\"\n                        },\n                        {\n                          \"const\": \"warn\"\n                        },\n                        {\n                          \"const\": \"restrict\"\n                        }\n                      ]\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"can_note\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_warn\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_mute\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_kick\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_ban\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_unban\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_view\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_addcase\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_massunban\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_massban\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_massmute\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_hidecase\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_deletecase\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_act_as_other\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"create_cases_for_manual_actions\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"dm_on_warn\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"dm_on_kick\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"dm_on_ban\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"message_on_warn\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"message_on_kick\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"message_on_ban\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"message_channel\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"warn_message\": {\n                        \"default\": \"You have received a warning on the {guildName} server: {reason}\",\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"kick_message\": {\n                        \"default\": \"You have been kicked from the {guildName} server. Reason given: {reason}\",\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"ban_message\": {\n                        \"default\": \"You have been banned from the {guildName} server. Reason given: {reason}\",\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"tempban_message\": {\n                        \"default\": \"You have been banned from the {guildName} server for {banTime}. Reason given: {reason}\",\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"alert_on_rejoin\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"alert_channel\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"warn_notify_enabled\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"warn_notify_threshold\": {\n                        \"default\": 5,\n                        \"type\": \"number\"\n                      },\n                      \"warn_notify_message\": {\n                        \"default\": \"The user already has **{priorWarnings}** warnings!\\n Please check their prior cases and assess whether or not to warn anyways.\\n Proceed with the warning?\",\n                        \"type\": \"string\"\n                      },\n                      \"ban_delete_message_days\": {\n                        \"default\": 1,\n                        \"type\": \"number\"\n                      },\n                      \"attachment_link_reaction\": {\n                        \"default\": \"warn\",\n                        \"anyOf\": [\n                          {\n                            \"anyOf\": [\n                              {\n                                \"const\": \"none\"\n                              },\n                              {\n                                \"const\": \"warn\"\n                              },\n                              {\n                                \"const\": \"restrict\"\n                              }\n                            ]\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"can_note\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_warn\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_mute\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_kick\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_ban\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_unban\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_view\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_addcase\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_massunban\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_massban\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_massmute\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_hidecase\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_deletecase\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_act_as_other\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"create_cases_for_manual_actions\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"mutes\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"mute_role\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"move_to_voice_channel\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"kick_from_voice_channel\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"dm_on_mute\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"dm_on_update\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"message_on_mute\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"message_on_update\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"message_channel\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"mute_message\": {\n                  \"default\": \"You have been muted on the {guildName} server. Reason given: {reason}\",\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"timed_mute_message\": {\n                  \"default\": \"You have been muted on the {guildName} server for {time}. Reason given: {reason}\",\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"update_mute_message\": {\n                  \"default\": \"Your mute on the {guildName} server has been updated to {time}.\",\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"remove_roles_on_mute\": {\n                  \"default\": false,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"boolean\"\n                    },\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    }\n                  ]\n                },\n                \"restore_roles_on_mute\": {\n                  \"default\": false,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"boolean\"\n                    },\n                    {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"string\"\n                      }\n                    }\n                  ]\n                },\n                \"can_view_list\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_cleanup\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"mute_role\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"move_to_voice_channel\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"kick_from_voice_channel\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"dm_on_mute\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"dm_on_update\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"message_on_mute\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"message_on_update\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"message_channel\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"mute_message\": {\n                        \"default\": \"You have been muted on the {guildName} server. Reason given: {reason}\",\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"timed_mute_message\": {\n                        \"default\": \"You have been muted on the {guildName} server for {time}. Reason given: {reason}\",\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"update_mute_message\": {\n                        \"default\": \"Your mute on the {guildName} server has been updated to {time}.\",\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"remove_roles_on_mute\": {\n                        \"default\": false,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      \"restore_roles_on_mute\": {\n                        \"default\": false,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      \"can_view_list\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_cleanup\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"name_history\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_view\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_view\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"persist\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"persisted_roles\": {\n                  \"default\": [],\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                },\n                \"persist_nicknames\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"persist_voice_mutes\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"persisted_roles\": {\n                        \"default\": [],\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"string\"\n                        }\n                      },\n                      \"persist_nicknames\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"persist_voice_mutes\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"phisherman\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"api_key\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\",\n                      \"maxLength\": 255\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"api_key\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\",\n                            \"maxLength\": 255\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"pingable_roles\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_manage\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_manage\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"post\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_post\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_post\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"reaction_roles\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"auto_refresh_interval\": {\n                  \"default\": 900000,\n                  \"type\": \"number\",\n                  \"minimum\": 900000\n                },\n                \"remove_user_reactions\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                },\n                \"can_manage\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"button_groups\": {\n                  \"default\": null,\n                  \"type\": \"null\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"auto_refresh_interval\": {\n                        \"default\": 900000,\n                        \"type\": \"number\",\n                        \"minimum\": 900000\n                      },\n                      \"remove_user_reactions\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_manage\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"button_groups\": {\n                        \"default\": null,\n                        \"type\": \"null\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"reminders\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_use\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_use\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"role_buttons\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"buttons\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"propertyNames\": {\n                    \"type\": \"string\"\n                  },\n                  \"additionalProperties\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"message\": {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"channel_id\": {\n                                \"type\": \"string\"\n                              },\n                              \"message_id\": {\n                                \"type\": \"string\"\n                              }\n                            },\n                            \"required\": [\n                              \"channel_id\",\n                              \"message_id\"\n                            ],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"channel_id\": {\n                                \"type\": \"string\"\n                              },\n                              \"content\": {\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"$ref\": \"#/$defs/strictMessageContent\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"required\": [\n                              \"channel_id\",\n                              \"content\"\n                            ],\n                            \"additionalProperties\": false\n                          }\n                        ]\n                      },\n                      \"options\": {\n                        \"maxItems\": 25,\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"role_id\": {\n                              \"type\": \"string\"\n                            },\n                            \"label\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"string\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"emoji\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"string\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"style\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"anyOf\": [\n                                    {\n                                      \"const\": 1\n                                    },\n                                    {\n                                      \"const\": 2\n                                    },\n                                    {\n                                      \"const\": 3\n                                    },\n                                    {\n                                      \"const\": 4\n                                    },\n                                    {\n                                      \"const\": \"PRIMARY\"\n                                    },\n                                    {\n                                      \"const\": \"SECONDARY\"\n                                    },\n                                    {\n                                      \"const\": \"SUCCESS\"\n                                    },\n                                    {\n                                      \"const\": \"DANGER\"\n                                    }\n                                  ]\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"start_new_row\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            }\n                          },\n                          \"required\": [\n                            \"role_id\"\n                          ],\n                          \"additionalProperties\": false\n                        }\n                      },\n                      \"exclusive\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [\n                      \"message\",\n                      \"options\"\n                    ],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"can_reset\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"buttons\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"message\": {\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"channel_id\": {\n                                      \"type\": \"string\"\n                                    },\n                                    \"message_id\": {\n                                      \"type\": \"string\"\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                },\n                                {\n                                  \"type\": \"object\",\n                                  \"properties\": {\n                                    \"channel_id\": {\n                                      \"type\": \"string\"\n                                    },\n                                    \"content\": {\n                                      \"anyOf\": [\n                                        {\n                                          \"type\": \"string\"\n                                        },\n                                        {\n                                          \"$ref\": \"#/$defs/strictMessageContent\"\n                                        }\n                                      ]\n                                    }\n                                  },\n                                  \"required\": [],\n                                  \"additionalProperties\": false\n                                }\n                              ]\n                            },\n                            \"options\": {\n                              \"maxItems\": 25,\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                  \"role_id\": {\n                                    \"type\": \"string\"\n                                  },\n                                  \"label\": {\n                                    \"default\": null,\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"string\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"emoji\": {\n                                    \"default\": null,\n                                    \"anyOf\": [\n                                      {\n                                        \"type\": \"string\"\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"style\": {\n                                    \"default\": null,\n                                    \"anyOf\": [\n                                      {\n                                        \"anyOf\": [\n                                          {\n                                            \"const\": 1\n                                          },\n                                          {\n                                            \"const\": 2\n                                          },\n                                          {\n                                            \"const\": 3\n                                          },\n                                          {\n                                            \"const\": 4\n                                          },\n                                          {\n                                            \"const\": \"PRIMARY\"\n                                          },\n                                          {\n                                            \"const\": \"SECONDARY\"\n                                          },\n                                          {\n                                            \"const\": \"SUCCESS\"\n                                          },\n                                          {\n                                            \"const\": \"DANGER\"\n                                          }\n                                        ]\n                                      },\n                                      {\n                                        \"type\": \"null\"\n                                      }\n                                    ]\n                                  },\n                                  \"start_new_row\": {\n                                    \"default\": false,\n                                    \"type\": \"boolean\"\n                                  }\n                                },\n                                \"required\": [],\n                                \"additionalProperties\": false\n                              }\n                            },\n                            \"exclusive\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      },\n                      \"can_reset\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"role_manager\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {},\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {},\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"roles\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_assign\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_mass_assign\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"assignable_roles\": {\n                  \"default\": [],\n                  \"maxItems\": 100,\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_assign\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_mass_assign\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"assignable_roles\": {\n                        \"default\": [],\n                        \"maxItems\": 100,\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"string\"\n                        }\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"self_grantable_roles\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"entries\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"propertyNames\": {\n                    \"type\": \"string\"\n                  },\n                  \"additionalProperties\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"roles\": {\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"maxItems\": 100,\n                          \"type\": \"array\",\n                          \"items\": {\n                            \"type\": \"string\"\n                          }\n                        }\n                      },\n                      \"can_use\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_ignore_cooldown\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"max_roles\": {\n                        \"default\": 0,\n                        \"type\": \"number\"\n                      }\n                    },\n                    \"required\": [\n                      \"roles\"\n                    ],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"mention_roles\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"entries\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"roles\": {\n                              \"type\": \"object\",\n                              \"propertyNames\": {\n                                \"type\": \"string\"\n                              },\n                              \"additionalProperties\": {\n                                \"maxItems\": 100,\n                                \"type\": \"array\",\n                                \"items\": {\n                                  \"type\": \"string\"\n                                }\n                              }\n                            },\n                            \"can_use\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"can_ignore_cooldown\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"max_roles\": {\n                              \"default\": 0,\n                              \"type\": \"number\"\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      },\n                      \"mention_roles\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"slowmode\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"use_native_slowmode\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                },\n                \"can_manage\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"is_affected\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"use_native_slowmode\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_manage\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"is_affected\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"spam\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"max_censor\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"max_messages\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"max_mentions\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"max_links\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"max_attachments\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"max_emojis\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"max_newlines\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"max_duplicates\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"max_characters\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"max_voice_moves\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"interval\": {\n                          \"type\": \"number\"\n                        },\n                        \"count\": {\n                          \"type\": \"number\"\n                        },\n                        \"mute\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        },\n                        \"mute_time\": {\n                          \"default\": null,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"number\"\n                            },\n                            {\n                              \"type\": \"null\"\n                            }\n                          ]\n                        },\n                        \"remove_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"restore_roles_on_mute\": {\n                          \"default\": false,\n                          \"anyOf\": [\n                            {\n                              \"type\": \"boolean\"\n                            },\n                            {\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            }\n                          ]\n                        },\n                        \"clean\": {\n                          \"default\": false,\n                          \"type\": \"boolean\"\n                        }\n                      },\n                      \"required\": [\n                        \"interval\",\n                        \"count\"\n                      ],\n                      \"additionalProperties\": false\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"max_censor\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"max_messages\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"max_mentions\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"max_links\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"max_attachments\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"max_emojis\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"max_newlines\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"max_duplicates\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"max_characters\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"max_voice_moves\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"object\",\n                            \"properties\": {\n                              \"interval\": {\n                                \"type\": \"number\"\n                              },\n                              \"count\": {\n                                \"type\": \"number\"\n                              },\n                              \"mute\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              },\n                              \"mute_time\": {\n                                \"default\": null,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"number\"\n                                  },\n                                  {\n                                    \"type\": \"null\"\n                                  }\n                                ]\n                              },\n                              \"remove_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"restore_roles_on_mute\": {\n                                \"default\": false,\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"boolean\"\n                                  },\n                                  {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                      \"type\": \"string\"\n                                    }\n                                  }\n                                ]\n                              },\n                              \"clean\": {\n                                \"default\": false,\n                                \"type\": \"boolean\"\n                              }\n                            },\n                            \"required\": [],\n                            \"additionalProperties\": false\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"starboard\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"boards\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"propertyNames\": {\n                    \"type\": \"string\"\n                  },\n                  \"additionalProperties\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"channel_id\": {\n                        \"type\": \"string\"\n                      },\n                      \"stars_required\": {\n                        \"type\": \"number\"\n                      },\n                      \"star_emoji\": {\n                        \"default\": [\n                          \"⭐\"\n                        ],\n                        \"type\": \"array\",\n                        \"items\": {\n                          \"type\": \"string\"\n                        }\n                      },\n                      \"allow_selfstars\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"copy_full_embed\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"enabled\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"show_star_count\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"color\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"number\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [\n                      \"channel_id\",\n                      \"stars_required\"\n                    ],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"can_migrate\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"boards\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"channel_id\": {\n                              \"type\": \"string\"\n                            },\n                            \"stars_required\": {\n                              \"type\": \"number\"\n                            },\n                            \"star_emoji\": {\n                              \"default\": [\n                                \"⭐\"\n                              ],\n                              \"type\": \"array\",\n                              \"items\": {\n                                \"type\": \"string\"\n                              }\n                            },\n                            \"allow_selfstars\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"copy_full_embed\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"enabled\": {\n                              \"default\": true,\n                              \"type\": \"boolean\"\n                            },\n                            \"show_star_count\": {\n                              \"default\": true,\n                              \"type\": \"boolean\"\n                            },\n                            \"color\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"number\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      },\n                      \"can_migrate\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"tags\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"prefix\": {\n                  \"default\": \"!!\",\n                  \"type\": \"string\"\n                },\n                \"delete_with_command\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                },\n                \"user_tag_cooldown\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"type\": \"number\"\n                        }\n                      ]\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"global_tag_cooldown\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"type\": \"number\"\n                        }\n                      ]\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"user_cooldown\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"type\": \"number\"\n                        }\n                      ]\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"allow_mentions\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"global_cooldown\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"anyOf\": [\n                        {\n                          \"type\": \"string\"\n                        },\n                        {\n                          \"type\": \"number\"\n                        }\n                      ]\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"auto_delete_command\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"categories\": {\n                  \"default\": {},\n                  \"type\": \"object\",\n                  \"propertyNames\": {\n                    \"type\": \"string\"\n                  },\n                  \"additionalProperties\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"prefix\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"delete_with_command\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"user_tag_cooldown\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"number\"\n                              }\n                            ]\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"user_category_cooldown\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"number\"\n                              }\n                            ]\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"global_tag_cooldown\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"number\"\n                              }\n                            ]\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"allow_mentions\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"global_category_cooldown\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"number\"\n                              }\n                            ]\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"auto_delete_command\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"tags\": {\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"anyOf\": [\n                            {\n                              \"type\": \"string\"\n                            },\n                            {\n                              \"$ref\": \"#/$defs/strictMessageContent\"\n                            }\n                          ]\n                        }\n                      },\n                      \"can_use\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"boolean\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [\n                      \"tags\"\n                    ],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"can_create\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_use\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_list\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"prefix\": {\n                        \"default\": \"!!\",\n                        \"type\": \"string\"\n                      },\n                      \"delete_with_command\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"user_tag_cooldown\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"number\"\n                              }\n                            ]\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"global_tag_cooldown\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"number\"\n                              }\n                            ]\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"user_cooldown\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"number\"\n                              }\n                            ]\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"allow_mentions\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"global_cooldown\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"anyOf\": [\n                              {\n                                \"type\": \"string\"\n                              },\n                              {\n                                \"type\": \"number\"\n                              }\n                            ]\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"auto_delete_command\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"categories\": {\n                        \"default\": {},\n                        \"type\": \"object\",\n                        \"propertyNames\": {\n                          \"type\": \"string\"\n                        },\n                        \"additionalProperties\": {\n                          \"type\": \"object\",\n                          \"properties\": {\n                            \"prefix\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"string\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"delete_with_command\": {\n                              \"default\": false,\n                              \"type\": \"boolean\"\n                            },\n                            \"user_tag_cooldown\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"type\": \"number\"\n                                    }\n                                  ]\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"user_category_cooldown\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"type\": \"number\"\n                                    }\n                                  ]\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"global_tag_cooldown\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"type\": \"number\"\n                                    }\n                                  ]\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"allow_mentions\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"boolean\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"global_category_cooldown\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"anyOf\": [\n                                    {\n                                      \"type\": \"string\"\n                                    },\n                                    {\n                                      \"type\": \"number\"\n                                    }\n                                  ]\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"auto_delete_command\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"boolean\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            },\n                            \"tags\": {\n                              \"type\": \"object\",\n                              \"propertyNames\": {\n                                \"type\": \"string\"\n                              },\n                              \"additionalProperties\": {\n                                \"anyOf\": [\n                                  {\n                                    \"type\": \"string\"\n                                  },\n                                  {\n                                    \"$ref\": \"#/$defs/strictMessageContent\"\n                                  }\n                                ]\n                              }\n                            },\n                            \"can_use\": {\n                              \"default\": null,\n                              \"anyOf\": [\n                                {\n                                  \"type\": \"boolean\"\n                                },\n                                {\n                                  \"type\": \"null\"\n                                }\n                              ]\n                            }\n                          },\n                          \"required\": [],\n                          \"additionalProperties\": false\n                        }\n                      },\n                      \"can_create\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_use\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_list\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"time_and_date\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"timezone\": {\n                  \"default\": \"Etc/UTC\",\n                  \"type\": \"string\"\n                },\n                \"date_formats\": {\n                  \"default\": {\n                    \"date\": \"MMM D, YYYY\",\n                    \"time\": \"H:mm\",\n                    \"pretty_datetime\": \"MMM D, YYYY [at] H:mm z\"\n                  },\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"date\": {\n                      \"default\": \"MMM D, YYYY\",\n                      \"type\": \"string\"\n                    },\n                    \"time\": {\n                      \"default\": \"H:mm\",\n                      \"type\": \"string\"\n                    },\n                    \"pretty_datetime\": {\n                      \"default\": \"MMM D, YYYY [at] H:mm z\",\n                      \"type\": \"string\"\n                    }\n                  },\n                  \"required\": [],\n                  \"additionalProperties\": false\n                },\n                \"can_set_timezone\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"timezone\": {\n                        \"default\": \"Etc/UTC\",\n                        \"type\": \"string\"\n                      },\n                      \"date_formats\": {\n                        \"default\": {\n                          \"date\": \"MMM D, YYYY\",\n                          \"time\": \"H:mm\",\n                          \"pretty_datetime\": \"MMM D, YYYY [at] H:mm z\"\n                        },\n                        \"type\": \"object\",\n                        \"properties\": {\n                          \"date\": {\n                            \"default\": \"MMM D, YYYY\",\n                            \"type\": \"string\"\n                          },\n                          \"time\": {\n                            \"default\": \"H:mm\",\n                            \"type\": \"string\"\n                          },\n                          \"pretty_datetime\": {\n                            \"default\": \"MMM D, YYYY [at] H:mm z\",\n                            \"type\": \"string\"\n                          }\n                        },\n                        \"required\": [],\n                        \"additionalProperties\": false\n                      },\n                      \"can_set_timezone\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"username_saver\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {},\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {},\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"utility\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"can_roles\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_level\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_search\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_clean\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_info\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_server\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_inviteinfo\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_channelinfo\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_messageinfo\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_userinfo\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_roleinfo\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_emojiinfo\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_snowflake\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_reload_guild\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_nickname\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_ping\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_source\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_vcmove\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_vckick\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_help\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_about\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_context\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"can_jumbo\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"jumbo_size\": {\n                  \"default\": 128,\n                  \"type\": \"number\"\n                },\n                \"can_avatar\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"info_on_single_result\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                },\n                \"autojoin_threads\": {\n                  \"default\": true,\n                  \"type\": \"boolean\"\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"can_roles\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_level\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_search\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_clean\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_info\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_server\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_inviteinfo\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_channelinfo\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_messageinfo\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_userinfo\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_roleinfo\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_emojiinfo\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_snowflake\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_reload_guild\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_nickname\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_ping\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_source\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_vcmove\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_vckick\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_help\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_about\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_context\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"can_jumbo\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"jumbo_size\": {\n                        \"default\": 128,\n                        \"type\": \"number\"\n                      },\n                      \"can_avatar\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"info_on_single_result\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      },\n                      \"autojoin_threads\": {\n                        \"default\": true,\n                        \"type\": \"boolean\"\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"welcome_message\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"send_dm\": {\n                  \"default\": false,\n                  \"type\": \"boolean\"\n                },\n                \"send_to_channel\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"message\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"send_dm\": {\n                        \"default\": false,\n                        \"type\": \"boolean\"\n                      },\n                      \"send_to_channel\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"message\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        },\n        \"common\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"config\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"success_emoji\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"error_emoji\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                },\n                \"attachment_storing_channel\": {\n                  \"default\": null,\n                  \"anyOf\": [\n                    {\n                      \"type\": \"string\"\n                    },\n                    {\n                      \"type\": \"null\"\n                    }\n                  ]\n                }\n              },\n              \"required\": [],\n              \"additionalProperties\": false\n            },\n            \"overrides\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"channel\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"category\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"level\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"user\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"role\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread\": {\n                    \"anyOf\": [\n                      {\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"array\",\n                            \"items\": {\n                              \"type\": \"string\"\n                            }\n                          }\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"is_thread\": {\n                    \"anyOf\": [\n                      {\n                        \"type\": \"boolean\"\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"thread_type\": {\n                    \"anyOf\": [\n                      {\n                        \"enum\": [\n                          \"public\",\n                          \"private\"\n                        ]\n                      },\n                      {\n                        \"type\": \"null\"\n                      }\n                    ]\n                  },\n                  \"extra\": {},\n                  \"zzz_dummy_property_do_not_use\": {},\n                  \"all\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"any\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/$defs/overrideCriteria\"\n                    }\n                  },\n                  \"not\": {\n                    \"$ref\": \"#/$defs/overrideCriteria\"\n                  },\n                  \"config\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                      \"success_emoji\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"error_emoji\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      },\n                      \"attachment_storing_channel\": {\n                        \"default\": null,\n                        \"anyOf\": [\n                          {\n                            \"type\": \"string\"\n                          },\n                          {\n                            \"type\": \"null\"\n                          }\n                        ]\n                      }\n                    },\n                    \"required\": [],\n                    \"additionalProperties\": false\n                  }\n                },\n                \"required\": [\n                  \"config\"\n                ],\n                \"additionalProperties\": false\n              }\n            }\n          },\n          \"required\": []\n        }\n      },\n      \"required\": [],\n      \"additionalProperties\": false\n    }\n  },\n  \"required\": [],\n  \"additionalProperties\": false,\n  \"$defs\": {\n    \"__schema0\": {\n      \"$ref\": \"#/$defs/overrideCriteria\"\n    },\n    \"overrideCriteria\": {\n      \"id\": \"overrideCriteria\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"channel\": {\n          \"anyOf\": [\n            {\n              \"anyOf\": [\n                {\n                  \"type\": \"string\"\n                },\n                {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              ]\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"category\": {\n          \"anyOf\": [\n            {\n              \"anyOf\": [\n                {\n                  \"type\": \"string\"\n                },\n                {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              ]\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"level\": {\n          \"anyOf\": [\n            {\n              \"anyOf\": [\n                {\n                  \"type\": \"string\"\n                },\n                {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              ]\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"user\": {\n          \"anyOf\": [\n            {\n              \"anyOf\": [\n                {\n                  \"type\": \"string\"\n                },\n                {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              ]\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"role\": {\n          \"anyOf\": [\n            {\n              \"anyOf\": [\n                {\n                  \"type\": \"string\"\n                },\n                {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              ]\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"thread\": {\n          \"anyOf\": [\n            {\n              \"anyOf\": [\n                {\n                  \"type\": \"string\"\n                },\n                {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              ]\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"is_thread\": {\n          \"anyOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"thread_type\": {\n          \"anyOf\": [\n            {\n              \"enum\": [\n                \"public\",\n                \"private\"\n              ]\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"extra\": {},\n        \"zzz_dummy_property_do_not_use\": {},\n        \"all\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/$defs/overrideCriteria\"\n          }\n        },\n        \"any\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/$defs/overrideCriteria\"\n          }\n        },\n        \"not\": {\n          \"$ref\": \"#/$defs/overrideCriteria\"\n        }\n      },\n      \"required\": [],\n      \"additionalProperties\": false\n    },\n    \"strictMessageContent\": {\n      \"id\": \"strictMessageContent\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"content\": {\n          \"type\": \"string\"\n        },\n        \"tts\": {\n          \"type\": \"boolean\"\n        },\n        \"embeds\": {\n          \"anyOf\": [\n            {\n              \"type\": \"array\",\n              \"items\": {\n                \"$ref\": \"#/$defs/embedInput\"\n              }\n            },\n            {\n              \"$ref\": \"#/$defs/embedInput\"\n            }\n          ]\n        },\n        \"embed\": {\n          \"$ref\": \"#/$defs/embedInput\"\n        }\n      },\n      \"required\": [],\n      \"additionalProperties\": false\n    },\n    \"embedInput\": {\n      \"id\": \"embedInput\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"title\": {\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"type\": \"string\"\n        },\n        \"url\": {\n          \"type\": \"string\"\n        },\n        \"timestamp\": {\n          \"type\": \"string\"\n        },\n        \"color\": {\n          \"type\": \"number\"\n        },\n        \"footer\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"text\": {\n              \"type\": \"string\"\n            },\n            \"icon_url\": {\n              \"type\": \"string\"\n            }\n          },\n          \"required\": [\n            \"text\"\n          ]\n        },\n        \"image\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"url\": {\n              \"type\": \"string\"\n            },\n            \"width\": {\n              \"type\": \"number\"\n            },\n            \"height\": {\n              \"type\": \"number\"\n            }\n          },\n          \"required\": []\n        },\n        \"thumbnail\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"url\": {\n              \"type\": \"string\"\n            },\n            \"width\": {\n              \"type\": \"number\"\n            },\n            \"height\": {\n              \"type\": \"number\"\n            }\n          },\n          \"required\": []\n        },\n        \"video\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"url\": {\n              \"type\": \"string\"\n            },\n            \"width\": {\n              \"type\": \"number\"\n            },\n            \"height\": {\n              \"type\": \"number\"\n            }\n          },\n          \"required\": []\n        },\n        \"provider\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"name\": {\n              \"type\": \"string\"\n            },\n            \"url\": {\n              \"type\": \"string\"\n            }\n          },\n          \"required\": [\n            \"name\"\n          ]\n        },\n        \"fields\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"name\": {\n                \"type\": \"string\"\n              },\n              \"value\": {\n                \"type\": \"string\"\n              },\n              \"inline\": {\n                \"type\": \"boolean\"\n              }\n            },\n            \"required\": []\n          }\n        },\n        \"author\": {\n          \"anyOf\": [\n            {\n              \"type\": \"object\",\n              \"properties\": {\n                \"name\": {\n                  \"type\": \"string\"\n                },\n                \"url\": {\n                  \"type\": \"string\"\n                },\n                \"width\": {\n                  \"type\": \"number\"\n                },\n                \"height\": {\n                  \"type\": \"number\"\n                }\n              },\n              \"required\": [\n                \"name\"\n              ]\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        }\n      },\n      \"required\": [],\n      \"additionalProperties\": false\n    }\n  },\n  \"$schema\": \"https://json-schema.org/draft-2020-12/schema\"\n}\n"
  },
  {
    "path": "config-checker/src/main.ts",
    "content": "import * as monaco from \"monaco-editor\";\nimport { configureMonacoYaml } from \"monaco-yaml\";\nimport schemaUri from \"/config-schema.json?url\";\n\nwindow.MonacoEnvironment = {\n  getWorker(_, label) {\n    switch (label) {\n      case \"editorWorkerService\":\n        return new Worker(new URL(\"monaco-editor/esm/vs/editor/editor.worker.js\", import.meta.url), { type: \"module\" });\n      case \"yaml\":\n        return new Worker(new URL(\"./yaml.worker.js\", import.meta.url), { type: \"module\" })\n      default:\n        throw new Error(`Unknown label ${label}`);\n    }\n  },\n};\n\nconfigureMonacoYaml(monaco, {\n  enableSchemaRequest: true,\n  schemas: [{\n    fileMatch: [\"**/config.yaml\"],\n    uri: schemaUri,\n  }],\n});\n\nconst initialModel = monaco.editor.createModel(\"# Paste your config here to check it\\n\", undefined, monaco.Uri.parse(\"file:///config.yaml\"));\ninitialModel.updateOptions({ tabSize: 2 });\n\nconst editorRoot = document.getElementById(\"editor\")!;\nconst errorsRoot = document.getElementById(\"errors\")!;\n\nmonaco.editor.defineTheme(\"zeppelin\", {\n  base: \"vs-dark\",\n  inherit: true,\n  rules: [],\n  colors: {\n    \"editor.background\": \"#00000000\",\n    \"editor.focusBorder\": \"#00000000\",\n    \"list.focusOutline\": \"#00000000\",\n    \"editorStickyScroll.background\": \"#070c11\",\n  },\n});\nmonaco.editor.create(editorRoot, {\n  automaticLayout: true,\n  model: initialModel,\n  quickSuggestions: {\n    other: true,\n    comments: true,\n    strings: true,\n  },\n  theme: \"zeppelin\",\n  minimap: {\n    enabled: false,\n  },\n});\n\nfunction showErrors(markers: monaco.editor.IMarker[]) {\n  if (markers.length) {\n    markers.sort((a, b) => a.startLineNumber - b.startLineNumber);\n    const frag = document.createDocumentFragment();\n    for (const marker of markers) {\n      const error = document.createElement(\"div\");\n      error.classList.add(\"error\");\n      \n      const lineMarker = document.createElement(\"strong\");\n      lineMarker.innerText = `Line ${marker.startLineNumber}: `;\n\n      const errorText = document.createElement(\"span\");\n      errorText.innerText = marker.message;\n\n      error.append(lineMarker, errorText);\n      frag.append(error);\n    }\n    errorsRoot.replaceChildren(frag);\n  } else {\n    const success = document.createElement(\"div\");\n    success.classList.add(\"noErrors\");\n    success.innerText = \"No errors!\";\n    errorsRoot.replaceChildren(success);\n  }\n}\n\nmonaco.editor.onDidChangeMarkers(([uri]) => {\n  const markers = monaco.editor.getModelMarkers({ resource: uri });\n  showErrors(markers);\n});\n\nshowErrors([]);\n"
  },
  {
    "path": "config-checker/src/style.css",
    "content": "*, *::before, *::after {\n  box-sizing: border-box;\n}\n\nbody {\n  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;\n  font-size: 14px;\n  background-color: black;\n  background: linear-gradient(45deg, #040a0e, #27699e);\n  color: #f8f8f8;\n  margin: 0;\n}\n\n.wrap {\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  padding: 16px;\n  gap: 16px;\n}\n\n.section {\n  background-color: #000000b8;\n  display: flex;\n  flex-direction: column;\n  border-radius: 4px;\n  overflow: hidden;\n  box-shadow: 0 0 12px rgba(0, 0, 0, 0.397);\n}\n\n.title {\n  flex: 0 0 32px;\n  background-color: #ffffff11;\n  display: flex;\n  align-items: center;\n  padding-left: 10px;\n}\n\n.title h1 {\n  margin: 0;\n  font-size: 12px;\n  line-height: 1;\n  text-transform: uppercase;\n}\n\n.content {\n  flex: 1 1 auto;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n.editor-wrap {\n  flex: 0 0 100%;\n\n  display: flex;\n  flex-direction: column;\n}\n\n#editor {\n  flex: 0 0 100%;\n}\n\n.monaco-editor {\n  outline: 0 !important;\n}\n\n.errors-wrap {\n  flex: 0 0 100%;\n\n  display: flex;\n  flex-direction: column;\n  \n  padding: 10px;\n\n  overflow-y: auto;\n}\n\n#errors {\n}\n\n.error {\n  color: hsl(10.7deg 58.76% 57.09%);\n}\n\n.noErrors {\n  color: hsl(93.81deg 56.52% 52.07%);\n  font-weight: 700;\n}\n"
  },
  {
    "path": "config-checker/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "config-checker/src/yaml.worker.js",
    "content": "import \"monaco-yaml/yaml.worker.js\";\n"
  },
  {
    "path": "config-checker/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "dashboard/.editorconfig",
    "content": "root = true\n\n[*]\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": "dashboard/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../.eslintrc.js\"],\n  \"rules\": {\n    \"@typescript-eslint/no-unused-vars\": 0,\n    \"no-self-assign\": 0,\n    \"no-empty\": 0,\n    \"@typescript-eslint/no-var-requires\": 0\n  }\n}\n"
  },
  {
    "path": "dashboard/.gitignore",
    "content": "/.cache\n/dist\n/node_modules\n"
  },
  {
    "path": "dashboard/.prettierignore",
    "content": "/dist\n"
  },
  {
    "path": "dashboard/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0\"\n    />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\" />\n    <title>Zeppelin - Moderation bot for Discord</title>\n  </head>\n  <body>\n    <noscript>\n      <h1>Zeppelin</h1>\n      The Zeppelin website requires JavaScript to load.\n    </noscript>\n    \n    <div id=\"app\"></div>\n\n    <script type=\"text/javascript\" src=\"/env.js\"></script>\n    <script type=\"module\" src=\"./src/index.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "dashboard/package.json",
    "content": "{\n  \"name\": \"@zeppelinbot/dashboard\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"devDependencies\": {\n    \"@tailwindcss/vite\": \"^4.1.8\",\n    \"@vitejs/plugin-vue\": \"^5.2.4\",\n    \"@vue/tsconfig\": \"^0.7.0\",\n    \"@zeppelinbot/shared\": \"workspace:*\",\n    \"cross-env\": \"^7.0.3\",\n    \"highlight.js\": \"^11.8.0\",\n    \"humanize-duration\": \"^3.27.0\",\n    \"js-yaml\": \"^4.1.0\",\n    \"marked\": \"^5.1.0\",\n    \"moment\": \"^2.29.4\",\n    \"postcss-nesting\": \"^13.0.1\",\n    \"tailwindcss\": \"^4.1.8\",\n    \"vite\": \"npm:rolldown-vite@latest\",\n    \"vue\": \"^3.5.13\",\n    \"vue-material-design-icons\": \"^5.3.1\",\n    \"vue-router\": \"^4.5.0\",\n    \"vue-tsc\": \"^2.2.10\",\n    \"vue3-ace-editor\": \"^2.2.4\",\n    \"vue3-highlightjs\": \"^1.0.5\",\n    \"vuex\": \"^4.1.0\"\n  },\n  \"dependencies\": {\n    \"@fastify/static\": \"^7.0.1\",\n    \"ace-builds\": \"1.43.4\",\n    \"fastify\": \"^4.26.2\"\n  },\n  \"browserslist\": [\n    \"last 2 Chrome versions\"\n  ]\n}\n"
  },
  {
    "path": "dashboard/postcss.config.js",
    "content": "import nesting from \"postcss-nesting\";\n\n/** @type {import('postcss-load-config').Config} */\nconst config = {\n  plugins: [nesting]\n}\n\nexport default config;\n"
  },
  {
    "path": "dashboard/public/env.js",
    "content": "// Don't edit this directly, it uses env vars in prod via serve.js\nwindow.API_URL = \"/api\";\n"
  },
  {
    "path": "dashboard/serve.js",
    "content": "import Fastify from \"fastify\";\nimport fastifyStatic from \"@fastify/static\";\nimport path from \"node:path\";\n\nconst fastify = Fastify({\n  // We already get logs from nginx, so disable here\n  logger: false,\n});\n\nfastify.addHook(\"preHandler\", (req, reply, done) => {\n  if (req.url === \"/env.js\") {\n    reply.header(\"Content-Type\", \"application/javascript; charset=utf8\");\n    reply.send(`window.API_URL = ${JSON.stringify(process.env.API_URL)};`);\n  }\n  done();\n});\n\nfastify.register(fastifyStatic, {\n  root: path.join(import.meta.dirname, \"dist\"),\n  wildcard: false,\n});\n\nfastify.get(\"*\", (req, reply) => {\n  reply.sendFile(\"index.html\");\n});\n\nfastify.listen({ port: 3002, host: '0.0.0.0' }, (err, address) => {\n  if (err) {\n    throw err;\n  }\n  console.log(`Server listening on ${address}`);\n});\n\nprocess.on(\"SIGTERM\", () => {\n  fastify.close().then(() => {\n    process.exit(0);\n  });\n});\n"
  },
  {
    "path": "dashboard/src/api.ts",
    "content": "import { RootStore } from \"./store\";\n\ntype QueryParamObject = { [key: string]: string | null };\n\nexport class ApiError extends Error {\n  public body: any;\n  public status: number;\n  public res: Response;\n\n  constructor(message: string, body: object, status: number, res: Response) {\n    super(message);\n    this.body = body;\n    this.status = status;\n    this.res = res;\n  }\n}\n\nfunction buildQueryString(params: QueryParamObject) {\n  if (Object.keys(params).length === 0) return \"\";\n\n  return (\n    \"?\" +\n    Array.from(Object.entries(params))\n      .map((pair) => `${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1] || \"\")}`)\n      .join(\"&\")\n  );\n}\n\nexport function request(resource, fetchOpts: RequestInit = {}) {\n  return fetch(`${window.API_URL}/${resource}`, fetchOpts).then(async (res) => {\n    if (!res.ok) {\n      if (res.status === 401) {\n        RootStore.dispatch(\"auth/expiredLogin\");\n        return;\n      }\n\n      const body = await res.json();\n      throw new ApiError(res.statusText, body, res.status, res);\n    }\n\n    return res.json();\n  });\n}\n\nexport function get(resource: string, params: QueryParamObject = {}) {\n  const headers: Record<string, string> = RootStore.state.auth.apiKey\n    ? { \"X-Api-Key\": RootStore.state.auth.apiKey }\n    : {};\n  return request(resource + buildQueryString(params), {\n    method: \"GET\",\n    headers,\n  });\n}\n\nexport function post(resource: string, params: QueryParamObject = {}) {\n  const headers: Record<string, string> = RootStore.state.auth.apiKey\n    ? { \"X-Api-Key\": RootStore.state.auth.apiKey }\n    : {};\n  return request(resource, {\n    method: \"POST\",\n    body: JSON.stringify(params),\n    headers: {\n      ...headers,\n      \"Content-Type\": \"application/json\",\n    },\n  });\n}\n\ntype FormPostOpts = {\n  target?: string;\n};\n\nexport function formPost(resource: string, body: Record<any, any> = {}, opts: FormPostOpts = {}) {\n  body[\"X-Api-Key\"] = RootStore.state.auth.apiKey;\n  const form = document.createElement(\"form\");\n  form.action = `${window.API_URL}/${resource}`;\n  form.method = \"POST\";\n  form.enctype = \"multipart/form-data\";\n  if (opts.target != null) {\n    form.target = opts.target;\n  }\n  for (const [key, value] of Object.entries(body)) {\n    const input = document.createElement(\"input\");\n    input.type = \"hidden\";\n    input.name = key;\n    input.value = value;\n    form.appendChild(input);\n  }\n  document.body.appendChild(form);\n  form.submit();\n\n  setTimeout(() => {\n    document.body.removeChild(form);\n  }, 1);\n}\n"
  },
  {
    "path": "dashboard/src/auth.ts",
    "content": "import { NavigationGuard } from \"vue-router\";\nimport { RootStore } from \"./store\";\n\nconst isAuthenticated = async () => {\n  if (RootStore.state.auth.apiKey) return true; // We have an API key -> authenticated\n  if (RootStore.state.auth.loadedInitialAuth) return false; // No API key and initial auth data was already loaded -> not authenticated\n  await RootStore.dispatch(\"auth/loadInitialAuth\"); // Initial auth data wasn't loaded yet (per above check) -> load it now\n  if (RootStore.state.auth.apiKey) return true;\n  return false; // Still no API key -> not authenticated\n};\n\nexport const authGuard: NavigationGuard = async (to, from, next) => {\n  if (await isAuthenticated()) return next();\n  window.location.href = `${window.API_URL}/auth/login`;\n};\n\nexport const loginCallbackGuard: NavigationGuard = async (to, from, next) => {\n  if (to.query.apiKey) {\n    await RootStore.dispatch(\"auth/setApiKey\", { key: to.query.apiKey });\n    window.location.href = \"/dashboard\";\n  } else {\n    window.location.href = `/?error=noAccess`;\n  }\n  return next();\n};\n\nexport const authRedirectGuard: NavigationGuard = async (to, form, next) => {\n  if (await isAuthenticated()) return next(\"/dashboard\");\n  window.location.href = `${window.API_URL}/auth/login`;\n  return next();\n};\n"
  },
  {
    "path": "dashboard/src/components/App.vue",
    "content": "<template>\n\t<router-view></router-view>\n</template>\n"
  },
  {
    "path": "dashboard/src/components/Expandable.vue",
    "content": "<template>\n  <div class=\"expandable mb-4 bg-gray-800 border border-gray-600 rounded overflow-hidden\"\n       ref=\"root\"\n       v-bind:class=\"{ 'shadow-xl': isOpen}\">\n    <div role=\"button\"\n         class=\"title p-2 focus:bg-gray-700\"\n         v-on:click=\"toggle\"\n         v-on:keydown.space=\"$event.preventDefault()\"\n         v-on:keyup.space=\"toggle\"\n         tabindex=\"0\">\n      <chevron-down decorative class=\"icon\" v-bind:class=\"{'icon-open': isOpen}\" />\n      <span class=\"title-text\"><slot name=\"title\"></slot></span>\n    </div>\n    <div class=\"content border-t border-gray-700\" ref=\"content\">\n      <div class=\"p-4 pb-0\">\n        <slot name=\"content\"></slot>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n  @reference \"../style/app.css\";\n\n  .expandable {\n    --animation-time: 400ms;\n    --target-height: auto;\n    transition: box-shadow var(--animation-time); /* hi */\n\n    & > .title {\n      &:hover {\n        & .title-text {\n          @apply underline;\n        }\n      }\n\n      & .icon {\n        transition: transform var(--animation-time);\n        transform-origin: 50% 50%;\n        position: relative;\n        top: 0.125rem;\n      }\n\n      & .icon-open {\n        transform: rotate(179deg);\n      }\n    }\n\n    & > .content {\n      overflow: hidden;\n      display: none;\n    }\n  }\n\n  @keyframes open {\n    0% { height: 0; }\n    100% { height: var(--target-height); }\n  }\n\n  @keyframes close {\n    100% { height: 0; }\n    0% { height: var(--target-height); }\n  }\n\n  .opening {\n    animation: open var(--animation-time) ease-in-out;\n  }\n\n  .closing {\n    animation: close var(--animation-time) ease-in-out;\n  }\n\n  .inline-code,\n  code:not([class]),\n  :deep(code:not([class])) {\n    @apply bg-gray-900;\n  }\n\n  :deep(.codeblock) {\n    box-shadow: none;\n  }\n</style>\n\n<script type=\"ts\">\n  import ChevronDown from 'vue-material-design-icons/ChevronDown.vue';\n\n  const ANIMATION_TIME = 400;\n\n  export default {\n    components: { ChevronDown },\n    mounted() {\n      this.$refs.root.style.setProperty('--animation-time', `${ANIMATION_TIME}ms`);\n    },\n    data() {\n      return {\n        isOpen: false,\n        animating: false,\n      };\n    },\n    methods: {\n      toggle() {\n        if (this.isOpen) this.close();\n        else this.open();\n      },\n      open() {\n        if (this.animating) return;\n        this.animating = true;\n        this.isOpen = true;\n\n        this.$refs.content.style.display = 'block';\n        const targetHeight = this.$refs.content.clientHeight;\n        this.$refs.content.style.setProperty('--target-height', `${targetHeight}px`);\n        this.$refs.content.classList.add('opening');\n\n        setTimeout(() => {\n          this.$refs.content.classList.remove('opening');\n          this.animating = false;\n        }, ANIMATION_TIME);\n      },\n      close() {\n        if (this.animating) return;\n        this.animating = true;\n        this.isOpen = false;\n\n        const targetHeight = this.$refs.content.clientHeight;\n        this.$refs.content.style.setProperty('--target-height', `${targetHeight}px`);\n        this.$refs.content.classList.add('closing');\n\n        setTimeout(() => {\n          this.$refs.content.classList.remove('closing');\n          this.$refs.content.style.display = 'none';\n          this.animating = false;\n        }, ANIMATION_TIME);\n      },\n    },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/PrivacyPolicy.vue",
    "content": "<template>\n  <div class=\"privacy-policy\">\n    <div class=\"wrapper\">\n      <h1>Zeppelin Privacy Policy</h1>\n\n      <h2>Zeppelin overview</h2>\n      <p>\n        Zeppelin is a moderation bot for Discord that allows server staff to\n        carry out moderator actions (warn, mute, kick, ban, clean messages, view\n        user information, etc.),\n        keep records of infractions, perform automated actions (\"automod\", e.g.\n        message filtering), post detailed logs on logging channels, and set up\n        systems such as reaction roles.\n        The bot also includes a web dashboard that server administrators can log\n        in to through Discord OAuth.\n      </p>\n      <p>\n        The bot's source code is available at\n        <a href=\"https://github.com/ZeppelinBot/Zeppelin\">\n          https://github.com/ZeppelinBot/Zeppelin\n        </a>\n      </p>\n\n      <h2>Stored data</h2>\n      <p>\n        When Zeppelin is used by a server, the following categories data can be\n        stored by the bot.\n        The specific categories of data saved for each server depends on how the\n        server has configured Zeppelin.\n      </p>\n      <ul>\n        <li>Recent messages and username/nickname changes of users engaged on\n          the server\n        </li>\n        <li>Recent bulk deleted messages</li>\n        <li>Basic user information, moderator-entered text, and relevant message\n          archives for infraction records\n        </li>\n        <li>A subset of previously held roles and nickname on the server to be\n          restored when a user rejoins\n        </li>\n        <li>Basic server details of the server using the bot</li>\n      </ul>\n      <p>\n        Additionally, when a user logs in to the web dashboard the following\n        types of data are stored:\n      </p>\n      <ul>\n        <li>Basic Discord user information</li>\n        <li>Time and originating IP address of the login for security audit\n          purposes\n        </li>\n      </ul>\n\n      <h2>Data retention</h2>\n      <ul>\n        <li>\n          Recent messages are stored for 24h\n          <ul>\n            <li>Deleted messages within this 24h are cleared 5 minutes after\n              deletion\n            </li>\n          </ul>\n        </li>\n        <li>5 most recent usernames and 10 most recent nicknames of users\n          engaged in chat or voice channels are stored for 30 days\n        </li>\n        <li>Archives of bulk-deleted messages are stored for 30 days</li>\n        <li>Infraction record data is kept until the server stops using Zeppelin\n          unless explicitly deleted\n        </li>\n        <li>Roles and nicknames that are restored on rejoin are cleared when the\n          user rejoins\n        </li>\n        <li>User information for users logged in to the bot's web dashboard via\n          Discord OAuth is stored as long as the server uses Zeppelin\n        </li>\n      </ul>\n\n      <h2>Data access and deletion requests</h2>\n      <p>\n        To request access to personal data stored about you, or to request its\n        deletion, to the extent permitted by GDPR, please send an email to <a\n        href=\"mailto:contact@mivir.fi\">contact@mivir.fi</a>.\n      </p>\n    </div></div>\n</template>\n\n<script type=\"ts\">\n  import \"../style/privacy-policy.css\";\n  export default {};\n</script>\n"
  },
  {
    "path": "dashboard/src/components/Splash.vue",
    "content": "<template>\n  <div class=\"splash\">\n    <div class=\"error\" v-if=\"error\">\n      <div class=\"message\">{{ error }}</div>\n    </div>\n    <div class=\"wrapper\">\n      <div class=\"logo-column\">\n        <img class=\"logo\" src=\"/img/logo.png\" alt=\"Zeppelin Logo\" />\n      </div>\n      <div class=\"info-column\">\n        <h1>Zeppelin</h1>\n        <div class=\"description\">\n          Zeppelin is a private moderation bot for Discord, designed with large servers and reliability in mind.\n        </div>\n        <div class=\"actions\">\n          <a class=\"btn\" href=\"/dashboard\">Dashboard</a>\n          <a class=\"btn\" href=\"/docs\">Documentation</a>\n        </div>\n        <ul class=\"links\">\n          <li>\n            <a href=\"https://discord.gg/zeppelin\">Official Discord Server</a>\n          </li>\n          <li>\n            <a href=\"https://github.com/Dragory/ZeppelinBot\">GitHub</a>\n          </li>\n          <li>\n            <a href=\"/privacy-policy\">Privacy Policy</a>\n          </li>\n        </ul>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, watch } from 'vue';\nimport { useRoute } from 'vue-router';\n\nconst errorMessages = {\n  noAccess: \"No dashboard access. If you think this is a mistake, please contact your server owner.\",\n  expiredLogin: \"Dashboard login expired. Please log in again.\",\n};\n\nconst route = useRoute();\nconst error = ref<string | null>(null);\n\nwatch(\n  () => route.query.error,\n  (value) => {\n    error.value = errorMessages[String(value)] || null;\n  },\n);\n</script>\n"
  },
  {
    "path": "dashboard/src/components/Tab.vue",
    "content": "<template>\n  <li style=\"padding-bottom: 1px\" :class=\"{active: active}\">\n    <slot></slot>\n  </li>\n</template>\n\n<style scoped>\n  @reference \"../style/app.css\";\n\n  li {\n    padding-bottom: 1px;\n\n    &.active {\n      padding-bottom: 0;\n      @apply border-b;\n      @apply border-gray-400;\n    }\n  }\n\n  :deep(a) {\n    @apply block;\n    @apply py-2;\n    @apply px-4;\n    @apply text-gray-500;\n\n    &:hover {\n      @apply text-gray-200;\n    }\n  }\n\n  .active :deep(a) {\n    @apply text-gray-200;\n  }\n</style>\n\n<script lang=\"ts\">\n  export default {\n    props: [\"active\"],\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/Tabs.vue",
    "content": "<template>\n  <ul class=\"list-none flex border-b border-gray-600 mb-4\">\n    <slot></slot>\n  </ul>\n</template>\n"
  },
  {
    "path": "dashboard/src/components/Title.vue",
    "content": "<template></template>\n\n<script lang=\"ts\">\n  export default {\n    props: ['title'],\n    watch: {\n      title: {\n        immediate: true,\n        handler() {\n          document.title = this.title;\n        },\n      },\n    },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/dashboard/GuildAccess.vue",
    "content": "<template>\n  <div>\n    <h1>Dashboard access</h1>\n    <p>\n      On this page you can manage who has access to the server's Zeppelin dashboard.\n    </p>\n\n    <h2 class=\"mt-8\">Roles</h2>\n    <ul>\n      <li>\n        <strong>Owner:</strong> All permissions. Managed automatically by the bot.\n      </li>\n      <li>\n        <strong>Bot manager:</strong> Can manage dashboard users (including other bot managers) and edit server configuration\n      </li>\n      <li>\n        <strong>Bot operator:</strong> Can edit server configuration\n      </li>\n    </ul>\n\n    <h2 class=\"mt-8\">Dashboard users</h2>\n    <div class=\"mt-4\">\n      <div v-if=\"permanentPermissionAssignments.length === 0\">\n        No dashboard users\n      </div>\n      <ul v-if=\"permanentPermissionAssignments.length\">\n        <li v-for=\"perm in permanentPermissionAssignments\">\n          <div class=\"flex gap-4\">\n            <div>\n              <strong>{{ perm.target_id }}</strong>\n            </div>\n            <div class=\"flex gap-4\">\n              <label class=\"block\" v-if=\"isOwner(perm)\">\n                <input type=\"checkbox\" checked=\"checked\" disabled>\n                Owner\n              </label>\n              <label class=\"block\">\n                <input\n                  type=\"checkbox\"\n                  :checked=\"hasPermission(perm, 'MANAGE_ACCESS')\"\n                  @change=\"ev => setPermissionValue(perm, 'MANAGE_ACCESS', ev.target.checked)\"\n                  :disabled=\"hasPermissionIndirectly(perm, 'MANAGE_ACCESS')\"\n                >\n                Bot manager\n              </label>\n              <label class=\"block\">\n                <input\n                  type=\"checkbox\"\n                  :checked=\"hasPermission(perm, 'EDIT_CONFIG')\"\n                  @change=\"ev => setPermissionValue(perm, 'EDIT_CONFIG', ev.target.checked)\"\n                  :disabled=\"hasPermissionIndirectly(perm, 'EDIT_CONFIG')\"\n                >\n                Bot operator\n              </label>\n              <a href=\"#\" v-on:click=\"deletePermissionAssignment(perm)\" v-if=\"!isOwner(perm)\">\n                Delete\n              </a>\n            </div>\n          </div>\n        </li>\n      </ul>\n      <div class=\"mt-2\">\n        <a href=\"#\" v-on:click=\"addPermissionAssignment()\">\n          Add new user\n        </a>\n      </div>\n    </div>\n\n    <h2 class=\"mt-8\">Temporary dashboard users</h2>\n    <p>\n      You can add temporary dashboard users to e.g. request help from a person outside your organization.<br>\n      Temporary users always have <strong>Bot operator</strong> permissions.\n    </p>\n    <div v-if=\"temporaryPermissionAssignments.length === 0\">\n      No temporary dashboard users\n    </div>\n    <ul v-if=\"temporaryPermissionAssignments.length\">\n      <li v-for=\"perm in temporaryPermissionAssignments\">\n        <div class=\"flex gap-4\">\n          <div>\n            <strong>{{ perm.target_id }}</strong>\n          </div>\n          <div>\n            Expires in {{ formatTimeRemaining(perm) }}\n          </div>\n          <div>\n            <a href=\"#\" v-on:click=\"deletePermissionAssignment(perm)\">\n              Delete\n            </a>\n          </div>\n        </div>\n      </li>\n    </ul>\n    <div class=\"mt-2\">\n      <a href=\"#\" v-on:click=\"addTemporaryPermissionAssignment()\">\n        Add temporary user for 1 hour\n      </a>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { ApiPermissions, hasPermission } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport PermissionTree from \"./PermissionTree.vue\";\nimport { mapState } from \"vuex\";\nimport {\n  GuildPermissionAssignment,\n  GuildState,\n  RootState\n} from \"../../store/types\";\nimport humanizeDuration from \"humanize-duration\";\nimport moment from \"moment\";\n\nexport default {\n    components: {PermissionTree},\n\n    data() {\n      return {\n        managerPermissions: new Set([ApiPermissions.ManageAccess]),\n      };\n    },\n\n    computed: {\n      ...mapState({\n        canManage(state: RootState): boolean {\n          const guildPermissions = state.guilds.guildPermissionAssignments[this.$route.params.guildId] || [];\n          const myPermissions = guildPermissions.find(p => p.type === \"USER\" && p.target_id === state.auth.userId) || null;\n          return myPermissions && hasPermission(myPermissions.permissions, ApiPermissions.ManageAccess);\n        },\n      }),\n      ...mapState<GuildState>(\"guilds\", {\n        permanentPermissionAssignments(guilds: GuildState): GuildPermissionAssignment[] {\n          return (guilds.guildPermissionAssignments[this.$route.params.guildId] || []).filter(perm => perm.expires_at == null);\n        },\n\n        temporaryPermissionAssignments(guilds: GuildState): GuildPermissionAssignment[] {\n          return (guilds.guildPermissionAssignments[this.$route.params.guildId] || []).filter(perm => perm.expires_at != null);\n        },\n      }),\n    },\n\n    async mounted() {\n      await this.$store.dispatch(\"guilds/loadGuildPermissionAssignments\", this.$route.params.guildId).catch(() => {});\n\n      if (! this.canManage) {\n        this.$router.push('/dashboard');\n        return;\n      }\n    },\n    methods: {\n      isOwner(perm: GuildPermissionAssignment) {\n        return perm.permissions.has(ApiPermissions.Owner);\n      },\n\n      hasPermission(perm: GuildPermissionAssignment, permissionName: ApiPermissions) {\n        return hasPermission(perm.permissions, permissionName);\n      },\n\n      hasPermissionIndirectly(perm: GuildPermissionAssignment, permissionName: ApiPermissions) {\n        return hasPermission(perm.permissions, permissionName) && ! perm.permissions.has(permissionName);\n      },\n\n      setPermissionValue(perm: GuildPermissionAssignment, permissionName: ApiPermissions, value) {\n        if (value) {\n          perm.permissions.add(permissionName);\n        } else {\n          perm.permissions.delete(permissionName);\n        }\n\n        this.$store.dispatch(\"guilds/setTargetPermissions\", {\n          guildId: this.$route.params.guildId,\n          type: perm.type,\n          targetId: perm.target_id,\n          permissions: Array.from(perm.permissions),\n          expiresAt: null,\n        });\n\n        perm.permissions = new Set(perm.permissions);\n      },\n\n      onTreeUpdate(targetPermissions) {\n        this.$store.dispatch(\"guilds/setTargetPermissions\", {\n          guildId: this.$route.params.guildId,\n          targetId: targetPermissions.target_id,\n          type: targetPermissions.type,\n          permissions: targetPermissions.permissions,\n        });\n      },\n\n      formatTimeRemaining(perm: GuildPermissionAssignment) {\n        const ms = Math.max(moment.utc(perm.expires_at).valueOf() - Date.now(), 0);\n        return humanizeDuration(ms, { largest: 2, round: true });\n      },\n\n      addPermissionAssignment() {\n        const userId = window.prompt(\"Enter user ID\");\n        if (!userId) {\n          return;\n        }\n\n        this.$store.dispatch(\"guilds/setTargetPermissions\", {\n          guildId: this.$route.params.guildId,\n          type: \"USER\",\n          targetId: userId,\n          permissions: [ApiPermissions.EditConfig],\n          expiresAt: null,\n        });\n      },\n\n      addTemporaryPermissionAssignment() {\n        const userId = window.prompt(\"Enter user ID\");\n        if (!userId) {\n          return;\n        }\n\n        const expiresAt = moment.utc().add(1, \"hour\").format(\"YYYY-MM-DD HH:mm:ss\");\n        this.$store.dispatch(\"guilds/setTargetPermissions\", {\n          guildId: this.$route.params.guildId,\n          type: \"USER\",\n          targetId: userId,\n          permissions: [ApiPermissions.EditConfig],\n          expiresAt,\n        });\n      },\n\n      deletePermissionAssignment(perm: GuildPermissionAssignment) {\n        const confirm = window.confirm(`Remove ${perm.target_id} from dashboard users?`);\n        if (! confirm) {\n          return;\n        }\n\n        this.$store.dispatch(\"guilds/setTargetPermissions\", {\n          guildId: this.$route.params.guildId,\n          type: perm.type,\n          targetId: perm.target_id,\n          permissions: [],\n          expiresAt: null,\n        });\n      },\n    }\n  }\n</script>\n"
  },
  {
    "path": "dashboard/src/components/dashboard/GuildConfigEditor.vue",
    "content": "<template>\n  <div v-if=\"loading\">\n    Loading...\n  </div>\n  <div v-else>\n    <div v-if=\"errors.length\" class=\"bg-gray-800 py-2 px-3 rounded shadow-md mb-4\">\n      <div class=\"font-semibold\">Errors:</div>\n      <pre v-for=\"error in errors\">{{ error }}</pre>\n    </div>\n\n    <div class=\"flex items-center flex-wrap\">\n      <h1 class=\"flex-full md:flex-auto\">Config for {{ guild.name }}</h1>\n      <button v-if=\"!saving\" class=\"flex-none bg-green-800 px-5 py-2 rounded hover:bg-green-700\" v-on:click=\"save\">\n        <span v-if=\"saved\">Saved!</span>\n        <span v-else>Save</span>\n      </button>\n      <div v-if=\"saving\" class=\"flex-none bg-gray-700 px-5 py-2 rounded\">\n        Saving...\n      </div>\n    </div>\n\n    <v-ace-editor class=\"rounded shadow-lg border border-gray-700 mt-4\"\n               v-model:value=\"editableConfig\"\n               @init=\"editorInit\"\n               lang=\"yaml\"\n               theme=\"tomorrow_night\"\n               ref=\"aceEditor\"\n               :options=\"{\n                  useSoftTabs: true,\n                  tabSize: 2\n                }\"\n                :style=\"{\n                  width: editorWidth + 'px',\n                  height: editorHeight + 'px',\n                }\" />\n  </div>\n</template>\n\n<script lang=\"ts\">\n  import {mapState} from \"vuex\";\n  import {ApiError} from \"../../api\";\n  import { GuildState } from \"../../store/types\";\n\n  import { VAceEditor } from \"vue3-ace-editor\";\n\n  import \"ace-builds/src-noconflict/ext-language_tools\";\n  import 'ace-builds/src-noconflict/ext-searchbox';\n  import \"ace-builds/src-noconflict/mode-yaml\";\n  import \"ace-builds/src-noconflict/theme-tomorrow_night\";\n\n  let editorKeybindListener;\n  let windowResizeListener;\n\n  export default {\n    components: {\n      VAceEditor,\n    },\n    async mounted() {\n      try {\n        await this.$store.dispatch(\"guilds/loadGuild\", this.$route.params.guildId);\n      } catch (err) {\n        if (err instanceof ApiError) {\n          this.$router.push('/dashboard');\n          return;\n        }\n\n        throw err;\n      }\n\n      if (this.guild == null) {\n        this.$router.push('/dashboard');\n        return;\n      }\n\n      await this.$store.dispatch(\"guilds/loadConfig\", this.$route.params.guildId);\n      this.editableConfig = this.config || \"\";\n      this.loading = false;\n    },\n    beforeRouteLeave(to, from, next) {\n      if (editorKeybindListener) {\n        window.removeEventListener(\"keydown\", editorKeybindListener);\n        editorKeybindListener = null;\n      }\n\n      if (windowResizeListener) {\n        window.removeEventListener(\"resize\", windowResizeListener);\n        windowResizeListener = null;\n      }\n\n      next();\n    },\n    data() {\n      return {\n        loading: true,\n        saving: false,\n        saved: false,\n        editableConfig: null,\n        errors: [],\n        editorWidth: 900,\n        editorHeight: 600,\n        savedTimeout: null,\n      };\n    },\n    computed: {\n      ...mapState(\"guilds\", {\n        guild(guilds: GuildState) {\n          return guilds.available.get(this.$route.params.guildId);\n        },\n        config(guilds: GuildState) {\n          return guilds.configs[this.$route.params.guildId];\n        },\n      }),\n    },\n    methods: {\n      editorInit() {\n        // Add Ctrl+S/Cmd+S save shortcut\n        const isMac = /mac/i.test(navigator.platform);\n        const modKeyPressed = (ev: KeyboardEvent) => (isMac ? ev.metaKey : ev.ctrlKey);\n        const nonModKeyPressed = (ev: KeyboardEvent) => (isMac ? ev.ctrlKey : ev.metaKey);\n        const shortcutModifierPressed = (ev: KeyboardEvent) => modKeyPressed(ev) && !nonModKeyPressed(ev) && !ev.altKey;\n\n        if (editorKeybindListener) {\n          // Make sure we clean up any potentially leftover event listeners\n          window.removeEventListener(\"keydown\", editorKeybindListener);\n        }\n\n        editorKeybindListener = (ev: KeyboardEvent) => {\n          if (shortcutModifierPressed(ev) && ev.key === \"s\") {\n            ev.preventDefault();\n            this.save();\n            return;\n          }\n\n          if (shortcutModifierPressed(ev) && ev.key === \"f\") {\n            ev.preventDefault();\n            this.$refs.aceEditor.getAceInstance().execCommand(\"find\");\n            return;\n          }\n        };\n        window.addEventListener(\"keydown\", editorKeybindListener);\n\n        // Auto-fit editor to window\n        this.fitEditorToWindow();\n\n        if (windowResizeListener) {\n          window.removeEventListener(\"resize\", windowResizeListener);\n        }\n\n        let debounceTimeout;\n        windowResizeListener = (ev: UIEvent) => {\n          if (debounceTimeout) {\n            clearTimeout(debounceTimeout);\n          }\n\n          debounceTimeout = setTimeout(() => {\n            this.fitEditorToWindow();\n          }, 350);\n        };\n        window.addEventListener(\"resize\", windowResizeListener);\n      },\n      fitEditorToWindow() {\n        const mainContainer = document.querySelector('.dashboard');\n        const mainContainerStyles = window.getComputedStyle(mainContainer);\n\n        const editorElem = this.$refs.aceEditor.$el;\n        const newWidth = editorElem.parentNode.clientWidth;\n        const rect = editorElem.getBoundingClientRect();\n        const newHeight = Math.round(window.innerHeight - rect.top - parseInt(mainContainerStyles.paddingLeft, 10));\n        this.resizeEditor(newWidth, newHeight);\n      },\n      resizeEditor(newWidth, newHeight) {\n        this.editorWidth = newWidth;\n        this.editorHeight = newHeight;\n\n        this.$nextTick(() => {\n          this.$refs.aceEditor.getAceInstance().resize();\n        });\n      },\n      async save() {\n        if (this.saving) return;\n\n        this.saved = false;\n        this.saving = true;\n        this.errors = [];\n\n        if (this.savedTimeout) {\n          clearTimeout(this.savedTimeout);\n        }\n\n        const minWaitTime = new Promise(resolve => setTimeout(resolve, 300));\n\n        try {\n          await this.$store.dispatch(\"guilds/saveConfig\", {\n            guildId: this.$route.params.guildId,\n            config: this.editableConfig,\n          });\n          await minWaitTime;\n\n          this.saving = false;\n          this.saved = true;\n          this.savedTimeout = setTimeout(() => this.saved = false, 3000);\n        } catch (e) {\n          if (e instanceof ApiError && (e.status === 400 || e.status === 422)) {\n            this.errors = e.body.errors || ['Error while saving config'];\n            this.saving = false;\n            return;\n          }\n\n          throw e;\n        }\n      },\n    },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/dashboard/GuildImportExport.vue",
    "content": "<template>\n  <div>\n    <h1>Import / Export</h1>\n    <p>\n      <strong>Note!</strong>\n      This feature is currently experimental. Make sure to always export a backup before importing server data. If you encounter any issues, please report them on the <a href=\"https://discord.gg/zeppelin\">Zeppelin Discord Server</a>.\n    </p>\n\n    <h2>Export server data</h2>\n    <button class=\"inline-block bg-gray-700 rounded px-1 hover:bg-gray-800 hover:bg-gray-800\" @click=\"runExport()\" :disabled=\"exporting\">Export data</button>\n\n    <p v-if=\"exporting\">Opened data export in new window!</p>\n    <p v-else>&nbsp;</p>\n\n    <h2>Import server data</h2>\n    <p>\n      <strong>Note!</strong>\n      Always take a backup of your existing data above before importing.\n    </p>\n    <div class=\"mb-4\">\n      <h3>Import file</h3>\n      <input type=\"file\" @change=\"selectImportFile($event.target.files[0])\">\n    </div>\n    <div class=\"mb-4\">\n      <h3>Case options</h3>\n      <label><input type=\"radio\" v-model=\"importCaseMode\" value=\"bumpImportedCases\"> Leave existing case numbers, start imported cases from the end</label><br>\n      <label><input type=\"radio\" v-model=\"importCaseMode\" value=\"bumpExistingCases\"> Leave imported case numbers, re-number existing cases to start after imported cases</label><br>\n      <label><input type=\"radio\" v-model=\"importCaseMode\" value=\"replace\"> Replace existing cases (!! THIS WILL DELETE ALL EXISTING CASES !!)</label>\n    </div>\n    <button class=\"inline-block bg-gray-700 rounded px-1 hover:bg-gray-800\" :class=\"{ 'bg-gray-800': importFile == null, 'hover:bg-gray-800': importFile != null }\" @click=\"runImport()\" :disabled=\"importFile == null\">Import selected file</button>\n\n    <p v-if=\"importError\">Error: {{ importError }}</p>\n    <p v-else-if=\"importing\">Importing...</p>\n    <p v-else>&nbsp;</p>\n  </div>\n</template>\n\n<script lang=\"ts\">\nimport { mapState } from \"vuex\";\nimport { ApiPermissions, hasPermission } from \"@zeppelinbot/shared/apiPermissions.js\";\nimport { AuthState, GuildState } from \"../../store/types\";\nimport { ApiError, formPost } from \"../../api\";\nimport moment from \"moment\";\n\nexport default {\n  async mounted() {\n    try {\n      await this.$store.dispatch(\"guilds/loadGuild\", this.$route.params.guildId);\n    } catch (err) {\n      if (err instanceof ApiError) {\n        this.$router.push('/dashboard');\n        return;\n      }\n\n      throw err;\n    }\n\n    if (this.guild == null) {\n      this.$router.push('/dashboard');\n      return;\n    }\n\n    this.loading = false;\n  },\n  computed: {\n    ...mapState(\"guilds\", {\n      guild(guilds: GuildState) {\n        return guilds.available.get(this.$route.params.guildId);\n      },\n    }),\n  },\n  data() {\n    return {\n      loading: true,\n\n      importing: false,\n      importError: null,\n      importFile: null,\n      importCaseMode: \"bumpImportedCases\",\n\n      exporting: false,\n    };\n  },\n  methods: {\n    selectImportFile(file: File) {\n      this.importFile = file;\n    },\n    async runImport() {\n      if (this.importing) {\n        return;\n      }\n\n      if (! this.importFile) {\n        return;\n      }\n\n      this.importError = null;\n      this.importing = true;\n\n      try {\n        await this.$store.dispatch(\"guilds/importData\", {\n          guildId: this.$route.params.guildId,\n          data: JSON.parse(await (this.importFile as File).text()),\n          caseHandlingMode: this.importCaseMode,\n        });\n      } catch (err) {\n        this.importError = err.body?.error ?? String(err);\n        return;\n      } finally {\n        this.importing = false;\n        this.importFile = null;\n      }\n\n      window.alert(\"Data imported successfully!\");\n    },\n    async runExport() {\n      if (this.exporting) {\n        return;\n      }\n\n      this.exporting = true;\n\n      formPost(`guilds/${this.$route.params.guildId}/export`, {}, { target: \"_blank\" });\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "dashboard/src/components/dashboard/GuildInfo.vue",
    "content": "<template>\n  <div>\n    <h1>Guild Info</h1>\n    <p>\n      <img class=\"inline-block w-16 mr-4\" style=\"vertical-align: -20px\" src=\"/img/squint.png\"> What are you doing here\n    </p>\n  </div>\n</template>\n"
  },
  {
    "path": "dashboard/src/components/dashboard/GuildList.vue",
    "content": "<template>\n\t<div v-if=\"loading\">\n\t\tLoading...\n\t</div>\n\t<div v-else>\n\t\t<h1>Guilds</h1>\n    <ul class=\"list-none flex flex-wrap -m-4 pt-4\">\n      <li v-for=\"guild in guilds\" class=\"flex-none p-4 w-full md:w-1/2 lg:w-1/3 xl:w-1/4\">\n        <div class=\"flex items-center\">\n          <div class=\"flex-none w-12 h-12\">\n            <img v-if=\"guild.icon\" class=\"rounded-full w-full h-full\" :src=\"guild.icon\" alt=\"\" :title=\"'Logo for guild ' + guild.name\">\n            <div v-else class=\"bg-gray-700 rounded-full w-full h-full\"></div>\n          </div>\n          <div class=\"flex-auto ml-4\">\n            <div>\n              <div class=\"font-semibold leading-tight\">{{ guild.name }}</div>\n              <div class=\"text-gray-600 text-sm leading-tight\">{{ guild.id }}</div>\n            </div>\n            <div class=\"pt-1\">\n              <router-link class=\"inline-block bg-gray-700 rounded px-1 hover:bg-gray-800\" :to=\"'/dashboard/guilds/' + guild.id + '/config'\">Config</router-link>\n              <router-link v-if=\"canManageAccess(guild.id)\" class=\"inline-block bg-gray-700 rounded px-1 hover:bg-gray-800\" :to=\"'/dashboard/guilds/' + guild.id + '/access'\">Access</router-link>\n              <router-link v-if=\"canManageAccess(guild.id)\" class=\"inline-block bg-gray-700 rounded px-1 hover:bg-gray-800\" :to=\"'/dashboard/guilds/' + guild.id + '/import-export'\">Import/export</router-link>\n            </div>\n          </div>\n        </div>\n      </li>\n    </ul>\n\t</div>\n</template>\n\n<script lang=\"ts\">\n  import { mapState } from \"vuex\";\n  import { ApiPermissions, hasPermission } from \"@zeppelinbot/shared/apiPermissions.js\";\n  import { AuthState, GuildState } from \"../../store/types\";\n\n  export default {\n    async mounted() {\n      await this.$store.dispatch(\"guilds/loadAvailableGuilds\");\n      await this.$store.dispatch(\"guilds/loadMyPermissionAssignments\");\n      this.loading = false;\n    },\n    data() {\n      return { loading: true };\n    },\n    computed: {\n      ...mapState('guilds', {\n        guilds: (state: GuildState) => {\n          const guilds = Array.from(state.available.values());\n          guilds.sort((a, b) => {\n            if (a.name > b.name) return 1;\n            if (a.name < b.name) return -1;\n            if (a.id > b.id) return 1;\n            if (a.id < b.id) return -1;\n            return 0;\n          });\n          return guilds;\n        },\n\n        guildPermissionAssignments: (state: GuildState) => state.guildPermissionAssignments,\n      }),\n\n      ...mapState('auth', {\n        userId: (state: AuthState) => state.userId!,\n      }),\n    },\n    methods: {\n      canManageAccess(guildId: string) {\n        const guildPermissions = this.guildPermissionAssignments[guildId] || [];\n        const myPermissions = guildPermissions.find(p => p.type === \"USER\" && p.target_id === this.userId) || null;\n        return myPermissions && hasPermission(new Set(myPermissions.permissions), ApiPermissions.ManageAccess);\n      },\n    },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/dashboard/Layout.vue",
    "content": "<template>\n  <div class=\"dashboard container mx-auto px-2 py-2 md:px-6 md:py-6\">\n    <Title title=\"Zeppelin - Dashboard\" />\n\n    <nav class=\"flex items-stretch flex-wrap pl-4 pr-2 py-1 border border-gray-700 rounded bg-gray-800 shadow-xl mb-8\">\n      <div class=\"flex-full md:flex-initial flex items-center\">\n        <img class=\"w-10 mr-5\" src=\"/img/logo.png\" alt=\"\" aria-hidden=\"true\">\n\n        <router-link to=\"/dashboard\">\n          <h1 class=\"font-semibold\">Zeppelin Dashboard</h1>\n        </router-link>\n      </div>\n\n      <div class=\"flex-1 flex items-center flex-wrap\">\n        <ul class=\"dashboard-nav list-none flex md:ml-8\">\n          <router-link class=\"flex-auto mr-4\" to=\"/dashboard\">Guilds</router-link>\n          <a href=\"javascript:void(0)\" class=\"navbar-item hover:text-red-400 mr-2\" v-on:click=\"logout()\">Log out</a>\n        </ul>\n\n        <div class=\"flex-1 flex items-center md:justify-end\">\n          <router-link\n            to=\"/docs\"\n            role=\"menuitem\"\n            class=\"py-1 px-2 rounded hover:bg-gray-700\">\n            Go to documentation\n          </router-link>\n        </div>\n      </div>\n    </nav>\n\n    <div class=\"main-content\">\n      <router-view></router-view>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n  .dashboard-nav a {\n    &:hover {\n      @apply underline;\n    }\n  }\n\n  .dashboard-nav .router-link-exact-active {\n    @apply underline;\n  }\n</style>\n\n<script>\n  import Title from \"../Title.vue\";\n\n  export default {\n    components: {\n      Title,\n    },\n    methods: {\n      async logout() {\n        await this.$store.dispatch(\"auth/logout\");\n        window.location.pathname = '/';\n      }\n    },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/dashboard/PermissionTree.vue",
    "content": "<template>\n  <ul class=\"nostyles\">\n    <li v-for=\"[permission, treeState, subTree] in tree\" :class=\"{locked: treeState.locked}\">\n      <label>\n        <input type=\"checkbox\"\n               :checked=\"grantedPermissions.has(permission) || treeState.redundant\"\n               v-on:input=\"togglePermission(permission)\"\n               :disabled=\"treeState.locked || treeState.redundant\">\n        <span>{{ permissionNames[permission] }}</span>\n      </label>\n      <permission-tree v-if=\"subTree && subTree.length\"\n                       :tree=\"subTree\"\n                       :granted-permissions=\"grantedPermissions\"\n                       :on-change=\"onChange\" />\n    </li>\n  </ul>\n</template>\n\n<style scoped>\n  ul {\n    list-style: none;\n\n    & ul {\n      padding-left: 16px;\n    }\n  }\n\n  .locked > label {\n    opacity: 0.5;\n  }\n</style>\n\n<script lang=\"ts\">\n  import { ApiPermissions, permissionNames } from \"@zeppelinbot/shared/apiPermissions.js\";\n  import { PropType } from \"vue\";\n  import { TPermissionHierarchyWithState } from \"./permissionTreeUtils\";\n\n  export default {\n    name: 'permission-tree',\n    props: {\n      tree: Array as PropType<TPermissionHierarchyWithState>,\n      grantedPermissions: Set as PropType<Set<ApiPermissions>>,\n      onChange: Function\n    },\n    data() {\n      return { permissionNames };\n    },\n    methods: {\n      togglePermission(permission) {\n        if (this.grantedPermissions.has(permission)) {\n          this.grantedPermissions.delete(permission);\n        } else {\n          this.grantedPermissions.add(permission);\n        }\n\n        if (this.onChange) {\n          this.onChange();\n        }\n      },\n    },\n  }\n</script>\n"
  },
  {
    "path": "dashboard/src/components/dashboard/permissionTreeUtils.ts",
    "content": "import { ApiPermissions, hasPermission, TPermissionHierarchy } from \"@zeppelinbot/shared/apiPermissions.js\";\n\nexport type TPermissionHierarchyState = {\n  locked: boolean;\n  redundant: boolean;\n};\n\nexport type TApiPermissionWithState = [ApiPermissions, TPermissionHierarchyState, TPermissionHierarchyWithState?];\nexport type TPermissionHierarchyWithState = TApiPermissionWithState[];\n\n/**\n * @param tree\n * @param grantedPermissions Permissions granted to the user being edited\n * @param managerPermissions Permissions granted to the user who's editing the other user's permissions\n * @param entireTreeIsGranted\n */\nexport function applyStateToPermissionHierarchy(\n  tree: TPermissionHierarchy,\n  grantedPermissions: Set<ApiPermissions>,\n  managerPermissions: Set<ApiPermissions> = new Set(),\n  entireTreeIsGranted = false,\n): TPermissionHierarchyWithState {\n  const result: TPermissionHierarchyWithState = [];\n\n  for (const item of tree) {\n    const [perm, nested] = Array.isArray(item) ? item : [item];\n\n    // Can't edit permissions you don't have yourself\n    const locked = !hasPermission(managerPermissions, perm);\n    const permissionWithState: TApiPermissionWithState = [perm, { locked, redundant: entireTreeIsGranted }];\n\n    if (nested) {\n      const subTreeGranted = entireTreeIsGranted || grantedPermissions.has(perm);\n      permissionWithState.push(\n        applyStateToPermissionHierarchy(nested, grantedPermissions, managerPermissions, subTreeGranted),\n      );\n    }\n\n    result.push(permissionWithState);\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "dashboard/src/components/docs/ArgumentTypes.vue",
    "content": "<template>\n  <div>\n    <h1>Argument Types</h1>\n    <p>\n      This page details the different argument types available for commands.\n    </p>\n\n    <h2 id=\"string\">string</h2>\n    <p>\n      Any text\n    </p>\n\n    <h2 id=\"number\">number</h2>\n    <p>\n      Any number\n    </p>\n\n    <h2 id=\"user\">user</h2>\n    <p>\n      Anything that uniquely identifies a user. This includes:\n    </p>\n    <ul>\n      <li>User ID <code>108552944961454080</code></li>\n      <li>User mention <code>@Dark#1010</code></li>\n      <li>Loose user mention <code>Dark#1010</code></li>\n    </ul>\n\n    <h2 id=\"userId\">userId</h2>\n    <p>\n      A valid user ID, e.g. <code>108552944961454080</code>\n    </p>\n\n    <h2 id=\"channel\">channel</h2>\n    <p>\n      Anything that uniquely identifies a channel. This includes:\n    </p>\n    <ul>\n      <li>Channel ID <code>473087035574321152</code></li>\n      <li>Channel mention <code>#my-channel</code></li>\n    </ul>\n\n    <h2 id=\"channelId\">channelId</h2>\n    <p>\n      A valid channel ID, e.g. <code>473087035574321152</code>\n    </p>\n\n    <h2 id=\"role\">role</h2>\n    <p>\n      Anything that uniquely identifies a role. This includes:\n    </p>\n    <ul>\n      <li>Role ID <code class=\"inline-code\">473085927053590538</code></li>\n      <li>Role mention <code>@MyRole</code></li>\n    </ul>\n\n    <h2 id=\"member\">member</h2>\n    <p>\n      Anything that uniquely identifies a member currently on the server. This includes:\n    </p>\n    <ul>\n      <li>User ID <code>108552944961454080</code></li>\n      <li>User Mention <code>@Dark#1010</code></li>\n      <li>Loose user mention <code>Dark#1010</code></li>\n    </ul>\n\n    <h2 id=\"resolvedMember\">resolvedMember</h2>\n    <p>\n      See <code>member</code> above\n    </p>\n\n    <h2 id=\"delay\">delay</h2>\n    <p>\n      A delay is used to specify an amount of time. It uses simple letters to specify time durations.<br>\n      For example, <code>2d15h27m3s</code> would be 2 days, 15 hours, 27 minutes and 3 seconds.\n    </p>\n    <p>\n      Note that the delay should always be written as 1 word, without spaces!\n    </p>\n\n    <Expandable>\n      <template v-slot:title>Additional information</template>\n      <template v-slot:content>\n        Durations:\n        <ul>\n          <li>\n            <code>w</code> Week\n          </li>\n          <li>\n            <code>d</code> Day\n          </li>\n          <li>\n            <code>h</code> Hour\n          </li>\n          <li>\n            <code>m</code> Minute\n          </li>\n          <li>\n            <code>s</code> Seconds\n          </li>\n        </ul>\n      </template>\n    </Expandable>\n  </div>\n</template>\n\n<script>\n  import CodeBlock from \"./CodeBlock.vue\";\n  import Expandable from \"../Expandable.vue\";\n\n  export default {\n    components: {\n      CodeBlock,\n      Expandable,\n    },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/CodeBlock.vue",
    "content": "<template>\n  <pre class=\"codeblock\" v-highlightjs><code :class=\"codeLang\" v-trim-indents=\"trim\"><slot></slot></code></pre>\n</template>\n\n<script>\n  export default {\n    props: [\"codeLang\", \"trim\"],\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/ConfigurationFormat.vue",
    "content": "<template>\n  <div>\n    <h1>Configuration format</h1>\n    <p>\n      This is the basic format of the bot configuration for a guild. The basic breakdown is:\n    </p>\n\n    <ul>\n      <li>Prefix (i.e. what character is preceding each command)</li>\n      <li>Permission levels (see <router-link to=\"/docs/configuration/permissions\">Permissions</router-link> for more info)</li>\n      <li>Plugin-specific configuration (see <router-link to=\"/docs/configuration/plugin-configuration\">Plugin configuration</router-link> for more info)</li>\n    </ul>\n\n    <CodeBlock code-lang=\"yaml\" trim=\"start\">\n      prefix: \"!\"\n\n      # role id: level\n      levels:\n        \"172949857164722176\": 100 # Example admin\n        \"172950000412655616\": 50 # Example mod\n\n      plugins:\n        mod_actions:\n          config:\n            kick_message: 'You have been kicked'\n            can_kick: false\n          overrides:\n            - level: '>=50'\n              config:\n                can_kick: true\n            - level: '>=100'\n              config:\n                kick_message: 'You have been kicked by an admin'\n    </CodeBlock>\n  </div>\n</template>\n\n<script lang=\"ts\">\n  import CodeBlock from \"./CodeBlock.vue\";\n\n  export default {\n    components: { CodeBlock },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/Counters.vue",
    "content": "<template>\n  <div>\n    <h1>Counters</h1>\n    <p>\n      Counters are an advanced feature in Zeppelin that allows you keep track of per-user, per-channel, or global numbers and trigger specific actions based on this number.\n      Common use cases are infraction points, XP systems, activity roles, and so on.\n    </p>\n    <p>\n      This guide will be expanded in the future. For now, it contains examples of common counter use cases.\n      Also see the <router-link to=\"/docs/plugins/counters\">documentation for the Counters plugin.</router-link>\n    </p>\n\n    <h2>Examples</h2>\n\n    <h3>Infraction points</h3>\n    <p>\n      In this example, warns, mutes, and kicks all accumulate \"infraction points\" for a user.\n      When the user reaches too many points, they are automatically banned.\n    </p>\n\n    <Expandable class=\"wide\">\n      <template v-slot:title>Click to view example</template>\n      <template v-slot:content>\n        <CodeBlock code-lang=\"yaml\">\n          plugins:\n\n            counters:\n              config:\n                counters:\n\n                  infraction_points:\n                    per_user: true\n                    triggers:\n                      # When a user accumulates 50 or more (>=50) infraction points, this trigger will activate.\n                      # The numbers here are arbitrary - you could choose to use 5 or 500 instead, depending on the granularity you want.\n                      autoban:\n                        condition: \">=50\"\n                    # Remove 1 infraction point each day\n                    decay:\n                      amount: 1\n                      every: 24h\n\n            automod:\n              config:\n                rules:\n\n                  add_infraction_points_on_warn:\n                    triggers:\n                      - warn: {}\n                    actions:\n                      add_to_counter:\n                        counter: \"infraction_points\"\n                        amount: 10\n\n                  add_infraction_points_on_mute:\n                    triggers:\n                      - mute: {}\n                    actions:\n                      add_to_counter:\n                        counter: \"infraction_points\"\n                        amount: 20\n\n                  add_infraction_points_on_kick:\n                    triggers:\n                      - kick: {}\n                    actions:\n                      add_to_counter:\n                        counter: \"infraction_points\"\n                        amount: 40\n\n                  autoban_on_too_many_infraction_points:\n                    triggers:\n                      # The counter trigger we specified further above, \"autoban\", is used to trigger an automod rule here\n                      - counter_trigger:\n                          counter: \"infraction_points\"\n                          trigger: \"autoban\"\n                    actions:\n                      ban:\n                        reason: \"Too many infraction points\"\n        </CodeBlock>\n      </template>\n    </Expandable>\n\n    <h3>Escalating automod punishments</h3>\n    <p>\n      This example allows users to trigger the `some_infraction` automod rule 3 times. On the 4th time, they are automatically muted.\n    </p>\n\n    <Expandable class=\"wide\">\n      <template v-slot:title>Click to view example</template>\n      <template v-slot:content>\n        <CodeBlock code-lang=\"yaml\">\n          plugins:\n\n            counters:\n              config:\n                counters:\n\n                  automod_infractions:\n                    per_user: true\n                    triggers:\n                      # When a user accumulates 100 or more (>=100) automod infraction points, this trigger will activate\n                      # The numbers here are arbitrary - you could choose to use 10 or 1000 instead.\n                      too_many_infractions:\n                        condition: \">=100\"\n                    # Remove 100 automod infraction points per hour\n                    decay:\n                      amount: 100\n                      every: 1h\n\n            automod:\n              config:\n                rules:\n\n                  # An example automod rule that adds automod infraction points\n                  some_infraction:\n                    triggers:\n                      - match_words:\n                          words: ['poopoo head']\n\n                    actions:\n                      clean: true\n                      reply: 'Do not insult other users'\n                      add_to_counter:\n                        counter: \"automod_infractions\"\n                        amount: 25 # This infraction adds 25 automod infraction points\n\n                  # An example rule that is triggered when the user accumulates too many automod infraction points\n                  automute_on_too_many_infractions:\n                    triggers:\n                      - counter_trigger:\n                          counter: \"automod_infractions\"\n                          trigger: \"too_many_infractions\"\n\n                    actions:\n                      mute:\n                        reason: \"You have been muted for tripping too many automod filters\"\n                        remove_roles_on_mute: true\n                        restore_roles_on_mute: true\n        </CodeBlock>\n      </template>\n    </Expandable>\n\n    <h3>Simple XP system</h3>\n    <p>\n      This example creates an XP system where every message sent grants you 1 XP, max once per minute.\n      At 100, 250, 500, and 1000 XP the system grants the user a new role.\n    </p>\n\n    <Expandable class=\"wide\">\n      <template v-slot:title>Click to view example</template>\n      <template v-slot:content>\n        <CodeBlock code-lang=\"yaml\">\n          plugins:\n\n            counters:\n              config:\n                counters:\n                  xp:\n                    per_user: true\n                    triggers:\n                      role_1:\n                        condition: \">=100\"\n                      role_2:\n                        condition: \">=250\"\n                      role_3:\n                        condition: \">=500\"\n                      role_4:\n                        condition: \">=1000\"\n\n            automod:\n              config:\n                rules:\n\n                  accumulate_xp:\n                    triggers:\n                      - any_message: {}\n\n                    actions:\n                      log: false # Don't spam logs with XP changes\n                      add_to_counter:\n                        counter: \"xp\"\n                        amount: 1 # Each message adds 1 XP\n\n                    cooldown: 1m # Only count 1 message per minute\n\n                  add_xp_role_1:\n                    triggers:\n                      - counter_trigger:\n                          counter: \"xp\"\n                          trigger: \"role_1\"\n\n                    actions:\n                      add_roles: [\"123456789123456789\"] # Role ID for xp role 1\n\n                  add_xp_role_2:\n                    triggers:\n                      - counter_trigger:\n                          counter: \"xp\"\n                          trigger: \"role_2\"\n\n                    actions:\n                      add_roles: [\"123456789123456789\"] # Role ID for xp role 2\n\n                  add_xp_role_3:\n                    triggers:\n                      - counter_trigger:\n                          counter: \"xp\"\n                          trigger: \"role_3\"\n\n                    actions:\n                      add_roles: [\"123456789123456789\"] # Role ID for xp role 3\n\n                  add_xp_role_4:\n                    triggers:\n                      - counter_trigger:\n                          counter: \"xp\"\n                          trigger: \"role_4\"\n\n                    actions:\n                      add_roles: [\"123456789123456789\"] # Role ID for xp role 4\n        </CodeBlock>\n      </template>\n    </Expandable>\n\n    <h3>Activity role (\"regular role\")</h3>\n    <p>\n      This example is similar to the XP system, but the number decays and the role granted by the system can be removed if the user's activity goes down.\n    </p>\n\n    <Expandable class=\"wide\">\n      <template v-slot:title>Click to view example</template>\n      <template v-slot:content>\n        <CodeBlock code-lang=\"yaml\">\n          plugins:\n\n            counters:\n              config:\n                counters:\n                  activity:\n                    per_user: true\n                    triggers:\n                      grant_role:\n                        condition: \">=100\"\n                        # We set a separate threshold for when the role should be removed. This is so the decay doesn't remove the activity role immediately.\n                        # If this value isn't set, reverse_condition defaults to the opposite of the condition, i.e. \"<100\" in this case.\n                        reverse_condition: \"<50\"\n                    decay:\n                      amount: 1\n                      every: 1h\n\n            automod:\n              config:\n                rules:\n\n                  accumulate_activity:\n                    triggers:\n                      - any_message: {}\n\n                    actions:\n                      log: false # Don't spam logs with activity changes\n                      add_to_counter:\n                        counter: \"activity\"\n                        amount: 1 # Each message adds 1 to the counter\n\n                    cooldown: 1m # Only count 1 message per minute\n\n                  grant_activity_role:\n                    triggers:\n                      - counter_trigger:\n                          counter: \"activity\"\n                          trigger: \"grant_role\"\n\n                    actions:\n                      add_roles: [\"123456789123456789\"] # Role ID for activity role\n\n                  remove_activity_role:\n                    triggers:\n                      - counter_trigger:\n                          counter: \"activity\"\n                          trigger: \"grant_role\"\n                          reverse: true # This indicates we want to use the *reverse* of the specified trigger, see reverse_condition in counters above\n\n                    actions:\n                      remove_roles: [\"123456789123456789\"] # Role ID for activity role\n        </CodeBlock>\n      </template>\n    </Expandable>\n\n    <h3>Auto-disable antiraid</h3>\n    <p>\n      This example disables antiraid after a specific delay.\n    </p>\n\n    <Expandable class=\"wide\">\n      <template v-slot:title>Click to view example</template>\n      <template v-slot:content>\n        <CodeBlock code-lang=\"yaml\">\n          plugins:\n\n            counters:\n              config:\n                counters:\n\n                  antiraid_decay:\n                    triggers:\n                      disable:\n                        condition: \"=0\"\n                    decay:\n                      amount: 1\n                      every: 1m\n\n            automod:\n              config:\n                rules:\n\n                  start_antiraid_timer_low:\n                    triggers:\n                      - antiraid_level:\n                          level: \"low\"\n                    actions:\n                      set_counter:\n                        counter: \"antiraid_decay\"\n                        value: 10 # \"Disable after 10min\"\n\n                  start_antiraid_timer_high:\n                    triggers:\n                      - antiraid_level:\n                          level: \"high\"\n                    actions:\n                      set_counter:\n                        counter: \"antiraid_decay\"\n                        value: 20 # \"Disable after 20min\"\n\n                  disable_antiraid_after_timer:\n                    triggers:\n                      - counter_trigger:\n                          counter: \"antiraid_decay\"\n                          trigger: \"disable\"\n                    actions:\n                      set_antiraid_level: null\n        </CodeBlock>\n      </template>\n    </Expandable>\n  </div>\n</template>\n\n<script>\nimport CodeBlock from \"./CodeBlock.vue\";\nimport Expandable from \"../Expandable.vue\";\n\nexport default {\n  components: { CodeBlock, Expandable },\n};\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/DocsLayout.vue",
    "content": "<template>\n  <div class=\"docs container mx-auto px-6 py-6\">\n    <Title title=\"Zeppelin - Documentation\" />\n\n    <!-- Top bar -->\n    <nav class=\"flex items-stretch pl-4 pr-2 py-1 border border-gray-700 rounded bg-gray-800 shadow-xl\">\n      <div class=\"flex-initial flex items-center\">\n        <img class=\"flex-auto w-10 mr-5\" src=\"/img/logo.png\" alt=\"\" aria-hidden=\"true\">\n\n        <router-link to=\"/docs\">\n          <h1 class=\"flex-auto font-semibold\">Zeppelin Documentation</h1>\n        </router-link>\n      </div>\n      <div class=\"flex-1 flex items-center justify-end\">\n        <router-link\n          to=\"/dashboard\"\n          role=\"menuitem\"\n          class=\"py-1 px-2 rounded hover:bg-gray-700 hidden lg:block\">\n          Go to dashboard\n        </router-link>\n        <button class=\"link-button text-2xl leading-zero lg:hidden\" v-on:click=\"toggleMobileMenu()\" aria-hidden=\"true\">\n          <Menu />\n        </button>\n      </div>\n    </nav>\n\n    <a class=\"sr-only-when-not-focused text-center block py-2\" href=\"#main-anchor\">Skip to main content</a>\n\n    <!-- Content wrapper -->\n    <div class=\"flex flex-wrap lg:flex-nowrap items-start mt-8 gap-8\">\n      <!-- Sidebar -->\n      <nav class=\"docs-sidebar px-4 pt-2 pb-3 border border-gray-700 rounded bg-gray-800 shadow-md flex-full lg:flex-none lg:block\" v-bind:class=\"{ closed: !mobileMenuOpen }\">\n        <div role=\"none\" v-for=\"(group, index) in menu\">\n          <h1 class=\"font-bold\" :aria-owns=\"'menu-group-' + index\" :class=\"{'mt-4': typeof index === 'number' && index !== 0}\">{{ group.label }}</h1>\n          <ul v-bind:id=\"'menu-group-' + index\" role=\"group\" class=\"list-none pl-2\">\n            <li role=\"none\" v-for=\"item in group.items\">\n              <router-link role=\"menuitem\" :to=\"item.to\" class=\"text-gray-300 hover:text-gray-500\" v-on:click.native=\"onChooseMenuItem()\">{{ item.label }}</router-link>\n            </li>\n          </ul>\n        </div>\n      </nav>\n\n      <!-- Content -->\n      <main class=\"docs-content main-content flex-auto overflow-x-hidden\">\n        <a id=\"main-anchor\" ref=\"main-anchor\" tabindex=\"-1\" class=\"sr-only\"></a>\n        <router-view :key=\"$route.fullPath\"></router-view>\n      </main>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\">\n  import {mapState} from \"vuex\";\n  import Menu from 'vue-material-design-icons/Menu.vue';\n  import Title from \"../Title.vue\";\n\n  type TMenuItem = {\n    to: string;\n    label: string;\n  };\n  type TMenuGroup = {\n    label: string;\n    items: TMenuItem[];\n  };\n  type TMenu = TMenuGroup[];\n\n  const menu: TMenu = [\n    {\n      label: 'General',\n      items: [\n        {\n          to: '/docs/introduction',\n          label: 'Introduction',\n        },\n      ],\n    },\n\n    {\n      label: 'Configuration',\n      items: [\n        {\n          to: '/docs/configuration/configuration-format',\n          label: 'Configuration format',\n        },\n        {\n          to: '/docs/configuration/plugin-configuration',\n          label: 'Plugin configuration',\n        },\n        {\n          to: '/docs/configuration/permissions',\n          label: 'Permissions',\n        },\n      ],\n    },\n\n    {\n      label: 'Reference',\n      items: [\n        {\n          to: '/docs/reference/argument-types',\n          label: 'Argument types',\n        },\n      ],\n    },\n\n    {\n      label: 'Setup guides',\n      items: [\n        {\n          to: '/docs/setup-guides/logs',\n          label: 'Logs',\n        },\n        {\n          to: '/docs/setup-guides/moderation',\n          label: 'Moderation',\n        },\n        {\n          to: '/docs/setup-guides/counters',\n          label: 'Counters',\n        },\n      ],\n    },\n  ];\n\n  export default {\n    components: { Menu, Title },\n    async mounted() {\n      await this.$store.dispatch(\"docs/loadAllPlugins\");\n    },\n\n    data() {\n      return {\n        mobileMenuOpen: false,\n      };\n    },\n\n    methods: {\n      toggleMobileMenu() {\n        console.log('hi');\n        this.mobileMenuOpen = !this.mobileMenuOpen;\n      },\n\n      onChooseMenuItem() {\n        this.mobileMenuOpen = false;\n        this.$refs['main-anchor'].focus();\n      },\n    },\n\n    computed: {\n      ...mapState('docs', {\n        plugins: 'allPlugins',\n      }),\n      menu() {\n        return [\n          ...menu,\n          {\n            label: 'Plugins',\n            items: this.plugins.filter(plugin => plugin.info.type === \"stable\").map(plugin => ({\n              label: plugin.info.prettyName || plugin.name,\n              to: `/docs/plugins/${plugin.name}`,\n            })),\n          },\n          {\n            label: \"Legacy Plugins\",\n            items: this.plugins.filter(plugin => plugin.info.type === \"legacy\").map(plugin => ({\n              label: plugin.info.prettyName || plugin.name,\n              to: `/docs/plugins/${plugin.name}`,\n            })),\n          }\n        ];\n      },\n    },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/Introduction.vue",
    "content": "<template>\n  <div>\n    <h1>Introduction</h1>\n    <p>\n      Zeppelin is a private moderation bot for Discord, designed with large servers and reliability in mind.\n    </p>\n\n    <!-- WIP bar -->\n    <div class=\"px-3 py-2 rounded bg-gray-800 shadow-md inline-block\">\n      <alert class=\"inline-icon mr-1 text-yellow-300\" title=\"Note!\" />\n      This documentation is a work in progress.\n    </div>\n\n    <h2>Getting the bot</h2>\n    <p>\n      Since the bot is currently private, access to the bot is granted on a case by case basis.<br>\n      There are plans to streamline this process in the future.\n    </p>\n\n    <h2>Configuration</h2>\n    <p>\n      All Zeppelin configuration is done through the dashboard by editing a YAML config file. By default, only the server\n      owner has access to this, but they can give other users access as they see fit. See <router-link to=\"/docs/configuration/configuration-format\">Configuration format</router-link> for more details.\n    </p>\n\n    <h2>Plugins</h2>\n    <p>\n      Zeppelin is divided into plugins: grouped functionality that can be enabled/disabled as needed, and that have their own configurations.\n    </p>\n\n    <h2>Commands</h2>\n    <p>\n      The commands for each plugin are listed on the plugin's page (see \"Plugins\" on the menu). On these pages, the command prefix is assumed to be <code>!</code> but this can be changed on a per-server basis.\n    </p>\n  </div>\n</template>\n\n<script lang=\"ts\">\n  import Alert from 'vue-material-design-icons/Alert.vue';\n\n  export default {\n    components: { Alert },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/MarkdownBlock.vue",
    "content": "<template>\n  <div class=\"markdown-block\" ref=\"rendered\"></div>\n</template>\n\n<script>\n  import { marked } from \"marked\";\n  import hljs from \"highlight.js\";\n\n  export default {\n    props: [\"content\"],\n    methods: {\n      renderContent() {\n        const rendered = marked(this.content || \"\");\n        const target = this.$refs.rendered;\n        target.innerHTML = rendered;\n        target.querySelectorAll(\"code[class*='language-']\").forEach(elem => {\n          if (elem.parentNode.tagName === 'PRE') {\n            elem.parentNode.classList.add('codeblock');\n          }\n\n          hljs.highlightBlock(elem);\n        });\n      }\n    },\n    mounted() {\n      this.renderContent();\n    },\n    watch: {\n      content() {\n        this.renderContent();\n      },\n    },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/Moderation.vue",
    "content": "<template>\n  <div>\n    <header>\n      <h1>Moderation</h1>\n      <p>\n        Moderation in Zeppelin is multi-layered. On top of typical actions such\n        as warning, muting, kicking, and banning, Zeppelin allows moderators to\n        utilise flags; create alerts; set thresholds; and act as others.\n      </p>\n      <p>\n        This guide explains the options available in the\n        <router-link to=\"/docs/plugins/modactions\"> Mod actions</router-link>\n        plugin. To best use this guide, read the default configuration of the\n        Mod actions plugin alongside this. This plugin does\n        <strong>not</strong> cover muting members, please see the\n        <router-link to=\"/docs/plugins/mutes\">Mutes</router-link>\n        plugin for that.\n      </p>\n      <p>\n        Please ensure you understand how\n        <router-link to=\"/docs/plugins/plugin-configuration\">plugin\n          configuration</router-link> and\n        <router-link to=\"/docs/configuration/permissions\">plugin\n          permissions</router-link> work before reading this guide since\n        the configs defined here rely on these concepts.\n      </p>\n    </header>\n\n    <h2>Moderation Commands</h2>\n    <p>\n      So that your moderators may use Zeppelin moderation, you must define the\n      moderator role id in the config, assign it a level (50), and enable the\n      Mod actions plugin.\n      <CodeBlock code-lang=\"yaml\" trim=\"start\">\n        levels:\n          \"PRETEND-ROLE-ID\": 50 # Mod\n\n        plugins:\n          mod_actions: {}\n      </CodeBlock>\n    </p>\n    <p>\n      Each moderation command has a permission attached to it, so if your\n      server has a hierarchical structure then you will be able to scope\n      these permissions by referencing the plugins permissions page.\n    </p>\n\n    <h2>Sanction Notifications</h2>\n    <p>These config options define how Zeppelin will interact with the\n      members it sanctions (warns, kicks, bans).</p>\n\n    <h3>DM Values</h3>\n    <p>\n      The values <code>dm_on_warn</code>, <code>dm_on_kick</code>, and\n      <code>dm_on_ban</code> determine whether a member will be notified of\n      their sanctions through DMs. Ignoring privacy settings, setting these to\n      <code>true</code> will notify the member. Temporary banning uses the\n      ban configuration.\n    </p>\n\n    <h3>Channel Values</h3>\n    <p>\n      An alternative way to notify members about sanctions is through\n      mentioning them in a message sent in a channel. To enable this feature,\n      set <code>message_on_warn</code>, <code>message_on_kick</code>, and\n      and <code>message_on_ban</code> to true, then assign a\n      <code>message_channel</code>.\n    </p>\n\n    <h3>Notifying messages</h3>\n    <p>\n      This is how you control the exact wording the member receives. You can\n      adjust the wording per sanction type. These variables are\n      <code>warn_message</code>, <code>kick_message</code>, and\n      <code>ban_message</code>. Please remember that YAML supports mutli-line\n      strings, this is how you can write newlines in your messages. Notably,\n      temporarily banning a member permits the inclusion of the\n      <code>banTime</code> variable through <code>tempban_message</code>.\n    </p>\n\n    <h3>Summary Example</h3>\n    <p>\n      Employing what we have learnt so far, we can write a configuration that:\n    </p>\n\n    <ul>\n      <li>Alerts members of their warns in a channel, instead of DMs.</li>\n      <li>Alerts members of kicks and bans in their DMs.</li>\n      <li>Makes use of multi-line strings to prepare a tidy message.</li>\n      <li>Includes the remaining ban time if a ban was temporary.</li>\n    </ul>\n    <CodeBlock code-lang=\"yaml\" trim=\"start\">\n      plugins:\n        mod_actions:\n          config:\n            dm_on_warn: false\n            message_on_warn: true\n            message_channel: \"PRETEND-CHANNEL-ID\"\n\n            dm_on_kick: true\n\n            dm_on_ban: true\n            tempban_message: |-\n              Dear {user.username},\n\n              As a result of {reason}, you have been banned from {guildName}\n              for {banTime}. We welcome you back provided you do not do this\n              again.\n    </CodeBlock>\n\n    <h2>Alerts</h2>\n    <p>\n      Alerts are a nifty way for moderators to be notified of members trying to\n      evade sanctions by promptly leaving and rejoining your server. To enable\n      this feature, assign a channel in <code>alert_channel</code> and enable\n      <code>alert_on_rejoin</code>.\n    </p>\n    <CodeBlock code-lang=\"yaml\" trim=\"start\">\n      plugins:\n        mod_actions:\n          config:\n            alert_on_rejoin: true\n            alert_channel: \"PRETEND-CHANNEL-ID\"\n    </CodeBlock>\n\n    <h2>Thresholds</h2>\n    <p>\n      Thresholds alert moderators if a member is about to exceed a\n      predetermined number of cases, prompting moderators to consider whether\n      alternative (harsher) action could be taken. To enable thresholds,\n      assign the threshold as <code>warn_notify_threshold</code>, adjust the\n      message under <code>warn_notify_message</code>, and enable\n      <code>warn_notify_enabled</code>.\n    </p>\n    <p>\n      Write your config cleverly, check the default values for\n      <code>warn_notify_threshold</code> and <code>warn_notify_message</code>,\n      if these are acceptable then all you need to do is enable\n      <code>warn_notify_enabled</code>.\n    </p>\n\n    <h2>Ban Message Deletion</h2>\n    <p>\n      When a member is banned, Zeppelin automatically deletes the last day of\n      message history. You can extend this through the\n      <code>ban_delete_message_days</code> option.\n    </p>\n  </div>\n</template>\n\n<script lang=\"ts\">\n  import CodeBlock from \"./CodeBlock.vue\";\n\n  export default {\n    components: { CodeBlock },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/Permissions.vue",
    "content": "<template>\n  <div>\n    <h1>Permissions</h1>\n    <p>\n      Permissions in Zeppelin are simply values in plugin configuration that are checked when the command is used.\n      These values can be changed with overrides (see <router-link to=\"/docs/configuration/plugin-configuration\">Plugin configuration</router-link> for more info)\n      and can depend on e.g. user id, role id, channel id, category id, or <strong>permission level</strong>.\n    </p>\n\n    <h2>Permission levels</h2>\n    <p>\n      The simplest way to control access to bot commands and features is via permission levels.\n      These levels are simply a number (usually between 0 and 100), based on the user's roles or user id, that can then\n      be used in permission overrides. By default, several commands are \"moderator only\" (level 50 and up) or \"admin only\" (level 100 and up).\n    </p>\n    <p>\n      Additionally, having a higher permission level means that certain commands (such as !ban) can't be used against\n      you by users with a lower or equal permission level (so e.g. moderators can't ban each other or admins above them).\n    </p>\n    <p>\n      Permission levels are defined in the config in the <strong>levels</strong> section. For example:\n    </p>\n\n    <CodeBlock code-lang=\"yaml\">\n      # \"role/user id\": level\n      levels:\n        \"172949857164722176\": 100 # Example admin\n        \"172950000412655616\": 50 # Example mod\n    </CodeBlock>\n\n    <h2>Examples</h2>\n\n    <h3>Basic overrides</h3>\n    <p>\n      For this example, let's assume we have a plugin called <code>cats</code> which has a command <code>!cat</code> locked behind the permission <code>can_cat</code>.\n      Let's say that by default, the plugin allows anyone to use <code>!cat</code>, but we want to restrict it to moderators only.\n    </p>\n    <p>\n      Here's what the configuration for this would look like:\n    </p>\n\n    <CodeBlock code-lang=\"yaml\">\n      plugins:\n        cats:\n          config:\n            can_cat: false # Here we set the permission false by default\n          overrides:\n            # In this override, can_cat is changed to \"true\" for anyone with a permission level of 50 or higher\n            - level: \">=50\"\n              config:\n                can_cat: true\n    </CodeBlock>\n\n    <h3>Replacing defaults</h3>\n    <p class=\"mb-1\">\n      In this example, let's assume you don't want to use the default permission levels of 50 and 100 for mods and admins respectively.\n      Let's say you're using various incremental levels instead: 10, 20, 30, 40, 50...<br>\n      We want to make it so moderator commands are available starting at level 70.\n      Additionally, we'd like to reserve banning for levels 90+ only.\n      To do this, we need to <strong>replace</strong> the default overrides that enable moderator commands at level 50.\n    </p>\n    <p class=\"mb-1\">\n      Here's what the configuration for this would look like:\n    </p>\n\n    <CodeBlock code-lang=\"yaml\">\n      plugins:\n        mod_actions:\n          replaceDefaultOverrides: true\n          overrides: # The \"=\" here means \"replace any defaults\"\n            - level: \">=70\"\n              config:\n                can_warn: true\n                can_mute: true\n                can_kick: true\n            - level: \">=90\"\n              config:\n                can_ban: true\n    </CodeBlock>\n  </div>\n</template>\n\n<script>\n  import CodeBlock from \"./CodeBlock.vue\";\n\n  export default {\n    components: { CodeBlock },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/Plugin.vue",
    "content": "<template>\n  <div v-if=\"loading\">\n    Loading...\n  </div>\n  <div v-else>\n    <h1>{{ data.info.prettyName || data.name }}</h1>\n\n    <!-- Description -->\n    <MarkdownBlock :content=\"data.info.description\" class=\"content\"></MarkdownBlock>\n\n    <div v-if=\"data.info.type === 'legacy'\">\n      <div class=\"px-3 py-2 mb-4 rounded bg-gray-800 shadow-md flex\">\n        <div class=\"flex-none mr-2\">\n          <alert class=\"inline-icon mr-1 text-yellow-300\" />\n        </div>\n        <div class=\"flex-auto\">\n          <strong>Note!</strong> This is a legacy plugin which is no longer actively maintained and may be removed in a future update.\n        </div>\n      </div>\n    </div>\n\n    <Tabs>\n      <Tab :active=\"tab === 'usage'\">\n        <router-link class=\"unstyled\" v-bind:to=\"'/docs/plugins/' + pluginName + '/usage'\">Usage</router-link>\n      </Tab>\n      <Tab :active=\"tab === 'configuration'\">\n        <router-link class=\"unstyled\" v-bind:to=\"'/docs/plugins/' + pluginName + '/configuration'\">Configuration</router-link>\n      </Tab>\n    </Tabs>\n\n    <!-- Usage tab -->\n    <div class=\"usage\" v-if=\"tab === 'usage'\">\n      <h2 class=\"sr-only\">Usage</h2>\n\n      <div v-if=\"!hasUsageInfo\">\n        This plugin has no usage information.\n        See <router-link v-bind:to=\"'/docs/plugins/' + pluginName + '/configuration'\">Configuration</router-link>.\n      </div>\n\n      <!-- Usage guide -->\n      <div v-if=\"data.info.usageGuide\">\n        <MarkdownBlock :content=\"data.info.usageGuide\" class=\"content\" />\n      </div>\n\n      <!-- Message Command list -->\n      <div v-if=\"data.messageCommands && data.messageCommands.length\">\n        <h3 id=\"commands\" class=\"text-2xl\">Message commands</h3>\n        <div v-for=\"command in (data.messageCommands || [])\"\n             class=\"command mb-4\"\n             v-bind:ref=\"getCommandSlug(command)\" v-bind:class=\"{target: targetCommandId === getCommandSlug(command)}\">\n          <h4 class=\"text-xl font-semibold mb-0\">\n            <span v-for=\"(trigger, index) in getTriggers(command)\"> <span class=\"text-gray-600\" v-if=\"typeof index === 'number' && index > 0\">/</span> !{{ trigger }} </span>\n          </h4>\n          <MarkdownBlock v-if=\"command.description\"\n                         :content=\"command.description\"\n                         class=\"content\" />\n\n          <div v-bind:class=\"{'-mt-2': command.description}\">\n            <div v-if=\"command.usage\">\n              <span class=\"font-semibold\">Basic usage:</span> <code class=\"inline-code\">{{ command.usage }}</code>\n            </div>\n          </div>\n\n          <Expandable class=\"mt-4\">\n            <template v-slot:title>Additional information</template>\n            <template v-slot:content>\n              <!--\n              <div v-if=\"command.config.extra.info && command.config.extra.info.usageGuide\">\n                <div class=\"font-semibold\">Usage guide:</div>\n                <MarkdownBlock :content=\"command.config.extra.info.usageGuide\"\n                               class=\"content\">\n                </MarkdownBlock>\n              </div>\n\n              <div v-if=\"command.config.extra.info && command.config.extra.info.examples\">\n                <div class=\"font-semibold\">Examples:</div>\n                <MarkdownBlock :content=\"command.config.extra.info.examples\"\n                               class=\"content\">\n                </MarkdownBlock>\n              </div>\n              -->\n\n              <p v-if=\"command.permission\">\n                <span class=\"font-semibold\">Permission:</span>\n                <code class=\"inline-code\">{{ command.permission }}</code>\n              </p>\n\n              <div v-if=\"command.signature\">\n                <h5 class=\"font-semibold mb-2\">Signatures:</h5>\n                <ul class=\"list-none\">\n                  <li v-for=\"(signature, index) in getCommandSignatures(command)\" v-bind:class=\"{'mt-8': typeof index === 'number' && index !== 0}\">\n                    <code class=\"inline-code bg-gray-900\">\n                      !{{ getTriggers(command)[0] }}\n                      <span v-for=\"paramInfo in getSignatureParameters(signature)\">{{ renderParameterOrOption(paramInfo.name, paramInfo.param) }} </span>\n                    </code>\n\n                    <div class=\"pl-4\">\n                      <div v-if=\"getSignatureParameters(signature).length\">\n                        <div class=\"font-semibold text-sm mt-2\">Parameters</div>\n                        <ul>\n                          <li v-for=\"paramInfo in getSignatureParameters(signature)\">\n                            <code>{{ renderParameter(paramInfo.name, paramInfo.param) }}</code>\n                            <router-link :to=\"'/docs/reference/argument-types#' + (paramInfo.param.type || 'string')\">{{ paramInfo.param.type || 'string' }}</router-link>\n                            <MarkdownBlock v-if=\"paramInfo.param.description\"\n                                           :content=\"paramInfo.param.description\"\n                                           class=\"content\">\n                            </MarkdownBlock>\n                          </li>\n                        </ul>\n                      </div>\n\n                      <div v-if=\"getSignatureOptions(signature).length\">\n                        <div class=\"font-semibold text-sm mt-2\">Options</div>\n                        <ul>\n                          <li v-for=\"optionInfo in getSignatureOptions(signature)\">\n                            <code>{{ renderOption(optionInfo.name, optionInfo.option) }}</code>\n                            <router-link :to=\"'/docs/reference/argument-types#' + (optionInfo.option.type || 'string')\">{{ optionInfo.option.type || 'string' }}</router-link>\n                            <MarkdownBlock v-if=\"optionInfo.option.description\"\n                                           :content=\"optionInfo.option.description\"\n                                           class=\"content\">\n                            </MarkdownBlock>\n                          </li>\n                        </ul>\n                      </div>\n                    </div>\n                  </li>\n                </ul>\n              </div>\n            </template>\n          </Expandable>\n        </div>\n      </div>\n    </div>\n\n    <!-- Configuration tab -->\n    <div class=\"configuration\" v-if=\"tab === 'configuration'\">\n      <!-- Basic config info -->\n      <p>\n        <strong>Name in config:</strong> <code>{{ data.name }}</code><br>\n        To enable this plugin with default configuration, add <code>{{ data.name }}: {}</code> to the <code>plugins</code> list in config\n      </p>\n\n      <!-- Configuration guide -->\n      <div v-if=\"data.info.configurationGuide\">\n        <h2 id=\"configuration-guide\">Configuration guide</h2>\n        <MarkdownBlock :content=\"data.info.configurationGuide\" class=\"content\"></MarkdownBlock>\n      </div>\n\n      <!-- Default configuration -->\n      <h2 id=\"default-configuration\">Default configuration</h2>\n      <CodeBlock code-lang=\"yaml\">{{ renderConfiguration(data.defaultOptions) }}</CodeBlock>\n\n      <!-- Config schema -->\n      <h2 id=\"config-schema\">Config schema</h2>\n      <Expandable class=\"wide\">\n        <template v-slot:title>Click to expand</template>\n        <template v-slot:content>\n          <CodeBlock code-lang=\"plain\">{{ data.configSchema }}</CodeBlock>\n        </template>\n      </Expandable>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n  @reference \"../../style/app.css\";\n\n  .command.target {\n    @apply mt-5 mb-3;\n    @apply pt-2 pb-2 pl-4 pr-4;\n    @apply border;\n    @apply border-gray-400;\n    @apply border-dashed;\n    @apply rounded;\n  }\n</style>\n\n<script lang=\"ts\">\n  import {mapState} from \"vuex\";\n  import yaml from \"js-yaml\";\n  import CodeBlock from \"./CodeBlock.vue\";\n  import MarkdownBlock from \"./MarkdownBlock.vue\";\n  import Tabs from \"../Tabs.vue\";\n  import Tab from \"../Tab.vue\";\n  import Expandable from \"../Expandable.vue\";\n  import { DocsState } from \"../../store/types\";\n  import Alert from 'vue-material-design-icons/Alert.vue';\n\n  const validTabs = ['usage', 'configuration'];\n  const defaultTab = 'usage';\n\n  export default {\n    components: { CodeBlock, MarkdownBlock, Tabs, Tab, Expandable, Alert },\n\n    async mounted() {\n      this.loading = true;\n\n      await this.$store.dispatch(\"docs/loadPluginData\", this.pluginName);\n\n      // If there's no usage info, use Configuration as the default tab\n      if (!this.hasUsageInfo && ! this.$route.params.tab) {\n        this.tab = 'configuration';\n      }\n\n      this.loading = false;\n\n      this.$nextTick(() => {\n        if (this.tab === \"usage\" && window.location.hash) {\n          this.scrollToCommand(window.location.hash.slice(1));\n        }\n      });\n\n      this.hashChangeListener = () => this.scrollToCommand(window.location.hash.slice(1));\n      window.addEventListener('hashchange', this.hashChangeListener);\n    },\n    beforeDestroy() {\n      window.removeEventListener('hashchange', this.hashChangeListener);\n    },\n    methods: {\n      renderConfiguration(options) {\n        return yaml.dump({\n          [this.pluginName]: options,\n        });\n      },\n      getTriggers(command) {\n        return Array.isArray(command.trigger)\n          ? command.trigger\n          : [command.trigger];\n      },\n      getCommandSignatures(command) {\n        if (!command.signature) {\n          return [];\n        }\n\n        return Array.isArray(command.signature)\n          ? command.signature\n          : [command.signature];\n      },\n      getSignatureParameters(signature) {\n        return Array.from(Object.entries(signature))\n          .filter(([name, paramOrOption]) => !(paramOrOption as any).option)\n          .map(([name, param]) => ({ name, param }));\n      },\n      getSignatureOptions(signature) {\n        return Array.from(Object.entries(signature))\n          .filter(([name, paramOrOption]) => Boolean((paramOrOption as any).option))\n          .map(([name, option]) => ({ name, option }));\n      },\n      renderParameterOrOption(name, paramOrOption) {\n        if (paramOrOption.option) {\n          return this.renderOption(name, paramOrOption);\n        }\n\n        return this.renderParameter(name, paramOrOption);\n      },\n      renderParameter(name, param) {\n        let str = name;\n        if (param.rest) str += '...';\n        if (param.required !== false) {\n          return `<${str}>`;\n        } else {\n          return `[${str}]`;\n        }\n      },\n      renderOption(name, opt) {\n        let str = `-${name}`;\n        if (opt.shortcut) {\n          str += `|-${opt.shortcut}`;\n        }\n        if (opt.required) {\n          return `<${str}>`;\n        } else {\n          return `[${str}]`;\n        }\n      },\n      getCommandSlug(command) {\n        const mainTrigger = this.getTriggers(command)[0];\n        return 'command-' + mainTrigger.trim().toLowerCase().replace(/\\s/g, '-');\n      },\n      scrollToCommand(hash) {\n        if (this.$refs[hash]) {\n          this.targetCommandId = hash;\n          (this.$refs[hash][0] as Element).scrollIntoView({\n            behavior: \"smooth\",\n            block: \"center\"\n          });\n        } else {\n          this.targetCommandId = null;\n        }\n      },\n    },\n    data() {\n      return {\n        loading: true,\n        pluginName: this.$route.params.pluginName,\n        tab: validTabs.includes(this.$route.params.tab)\n          ? this.$route.params.tab\n          : defaultTab,\n        targetCommandId: null,\n        hashChangeListener: null\n      };\n    },\n    computed: {\n      ...mapState(\"docs\", {\n        data(state: DocsState) {\n          return state.plugins[this.pluginName];\n        },\n        hasUsageInfo() {\n          if (!this.data) return true;\n          if (this.data.messageCommands?.length) return true;\n          if (this.data.slashCommands?.length) return true;\n          if (this.data.info?.usageGuide) return true;\n          return false;\n        },\n      }),\n    },\n  }\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/PluginConfiguration.vue",
    "content": "<template>\n  <div>\n    <h1>Plugin configuration</h1>\n    <p>\n      Most plugins in Zeppelin have configurable options. The values for these options come from 3 places:\n    </p>\n    <ol>\n      <li>\n        <strong>Default options</strong> (from Zeppelin)\n      </li>\n      <li>\n        <strong>Custom options</strong> (set by you in config)\n      </li>\n      <li>\n        <strong>Overrides</strong> (conditional config values, see below)\n      </li>\n    </ol>\n    <p>\n      Permissions are also just regular config options with a <code>true</code>/<code>false</code> value.\n      <router-link to=\"/docs/configuration/permissions\">See the Permissions page for more info.</router-link>\n    </p>\n    <p>\n      Information about each plugin's options can be found on the plugin's page, which can be accessed from the sidebar.\n      <router-link to=\"/docs/configuration/configuration-format\">See the Configuration format page for an example of a full config.</router-link>\n    </p>\n\n    <h2>Overrides</h2>\n    <p>\n      Overrides are the primary mechanism of changing options and permissions based on permission levels, roles,\n      channels, user ids, etc.\n    </p>\n\n    <Expandable class=\"wide\">\n      <template v-slot:title>Click to see examples of different types of overrides</template>\n      <template v-slot:content>\n        <CodeBlock code-lang=\"yaml\">\n          plugins:\n            example_plugin:\n              config:\n                can_kick: false\n                kick_message: \"You have been kicked\"\n                nested:\n                  value: \"Hello\"\n                  other_value: \"Foo\"\n              overrides:\n                # Simple permission level based override to allow kicking only for levels 50 and up\n                - level: '>=50'\n                  config:\n                    can_kick: true\n                    nested:\n                      # This only affects nested.other_value; nested.value is still \"Hello\"\n                      other_value: \"Bar\"\n                # Channel override - don't allow kicking on the specified channel\n                - channel: \"109672661671505920\"\n                  config:\n                    can_kick: false\n                # Don't allow kicking on any thread\n                - is_thread: true\n                  config:\n                    can_kick: false\n                # Don't allow kicking on a specific thread\n                - thread_id: \"109672661671505920\"\n                  config:\n                    can_kick: false\n                # Same as above, but for a full category\n                - category: \"360735466737369109\"\n                  config:\n                    can_kick: false\n                # Multiple channels. If any of them match, this override applies.\n                - channel: [\"109672661671505920\", \"570714864285253677\"]\n                  config:\n                    can_kick: false\n                # Match based on a role\n                - role: \"172950000412655616\"\n                  config:\n                    can_kick: false\n                # Match based on multiple roles. The user must have ALL roles mentioned here for this override to apply.\n                - role: [\"172950000412655616\", \"172949857164722176\"]\n                  config:\n                    can_kick: false\n                # Match on user id\n                - user: \"106391128718245888\"\n                  config:\n                    kick_message: \"You have been kicked by Dragory\"\n                # Match on multiple conditions\n                - channel: \"109672661671505920\"\n                  role: \"172950000412655616\"\n                  config:\n                    can_kick: false\n                # Match on ANY of multiple conditions\n                - any:\n                  - channel: \"109672661671505920\"\n                  - role: \"172950000412655616\"\n                  config:\n                    can_kick: false\n                # Match on either of two complex conditions\n                - any:\n                  - all:\n                    - channel: \"109672661671505920\"\n                      role: \"172950000412655616\"\n                    - not:\n                        role: \"473085927053590538\"\n                  - channel: \"534727637017559040\"\n                    role: \"473086848831455234\"\n                  config:\n                    can_kick: false\n        </CodeBlock>\n      </template>\n    </Expandable>\n\n    <h2>Default overrides</h2>\n    <p>\n      Many plugins have some overrides by default, usually for the default mod level (50) and/or the default admin level\n      (100). These are applied before any custom overrides in the config.\n    </p>\n    <p>\n      You can see the default overrides for each plugin by checking the <strong>Default configuration section</strong>\n      under the <strong>Configuration tab</strong> on the plugin's documentation page.\n    </p>\n    <p>\n      To replace a plugin's default overrides entirely, set <code>replaceDefaultOverrides</code> to <code>true</code> in\n      plugin options, on the same level as <code>config</code> and <code>overrides</code>. In the following example, any\n      default overrides the plugin had will no longer have an effect:\n    </p>\n\n    <CodeBlock code-lang=\"yaml\">\n      example_plugin:\n        config:\n          can_kick: false\n        replaceDefaultOverrides: true # <-- Here\n        overrides:\n          - level: \">=25\"\n            config:\n              can_kick: true\n    </CodeBlock>\n  </div>\n</template>\n\n<script lang=\"ts\">\n  import CodeBlock from \"./CodeBlock.vue\";\n  import Expandable from \"../Expandable.vue\";\n\n  export default {\n    components: { CodeBlock, Expandable },\n  };\n</script>\n"
  },
  {
    "path": "dashboard/src/components/docs/WorkInProgress.vue",
    "content": "<template>\n  <div>\n    <h1>Work in progress</h1>\n    <p>\n      This page is a work in progress.\n    </p>\n  </div>\n</template>\n"
  },
  {
    "path": "dashboard/src/directives/trim-indents.ts",
    "content": "import { Directive } from \"vue\";\n\nexport const trimIndents: Directive = {\n  beforeMount(el, binding) {\n    const withoutStartEndWhitespace = el.innerHTML.replace(/(^\\n+|\\n+$)/g, \"\");\n\n    const mode = binding.value != null ? binding.value : \"start\";\n\n    let spacesToTrim;\n    if (mode === \"start\") {\n      const match = withoutStartEndWhitespace.match(/^\\s+/);\n      spacesToTrim = match ? match[0].length : 0;\n    } else if (mode === \"end\") {\n      const match = withoutStartEndWhitespace.match(/\\s+$/);\n      spacesToTrim = match ? match[0].length : 0;\n    } else {\n      spacesToTrim = parseInt(mode, 10);\n    }\n\n    el.innerHTML = withoutStartEndWhitespace\n      .split(\"\\n\")\n      .map((line) => line.slice(spacesToTrim))\n      .join(\"\\n\");\n  },\n};\n"
  },
  {
    "path": "dashboard/src/index.ts",
    "content": "import \"./style/app.css\";\n\nimport { createApp } from \"vue\";\n\nimport \"highlight.js/styles/base16/ocean.css\";\nimport VueHighlightJS from \"vue3-highlightjs\";\n\nimport { router } from \"./routes\";\nimport { RootStore } from \"./store\";\n\nimport \"./directives/trim-indents\";\n\nimport App from \"./components/App.vue\";\nimport { trimIndents } from \"./directives/trim-indents\";\n\nif (!window.API_URL) {\n  throw new Error(\"Missing API_URL\");\n}\n\nconst app = createApp(App);\n\napp.use(router);\napp.use(RootStore);\napp.use(VueHighlightJS);\n\napp.directive(\"trim-indents\", trimIndents);\n\napp.mount(\"#app\");\n"
  },
  {
    "path": "dashboard/src/routes.ts",
    "content": "import { createRouter, createWebHistory } from \"vue-router\";\nimport { authGuard, authRedirectGuard, loginCallbackGuard } from \"./auth\";\nimport Splash from \"./components/Splash.vue\";\n\nexport const router = createRouter({\n  history: createWebHistory(),\n  routes: [\n    { path: \"/\", component: Splash },\n\n    { path: \"/login\", components: {}, beforeEnter: authRedirectGuard },\n    { path: \"/login-callback\", component: {}, beforeEnter: loginCallbackGuard },\n\n    // Privacy policy\n    {\n      path: \"/privacy-policy\",\n      component: () => import(\"./components/PrivacyPolicy.vue\"),\n    },\n\n    // Docs\n    {\n      path: \"/docs\",\n      component: () => import(\"./components/docs/DocsLayout.vue\"),\n      children: [\n        {\n          path: \"\",\n          redirect: \"/docs/introduction\",\n        },\n        {\n          path: \"introduction\",\n          component: () => import(\"./components/docs/Introduction.vue\"),\n        },\n        {\n          path: \"configuration/configuration-format\",\n          component: () => import(\"./components/docs/ConfigurationFormat.vue\"),\n        },\n        {\n          path: \"configuration/permissions\",\n          component: () => import(\"./components/docs/Permissions.vue\"),\n        },\n        {\n          path: \"configuration/plugin-configuration\",\n          component: () => import(\"./components/docs/PluginConfiguration.vue\"),\n        },\n        {\n          path: \"reference/argument-types\",\n          component: () => import(\"./components/docs/ArgumentTypes.vue\"),\n        },\n        {\n          path: \"setup-guides/logs\",\n          component: () => import(\"./components/docs/WorkInProgress.vue\"),\n        },\n        {\n          path: \"setup-guides/moderation\",\n          component: () => import(\"./components/docs/Moderation.vue\"),\n        },\n        {\n          path: \"setup-guides/counters\",\n          component: () => import(\"./components/docs/Counters.vue\"),\n        },\n        {\n          path: \"plugins/:pluginName/:tab?\",\n          component: () => import(\"./components/docs/Plugin.vue\"),\n        },\n      ],\n    },\n\n    // Dashboard\n    {\n      path: \"/dashboard\",\n      component: () => import(\"./components/dashboard/Layout.vue\"),\n      beforeEnter: authGuard,\n      children: [\n        {\n          path: \"\",\n          component: () => import(\"./components/dashboard/GuildList.vue\"),\n        },\n        {\n          path: \"guilds/:guildId/info\",\n          component: () => import(\"./components/dashboard/GuildInfo.vue\"),\n        },\n        {\n          path: \"guilds/:guildId/config\",\n          component: () => import(\"./components/dashboard/GuildConfigEditor.vue\"),\n        },\n        {\n          path: \"guilds/:guildId/access\",\n          component: () => import(\"./components/dashboard/GuildAccess.vue\"),\n        },\n        {\n          path: \"guilds/:guildId/import-export\",\n          component: () => import(\"./components/dashboard/GuildImportExport.vue\"),\n        },\n      ],\n    },\n  ],\n\n  scrollBehavior(to, from, savedPosition) {\n    if (to.hash) {\n      return {\n        el: to.hash,\n      };\n    } else if (savedPosition) {\n      return savedPosition;\n    } else {\n      return { left: 0, top: 0 };\n    }\n  },\n});\n"
  },
  {
    "path": "dashboard/src/store/auth.ts",
    "content": "import { Module } from \"vuex\";\nimport { post } from \"../api\";\nimport { AuthState, IntervalType, RootState } from \"./types\";\n\n// Refresh auth every 15 minutes\nconst AUTH_REFRESH_INTERVAL = 1000 * 60 * 15;\n\nexport const AuthStore: Module<AuthState, RootState> = {\n  namespaced: true,\n\n  state: {\n    apiKey: null,\n    loadedInitialAuth: false,\n    authRefreshInterval: null,\n    userId: null,\n  },\n\n  actions: {\n    async loadInitialAuth({ dispatch, commit, state }) {\n      if (state.loadedInitialAuth) return;\n\n      const storedKey = localStorage.getItem(\"apiKey\");\n      if (storedKey) {\n        try {\n          const result = await post(\"auth/validate-key\", { key: storedKey });\n          if (result.valid) {\n            await dispatch(\"setApiKey\", { key: storedKey, userId: result.userId });\n            return;\n          }\n        } catch {} // tslint:disable-line\n\n        console.log(\"Unable to validate key, removing from localStorage\"); // tslint:disable-line\n        localStorage.removeItem(\"apiKey\");\n      }\n\n      commit(\"markInitialAuthLoaded\");\n    },\n\n    setApiKey({ commit, state, dispatch }, { key, userId }) {\n      localStorage.setItem(\"apiKey\", key);\n      commit(\"setApiKey\", { key, userId });\n\n      dispatch(\"startAuthAutoRefresh\");\n    },\n\n    async startAuthAutoRefresh({ commit, state, dispatch }) {\n      // End a previously active auto-refresh, if any\n      await dispatch(\"endAuthAutoRefresh\");\n\n      // Start new auto-refresh\n      const refreshInterval = setInterval(async () => {\n        await post(\"auth/refresh\", { key: state.apiKey });\n      }, AUTH_REFRESH_INTERVAL);\n      commit(\"setAuthRefreshInterval\", refreshInterval);\n    },\n\n    endAuthAutoRefresh({ commit, state }) {\n      if (state.authRefreshInterval) {\n        window.clearInterval(state.authRefreshInterval);\n      }\n      commit(\"setAuthRefreshInterval\", null);\n    },\n\n    async clearApiKey({ commit, dispatch }) {\n      await dispatch(\"endAuthAutoRefresh\");\n\n      localStorage.removeItem(\"apiKey\");\n      commit(\"setApiKey\", { key: null, userId: null });\n    },\n\n    async logout({ dispatch }) {\n      await post(\"auth/logout\");\n      await dispatch(\"clearApiKey\");\n    },\n\n    async expiredLogin({ dispatch }) {\n      await dispatch(\"clearApiKey\");\n      window.location.assign(\"/?error=expiredLogin\");\n    },\n  },\n\n  mutations: {\n    setApiKey(state: AuthState, { key, userId }) {\n      state.apiKey = key;\n      state.userId = userId;\n    },\n\n    setAuthRefreshInterval(state: AuthState, interval: IntervalType | null) {\n      state.authRefreshInterval = interval;\n    },\n\n    markInitialAuthLoaded(state: AuthState) {\n      state.loadedInitialAuth = true;\n    },\n  },\n};\n"
  },
  {
    "path": "dashboard/src/store/docs.ts",
    "content": "import { Module } from \"vuex\";\nimport { get } from \"../api\";\nimport { DocsState, RootState } from \"./types\";\n\nexport const DocsStore: Module<DocsState, RootState> = {\n  namespaced: true,\n\n  state: {\n    allPlugins: [],\n    loadingAllPlugins: false,\n\n    plugins: {},\n  },\n\n  actions: {\n    async loadAllPlugins({ state, commit }) {\n      if (state.loadingAllPlugins) return;\n      commit(\"setAllPluginLoadStatus\", true);\n\n      const plugins = await get(\"docs/plugins\");\n      plugins.sort((a, b) => {\n        const aName = (a.info.prettyName || a.name).toLowerCase();\n        const bName = (b.info.prettyName || b.name).toLowerCase();\n        if (aName > bName) return 1;\n        if (aName < bName) return -1;\n        return 0;\n      });\n      commit(\"setAllPlugins\", plugins);\n\n      commit(\"setAllPluginLoadStatus\", false);\n    },\n\n    async loadPluginData({ state, commit }, name) {\n      if (state.plugins[name]) return;\n\n      const data = await get(`docs/plugins/${name}`);\n      if (data && data.messageCommands) {\n        data.messageCommands.sort((a, b) => {\n          const aName = (Array.isArray(a.trigger) ? a.trigger[0] : a.trigger).toLowerCase();\n          const bName = (Array.isArray(b.trigger) ? b.trigger[0] : b.trigger).toLowerCase();\n          if (aName > bName) return 1;\n          if (aName < bName) return -1;\n          return 0;\n        });\n      }\n      commit(\"setPluginData\", { name, data });\n    },\n  },\n\n  mutations: {\n    setAllPluginLoadStatus(state: DocsState, status: boolean) {\n      state.loadingAllPlugins = status;\n    },\n\n    setAllPlugins(state: DocsState, plugins) {\n      state.allPlugins = plugins;\n    },\n\n    setPluginData(state: DocsState, { name, data }) {\n      state.plugins[name] = data;\n    },\n  },\n};\n"
  },
  {
    "path": "dashboard/src/store/guilds.ts",
    "content": "import { Module } from \"vuex\";\nimport { get, post } from \"../api\";\nimport { GuildState, LoadStatus, RootState } from \"./types\";\n\nexport const GuildStore: Module<GuildState, RootState> = {\n  namespaced: true,\n\n  state: {\n    availableGuildsLoadStatus: LoadStatus.None,\n    available: new Map(),\n    configs: {},\n    guildPermissionAssignments: {},\n  },\n\n  actions: {\n    async loadAvailableGuilds({ dispatch, commit, state }) {\n      if (state.availableGuildsLoadStatus !== LoadStatus.None) return;\n      commit(\"setAvailableGuildsLoadStatus\", LoadStatus.Loading);\n\n      const availableGuilds = await get(\"guilds/available\");\n      for (const guild of availableGuilds) {\n        commit(\"addGuild\", guild);\n      }\n\n      commit(\"setAvailableGuildsLoadStatus\", LoadStatus.Done);\n    },\n\n    async loadGuild({ commit, state }, guildId) {\n      if (state.available.has(guildId)) {\n        return;\n      }\n\n      const guild = await get(`guilds/${guildId}`);\n      if (guild) {\n        commit(\"addGuild\", guild);\n      }\n    },\n\n    async loadConfig({ commit }, guildId) {\n      const result = await get(`guilds/${guildId}/config`);\n      commit(\"setConfig\", { guildId, config: result.config });\n    },\n\n    async saveConfig({ commit }, { guildId, config }) {\n      await post(`guilds/${guildId}/config`, { config });\n    },\n\n    async loadMyPermissionAssignments({ commit }) {\n      const myPermissionAssignments = await get(`guilds/my-permissions`);\n      for (const permissionAssignment of myPermissionAssignments) {\n        commit(\"setGuildPermissionAssignments\", {\n          guildId: permissionAssignment.guild_id,\n          permissionAssignments: [permissionAssignment],\n        });\n      }\n    },\n\n    async loadGuildPermissionAssignments({ commit }, guildId) {\n      const permissionAssignments = await get(`guilds/${guildId}/permissions`);\n      commit(\"setGuildPermissionAssignments\", { guildId, permissionAssignments });\n    },\n\n    async setTargetPermissions({ commit }, { guildId, targetId, type, permissions, expiresAt }) {\n      await post(`guilds/${guildId}/set-target-permissions`, { guildId, targetId, type, permissions, expiresAt });\n      commit(\"setTargetPermissions\", { guildId, targetId, type, permissions, expiresAt });\n    },\n\n    async importData({ commit }, { guildId, data, caseHandlingMode }) {\n      return post(`guilds/${guildId}/import`, {\n        data,\n        caseHandlingMode,\n      });\n    },\n\n    async exportData({ commit }, { guildId }) {\n      return post(`guilds/${guildId}/export`);\n    },\n  },\n\n  mutations: {\n    setAvailableGuildsLoadStatus(state: GuildState, status: LoadStatus) {\n      state.availableGuildsLoadStatus = status;\n    },\n\n    addGuild(state: GuildState, guild) {\n      state.available.set(guild.id, guild);\n      state.available = state.available;\n    },\n\n    setConfig(state: GuildState, { guildId, config }) {\n      state.configs[guildId] = config;\n    },\n\n    setGuildPermissionAssignments(state: GuildState, { guildId, permissionAssignments }) {\n      if (!state.guildPermissionAssignments) {\n        state.guildPermissionAssignments = {};\n      }\n\n      state.guildPermissionAssignments[guildId] = permissionAssignments.map((p) => ({\n        ...p,\n        permissions: new Set(p.permissions),\n      }));\n    },\n\n    setTargetPermissions(state: GuildState, { guildId, targetId, type, permissions, expiresAt }) {\n      const guildPermissionAssignments = state.guildPermissionAssignments[guildId] || [];\n      if (permissions.length === 0) {\n        // No permissions -> remove permission assignment\n        guildPermissionAssignments.splice(\n          guildPermissionAssignments.findIndex((p) => p.target_id === targetId && p.type === type),\n          1,\n        );\n      } else {\n        // Update/add permission assignment\n        const itemToEdit = guildPermissionAssignments.find((p) => p.target_id === targetId && p.type === type);\n        if (itemToEdit) {\n          itemToEdit.permissions = new Set(permissions);\n        } else {\n          state.guildPermissionAssignments[guildId].push({\n            type,\n            target_id: targetId,\n            permissions: new Set(permissions),\n            expires_at: expiresAt,\n          });\n        }\n      }\n\n      state.guildPermissionAssignments = { ...state.guildPermissionAssignments };\n    },\n  },\n};\n"
  },
  {
    "path": "dashboard/src/store/index.ts",
    "content": "import { createStore, Store } from \"vuex\";\n\nimport { AuthStore } from \"./auth\";\nimport { DocsStore } from \"./docs\";\nimport { GuildStore } from \"./guilds\";\nimport { RootState } from \"./types\";\n\nexport const RootStore = createStore({\n  modules: {\n    auth: AuthStore,\n    guilds: GuildStore,\n    docs: DocsStore,\n  },\n});\n\n// Set up typings so Vue/our components know about the state's types\ndeclare module \"vue\" {\n  interface ComponentCustomProperties {\n    $store: Store<RootState>;\n  }\n}\n"
  },
  {
    "path": "dashboard/src/store/staff.ts",
    "content": "import { Module } from \"vuex\";\nimport { get } from \"../api\";\nimport { RootState, StaffState } from \"./types\";\n\nexport const StaffStore: Module<StaffState, RootState> = {\n  namespaced: true,\n\n  state: {\n    isStaff: false,\n  },\n\n  actions: {\n    async checkStatus({ commit }) {\n      const status = await get(\"staff/status\");\n      commit(\"setStatus\", status.isStaff);\n    },\n  },\n\n  mutations: {\n    setStatus(state: StaffState, value: boolean) {\n      state.isStaff = value;\n    },\n  },\n};\n"
  },
  {
    "path": "dashboard/src/store/types.ts",
    "content": "import { ApiPermissions } from \"@zeppelinbot/shared/apiPermissions.js\";\n\nexport enum LoadStatus {\n  None = 1,\n  Loading,\n  Done,\n}\n\nexport type TimeoutType = ReturnType<typeof setTimeout>;\nexport type IntervalType = ReturnType<typeof setInterval>;\n\nexport interface AuthState {\n  apiKey: string | null;\n  loadedInitialAuth: boolean;\n  authRefreshInterval: IntervalType | null;\n  userId: string | null;\n}\n\nexport interface GuildPermissionAssignment {\n  type: string;\n  target_id: string;\n  permissions: Set<ApiPermissions>;\n  expires_at: string | null;\n}\n\nexport interface GuildState {\n  availableGuildsLoadStatus: LoadStatus;\n  available: Map<\n    string,\n    {\n      id: string;\n      name: string;\n      icon: string | null;\n    }\n  >;\n  configs: {\n    [key: string]: string;\n  };\n  guildPermissionAssignments: {\n    [guildId: string]: GuildPermissionAssignment[];\n  };\n}\n\nexport interface StaffState {\n  isStaff: boolean;\n}\n\nexport interface ThinDocsPlugin {\n  name: string;\n  info: {\n    name: string;\n    description?: string;\n  };\n}\n\nexport interface DocsPlugin extends ThinDocsPlugin {\n  messageCommands: any[];\n  slashCommands: any[];\n  defaultOptions: any;\n  configSchema?: string;\n  info: {\n    name: string;\n    description?: string;\n    usageGuide?: string;\n    configurationGuide?: string;\n  };\n}\n\nexport interface DocsState {\n  allPlugins: ThinDocsPlugin[];\n  loadingAllPlugins: boolean;\n\n  plugins: {\n    [key: string]: DocsPlugin;\n  };\n}\n\nexport type RootState = {\n  auth: AuthState;\n  guilds: GuildState;\n  docs: DocsState;\n  staff: StaffState;\n};\n"
  },
  {
    "path": "dashboard/src/style/app.css",
    "content": "@import \"./reset.css\";\n@import \"./base.css\";\n@import \"./splash.css\";\n\n@import \"tailwindcss\";\n@import \"vue-material-design-icons/styles.css\";\n\n@import \"./content.css\";\n@import \"./docs.css\";\n\n/* Reset some icon default styles for more predictable alignment */\n.material-design-icon > .material-design-icon__svg {\n  position: static;\n  bottom: 0;\n}\n\nbody {\n  overflow-y: scroll;\n\n  @apply bg-gray-900;\n  @apply text-gray-300;\n  @apply text-base;\n}\n"
  },
  {
    "path": "dashboard/src/style/base.css",
    "content": "body {\n  font: normal 18px/1.5 sans-serif;\n  font-family:\n    system,\n    -apple-system,\n    \".SFNSText-Regular\",\n    \"San Francisco\",\n    \"Roboto\",\n    \"Segoe UI\",\n    \"Helvetica Neue\",\n    \"Lucida Grande\",\n    sans-serif;\n}\n"
  },
  {
    "path": "dashboard/src/style/components.css",
    "content": ".link {\n  @apply text-blue-400;\n  @apply underline;\n\n  &:hover {\n    @apply text-blue-200;\n  }\n}\n\n.inline-code {\n  @apply inline-block;\n  @apply bg-gray-800;\n  @apply px-1;\n  @apply rounded;\n  @apply text-sm;\n}\n\n.codeblock {\n  @apply bg-gray-800;\n  @apply p-3;\n  @apply mb-4;\n  @apply rounded;\n  @apply text-sm;\n  @apply shadow-md;\n\n  & .hljs {\n    @apply bg-transparent;\n    @apply p-0;\n  }\n}\n\n.inline-icon {\n  top: 0.125rem;\n}\n\n.sr-only-when-not-focused:not(:focus-within) {\n  @apply sr-only;\n}\n"
  },
  {
    "path": "dashboard/src/style/content.css",
    "content": "@import \"./components.css\";\n\n.main-content {\n  & h1 {\n    @apply text-3xl;\n    @apply font-semibold;\n    @apply leading-none;\n    @apply mb-4;\n  }\n\n  & h2 {\n    @apply text-2xl;\n    @apply font-semibold;\n    @apply mt-2;\n    @apply mb-1;\n  }\n\n  & h3 {\n    @apply text-xl;\n    @apply font-semibold;\n    @apply mb-1;\n  }\n\n  & p {\n    @apply mb-4;\n  }\n\n  & a:not([class]),\n  & a[class=\"\"] {\n    @apply text-blue-400;\n    @apply underline;\n\n    &:hover {\n      @apply text-blue-200;\n    }\n  }\n\n  & ul:not([class]) {\n    @apply list-disc;\n    @apply mb-4;\n\n    & li {\n      @apply ml-6;\n    }\n  }\n\n  & ol:not([class]) {\n    @apply list-decimal;\n    @apply mb-4;\n\n    & li {\n      @apply ml-6;\n    }\n  }\n\n  & code:not([class]) {\n    @apply inline-block;\n    @apply bg-gray-800;\n    @apply px-1;\n    @apply rounded;\n    @apply text-sm;\n  }\n\n  & .expandable:not(.wide) {\n    max-width: 600px;\n  }\n}\n\n@media (width >= theme(--breakpoint-lg)) {\n  .main-content {\n    & h1 {\n      @apply text-5xl;\n    }\n  }\n}\n@media (width >= theme(--breakpoint-xl)) {\n  .main-content {\n    & a:not([class]),\n    & a[class=\"\"] {\n      white-space: nowrap;\n    }\n  }\n}\n"
  },
  {
    "path": "dashboard/src/style/docs.css",
    "content": ".docs-sidebar {\n  & .router-link-active {\n    @apply underline;\n  }\n}\n\n@media (width < theme(--breakpoint-lg)) {\n  .docs-sidebar.closed:not(:focus-within) {\n    @apply sr-only;\n  }\n}\n"
  },
  {
    "path": "dashboard/src/style/privacy-policy.css",
    "content": ".privacy-policy {\n  padding: 16px;\n\n  width: 100%;\n  min-height: 100vh;\n\n  background-color: #7289da;\n  background-image: linear-gradient(225deg, #7289da 0%, #5d70b4 100%);\n  color: #fff;\n\n  display: flex;\n  flex-direction: column;\n  justify-content: flex-start;\n  align-items: center;\n\n  & a {\n    color: #fff;\n    text-decoration: underline;\n  }\n\n  & .wrapper {\n    flex: 0 0 auto;\n\n    width: 100%;\n    max-width: 800px;\n  }\n\n  & h1 {\n    font-size: 60px;\n    font-weight: 300;\n  }\n\n  & h2 {\n    font-size: 30px;\n    margin-top: 16px;\n  }\n\n  & p {\n    margin-bottom: 16px;\n  }\n\n  & ul {\n    list-style: disc;\n    margin-left: 24px;\n    margin-bottom: 16px;\n\n    & ul {\n      margin-bottom: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "dashboard/src/style/reset.css",
    "content": "@layer base {\n  /* Box sizing rules */\n  *,\n  *::before,\n  *::after {\n    box-sizing: border-box;\n  }\n\n  /* Remove default padding */\n  ul,\n  ol {\n    padding: 0;\n  }\n\n  /* Remove default margin */\n  body,\n  h1,\n  h2,\n  h3,\n  h4,\n  p,\n  ul,\n  ol,\n  li,\n  figure,\n  figcaption,\n  blockquote,\n  dl,\n  dd {\n    margin: 0;\n  }\n\n  /* Inherit fonts for inputs and buttons */\n  input,\n  button,\n  textarea,\n  select {\n    font: inherit;\n  }\n\n  /* Remove all animations and transitions for people that prefer not to see them */\n  @media (prefers-reduced-motion: reduce) {\n    * {\n      animation-duration: 0.01ms !important;\n      animation-iteration-count: 1 !important;\n      transition-duration: 0.01ms !important;\n      scroll-behavior: auto !important;\n    }\n  }\n}\n"
  },
  {
    "path": "dashboard/src/style/splash.css",
    "content": ".splash {\n  width: 100vw;\n  min-height: 100vh;\n\n  background-color: #7289da;\n  background-image: linear-gradient(225deg, #7289da 0%, #5d70b4 100%);\n  color: #fff;\n\n  display: flex;\n  flex-direction: column;\n  justify-content: flex-start;\n  align-items: center;\n\n  & a {\n    color: #fff;\n  }\n\n  & > .error {\n    display: flex;\n    width: 100%;\n    max-width: 750px;\n    flex-direction: row;\n    justify-content: center;\n    margin-top: 16px;\n\n    & .message {\n      flex: 0 1 auto;\n      text-align: left;\n      padding: 8px 12px;\n      background-color: #404040;\n      border-radius: 4px;\n      box-shadow: 0 3px 12px -2px hsla(0, 0%, 0%, 0.2);\n    }\n  }\n\n  & .wrapper {\n    flex: 0 0 auto;\n\n    width: 800px;\n    max-width: 100%;\n\n    display: flex;\n    flex-direction: row;\n    justify-content: center;\n    flex-wrap: wrap;\n\n    & .logo-column {\n      flex: 0 0 auto;\n      padding: 24px;\n    }\n\n    & .info-column {\n      flex: 1 1 100%;\n      max-width: 448px;\n      padding: 24px;\n      text-align: center;\n    }\n\n    & .logo {\n      width: 300px;\n      height: 300px;\n    }\n\n    & h1 {\n      font-size: 80px;\n      font-weight: 300;\n    }\n\n    & .description {\n      color: #f1f5ff;\n    }\n\n    & .actions {\n      display: grid;\n      grid-template-columns: 1fr 1fr;\n\n      margin-top: 8px;\n\n      & .btn {\n        margin: 12px;\n        text-decoration: none;\n        text-align: center;\n        padding: 8px 24px;\n        border: 1px solid #fff;\n        border-radius: 4px;\n        transition: all 120ms ease-in-out;\n        background-color: hsla(0, 0%, 100%, 0.05);\n\n        &:not(.disabled):hover {\n          background-color: hsla(0, 0%, 100%, 0.25);\n          box-shadow: 0 3px 12px -2px hsla(0, 0%, 0%, 0.2);\n        }\n\n        &.disabled {\n          cursor: default;\n          opacity: 0.75;\n        }\n      }\n    }\n\n    & .links {\n      list-style: none;\n      display: flex;\n      font-size: 14px;\n      justify-content: center;\n\n      margin-top: 12px;\n\n      & li {\n        padding: 0 16px;\n        position: relative;\n      }\n\n      & li:first-child {\n        padding-left: 0;\n      }\n\n      & li:last-child {\n        padding-right: 0;\n      }\n\n      & li:not(:first-child)::before {\n        display: block;\n        content: \" \";\n        width: 4px;\n        height: 4px;\n        border-radius: 50%;\n        background-color: rgb(226, 232, 253);\n\n        position: absolute;\n        top: 50%;\n        left: 0;\n        transform: translate(-50%, -50%);\n      }\n\n      & a {\n        color: rgb(226, 232, 253);\n        text-decoration: none;\n\n        &:hover {\n          color: white;\n        }\n      }\n    }\n\n    & .error {\n      margin-top: 8px;\n      background-color: hsl(224, 52%, 32%);\n      padding: 12px;\n      border-radius: 4px;\n    }\n  }\n}\n"
  },
  {
    "path": "dashboard/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ndeclare module \"*.html\" {\n  const value: string;\n  export default value;\n}\n\ninterface Window {\n  API_URL: string;\n}\n"
  },
  {
    "path": "dashboard/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"moduleResolution\": \"bundler\",\n    \"module\": \"esnext\",\n    \"target\": \"es2018\",\n    \"sourceMap\": true,\n    \"noImplicitAny\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true,\n    \"strict\": false,\n    \"lib\": [\"esnext\", \"dom\"],\n    \"baseUrl\": \".\",\n    \"resolveJsonModule\": true,\n    \"esModuleInterop\": true,\n    \"allowJs\": true\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.vue\"],\n  \"references\": [\n    {\n      \"path\": \"../shared/tsconfig.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "dashboard/vite.config.ts",
    "content": "import { defineConfig } from \"vite\";\nimport vue from \"@vitejs/plugin-vue\";\nimport tailwind from \"@tailwindcss/vite\";\n\nexport default defineConfig((configEnv) => {\n  return {\n    server: {\n      port: 3002,\n      host: \"0.0.0.0\",\n      allowedHosts: true,\n    },\n    plugins: [\n      vue({\n        template: {\n          compilerOptions: {\n            // Needed to prevent hardcoded code blocks from breaking in docs\n            whitespace: \"preserve\",\n          },\n        },\n      }),\n      tailwind(),\n    ],\n  };\n});\n"
  },
  {
    "path": "docker/development/devenv/Dockerfile",
    "content": "FROM ubuntu:24.04\n\nARG DEVELOPMENT_UID\nARG DEVELOPMENT_SSH_PASSWORD\n\nENV DEBIAN_FRONTEND=noninteractive\nENV TZ=UTC\n\n# Set up some core packages\nRUN apt-get update\nRUN apt-get install -y sudo curl software-properties-common\n\nRUN add-apt-repository ppa:git-core/ppa && apt-get update && apt-get install -y git\n\n# Set up SSH access\nRUN apt-get install -y openssh-server iptables\nRUN mkdir /var/run/sshd\nRUN usermod -G sudo -u $DEVELOPMENT_UID ubuntu\nRUN echo \"ubuntu:${DEVELOPMENT_SSH_PASSWORD}\" | chpasswd\n\n# Install Node.js 24 and packages needed to build native packages\nRUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash -\nRUN apt-get install -y nodejs gcc g++ make python3\n\n# Install pnpm\nRUN npm install -g pnpm@10.19.0\n\nCMD [\"/usr/sbin/sshd\", \"-D\", \"-e\"]\n"
  },
  {
    "path": "docker/development/nginx/Dockerfile",
    "content": "FROM nginx\n\nRUN apt-get update && apt-get install -y openssl\nRUN openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/private/localhost-cert.key -out /etc/ssl/certs/localhost-cert.pem -days 3650 -subj '/CN=localhost' -nodes\n\nCOPY ./default.conf /etc/nginx/conf.d/default.conf\n"
  },
  {
    "path": "docker/development/nginx/default.conf",
    "content": "server {\n  listen 443 ssl http2;\n  listen [::]:443 ssl http2;\n  server_name localhost;\n\n  # Using a variable here stops nginx from crashing if the dev container is restarted or becomes otherwise unavailable\n  set $backend_upstream \"http://devenv:3001\";\n  set $dashboard_upstream \"http://devenv:3002\";\n\n  location / {\n    # Using a variable in proxy_pass also requires resolver to be set.\n    # This is the address of the internal docker compose DNS server.\n    resolver 127.0.0.11;\n    proxy_pass $dashboard_upstream$uri$is_args$args;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection \"upgrade\";\n    proxy_set_header Host $host;\n    proxy_http_version 1.1;\n    proxy_cache_bypass $http_upgrade;\n  }\n\n  location /api {\n    resolver 127.0.0.11;\n    proxy_pass $backend_upstream$uri$is_args$args;\n    proxy_redirect off;\n\n    client_max_body_size 200M;\n  }\n\n  ssl_certificate /etc/ssl/certs/localhost-cert.pem;\n  ssl_certificate_key /etc/ssl/private/localhost-cert.key;\n\n  ssl_session_timeout 1d;\n  ssl_session_cache shared:MozSSL:10m;\n  ssl_session_tickets off;\n\n  ssl_protocols TLSv1.3;\n  ssl_prefer_server_ciphers off;\n}\n"
  },
  {
    "path": "docker/production/nginx/Dockerfile",
    "content": "FROM nginx\n\nRUN apt-get update && apt-get install -y openssl\nRUN openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/private/localhost-cert.key -out /etc/ssl/certs/localhost-cert.pem -days 3650 -subj '/CN=localhost' -nodes\n\nCOPY ./docker/production/nginx/default.conf /etc/nginx/conf.d/default.conf\n"
  },
  {
    "path": "docker/production/nginx/default.conf",
    "content": "server {\n  listen 443 ssl http2;\n  listen [::]:443 ssl http2;\n  server_name _;\n\n  # Using a variable here stops nginx from crashing if the dev container is restarted or becomes otherwise unavailable\n  set $backend_upstream \"http://api:3001\";\n  set $dashboard_upstream \"http://dashboard:3002\";\n\n  location / {\n    # Using a variable in proxy_pass also requires resolver to be set.\n    # This is the address of the internal docker compose DNS server.\n    resolver 127.0.0.11;\n    proxy_pass $dashboard_upstream$uri$is_args$args;\n  }\n\n  location /api {\n    resolver 127.0.0.11;\n    proxy_pass $backend_upstream$uri$is_args$args;\n    proxy_redirect off;\n\n    client_max_body_size 200M;\n  }\n\n  ssl_certificate /etc/ssl/certs/localhost-cert.pem;\n  ssl_certificate_key /etc/ssl/private/localhost-cert.key;\n\n  ssl_session_timeout 1d;\n  ssl_session_cache shared:MozSSL:10m;\n  ssl_session_tickets off;\n\n  ssl_protocols TLSv1.3;\n  ssl_prefer_server_ciphers off;\n}\n"
  },
  {
    "path": "docker-compose.development.yml",
    "content": "name: zeppelin-dev\nvolumes:\n  home: {}\n  mysql-data: {}\n  redis-data: {}\nservices:\n  nginx:\n    build:\n      context: ./docker/development/nginx\n      args:\n        DEVELOPMENT_WEB_PORT: ${DEVELOPMENT_WEB_PORT:?Missing DEVELOPMENT_WEB_PORT}\n    ports:\n      - \"${DEVELOPMENT_WEB_PORT:?Missing DEVELOPMENT_WEB_PORT}:443\"\n\n  mysql:\n    image: mysql:8.0\n    environment:\n      MYSQL_ROOT_PASSWORD: ${DEVELOPMENT_MYSQL_ROOT_PASSWORD?:Missing DEVELOPMENT_MYSQL_ROOT_PASSWORD}\n      MYSQL_DATABASE: zeppelin\n      MYSQL_USER: zeppelin\n      MYSQL_PASSWORD: ${DEVELOPMENT_MYSQL_PASSWORD?:Missing DEVELOPMENT_MYSQL_PASSWORD}\n    ports:\n      - ${DEVELOPMENT_MYSQL_PORT:?Missing DEVELOPMENT_MYSQL_PORT}:3306\n    # If you're upgrading from an older version, you can load your old database by switching the volumes below.\n    # Then, take a database dump from the old database, switch the volumes back, and load the dump into the new database.\n    volumes:\n      - mysql-data:/var/lib/mysql\n      # - ./docker/production/data/mysql:/var/lib/mysql\n    command: --authentication-policy=mysql_native_password\n\n  redis:\n    image: redis:8.2.3\n    volumes:\n      - redis-data:/data\n\n  devenv:\n    build:\n      context: ./docker/development/devenv\n      args:\n        DEVELOPMENT_SSH_PASSWORD: ${DEVELOPMENT_SSH_PASSWORD:?Missing DEVELOPMENT_SSH_PASSWORD}\n        DEVELOPMENT_UID: ${DEVELOPMENT_UID:-1000}\n    ports:\n      - \"${DEVELOPMENT_SSH_PORT:?Missing DEVELOPMENT_SSH_PORT}:22\"\n    volumes:\n      - home:/home/ubuntu\n      - ./:/workspace/zeppelin\n\n  cftunnel:\n    profiles: [cftunnel]\n    image: cloudflare/cloudflared:latest\n    command: tunnel run\n    environment:\n      TUNNEL_TOKEN: ${CF_TUNNEL_TOKEN}\n"
  },
  {
    "path": "docker-compose.lightweight.yml",
    "content": "version: '3'\nname: zeppelin-lightweight\nservices:\n  migrate:\n    build: &build\n      context: .\n      args:\n        # Used at compile-time by dashboard\n        API_URL:\n    environment: &env\n      - NODE_ENV=production\n      - DB_HOST=${LIGHTWEIGHT_DB_HOST}\n      - DB_PORT=${LIGHTWEIGHT_DB_PORT}\n      - DB_USER=${LIGHTWEIGHT_DB_USER}\n      - DB_PASSWORD=${LIGHTWEIGHT_DB_PASSWORD}\n      - DB_DATABASE=${LIGHTWEIGHT_DB_DATABASE}\n      - API_PATH_PREFIX=${LIGHTWEIGHT_API_PATH_PREFIX}\n    env_file:\n      - .env\n    working_dir: /zeppelin/backend\n    command: [\"npm\", \"run\", \"migrate-prod\"]\n\n  api:\n    depends_on:\n      migrate:\n        condition: service_completed_successfully\n    build: *build\n    restart: on-failure\n    environment: *env\n    env_file:\n      - .env\n    ports:\n      - \"${LIGHTWEIGHT_API_PORT}:3001\"\n    working_dir: /zeppelin/backend\n    command: [\"npm\", \"run\", \"start-api-prod\"]\n\n  bot:\n    depends_on:\n      migrate:\n        condition: service_completed_successfully\n    build: *build\n    restart: on-failure\n    environment: *env\n    env_file:\n      - .env\n    working_dir: /zeppelin/backend\n    command: [\"npm\", \"run\", \"start-bot-prod\"]\n  \n  dashboard:\n    depends_on:\n      migrate:\n        condition: service_completed_successfully\n    build: *build\n    restart: on-failure\n    environment: *env\n    env_file:\n      - .env\n    ports:\n      - \"${LIGHTWEIGHT_DASHBOARD_PORT}:3002\"\n    working_dir: /zeppelin/dashboard\n    command: [\"node\", \"serve.js\"]\n"
  },
  {
    "path": "docker-compose.standalone.yml",
    "content": "version: '3'\nname: zeppelin-prod\nvolumes:\n  mysql-data: {}\n  redis-data: {}\nservices:\n  mysql:\n    image: mysql:8.0\n    environment:\n      MYSQL_ROOT_PASSWORD: ${STANDALONE_MYSQL_ROOT_PASSWORD?:Missing STANDALONE_MYSQL_ROOT_PASSWORD}\n      MYSQL_DATABASE: zeppelin\n      MYSQL_USER: zeppelin\n      MYSQL_PASSWORD: ${STANDALONE_MYSQL_PASSWORD?:Missing STANDALONE_MYSQL_PASSWORD}\n    ports:\n      - 127.0.0.1:${STANDALONE_MYSQL_PORT:?Missing STANDALONE_MYSQL_PORT}:3306\n    # If you're upgrading from an older version, you can load your old database by switching the volumes below.\n    # Then, take a database dump from the old database, switch the volumes back, and load the dump into the new database.\n    volumes:\n      - mysql-data:/var/lib/mysql\n      # - ./docker/production/data/mysql:/var/lib/mysql\n    command:\n      - \"--skip-log-bin\"\n    healthcheck:\n      test: \"/usr/bin/mysql --host=127.0.0.1 --user=root --password=\\\"${STANDALONE_MYSQL_ROOT_PASSWORD}\\\" --execute \\\"SHOW DATABASES;\\\"\"\n      interval: 1s\n      timeout: 5s\n      retries: 60\n\n  redis:\n    image: redis:8.2.3\n    volumes:\n      - redis-data:/data\n\n  nginx:\n    build:\n      context: .\n      dockerfile: docker/production/nginx/Dockerfile\n    ports:\n      - \"${STANDALONE_WEB_PORT:?Missing STANDALONE_WEB_PORT}:443\"\n\n  migrate:\n    image: dragory/zeppelin\n    depends_on:\n      mysql:\n        condition: service_healthy\n    environment: &env\n      - NODE_ENV=production\n      - DB_HOST=mysql\n      - DB_PORT=3306\n      - DB_USER=zeppelin\n      - DB_PASSWORD=${STANDALONE_MYSQL_PASSWORD}\n      - DB_DATABASE=zeppelin\n      - API_PATH_PREFIX=/api\n    env_file:\n      - .env\n    working_dir: /zeppelin/backend\n    command: [\"npm\", \"run\", \"migrate-prod\"]\n\n  api:\n    image: dragory/zeppelin\n    depends_on:\n      migrate:\n        condition: service_completed_successfully\n    restart: on-failure\n    environment: *env\n    env_file:\n      - .env\n    working_dir: /zeppelin/backend\n    command: [\"npm\", \"run\", \"start-api-prod\"]\n\n  bot:\n    image: dragory/zeppelin\n    depends_on:\n      migrate:\n        condition: service_completed_successfully\n    restart: on-failure\n    environment: *env\n    env_file:\n      - .env\n    working_dir: /zeppelin/backend\n    command: [\"npm\", \"run\", \"start-bot-prod\"]\n  \n  dashboard:\n    image: dragory/zeppelin\n    depends_on:\n      migrate:\n        condition: service_completed_successfully\n    restart: on-failure\n    environment: *env\n    env_file:\n      - .env\n    working_dir: /zeppelin/dashboard\n    command: [\"node\", \"serve.js\"]\n"
  },
  {
    "path": "docs/DEVELOPMENT.md",
    "content": "# Zeppelin development environment\n\n⚠️ **Updating from before 30 Mar 2024? See [MIGRATE_DEV.md](./MIGRATE_DEV.md) for instructions.**\n\nZeppelin's development environment runs entirely within a Docker container.\nBelow you can find instructions for setting up the environment and getting started with development!\n\n**Note:** If you'd just like to run the bot for your own server, see 👉 **[PRODUCTION.md](./PRODUCTION.md)** 👈\n\n## Starting the development environment\n\n### Using VSCode devcontainers\n1. Install Docker\n2. Make a copy of `.env.example` called `.env`\n3. Fill in the missing values in `.env`\n4. In VSCode: Install the `Dev Containers` plugin\n5. In VSCode: Run `Dev Containers: Open Folder in Container...` and select the Zeppelin folder\n\n### Using VSCode remote SSH plugin\n1. Install Docker\n2. Make a copy of `.env.example` called `.env`\n3. Fill in the missing values in `.env`\n4. Run `docker compose -f docker-compose.development.yml up` to start the development environment\n5. In VSCode: Install the `Remote - SSH` plugin\n6. In VSCode: Run `Remote-SSH: Connect to Host...`\n    * As the address, use `ubuntu@127.0.0.1:3022` (where `3022` matches `DEVELOPMENT_SSH_PORT` in `.env`)\n    * Use the password specified in `.env` as `DEVELOPMENT_SSH_PASSWORD`\n7. In VSCode: Once connected, click `Open folder...` and select `/home/ubuntu/zeppelin`\n\n### Using JetBrains Gateway\n1. Install Docker\n2. Make a copy of `.env.example` called `.env`\n3. Fill in the missing values in `.env`\n4. Run `docker compose -f docker-compose.development.yml up` to start the development environment\n5. Choose `Connect via SSH` and create a new connection:\n    * Username: `ubuntu`\n    * Host: `127.0.0.1`\n    * Port: `3022` (matching the `DEVELOPMENT_SSH_PORT` value in `.env`)\n6. Click `Check Connection and Continue` and enter the password specified in `.env` as `DEVELOPMENT_SSH_PASSWORD` when asked\n7. In the next pane:\n    * IDE version: WebStorm, PHPStorm, or IntelliJ IDEA\n    * Project directory: `/home/ubuntu/zeppelin`\n8. Click `Download and Start IDE`\n\n### Using any other IDE with SSH development support\n1. Install Docker\n2. Make a copy of `.env.example` called `.env`\n3. Fill in the missing values in `.env`\n4. Run `docker compose -f docker-compose.development.yml up` to start the development environment\n5. Use the following credentials for connecting with your IDE:\n    * Host: `127.0.0.1`\n    * Port: `3022` (matching the `DEVELOPMENT_SSH_PORT` value in `.env`)\n    * Username: `ubuntu`\n    * Password: As specified in `.env` as `DEVELOPMENT_SSH_PASSWORD`\n\n## Starting the project\nThese commands are run inside the dev container. You should be able to open a terminal in your IDE after connecting to the dev environment.\n\n### 1. Install dependencies\n\n1. `pnpm install`\n\n### Starting the backend (bot + api)\n\n1. `cd ~/zeppelin/backend`\n2. `pnpm run watch`\n\n### Starting the dashboard\n\n1. `cd ~/zeppelin/dashboard`\n2. `pnpm run watch`\n\n### Opening the dashboard\nBrowse to https://localhost:3300 to view the dashboard\n"
  },
  {
    "path": "docs/MANAGEMENT.md",
    "content": "# Management\nAfter starting Zeppelin -- either in the [development](./DEVELOPMENT.md) or [production](./PRODUCTION.md) environment -- you have several tools available to manage it.\n\n## Note\nMake sure to add yourself to the list of staff members (`STAFF`) in `.env` and allow at least one server by default (`DEFAULT_ALLOWED_SERVERS`). Then, invite the bot to the server.\n\nIn all examples below, `@Bot` refers to a user mention of the bot user. Make sure to run the commands on a server with the bot, in a channel that the bot can see.\n\nIn the command parameters, `<this>` refers to a required parameter (don't include the `< >` symbols) and `[this]` refers to an optional parameter (don't include the `[ ]` symbols). `<this...>` refers to being able to list multiple values, e.g. `value1 value2 value3`.\n\n## Allow a server to invite the bot\nRun the following command:\n```\n@Bot allow_server <serverId> [userId]\n```\nWhen specifying a user ID, that user will be given \"Bot manager\" level access to the server's dashboard, allowing them to manage access for other users.\n\n## Disallow a server\nRun the following command:\n```\n@Bot disallow_server <serverId>\n```\n\n## Grant access to a server's dashboard\nRun the following command:\n```\n@Bot add_dashboard_user <serverId> <userId...>\n```\n\n## Remove access to a server's dashboard\nRun the following command:\n```\n@Bot remove_dashboard_user <serverId> <userId...>\n```\n"
  },
  {
    "path": "docs/MIGRATE_DEV.md",
    "content": "# Migrating from a version before 30 Mar 2024\nZeppelin's development environment was restructured on 30 Mar 2024. Here's a list of changes to keep in mind when updating to the new version:\n* Env variables in `backend/bot.env` and `backend/api.env` have been consolidated into `.env` at the root directory\n  * It is recommended to create a fresh `.env` file based on `.env.example`\n* MySQL data is no longer symlinked to `docker/development/data`. This means that when you start the dev env for the first time, the database will also be created fresh.\n  * The data is now saved to a named Docker volume instead\n  * If you need to move over the old data, check the `volumes` section of the `mysql` service in [docker-compose.development.yml](../docker-compose.development.yml) for instructions.\n* The recommended dashboard watch command has changed from `npm run watch-build` to `npm run watch`\n* If you had made changes to the home folder of the devenv (i.e. `/home/ubuntu` inside the `devenv` container), e.g. by adding SSH keys to `.ssh`, these will need to be re-applied\n  * For SSH specifically, it is recommended to use SSH agent forwarding rather than copying key files directly to the container. VS Code and Jetbrains Gateway handle this for you automatically.\n"
  },
  {
    "path": "docs/MIGRATE_PROD.md",
    "content": "# Migrating from a version before 30 Mar 2024\nZeppelin's production environment was restructured on 30 Mar 2024. Here's a list of changes to keep in mind when updating to the new version:\n* The docker compose file for the production environment is now called `docker-compose.standalone.yml`. There is also a `docker-compose.lightweight.yml` file for different use cases, see [PRODUCTION.md](PRODUCTION.md) for details.\n* Env variables in `backend/bot.env` and `backend/api.env` have been consolidated into `.env` at the root directory\n  * It is recommended to create a fresh `.env` file based on `.env.example`\n* MySQL data is no longer symlinked to `docker/production/data`. This means that when you start the bot for the first time, the database will also be created fresh.\n  * To migrate your data, connect to the database and import a database dump\n  * If you did not take a backup of your data before updating, check the `volumes` section of the `mysql` service in [docker-compose.production.yml](../docker-compose.production.yml) for instructions on loading the old data folder\n* When the production Docker image is being built, files from the bot's folder are now *copied* rather than linked. This means that if you make changes to the files, you need to rebuild the services to see the changes.\n\nIf you need help with any of these steps, please join us on the Zeppelin self-hosting community The Hangar at [https://discord.gg/uTcdUmF6Q7](https://discord.gg/uTcdUmF6Q7)!\n"
  },
  {
    "path": "docs/PRODUCTION.md",
    "content": "# Zeppelin production environment\n\n⚠️ **Updating from before 30 Mar 2024? See [MIGRATE_PROD.md](./MIGRATE_PROD.md) for instructions.**\n\nZeppelin's production environment uses Docker. There are a few different ways to run Zeppelin in production:\n\n1. **Standalone**\n  * The easiest way to get Zeppelin up and running. This setup comes with a built-in database and web server.\n2. **Lightweight**\n  * In case you don't want to use the built-in database and web server. This setup only runs the bot, API, and dashboard themselves. You'll have to provide your own database connection options and set up a proxy server for the API and dashboard.\n3. **Manual**\n  * If you only want to run a specific service, you can use Zeppelin's Dockerfile directly.\n\n## Standalone\n\n### Setup\n1. Install Docker on the machine running the bot\n2. Make a copy of `.env.example` called `.env`\n3. Fill in the missing values in `.env` (including the \"PRODUCTION - STANDALONE\" section)\n\n**Note:** The dashboard and API are served insecurely over HTTP. It is recommended to set up a proxy with a TLS certificate in front of them. A popular option for this is [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/).\n\n### Running the bot\n`docker compose -f docker-compose.standalone.yml up -d`\n\n### Shutting the bot down\n`docker compose -f docker-compose.standalone.yml down`\n\n### Updating the bot\n1. Shut the bot down\n2. Update the files (e.g. `git pull`)\n3. Update images: `docker compose -f docker-compose.standalone.yml pull`\n4. Rebuild: `docker compose -f docker-compose.standalone.yml build`\n5. Run the bot again\n\n### Viewing logs\n`docker compose -f docker-compose.standalone.yml logs -t -f`\n\n## Lightweight\n\n### Setup\n1. Install Docker on the machine running the bot\n2. Make a copy of `.env.example` called `.env`\n3. Fill in the missing values in `.env` (including the \"PRODUCTION - LIGHTWEIGHT\" section)\n\n### Running the bot\n`docker compose -f docker-compose.lightweight.yml up -d`\n\n### Shutting the bot down\n`docker compose -f docker-compose.lightweight.yml down`\n\n### Updating the bot\n1. Shut the bot down\n2. Update the files (e.g. `git pull`)\n3. Update images: `docker compose -f docker-compose.standalone.yml pull`\n4. Rebuild: `docker compose -f docker-compose.lightweight.yml build`\n5. Run the bot again\n\n### Viewing logs\n`docker compose -f docker-compose.lightweight.yml logs -t -f`\n\n## Manual\n1. Build the Zeppelin image: `docker build --tag 'zeppelin' .`\n2. Run the service:\n  * Bot: `docker run zeppelin pnpm run start-bot`\n  * API: `docker run zeppelin pnpm run start-api`\n  * Dashboard: `docker run zeppelin pnpm run start-dashboard`\n\nIf you're using an application platform such as Railway, you can simply point it to Zeppelin's repository and it should pick up the Dockerfile from there.\nFor the start command, you can use the same commands as above: `pnpm run start-bot`, `pnpm run start-api`, `pnpm run start-dashboard`.\nMake sure to also run migrations when you update the bot.\n\n### Environment variables\nYou'll need to provide the necessary env variables in the manual setup. For example, `docker run -e NODE_ENV=production --env-file .env zeppelin`\n\nThe following env variables can be used to set up the database credentials:\n* `DB_HOST`\n* `DB_PORT`\n* `DB_USER`\n* `DB_PASSWORD`\n* `DB_DATABASE`\n\nThe following env variable can be used to configure the API path prefix:\n* `API_PATH_PREFIX`\n\nRemember to always set `NODE_ENV` to `production` for production setups.\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@zeppelinbot/zeppelin\",\n  \"version\": \"0.0.1\",\n  \"description\": \"\",\n  \"private\": true,\n  \"scripts\": {\n    \"format\": \"prettier --write \\\"./backend/src/**/*.{css,html,js,json,ts,tsx}\\\" \\\"./dashboard/src/**/*.{css,html,js,json,ts,tsx}\\\"\",\n    \"lint\": \"eslint \\\"./backend/src/**/*.{js,ts,tsx}\\\" \\\"./dashboard/src/**/*.{js,ts,tsx}\\\"\",\n    \"codestyle-check\": \"prettier --check \\\"./backend/src/**/*.{css,html,js,json,ts,tsx}\\\" \\\"./dashboard/src/**/*.{css,html,js,json,ts,tsx}\\\"\",\n    \"start-bot\": \"cd backend && pnpm run start-bot-prod\",\n    \"start-api\": \"cd backend && pnpm run start-api-prod\",\n    \"start-dashboard\": \"cd dashboard && node serve.js\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^22.15.18\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.59.5\",\n    \"@typescript-eslint/parser\": \"^5.59.5\",\n    \"eslint\": \"^8.40.0\",\n    \"eslint-config-prettier\": \"^8.8.0\",\n    \"prettier\": \"^3.5.3\",\n    \"prettier-plugin-organize-imports\": \"^3.2.2\",\n    \"ts-toolbelt\": \"^9.6.0\",\n    \"tsc-watch\": \"^6.0.4\",\n    \"typescript\": \"=5.9.3\"\n  },\n  \"dependencies\": {\n    \"dotenv\": \"^16.5.0\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  }\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - 'backend'\n  - 'shared'\n  - 'dashboard'\n\nminimumReleaseAge: 20160 # in minutes (2 weeks)\nminimumReleaseAgeExclude:\n  - 'vety'\n  - 'knub-command-manager'\n\noverrides:\n  # Keep in sync with Vety's discord.js version\n  discord.js: \"14.23.2\"\n  discord-api-types: \"0.38.30\"\n"
  },
  {
    "path": "presetup-configurator/.gitignore",
    "content": "/build\n"
  },
  {
    "path": "presetup-configurator/.prettierignore",
    "content": "/build\n"
  },
  {
    "path": "presetup-configurator/package.json",
    "content": "{\n  \"name\": \"zeppelin-presetup-configurator\",\n  \"private\": true,\n  \"scripts\": {\n    \"watch\": \"snowpack dev\",\n    \"build\": \"snowpack build\",\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"devDependencies\": {\n    \"@snowpack/plugin-typescript\": \"^1.2.1\",\n    \"@types/node\": \"^14.14.21\",\n    \"@types/react\": \"^17.0.0\",\n    \"@types/react-dom\": \"^17.0.0\",\n    \"snowpack\": \"^3.0.11\"\n  },\n  \"dependencies\": {\n    \"js-yaml\": \"^4.0.0\",\n    \"react\": \"^17.0.1\",\n    \"react-dom\": \"^17.0.1\"\n  }\n}\n"
  },
  {
    "path": "presetup-configurator/snowpack.config.js",
    "content": "module.exports = {\n  mount: {\n    src: \"/\",\n  },\n  plugins: [\"@snowpack/plugin-typescript\"],\n};\n"
  },
  {
    "path": "presetup-configurator/src/App.css",
    "content": ".App {\n  display: flex;\n  justify-content: center;\n}\n\n.App .wrapper {\n  flex: 0 1 800px;\n}\n"
  },
  {
    "path": "presetup-configurator/src/App.tsx",
    "content": "import React from \"react\";\nimport \"./App.css\";\nimport { Configurator } from \"./Configurator\";\n\nexport function App() {\n  return (\n    <div className=\"App\">\n      <div className=\"wrapper\">\n        <Configurator />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "presetup-configurator/src/Configurator.css",
    "content": ".Configurator {\n}\n\n.Configurator .options {\n  display: grid;\n  grid-auto-columns: min-content auto;\n  grid-gap: 1px;\n\n  overflow: hidden;\n  border: 1px solid #444;\n  border-radius: 4px;\n  background-color: #fff;\n}\n\n.Configurator .options > h2 {\n  grid-column: 1;\n\n  margin: 0;\n  padding: 8px 24px 8px 8px;\n\n  white-space: nowrap;\n  text-align: right;\n  font-size: 16px;\n  font-weight: 600;\n\n  box-shadow: 0 0 0 1px #444;\n}\n\n.Configurator .options > .control {\n  grid-column: 2;\n\n  padding: 8px;\n  box-shadow: 0 0 0 1px #444;\n}\n\n.Configurator label {\n  display: block;\n  padding: 0 0 8px;\n}\n\n.Configurator .result {\n  margin-top: 16px;\n  width: 100%;\n  background-color: #eee;\n  padding: 8px;\n  border: 1px solid #444;\n  border-radius: 4px;\n  box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.2);\n  cursor: copy;\n}\n"
  },
  {
    "path": "presetup-configurator/src/Configurator.tsx",
    "content": "import yaml from \"js-yaml\";\nimport React, { useEffect, useState } from \"react\";\nimport \"./Configurator.css\";\nimport { LevelEntry, Levels } from \"./Levels\";\nimport { LogChannel, LogChannels } from \"./LogChannels\";\n\nexport function Configurator() {\n  const [prefix, setPrefix] = useState(\"!\");\n  const [levels, setLevels] = useState<LevelEntry[]>([]);\n\n  const [withModCommands, setWithModCommands] = useState(false);\n  const [muteRoleId, setMuteRoleId] = useState(\"\");\n  const [caseChannelId, setCaseChannelId] = useState(\"\");\n  const [dmModActionReasons, setDmModActionReasons] = useState(false);\n\n  const [withLogs, setWithLogs] = useState(false);\n  const [logChannels, setLogChannels] = useState<LogChannel[]>([]);\n\n  const [result, setResult] = useState({});\n  useEffect(() => {\n    const resultObj: any = {\n      prefix,\n      levels: levels.reduce((obj, entry) => {\n        obj[entry[0]] = entry[1];\n        return obj;\n      }, {}),\n      plugins: {\n        utility: {},\n      },\n    };\n\n    if (withModCommands) {\n      resultObj.plugins.cases = {\n        config: {\n          case_log_channel: caseChannelId,\n        },\n      };\n\n      resultObj.plugins.mod_actions = {};\n\n      if (muteRoleId) {\n        resultObj.plugins.mutes = {\n          config: {\n            mute_role: muteRoleId,\n          },\n        };\n\n        if (dmModActionReasons) {\n          resultObj.plugins.mutes.config.dm_on_mute = true;\n        }\n      }\n\n      if (dmModActionReasons) {\n        resultObj.plugins.mod_actions = {\n          config: {\n            dm_on_warn: true,\n            dm_on_kick: true,\n            dm_on_ban: true,\n          },\n        };\n      }\n    }\n\n    if (withLogs) {\n      resultObj.plugins.logs = {\n        config: {\n          channels: logChannels.reduce((obj, logChannel) => {\n            if (logChannel.includeExclude === \"include\") {\n              obj[logChannel.id] = {\n                include: Array.from(logChannel.logTypes.values()),\n              };\n            } else {\n              obj[logChannel.id] = {\n                exclude: Array.from(logChannel.logTypes.values()),\n              };\n            }\n            return obj;\n          }, {}),\n        },\n      };\n    }\n\n    setResult(resultObj);\n  }, [prefix, levels, withModCommands, muteRoleId, caseChannelId, dmModActionReasons, withLogs, logChannels]);\n\n  const [formattedResult, setFormattedResult] = useState(\"\");\n  useEffect(() => {\n    let _formattedResult = yaml.dump(result);\n\n    // Add line break before each unquoted top-level or second-level property\n    _formattedResult = _formattedResult.replace(/^ {0,2}[a-z_]+:/gm, \"\\n$&\").trim();\n\n    // Add additional line break at the end\n    _formattedResult += \"\\n\";\n\n    // Explain \"exclude: []\"\n    _formattedResult = _formattedResult.replace(/exclude: \\[]/, \"$& # Exclude nothing = include everything\");\n\n    setFormattedResult(_formattedResult);\n  }, [result]);\n\n  const resultRows = formattedResult.split(\"\\n\").length || 1;\n\n  const [copied, setCopied] = useState(false);\n  function copyResultText(textarea: HTMLTextAreaElement) {\n    textarea.select();\n    document.execCommand(\"copy\");\n    setCopied(true);\n  }\n\n  const [copyResetTimeout, setCopyResetTimeout] = useState<number | null>(null);\n  useEffect(() => {\n    if (!copied) {\n      return;\n    }\n\n    if (copyResetTimeout != null) {\n      window.clearTimeout(copyResetTimeout);\n    }\n\n    const timeout = window.setTimeout(() => setCopied(false), 3000);\n    setCopyResetTimeout(timeout);\n  }, [copied]);\n\n  return (\n    <div className=\"Configurator\">\n      {/* Options */}\n      <div className=\"options\">\n        <h2>Prefix</h2>\n        <div className=\"control\">\n          <label>\n            Bot prefix\n            <br />\n            <input value={prefix} onChange={(e) => setPrefix(e.target.value)} />\n          </label>\n        </div>\n\n        <h2>Levels</h2>\n        <div className=\"control\">\n          <Levels levels={levels} setLevels={setLevels} />\n        </div>\n\n        <h2>Mod commands</h2>\n        <div className=\"control\">\n          <label>\n            <input type=\"checkbox\" checked={withModCommands} onChange={(e) => setWithModCommands(e.target.checked)} />\n            Start with a basic mod command setup\n          </label>\n\n          {withModCommands && (\n            <div>\n              <label>\n                Mute role ID\n                <br />\n                <input value={muteRoleId} onChange={(e) => setMuteRoleId(e.target.value)} />\n              </label>\n\n              <label>\n                Case channel ID\n                <br />\n                <input value={caseChannelId} onChange={(e) => setCaseChannelId(e.target.value)} />\n              </label>\n\n              <label>\n                <input\n                  type=\"checkbox\"\n                  checked={dmModActionReasons}\n                  onChange={(e) => setDmModActionReasons(e.target.checked)}\n                />\n                DM reason with mod actions\n              </label>\n            </div>\n          )}\n        </div>\n\n        <h2>Logs</h2>\n        <div className=\"control\">\n          <label>\n            <input type=\"checkbox\" checked={withLogs} onChange={(e) => setWithLogs(e.target.checked)} />\n            Start with a basic logging setup\n          </label>\n\n          {withLogs && <LogChannels logChannels={logChannels} setLogChannels={setLogChannels} />}\n        </div>\n      </div>\n\n      {/* Result */}\n      <textarea\n        className=\"result\"\n        rows={resultRows}\n        readOnly={true}\n        value={formattedResult}\n        onClick={(e) => copyResultText(e.target as HTMLTextAreaElement)}\n      />\n      {copied ? <em>Copied!</em> : <em>Click textarea to copy</em>}\n    </div>\n  );\n}\n"
  },
  {
    "path": "presetup-configurator/src/Levels.tsx",
    "content": "import React from \"react\";\n\nconst LEVEL_ADMIN = 100;\nconst LEVEL_MODERATOR = 50;\n\nexport type LevelEntry = [string, number]; // id, level\n\nexport function Levels({ levels, setLevels }) {\n  function addLevel() {\n    setLevels((arr) => [...arr, [\"\", LEVEL_MODERATOR]]);\n  }\n\n  function removeLevel(index) {\n    setLevels((arr) => [...arr].splice(index, 1));\n  }\n\n  function updateLevelId(index, id) {\n    const validId = id.replace(/[^0-9]/g, \"\");\n    setLevels((arr) => {\n      arr[index][0] = validId;\n      return [...arr];\n    });\n  }\n\n  function updateLevelLevel(index, level) {\n    setLevels((arr) => {\n      arr[index][1] = parseInt(level, 10);\n      return [...arr];\n    });\n  }\n\n  return (\n    <div>\n      {levels.map(([id, level], index) => (\n        <div key={index}>\n          <input value={id} onChange={(e) => updateLevelId(index, e.target.value)} />\n          <select value={level} onChange={(e) => updateLevelLevel(index, e.target.value)}>\n            <option value={LEVEL_ADMIN}>Admin</option>\n            <option value={LEVEL_MODERATOR}>Moderator</option>\n          </select>\n        </div>\n      ))}\n      <button onClick={addLevel}>Add</button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "presetup-configurator/src/LogChannels.css",
    "content": ".LogChannels .log-channel {\n  margin: 16px 0;\n}\n\n.LogChannels .log-types {\n  height: 200px;\n  overflow-y: scroll;\n  border: 1px solid #aaa;\n  border-radius: 3px;\n  padding: 4px;\n  margin-top: 8px;\n}\n"
  },
  {
    "path": "presetup-configurator/src/LogChannels.tsx",
    "content": "import React, { SetStateAction } from \"react\";\nimport \"./LogChannels.css\";\n\nconst LOG_TYPES = {\n  MEMBER_WARN: \"Member warned\",\n  MEMBER_MUTE: \"Member muted\",\n  MEMBER_UNMUTE: \"Member unmuted\",\n  MEMBER_MUTE_EXPIRED: \"Mute expired\",\n  MEMBER_KICK: \"Member kicked\",\n  MEMBER_BAN: \"Member banned\",\n  MEMBER_UNBAN: \"Member unbanned\",\n  MEMBER_FORCEBAN: \"Member forcebanned\",\n  MEMBER_SOFTBAN: \"Member softbanned\",\n  MEMBER_JOIN: \"Member joined\",\n  MEMBER_LEAVE: \"Member left\",\n  MEMBER_ROLE_ADD: \"Member, role added\",\n  MEMBER_ROLE_REMOVE: \"Member, role removed\",\n  MEMBER_NICK_CHANGE: \"Member nickname changed\",\n  MEMBER_USERNAME_CHANGE: \"Member username changed\",\n  MEMBER_RESTORE: \"Member roles restored\",\n  CHANNEL_CREATE: \"Channel created\",\n  CHANNEL_DELETE: \"Channel deleted\",\n  CHANNEL_UPDATE: \"Channel updated\",\n  THREAD_CREATE: \"Thread created\",\n  THREAD_DELETE: \"Thread deleted\",\n  THREAD_UPDATE: \"Thread updated\",\n  ROLE_CREATE: \"Role created\",\n  ROLE_DELETE: \"Role deleted\",\n  ROLE_UPDATE: \"Role updated\",\n  MESSAGE_EDIT: \"Message edited\",\n  MESSAGE_DELETE: \"Message deleted\",\n  MESSAGE_DELETE_BULK: \"Messages deleted in bulk\",\n  MESSAGE_DELETE_BARE: \"Message deleted (bare)\",\n  VOICE_CHANNEL_JOIN: \"Voice channel join\",\n  VOICE_CHANNEL_LEAVE: \"Voice channel leave\",\n  VOICE_CHANNEL_MOVE: \"Voice channel move\",\n  STAGE_INSTANCE_CREATE: \"Stage created\",\n  STAGE_INSTANCE_DELETE: \"Stage deleted\",\n  STAGE_INSTANCE_UPDATE: \"Stage updated\",\n  EMOJI_CREATE: \"Emoji created\",\n  EMOJI_DELETE: \"Emoji deleted\",\n  EMOJI_UPDATE: \"Emoji updated\",\n  STICKER_CREATE: \"Sticker created\",\n  STICKER_DELETE: \"Sticker deleted\",\n  STICKER_UPDATE: \"Sticker updated\",\n  COMMAND: \"Command used\",\n  MESSAGE_SPAM_DETECTED: \"Message spam detected\",\n  CENSOR: \"Message censored\",\n  CLEAN: \"Messages cleaned\",\n  CASE_CREATE: \"Case created\",\n  MASSBAN: \"Massbanned\",\n  MASSMUTE: \"Massmuted\",\n  MEMBER_TIMED_MUTE: \"Member temporarily muted\",\n  MEMBER_TIMED_UNMUTE: \"Member, scheduled unmute\",\n  MEMBER_JOIN_WITH_PRIOR_RECORDS: \"Member joined with prior records\",\n  OTHER_SPAM_DETECTED: \"Non-message spam detected\",\n  MEMBER_ROLE_CHANGES: \"Member roles changed\",\n  VOICE_CHANNEL_FORCE_MOVE: \"Force-moved to a voice channel\",\n  CASE_UPDATE: \"Case updated\",\n  MEMBER_MUTE_REJOIN: \"Muted member rejoined\",\n  SCHEDULED_MESSAGE: \"Scheduled message to be posted\",\n  POSTED_SCHEDULED_MESSAGE: \"Posted scheduled message\",\n  BOT_ALERT: \"Bot alert\",\n  AUTOMOD_ACTION: \"Automod action\",\n  SCHEDULED_REPEATED_MESSAGE: \"Scheduled message to be posted repeatedly\",\n  REPEATED_MESSAGE: \"Set a message to be posted repeatedly\",\n  MESSAGE_DELETE_AUTO: \"Message deleted (auto)\",\n  SET_ANTIRAID_USER: \"Set antiraid (user)\",\n  SET_ANTIRAID_AUTO: \"Set antiraid (auto)\",\n  MEMBER_NOTE: \"Member noted\",\n  CASE_DELETE: \"Case deleted\",\n  DM_FAILED: \"Failed to DM member\",\n};\n\nconst sortedLogTypes = Object.fromEntries(\n  Object.entries(LOG_TYPES).sort((a, b) => {\n    if (a[1].toLowerCase() > b[1].toLowerCase()) return 1;\n    if (a[1].toLowerCase() < b[1].toLowerCase()) return -1;\n    if (a[0].toLowerCase() > b[0].toLowerCase()) return 1;\n    if (a[0].toLowerCase() < b[0].toLowerCase()) return -1;\n    return 0;\n  }),\n) as typeof LOG_TYPES;\n\ntype LOG_TYPE = keyof typeof LOG_TYPES;\n\nexport interface LogChannel {\n  id: string;\n  includeExclude: \"include\" | \"exclude\";\n  logTypes: Set<LOG_TYPE>;\n}\n\ninterface Props {\n  logChannels: LogChannel[];\n  setLogChannels: React.Dispatch<SetStateAction<LogChannel[]>>;\n}\n\nexport function LogChannels({ logChannels, setLogChannels }: Props) {\n  function addLogChannel(props: Partial<LogChannel> = {}) {\n    setLogChannels((_logChannels) => {\n      return [\n        ..._logChannels,\n        {\n          id: \"\",\n          includeExclude: \"include\",\n          logTypes: new Set(),\n          ...props,\n        },\n      ];\n    });\n  }\n\n  function deleteLogChannel(index) {\n    setLogChannels((_logChannels) => {\n      const newArr = [..._logChannels];\n      newArr.splice(index, 1);\n      return newArr;\n    });\n  }\n\n  function addReverseLogChannel() {\n    const includedLogTypesInOtherLogChannels = new Set(logChannels.map((l) => Array.from(l.logTypes)).flat());\n    addLogChannel({\n      includeExclude: \"exclude\",\n      logTypes: includedLogTypesInOtherLogChannels,\n    });\n  }\n\n  function setId(index: number, id: string) {\n    setLogChannels((_logChannels) => {\n      _logChannels[index].id = id;\n      return [..._logChannels];\n    });\n  }\n\n  function setIncludeExclude(index: number, includeExclude: LogChannel[\"includeExclude\"]) {\n    setLogChannels((_logChannels) => {\n      _logChannels[index].includeExclude = includeExclude;\n      return [..._logChannels];\n    });\n  }\n\n  function toggleLogType(index: number, logType: LOG_TYPE, enabled: boolean) {\n    setLogChannels((_logChannels) => {\n      if (enabled) {\n        _logChannels[index].logTypes.add(logType);\n      } else {\n        _logChannels[index].logTypes.delete(logType);\n      }\n\n      return [..._logChannels];\n    });\n  }\n\n  return (\n    <div className=\"LogChannels\">\n      {logChannels.map((logChannel, index) => (\n        <div className=\"log-channel\">\n          <label>\n            ID: <input value={logChannel.id} onChange={(e) => setId(index, e.target.value)} />\n          </label>\n          <label>\n            Mode:\n            <select\n              value={logChannel.includeExclude}\n              onChange={(e) => setIncludeExclude(index, e.target.value as LogChannel[\"includeExclude\"])}\n            >\n              <option value={\"include\"}>Include</option>\n              <option value={\"exclude\"}>Exclude</option>\n            </select>\n          </label>\n          <div className=\"log-types\">\n            {Object.entries(sortedLogTypes).map(([logType, description]) => (\n              <label>\n                <input\n                  type=\"checkbox\"\n                  checked={logChannel.logTypes.has(logType as LOG_TYPE)}\n                  onChange={(e) => toggleLogType(index, logType as LOG_TYPE, e.target.checked)}\n                />\n                {description}\n              </label>\n            ))}\n          </div>\n          <button onClick={() => deleteLogChannel(index)}>Delete</button>\n        </div>\n      ))}\n      <button onClick={() => addLogChannel()}>Add</button>\n      <button onClick={() => addReverseLogChannel()}>Add \"everything else\"</button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "presetup-configurator/src/index.css",
    "content": "*,\n*::before,\n*::after {\n  box-sizing: border-box;\n}\n\nhtml {\n  font-family: Arial, sans-serif;\n}\n\nbody {\n  background-color: #f8f8f8;\n  color: #222;\n}\n"
  },
  {
    "path": "presetup-configurator/src/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0\"\n    />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\" />\n    <title>Document</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"./index.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "presetup-configurator/src/index.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { App } from \"./App\";\nimport \"./index.css\";\n\nReactDOM.render(\n  <React.StrictMode>\n    <App />\n  </React.StrictMode>,\n  document.getElementById(\"root\"),\n);\n\nif ((import.meta as any).hot) {\n  (import.meta as any).hot.accept();\n}\n"
  },
  {
    "path": "presetup-configurator/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"moduleResolution\": \"node\",\n    \"module\": \"esnext\",\n    \"target\": \"es2018\",\n    \"sourceMap\": true,\n    \"noImplicitAny\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"alwaysStrict\": true,\n    \"noImplicitThis\": true,\n    \"strictPropertyInitialization\": false,\n    \"lib\": [\"esnext\", \"dom\"],\n    \"baseUrl\": \".\",\n    \"resolveJsonModule\": true,\n    \"esModuleInterop\": true,\n    \"allowJs\": true,\n    \"jsx\": \"react\",\n    \"skipLibCheck\": true\n  }\n}\n"
  },
  {
    "path": "shared/.gitignore",
    "content": "/dist\n"
  },
  {
    "path": "shared/package.json",
    "content": "{\n  \"name\": \"@zeppelinbot/shared\",\n  \"version\": \"0.0.1\",\n  \"description\": \"\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \"./*\": \"./dist/*\"\n  },\n  \"scripts\": {\n    \"test\": \"ava\"\n  },\n  \"devDependencies\": {\n    \"ava\": \"^5.3.1\",\n    \"ts-node\": \"^10.9.1\"\n  },\n  \"ava\": {\n    \"files\": [\n      \"src/**/*.test.ts\"\n    ],\n    \"extensions\": [\n      \"ts\"\n    ],\n    \"require\": [\n      \"ts-node/register\"\n    ]\n  }\n}\n"
  },
  {
    "path": "shared/src/apiPermissions.test.ts",
    "content": "import test from \"ava\";\nimport { ApiPermissions, hasPermission } from \"./apiPermissions.js\";\n\ntest(\"Directly granted permissions match\", (t) => {\n  t.is(hasPermission(new Set([ApiPermissions.ManageAccess]), ApiPermissions.ManageAccess), true);\n  t.is(hasPermission(new Set([ApiPermissions.ManageAccess]), ApiPermissions.Owner), false);\n});\n\ntest(\"Implicitly granted permissions by hierarchy match\", (t) => {\n  t.is(hasPermission(new Set([ApiPermissions.ManageAccess]), ApiPermissions.EditConfig), true);\n  t.is(hasPermission(new Set([ApiPermissions.ManageAccess]), ApiPermissions.ReadConfig), true);\n  t.is(hasPermission(new Set([ApiPermissions.EditConfig]), ApiPermissions.ManageAccess), false);\n});\n"
  },
  {
    "path": "shared/src/apiPermissions.ts",
    "content": "export enum ApiPermissions {\n  Owner = \"OWNER\",\n  ManageAccess = \"MANAGE_ACCESS\",\n  EditConfig = \"EDIT_CONFIG\",\n  ReadConfig = \"READ_CONFIG\",\n  ViewGuild = \"VIEW_GUILD\",\n}\n\nconst reverseApiPermissions = Object.entries(ApiPermissions).reduce((map, [key, value]) => {\n  map[value] = key;\n  return map;\n}, {});\n\nexport const permissionNames = {\n  [ApiPermissions.Owner]: \"Server owner\",\n  [ApiPermissions.ManageAccess]: \"Bot manager\",\n  [ApiPermissions.EditConfig]: \"Bot operator\",\n  [ApiPermissions.ReadConfig]: \"Read config\",\n  [ApiPermissions.ViewGuild]: \"View server\",\n};\n\nexport type TPermissionHierarchy = Array<ApiPermissions | [ApiPermissions, TPermissionHierarchy]>;\n\n// prettier-ignore\nexport const permissionHierarchy: TPermissionHierarchy = [\n  [ApiPermissions.Owner, [\n    [ApiPermissions.ManageAccess, [\n      [ApiPermissions.EditConfig, [\n        [ApiPermissions.ReadConfig, [\n          ApiPermissions.ViewGuild,\n        ]],\n      ]],\n    ]],\n  ]],\n];\n\nexport function permissionArrToSet(permissions: string[]): Set<ApiPermissions> {\n  return new Set(permissions.filter((p) => reverseApiPermissions[p])) as Set<ApiPermissions>;\n}\n\n/**\n * Checks whether granted permissions include the specified permission, taking into account permission hierarchy i.e.\n * that in the case of nested permissions, having a top level permission implicitly grants you any permissions nested\n * under it as well\n */\nexport function hasPermission(grantedPermissions: Set<ApiPermissions>, permissionToCheck: ApiPermissions): boolean {\n  // Directly granted\n  if (grantedPermissions.has(permissionToCheck)) {\n    return true;\n  }\n\n  // Check by hierarchy\n  if (checkTreeForPermission(permissionHierarchy, grantedPermissions, permissionToCheck)) {\n    return true;\n  }\n\n  return false;\n}\n\nfunction checkTreeForPermission(\n  tree: TPermissionHierarchy,\n  grantedPermissions: Set<ApiPermissions>,\n  permission: ApiPermissions,\n): boolean {\n  for (const item of tree) {\n    const [perm, nested] = Array.isArray(item) ? item : [item];\n\n    // Top-level permission granted, implicitly grant all nested permissions as well\n    if (grantedPermissions.has(perm)) {\n      // Permission we were looking for was found nested under this permission -> granted\n      if (nested && treeIncludesPermission(nested, permission)) {\n        return true;\n      }\n\n      // Permission we were looking for was not found nested under this permission\n      // Since direct grants are not handled by this function, we can skip any further checks for this nested tree\n      continue;\n    }\n\n    // Top-level permission not granted, check further nested permissions\n    if (nested && checkTreeForPermission(nested, grantedPermissions, permission)) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\nfunction treeIncludesPermission(tree: TPermissionHierarchy, permission: ApiPermissions): boolean {\n  for (const item of tree) {\n    const [perm, nested] = Array.isArray(item) ? item : [item];\n\n    if (perm === permission) {\n      return true;\n    }\n\n    const nestedResult = nested && treeIncludesPermission(nested, permission);\n    if (nestedResult) {\n      return true;\n    }\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "shared/tsconfig.json",
    "content": "{\n  \"extends\": \"../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"moduleResolution\": \"NodeNext\",\n    \"module\": \"NodeNext\",\n    \"baseUrl\": \"./src\",\n    \"rootDir\": \"./src\",\n    \"outDir\": \"./dist\",\n    \n    \"composite\": true,\n    \"declaration\": true\n  },\n  \"include\": [\"src/**/*.ts\"]\n}\n"
  },
  {
    "path": "tsconfig.base.json",
    "content": "{\n  \"compilerOptions\": {\n    \"noImplicitAny\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true,\n    \"target\": \"esnext\",\n    \"lib\": [\"es2023\"],\n    \"resolveJsonModule\": true,\n    \"esModuleInterop\": true,\n    \"sourceMap\": true,\n    \"alwaysStrict\": true,\n    \"noImplicitThis\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"strictPropertyInitialization\": false,\n    \"useUnknownInCatchVariables\": false\n  }\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    {\n      \"path\": \"./shared/tsconfig.json\"\n    },\n    {\n      \"path\": \"./backend/tsconfig.json\"\n    },\n    {\n      \"path\": \"./dashboard/tsconfig.json\"\n    }\n  ]\n}\n"
  }
]