├── .all-contributorsrc ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md └── workflows │ ├── codeql-analysis.yml │ ├── docker.yml │ ├── i18n.yml │ └── lint.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .nvmrc ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── Dockerfile ├── LICENSE ├── README.md ├── db ├── mysql │ ├── migrations │ │ ├── 20230309144248_4_0_0 │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma ├── postgresql │ ├── migrations │ │ ├── 20230309132703_4_0_0 │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma └── sqlite │ ├── migrations │ ├── 20230309142817_4_0_0 │ │ └── migration.sql │ └── migration_lock.toml │ └── schema.prisma ├── docker-compose.yml ├── eggs ├── pelican.json └── pterodactyl.json ├── eslint.config.mjs ├── jsconfig.json ├── package.json ├── pnpm-lock.yaml ├── scripts ├── check-i18n.js ├── dump.mjs ├── keygen.js ├── postinstall.js ├── preinstall.js ├── prune.mjs ├── restore.mjs └── start.sh └── src ├── autocomplete ├── category.js ├── references.js ├── tag.js └── ticket.js ├── buttons ├── claim.js ├── close.js ├── create.js ├── edit.js ├── transcript.js └── unclaim.js ├── client.js ├── commands ├── message │ ├── create.js │ └── pin.js ├── slash │ ├── add.js │ ├── claim.js │ ├── close.js │ ├── force-close.js │ ├── help.js │ ├── move.js │ ├── new.js │ ├── priority.js │ ├── release.js │ ├── remove.js │ ├── rename.js │ ├── tag.js │ ├── tickets.js │ ├── topic.js │ ├── transcript.js │ └── transfer.js └── user │ └── create.js ├── env.js ├── http.js ├── i18n ├── bg.yml ├── cs.yml ├── da.yml ├── de.yml ├── el.yml ├── en-GB.yml ├── en-US.yml ├── es-ES.yml ├── fi.yml ├── fr.yml ├── hu.yml ├── it.yml ├── ja.yml ├── ko.yml ├── lt.yml ├── nl.yml ├── no.yml ├── pl.yml ├── pt-BR.yml ├── ro.yml ├── ru.yml ├── sv-SE.yml ├── th.yml ├── tr.yml ├── uk.yml ├── vi.yml └── zh-TW.yml ├── index.js ├── lib ├── banner.js ├── embed.js ├── error.js ├── logger.js ├── logging.js ├── middleware │ └── prisma-sqlite.js ├── misc.js ├── stats.js ├── sync.js ├── threads.js ├── tickets │ ├── archiver.js │ ├── manager.js │ └── utils.js ├── updates.js ├── users.js └── workers │ ├── crypto.js │ ├── export.js │ ├── import.js │ ├── stats.js │ └── transcript.js ├── listeners ├── autocomplete │ ├── componentLoad.js │ ├── error.js │ └── run.js ├── buttons │ ├── componentLoad.js │ ├── error.js │ └── run.js ├── client │ ├── channelDelete.js │ ├── error.js │ ├── guildCreate.js │ ├── guildDelete.js │ ├── guildMemberRemove.js │ ├── messageCreate.js │ ├── messageDelete.js │ ├── messageUpdate.js │ ├── ready.js │ └── warn.js ├── commands │ ├── componentLoad.js │ ├── error.js │ └── run.js ├── menus │ ├── componentLoad.js │ ├── error.js │ └── run.js ├── modals │ ├── componentLoad.js │ ├── error.js │ └── run.js └── stdin │ └── unknown.js ├── menus └── create.js ├── modals ├── feedback.js ├── questions.js └── topic.js ├── routes ├── api │ ├── admin │ │ └── guilds │ │ │ ├── [guild] │ │ │ ├── categories │ │ │ │ ├── [category] │ │ │ │ │ ├── index.js │ │ │ │ │ └── questions │ │ │ │ │ │ └── [question].js │ │ │ │ └── index.js │ │ │ ├── data.js │ │ │ ├── export.js │ │ │ ├── import.js │ │ │ ├── index.js │ │ │ ├── panels.js │ │ │ ├── problems.js │ │ │ ├── settings.js │ │ │ └── tags │ │ │ │ ├── [tag].js │ │ │ │ └── index.js │ │ │ └── index.js │ ├── client.js │ ├── guilds │ │ ├── [guild] │ │ │ ├── index.js │ │ │ └── tickets │ │ │ │ └── @me.js │ │ └── index.js │ ├── locales.js │ └── users │ │ └── @me │ │ ├── index.js │ │ └── key.js ├── auth │ ├── callback.js │ ├── login.js │ └── logout.js └── status.js ├── schemas └── settings.js ├── stdin ├── commands.js ├── eval.js ├── exit.js ├── help.js ├── npx.js ├── reload.js ├── settings.js ├── suid-time.js ├── sync.js └── version.js └── user ├── avatars └── .gitkeep ├── banned-guilds.txt ├── config.yml ├── templates └── transcript.md.mustache └── uploads └── .gitkeep /.dockerignore: -------------------------------------------------------------------------------- 1 | # ignore everything 2 | * 3 | # except these directories 4 | !db 5 | !scripts 6 | !src 7 | !user 8 | # and these files 9 | !.npmrc 10 | !package.json 11 | !pnpm-lock.yaml 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | [*.{yml,yaml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | indent_style = space 17 | indent_size = 4 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | **Versioning information** 8 | 9 | 10 | 11 | - [ ] This includes major changes (breaking changes) 12 | - [ ] This includes minor changes (minimal usage changes, minor new features) 13 | - [ ] This includes patches (bug fixes) 14 | - [ ] This does not change functionality at all (code refactoring, comments) 15 | 16 | **Is this related to an issue?** 17 | 18 | 19 | 20 | **Changes made** 21 | 22 | 23 | 24 | **Confirmations** 25 | 26 | 27 | 28 | - [ ] I have updated related documentation (if necessary) 29 | - [ ] My changes use consistent code style 30 | - [ ] My changes have been tested and confirmed to work 31 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security policy 2 | 3 | ## Supported versions 4 | 5 | Release versions that will receive security updates. 6 | 7 | | Version | Supported | 8 | | ------- | --------- | 9 | | 1.x | ❌ | 10 | | 2.x | ❌ | 11 | | 3.x | ❌ | 12 | | 4.x | ✅ | 13 | 14 | ## Reporting a vulnerability 15 | 16 | If you find a vulnerability, please use for the [security advisories form](https://github.com/discord-tickets/bot/security/advisories/new) 17 | or send an email to [contact@discordtickets.app](mailto:contact@discordtickets.app). 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '18 6 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | # branches: 6 | # - 'main' 7 | # tags: 8 | # - 'v*' 9 | pull_request: 10 | branches: 11 | - 'main' 12 | 13 | jobs: 14 | push_to_registries: 15 | name: Push Docker image to multiple registries 16 | runs-on: ubuntu-latest 17 | permissions: 18 | packages: write 19 | contents: read 20 | steps: 21 | - name: Check out the repo 22 | uses: actions/checkout@v3 23 | 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v2 26 | 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v2 29 | 30 | - name: Log in to Docker Hub 31 | if: github.event_name != 'pull_request' 32 | uses: docker/login-action@v2 33 | with: 34 | username: ${{ secrets.DOCKER_USERNAME }} 35 | password: ${{ secrets.DOCKER_TOKEN }} 36 | 37 | - name: Log in to the Container registry 38 | if: github.event_name != 'pull_request' 39 | uses: docker/login-action@v2 40 | with: 41 | registry: ghcr.io 42 | username: ${{ github.actor }} 43 | password: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - name: Extract metadata (tags, labels) for Docker 46 | id: meta 47 | uses: docker/metadata-action@v4 48 | with: 49 | images: | 50 | eartharoid/discord-tickets 51 | ghcr.io/${{ github.repository }} 52 | tags: | 53 | type=ref,event=branch 54 | type=ref,event=pr 55 | type=semver,pattern={{version}} 56 | type=semver,pattern={{major}}.{{minor}} 57 | 58 | - name: Build and push Docker images 59 | uses: docker/build-push-action@v4 60 | with: 61 | context: . 62 | platforms: linux/amd64,linux/arm64 63 | push: ${{ github.event_name != 'pull_request' }} 64 | tags: ${{ steps.meta.outputs.tags }} 65 | labels: ${{ steps.meta.outputs.labels }} 66 | -------------------------------------------------------------------------------- /.github/workflows/i18n.yml: -------------------------------------------------------------------------------- 1 | name: 'Check localisations' 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | # branches: 7 | # - main 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | pull-requests: read 13 | 14 | concurrency: 15 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | check: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | - uses: pnpm/action-setup@v4 26 | with: 27 | version: 10 28 | 29 | - name: Setup node 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: 22 33 | cache: 'pnpm' 34 | 35 | - name: Install dependencies 36 | run: pnpm install 37 | 38 | - name: Validate localisations 39 | run: node ./scripts/check-i18n.js 40 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: [push, pull_request] 3 | # push: 4 | # branches: 5 | # - main 6 | # pull_request: 7 | # branches: 8 | # - main 9 | 10 | jobs: 11 | commitlint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | - uses: wagoid/commitlint-github-action@v5 18 | with: 19 | configFile: .commitlintrc.js 20 | eslint: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v3 24 | - uses: pnpm/action-setup@v4 25 | with: 26 | version: 10 27 | - uses: actions/setup-node@v3 28 | with: 29 | node-version: 22 30 | cache: 'pnpm' 31 | - run: pnpm install 32 | - run: pnpm run lint 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # directories 2 | /.history/ 3 | /node_modules/ 4 | /prisma/ 5 | /user/ 6 | 7 | # files 8 | *.env* 9 | *.db* 10 | *.log 11 | summary.md 12 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | . "$(where npx | head -n 1)" --no -- commitlint --edit ${1} 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | . "$(where npx | head -n 1)" lint-staged 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | engine-strict=true -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "dbaeumer.vscode-eslint" 8 | ], 9 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 10 | "unwantedRecommendations": [ 11 | 12 | ] 13 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 4 | }, 5 | "[typescript]": { 6 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 7 | }, 8 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM node:22-alpine3.20 AS builder 4 | 5 | # install required depencencies for node-gyp 6 | RUN apk add --no-cache make gcc g++ python3 7 | 8 | RUN npm install -g pnpm 9 | 10 | WORKDIR /build 11 | 12 | COPY --link scripts scripts 13 | RUN chmod +x ./scripts/start.sh 14 | 15 | COPY package.json pnpm-lock.yaml ./ 16 | 17 | RUN CI=true pnpm install --prod --frozen-lockfile 18 | 19 | COPY --link . . 20 | 21 | FROM node:22-alpine3.20 AS runner 22 | LABEL org.opencontainers.image.source=https://github.com/discord-tickets/bot \ 23 | org.opencontainers.image.description="The most popular open-source ticket bot for Discord." \ 24 | org.opencontainers.image.licenses="GPL-3.0-or-later" 25 | 26 | RUN apk --no-cache add curl 27 | 28 | RUN adduser --disabled-password --home /home/container container 29 | RUN mkdir /app \ 30 | && chown container:container /app \ 31 | && chmod -R 777 /app 32 | 33 | RUN mkdir -p /home/container/user /home/container/logs \ 34 | && chown -R container:container /home/container 35 | 36 | USER container 37 | ENV USER=container \ 38 | HOME=/home/container \ 39 | NODE_ENV=production \ 40 | HTTP_HOST=0.0.0.0 \ 41 | DOCKER=true 42 | 43 | WORKDIR /home/container 44 | 45 | COPY --from=builder --chown=container:container --chmod=777 /build /app 46 | 47 | ENTRYPOINT [ "/app/scripts/start.sh" ] 48 | HEALTHCHECK --interval=15s --timeout=5s --start-period=60s \ 49 | CMD curl -f http://localhost:${HTTP_PORT}/status || exit 1 50 | -------------------------------------------------------------------------------- /db/mysql/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /db/postgresql/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /db/sqlite/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | mysql: 5 | image: mysql:8 6 | restart: unless-stopped 7 | hostname: mysql 8 | networks: 9 | - discord-tickets 10 | volumes: 11 | - tickets-mysql:/var/lib/mysql 12 | environment: 13 | MYSQL_DATABASE: tickets 14 | MYSQL_PASSWORD: insecure # change this to a secure password 15 | MYSQL_ROOT_PASSWORD: insecure # change this to a (different) secure password 16 | MYSQL_USER: tickets 17 | 18 | bot: 19 | image: eartharoid/discord-tickets:4.0 20 | depends_on: 21 | - mysql 22 | restart: unless-stopped 23 | hostname: bot 24 | networks: 25 | - discord-tickets 26 | ports: 27 | - 8169:8169 28 | volumes: 29 | - tickets-bot:/home/container/user 30 | - /etc/timezone:/etc/timezone:ro 31 | - /etc/localtime:/etc/localtime:ro 32 | tty: true 33 | stdin_open: true 34 | # Please refer to the documentation: 35 | # https://discordtickets.app/self-hosting/configuration/#environment-variables 36 | environment: 37 | DB_CONNECTION_URL: mysql://tickets:insecure@mysql/tickets # change `insecure` to the MYSQL_PASSWORD you set above 38 | DISCORD_SECRET: # required 39 | DISCORD_TOKEN: # required 40 | ENCRYPTION_KEY: # required 41 | DB_PROVIDER: mysql 42 | HTTP_EXTERNAL: http://127.0.0.1:8169 # change this to your server's external IP (or domain) 43 | HTTP_HOST: 0.0.0.0 44 | HTTP_INTERNAL: 45 | HTTP_PORT: 8169 46 | HTTP_TRUST_PROXY: "false" # set to true if you're using a reverse proxy 47 | INVALIDATE_TOKENS: 48 | OVERRIDE_ARCHIVE: null 49 | PUBLIC_BOT: "false" 50 | PUBLISH_COMMANDS: "false" 51 | SUPER: 319467558166069248 # optionally add `,youruseridhere` 52 | 53 | networks: 54 | discord-tickets: 55 | 56 | volumes: 57 | tickets-mysql: 58 | tickets-bot: 59 | -------------------------------------------------------------------------------- /eggs/pterodactyl.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", 3 | "meta": { 4 | "version": "PTDL_v2", 5 | "update_url": null 6 | }, 7 | "exported_at": "2024-01-15T00:25:21+00:00", 8 | "name": "Discord Tickets Bot", 9 | "author": "contact@bisecthosting.com", 10 | "description": "The official egg for the Discord Tickets bot.", 11 | "features": null, 12 | "docker_images": { 13 | "4.0 (recommended)": "ghcr.io\/discord-tickets\/bot:4.0", 14 | "latest": "ghcr.io\/discord-tickets\/bot:latest", 15 | "main (unstable)": "ghcr.io\/discord-tickets\/bot:main" 16 | }, 17 | "file_denylist": [], 18 | "startup": "\/app\/scripts\/start.sh", 19 | "config": { 20 | "files": "{\r\n \".env\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"HTTP_PORT\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", 21 | "startup": "{\r\n \"done\": \"SUCCESS\"\r\n}", 22 | "logs": "{}", 23 | "stop": "exit" 24 | }, 25 | "scripts": { 26 | "installation": { 27 | "script": "output_file=\"\/mnt\/server\/.env\"\r\nrandom=$(cat \/dev\/urandom | tr -dc 'a-f0-9' | fold -w 48 | head -n 1)\r\necho \"# Most environment variables can be found on the Startup page\" >> $output_file\r\necho \"ENCRYPTION_KEY=${random}\" >> $output_file\r\necho \"HTTP_EXTERNAL=http:\/\/${SERVER_IP}:${SERVER_PORT}\" >> $output_file\r\necho \"HTTP_PORT=\" >> $output_file\r\necho \"Environment variables written to $output_file\"", 28 | "container": "node:18-buster-slim", 29 | "entrypoint": "bash" 30 | } 31 | }, 32 | "variables": [ 33 | { 34 | "name": "Database Provider", 35 | "description": "The type of database you want to use. SQLite is not recommended in production.", 36 | "env_variable": "DB_PROVIDER", 37 | "default_value": "sqlite", 38 | "user_viewable": true, 39 | "user_editable": true, 40 | "rules": "required|string|max:20|in:sqlite,mysql,postgresql", 41 | "field_type": "text" 42 | }, 43 | { 44 | "name": "Database URL", 45 | "description": "Leave blank if using SQLite, but SQLite is not recommended in production.\r\nhttps:\/\/discordtickets.app\/self-hosting\/configuration\/#db_connection_url", 46 | "env_variable": "DB_CONNECTION_URL", 47 | "default_value": "", 48 | "user_viewable": true, 49 | "user_editable": true, 50 | "rules": "nullable|string|max:250", 51 | "field_type": "text" 52 | }, 53 | { 54 | "name": "Discord 0Auth2 Secret", 55 | "description": "", 56 | "env_variable": "DISCORD_SECRET", 57 | "default_value": "", 58 | "user_viewable": true, 59 | "user_editable": true, 60 | "rules": "nullable|string|max:75", 61 | "field_type": "text" 62 | }, 63 | { 64 | "name": "Discord Bot Token", 65 | "description": "", 66 | "env_variable": "DISCORD_TOKEN", 67 | "default_value": "", 68 | "user_viewable": true, 69 | "user_editable": true, 70 | "rules": "nullable|string|max:100", 71 | "field_type": "text" 72 | }, 73 | { 74 | "name": "Owner IDs", 75 | "description": "Comma-separated list of the bot owners' Discord IDs. The default is that of the bot developer and is recommended to leave in if you want them to be able to debug issues for you.", 76 | "env_variable": "SUPER", 77 | "default_value": "319467558166069248", 78 | "user_viewable": true, 79 | "user_editable": true, 80 | "rules": "nullable|string|max:200", 81 | "field_type": "text" 82 | }, 83 | { 84 | "name": "PTERODACTYL", 85 | "description": "Internal variable to denote to the bot that it is in a pterodactyl container, required for functionality", 86 | "env_variable": "PTERODACTYL", 87 | "default_value": "true", 88 | "user_viewable": false, 89 | "user_editable": false, 90 | "rules": "required|string", 91 | "field_type": "text" 92 | }, 93 | { 94 | "name": "Trust proxies?", 95 | "description": "Should proxy headers be trusted?\r\nOnly enable this if running behind a safe reverse proxy.", 96 | "env_variable": "HTTP_TRUST_PROXY", 97 | "default_value": "false", 98 | "user_viewable": true, 99 | "user_editable": true, 100 | "rules": "required|string|max:20|in:true,false", 101 | "field_type": "text" 102 | }, 103 | { 104 | "name": "Override archive", 105 | "description": "If set to \"false\", archives will be forcibly disabled in all servers.", 106 | "env_variable": "OVERRIDE_ARCHIVE", 107 | "default_value": "", 108 | "user_viewable": true, 109 | "user_editable": true, 110 | "rules": "nullable|string|max:20", 111 | "field_type": "text" 112 | }, 113 | { 114 | "name": "Auto-publish commands?", 115 | "description": "If enabled, commands will be published on startup so you don't need to do it manually.", 116 | "env_variable": "PUBLISH_COMMANDS", 117 | "default_value": "true", 118 | "user_viewable": true, 119 | "user_editable": true, 120 | "rules": "required|string|max:20|in:true,false", 121 | "field_type": "text" 122 | } 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginJs from '@eslint/js'; 3 | import eartharoid from '@eartharoid/eslint-rules-js'; 4 | 5 | /** @type {import('eslint').Linter.Config[]} */ 6 | export default [ 7 | { files: ['**/*.{js,mjs,cjs,ts}'] }, 8 | { languageOptions: { globals: globals.node } }, 9 | pluginJs.configs.recommended, 10 | eartharoid, 11 | { 12 | rules: { 13 | 'no-console': [ 14 | 'warn', 15 | ], 16 | }, 17 | }, 18 | ]; 19 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ESNext", 5 | "moduleResolution": "Node", 6 | "baseUrl": "src", 7 | "resolveJsonModule": true, 8 | "checkJs": false, 9 | }, 10 | "include": [ 11 | "src/**/*.js" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-tickets", 3 | "version": "4.0.40", 4 | "private": "true", 5 | "description": "The most popular open-source ticket management bot for Discord.", 6 | "main": "src/", 7 | "scripts": { 8 | "db.dump": "node scripts/dump.mjs", 9 | "db.prune": "node scripts/prune.mjs", 10 | "db.restore": "node scripts/restore.mjs", 11 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", 12 | "contributors:add": "all-contributors add", 13 | "contributors:generate": "all-contributors generate", 14 | "keygen": "node scripts/keygen", 15 | "lint": "eslint src scripts --fix", 16 | "preinstall": "node scripts/preinstall", 17 | "postinstall": "node scripts/postinstall", 18 | "start": "node .", 19 | "studio": "npx prisma studio", 20 | "test": "node scripts/check-i18n", 21 | "__prepare": "husky install" 22 | }, 23 | "commitlint": { 24 | "extends": [ 25 | "@commitlint/config-conventional" 26 | ] 27 | }, 28 | "lint-staged": { 29 | "**/*.js": [ 30 | "npm run lint --" 31 | ] 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/discord-tickets/bot.git" 36 | }, 37 | "keywords": [ 38 | "discord", 39 | "tickets", 40 | "bot" 41 | ], 42 | "author": "eartharoid", 43 | "license": "GPL-3.0-or-later", 44 | "bugs": { 45 | "url": "https://github.com/discord-tickets/bot/issues" 46 | }, 47 | "homepage": "https://discordtickets.app", 48 | "engines": { 49 | "node": ">=18" 50 | }, 51 | "dependencies": { 52 | "@discord-tickets/settings": "~2.5.4", 53 | "@eartharoid/dbf": "^0.4.2", 54 | "@eartharoid/dtf": "^2.0.1", 55 | "@eartharoid/i18n": "^1.2.1", 56 | "@fastify/cookie": "^11.0.2", 57 | "@fastify/jwt": "^9.0.4", 58 | "@fastify/multipart": "^9.0.3", 59 | "@prisma/client": "^5.22.0", 60 | "archiver": "^7.0.1", 61 | "boxen": "^7.1.1", 62 | "commander": "^12.1.0", 63 | "cryptr": "^6.3.0", 64 | "discord.js": "^14.18.0", 65 | "dotenv": "^16.4.7", 66 | "fastify": "^5.3.2", 67 | "figlet": "^1.8.0", 68 | "fs-extra": "^10.1.0", 69 | "keyv": "^4.5.4", 70 | "leeks.js": "^0.3.0", 71 | "leekslazylogger": "^6.0.0", 72 | "ms": "^2.1.3", 73 | "mustache": "^4.2.0", 74 | "node-dir": "^0.1.17", 75 | "node-emoji": "^1.11.0", 76 | "object-diffy": "^1.0.4", 77 | "ora": "^8.2.0", 78 | "prisma": "^5.22.0", 79 | "semver": "^7.7.1", 80 | "short-unique-id": "^4.4.4", 81 | "spacetime": "^7.7.0", 82 | "terminal-link": "^2.1.1", 83 | "threads": "^1.7.0", 84 | "unzipper": "^0.12.3", 85 | "yaml": "^2.7.0" 86 | }, 87 | "devDependencies": { 88 | "@commitlint/cli": "^17.8.1", 89 | "@commitlint/config-conventional": "^17.8.1", 90 | "@eartharoid/eslint-rules-js": "^1.1.0", 91 | "@eslint/js": "^9.21.0", 92 | "all-contributors-cli": "^6.26.1", 93 | "conventional-changelog-cli": "^2.2.2", 94 | "eslint": "^9.21.0", 95 | "globals": "^15.15.0", 96 | "husky": "^8.0.3", 97 | "lint-staged": "^13.3.0", 98 | "markdown-table": "^3.0.4", 99 | "nodemon": "^2.0.22" 100 | }, 101 | "optionalDependencies": { 102 | "bufferutil": "^4.0.9", 103 | "erlpack": "github:discord/erlpack", 104 | "utf-8-validate": "^5.0.10", 105 | "zlib-sync": "^0.1.9" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /scripts/dump.mjs: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import fse from 'fs-extra'; 3 | import { join } from 'path'; 4 | import ora from 'ora'; 5 | import { PrismaClient } from '@prisma/client'; 6 | import DTF from '@eartharoid/dtf'; 7 | 8 | config(); 9 | 10 | const dtf = new DTF('en-GB'); 11 | 12 | let spinner = ora('Connecting').start(); 13 | 14 | fse.ensureDirSync(join(process.cwd(), './user/dumps')); 15 | const file_path = join(process.cwd(), './user/dumps', `${dtf.fill('YYYY-MM-DD-HH-mm-ss')}-db.json`); 16 | 17 | const prisma_options = {}; 18 | 19 | if (process.env.DB_PROVIDER === 'sqlite' && !process.env.DB_CONNECTION_URL) { 20 | prisma_options.datasources = { db: { url: 'file:' + join(process.cwd(), './user/database.db') } }; 21 | } 22 | 23 | const prisma = new PrismaClient(prisma_options); 24 | 25 | if (process.env.DB_PROVIDER === 'sqlite') { 26 | const { default: sqliteMiddleware } = await import('../src/lib/middleware/prisma-sqlite.js'); 27 | prisma.$use(sqliteMiddleware); 28 | await prisma.$queryRaw`PRAGMA journal_mode=WAL;`; 29 | await prisma.$queryRaw`PRAGMA synchronous=normal;`; 30 | } 31 | 32 | spinner.succeed('Connected'); 33 | 34 | const models = [ 35 | 'user', 36 | 'guild', 37 | 'tag', 38 | 'category', 39 | 'question', 40 | 'ticket', 41 | 'feedback', 42 | 'questionAnswer', 43 | 'archivedChannel', 44 | 'archivedRole', 45 | 'archivedUser', 46 | 'archivedMessage', 47 | ]; 48 | 49 | const dump = await Promise.all( 50 | models.map(async model => { 51 | spinner = ora(`Exporting ${model}`).start(); 52 | const data = await prisma[model].findMany(); 53 | spinner.succeed(`Exported ${data.length} from ${model}`); 54 | return [model, data]; 55 | }), 56 | ); 57 | 58 | spinner = ora('Writing').start(); 59 | await fse.promises.writeFile(file_path, JSON.stringify(dump)); 60 | spinner.succeed(`Written to "${file_path}"`); 61 | process.exit(0); 62 | -------------------------------------------------------------------------------- /scripts/keygen.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { randomBytes } = require('crypto'); 3 | const { short } = require('leeks.js'); 4 | 5 | console.log(short( 6 | 'Set the "ENCRYPTION_KEY" environment variable to: \n&!b ' + 7 | randomBytes(24).toString('hex') + 8 | ' &r\n\n&0&!e WARNING &r &e&lIf you lose the encryption key, most of the data in the database will become unreadable, requiring a new key and a full reset.', 9 | )); -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | require('dotenv').config(); 3 | const fs = require('fs-extra'); 4 | const util = require('util'); 5 | const exec = util.promisify(require('child_process').exec); 6 | const { short } = require('leeks.js'); 7 | const { 8 | resolve, join, 9 | } = require('path'); 10 | 11 | const fallback = { prisma: './node_modules/prisma/build/index.js' }; 12 | 13 | function pathify(path) { 14 | return resolve(__dirname, '../', path); 15 | } 16 | 17 | function log(...strings) { 18 | console.log(short('&9[postinstall]&r'), ...strings); 19 | } 20 | 21 | async function npx(cmd) { 22 | const parts = cmd.split(' '); 23 | // fallback for environments with no symlink/npx support (PebbleHost) 24 | if (!fs.existsSync(pathify(`./node_modules/.bin/${parts[0]}`))) { 25 | const x = parts.shift(); 26 | cmd = 'node ' + fallback[x] + ' ' + parts.join(' '); 27 | } else { 28 | cmd = 'npx ' + cmd; 29 | } 30 | log(`> ${cmd}`); 31 | const { 32 | stderr, 33 | stdout, 34 | } = await exec(cmd, { cwd: pathify('./') }); // { env } = process.env 35 | if (stdout) console.log(stdout.toString()); 36 | if (stderr) console.log(stderr.toString()); 37 | } 38 | 39 | const providers = ['mysql', 'postgresql', 'sqlite']; 40 | const provider = process.env.DB_PROVIDER; 41 | 42 | if (!provider) { 43 | log('environment not set, exiting.'); 44 | process.exit(0); 45 | } 46 | 47 | if (!providers.includes(provider)) throw new Error(`DB_PROVIDER must be one of: ${providers}`); 48 | 49 | log(`provider=${provider}`); 50 | log(`copying ${provider} schema & migrations`); 51 | 52 | if (fs.existsSync(pathify('./prisma'))) { 53 | fs.rmSync('./prisma', { 54 | force: true, 55 | recursive: true, 56 | }); 57 | } else { 58 | fs.mkdirSync(pathify('./prisma')); 59 | } 60 | fs.copySync(pathify(`./db/${provider}`), pathify('./prisma')); // copy schema & migrations 61 | 62 | if (provider === 'sqlite' && !process.env.DB_CONNECTION_URL) { 63 | process.env.DB_CONNECTION_URL = 'file:' + join(process.cwd(), './user/database.db'); 64 | log(`set DB_CONNECTION_URL=${process.env.DB_CONNECTION_URL}`); 65 | } 66 | 67 | (async () => { 68 | await npx('prisma generate'); 69 | await npx('prisma migrate deploy'); 70 | })(); 71 | -------------------------------------------------------------------------------- /scripts/preinstall.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { randomBytes } = require('crypto'); 3 | const fs = require('fs'); 4 | const { short } = require('leeks.js'); 5 | 6 | function log (...strings) { 7 | console.log(short('&9[preinstall]&r'), ...strings); 8 | } 9 | 10 | if (process.env.CI) { 11 | log('CI detected, skipping'); 12 | process.exit(0); 13 | } 14 | 15 | const env = { 16 | DB_CONNECTION_URL: '', 17 | DB_PROVIDER: '', // don't default to sqlite, postinstall checks if empty 18 | DISCORD_SECRET: '', 19 | DISCORD_TOKEN: '', 20 | ENCRYPTION_KEY: randomBytes(24).toString('hex'), 21 | HTTP_EXTERNAL: 'http://127.0.0.1:8169', 22 | HTTP_HOST: '0.0.0.0', 23 | HTTP_INTERNAL: '', 24 | HTTP_PORT: 8169, 25 | HTTP_TRUST_PROXY: false, 26 | INVALIDATE_TOKENS: '', 27 | NODE_ENV: 'production', // not bot-specific 28 | OVERRIDE_ARCHIVE: '', 29 | PUBLIC_BOT: false, 30 | PUBLISH_COMMANDS: false, 31 | SUPER: '319467558166069248', 32 | }; 33 | 34 | // check ENCRYPTION_KEY because we don't want to force use of the .env file 35 | if (!process.env.ENCRYPTION_KEY && !fs.existsSync('./.env')) { 36 | log('generating ENCRYPTION_KEY'); 37 | fs.writeFileSync('./.env', Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n')); 38 | log('created .env file'); 39 | log(short('&r&0&!e WARNING &r &e&lkeep your environment variables safe, don\'t lose your encryption key or you will lose data')); 40 | } else { 41 | log('nothing to do'); 42 | } 43 | -------------------------------------------------------------------------------- /scripts/prune.mjs: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import { program } from 'commander'; 3 | import { join } from 'path'; 4 | import ora from 'ora'; 5 | import { PrismaClient } from '@prisma/client'; 6 | 7 | config(); 8 | 9 | program 10 | .requiredOption('-y, --yes', 'ARE YOU SURE?') 11 | .option('-a, --age ', 'delete guilds older than days', 90) 12 | .option('-t, --ticket ', 'where the most recent ticket was created over days ago', 365); 13 | 14 | program.parse(); 15 | 16 | const options = program.opts(); 17 | 18 | let spinner = ora('Connecting').start(); 19 | 20 | const prisma_options = {}; 21 | 22 | if (process.env.DB_PROVIDER === 'sqlite' && !process.env.DB_CONNECTION_URL) { 23 | prisma_options.datasources = { db: { url: 'file:' + join(process.cwd(), './user/database.db') } }; 24 | } 25 | 26 | const prisma = new PrismaClient(prisma_options); 27 | 28 | if (process.env.DB_PROVIDER === 'sqlite') { 29 | const { default: sqliteMiddleware } = await import('../src/lib/middleware/prisma-sqlite.js'); 30 | prisma.$use(sqliteMiddleware); 31 | await prisma.$queryRaw`PRAGMA journal_mode=WAL;`; 32 | await prisma.$queryRaw`PRAGMA synchronous=normal;`; 33 | } 34 | 35 | spinner.succeed('Connected'); 36 | 37 | const now = Date.now(); 38 | const day = 1000 * 60 * 60 * 24; 39 | 40 | spinner = ora('Counting total guilds').start(); 41 | const total = await prisma.guild.count(); 42 | spinner.succeed(`Found ${total} total guilds`); 43 | 44 | // ! the bot might still be in these guilds 45 | spinner = ora(`Deleting guilds inactive for more than ${options.ticket} days`).start(); 46 | const result = await prisma.guild.deleteMany({ 47 | where: { 48 | createdAt: { lt: new Date(now - (day * options.age)) }, 49 | tickets: { none: { createdAt: { gt: new Date(now - (day * options.ticket)) } } }, 50 | }, 51 | }); 52 | spinner.succeed(`Deleted ${result.count} guilds; ${total - result.count} remaining`); 53 | 54 | process.exit(0); 55 | -------------------------------------------------------------------------------- /scripts/restore.mjs: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import { program } from 'commander'; 3 | import fse from 'fs-extra'; 4 | import { join } from 'path'; 5 | import ora from 'ora'; 6 | import { PrismaClient } from '@prisma/client'; 7 | 8 | config(); 9 | 10 | program 11 | .requiredOption('-f, --file ', 'the path of the dump to import') 12 | .requiredOption('-y, --yes', 'yes, DELETE EVERYTHING in the database'); 13 | 14 | program.parse(); 15 | 16 | const options = program.opts(); 17 | 18 | let spinner = ora('Connecting').start(); 19 | 20 | const prisma_options = {}; 21 | 22 | if (process.env.DB_PROVIDER === 'sqlite' && !process.env.DB_CONNECTION_URL) { 23 | prisma_options.datasources = { db: { url: 'file:' + join(process.cwd(), './user/database.db') } }; 24 | } 25 | 26 | const prisma = new PrismaClient(prisma_options); 27 | 28 | if (process.env.DB_PROVIDER === 'sqlite') { 29 | const { default: sqliteMiddleware } = await import('../src/lib/middleware/prisma-sqlite.js'); 30 | prisma.$use(sqliteMiddleware); 31 | await prisma.$queryRaw`PRAGMA journal_mode=WAL;`; 32 | await prisma.$queryRaw`PRAGMA synchronous=normal;`; 33 | } 34 | 35 | spinner.succeed('Connected'); 36 | 37 | spinner = ora(`Reading ${options.file}`).start(); 38 | const dump = JSON.parse(await fse.promises.readFile(options.file, 'utf8')); 39 | spinner.succeed(`Parsed ${options.file}`); 40 | 41 | // ! this order is important 42 | const queries = [ 43 | prisma.guild.deleteMany(), 44 | prisma.user.deleteMany(), 45 | ]; 46 | 47 | for (const [model, data] of dump) queries.push(prisma[model].createMany({ data })); 48 | spinner = ora('Importing').start(); 49 | const [,, ...results] = await prisma.$transaction(queries); 50 | for (const idx in results) spinner.succeed(`Imported ${results[idx].count} into ${dump[idx][0]}`); 51 | process.exit(0); 52 | -------------------------------------------------------------------------------- /scripts/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ "$PTERODACTYL" = "true" ]; then 4 | rm -rf /home/container/app 5 | cp -R /app /home/container/ 6 | base_dir="/home/container/app" 7 | elif [ "$DOCKER" = "true" ]; then 8 | base_dir="/app" 9 | else 10 | source="${BASH_SOURCE}" 11 | base_dir=$(dirname $(dirname "$source")) 12 | fi 13 | 14 | echo "Checking environment..." 15 | script=scripts/preinstall 16 | node "$base_dir/$script" 17 | 18 | echo "Preparing the database..." 19 | script=scripts/postinstall 20 | node "$base_dir/$script" 21 | 22 | echo "Starting..." 23 | script=src/ 24 | node "$base_dir/$script" 25 | -------------------------------------------------------------------------------- /src/autocomplete/category.js: -------------------------------------------------------------------------------- 1 | const { Autocompleter } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class CategoryCompleter extends Autocompleter { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'category', 8 | }); 9 | } 10 | 11 | /** 12 | * @param {string} value 13 | * @param {*} command 14 | * @param {import("discord.js").AutocompleteInteraction} interaction 15 | */ 16 | async run(value, command, interaction) { 17 | /** @type {import("client")} */ 18 | const client = this.client; 19 | 20 | let categories = await client.prisma.category.findMany({ where: { guildId: interaction.guild.id } }); 21 | 22 | if (command.name === 'move') { 23 | const ticket = await client.prisma.ticket.findUnique({ where: { id: interaction.channel.id } }); 24 | if (ticket) categories = categories.filter(category => ticket.categoryId !== category.id); 25 | } 26 | 27 | const options = value ? categories.filter(category => category.name.match(new RegExp(value, 'i'))) : categories; 28 | await interaction.respond( 29 | options 30 | .slice(0, 25) 31 | .map(category => ({ 32 | name: category.name, 33 | value: category.id, 34 | })), 35 | ); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/autocomplete/references.js: -------------------------------------------------------------------------------- 1 | const { Autocompleter } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class ReferencesCompleter extends Autocompleter { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'references', 8 | }); 9 | } 10 | 11 | 12 | /** 13 | * @param {string} value 14 | * @param {*} comamnd 15 | * @param {import("discord.js").AutocompleteInteraction} interaction 16 | */ 17 | async run(value, comamnd, interaction) { 18 | await interaction.respond( 19 | await this.client.autocomplete.components.get('ticket').getOptions(value, { 20 | interaction, 21 | open: false, 22 | userId: interaction.user.id, 23 | }), 24 | ); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/autocomplete/tag.js: -------------------------------------------------------------------------------- 1 | const { Autocompleter } = require('@eartharoid/dbf'); 2 | const ms = require('ms'); 3 | 4 | module.exports = class TagCompleter extends Autocompleter { 5 | constructor(client, options) { 6 | super(client, { 7 | ...options, 8 | id: 'tag', 9 | }); 10 | } 11 | 12 | /** 13 | * @param {string} value 14 | * @param {*} command 15 | * @param {import("discord.js").AutocompleteInteraction} interaction 16 | */ 17 | async run(value, command, interaction) { 18 | /** @type {import("client")} */ 19 | const client = this.client; 20 | 21 | const cacheKey = `cache/guild-tags:${interaction.guild.id}`; 22 | let tags = await client.keyv.get(cacheKey); 23 | if (!tags) { 24 | tags = await client.prisma.tag.findMany({ 25 | select: { 26 | content: true, 27 | id: true, 28 | name: true, 29 | regex: true, 30 | }, 31 | where: { guildId: interaction.guild.id }, 32 | }); 33 | client.keyv.set(cacheKey, tags, ms('1h')); 34 | } 35 | 36 | const options = value ? tags.filter(tag => 37 | tag.name.match(new RegExp(value, 'i')) || 38 | tag.content.match(new RegExp(value, 'i')) || 39 | tag.regex?.match(new RegExp(value, 'i')), 40 | ) : tags; 41 | await interaction.respond( 42 | options 43 | .slice(0, 25) 44 | .map(tag => ({ 45 | name: tag.name, 46 | value: tag.id, 47 | })), 48 | ); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/autocomplete/ticket.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | const { Autocompleter } = require('@eartharoid/dbf'); 3 | const emoji = require('node-emoji'); 4 | const Keyv = require('keyv'); 5 | const ms = require('ms'); 6 | const { isStaff } = require('../lib/users'); 7 | const { reusable } = require('../lib/threads'); 8 | 9 | module.exports = class TicketCompleter extends Autocompleter { 10 | constructor(client, options) { 11 | super(client, { 12 | ...options, 13 | id: 'ticket', 14 | }); 15 | 16 | this.cache = new Keyv(); 17 | } 18 | 19 | async getOptions(value, { 20 | interaction, 21 | open, 22 | userId, 23 | }) { 24 | /** @type {import("client")} */ 25 | const client = this.client; 26 | const guildId = interaction.guild.id; 27 | const cacheKey = [guildId, userId, open].join('/'); 28 | 29 | let tickets = await this.cache.get(cacheKey); 30 | 31 | if (!tickets) { 32 | const cmd = client.commands.commands.slash.get('transcript'); 33 | const { locale } = await client.prisma.guild.findUnique({ 34 | select: { locale: true }, 35 | where: { id: guildId }, 36 | }); 37 | tickets = await client.prisma.ticket.findMany({ 38 | include: { category: true }, 39 | where: { 40 | createdById: userId, 41 | guildId, 42 | open, 43 | }, 44 | }); 45 | 46 | const worker = await reusable('crypto'); 47 | try { 48 | tickets = await Promise.all( 49 | tickets 50 | .filter(ticket => cmd.shouldAllowAccess(interaction, ticket)) 51 | .map(async ticket => { 52 | const getTopic = async () => (await worker.decrypt(ticket.topic)).replace(/\n/g, ' ').substring(0, 50); 53 | const date = new Date(ticket.createdAt).toLocaleString([locale, 'en-GB'], { dateStyle: 'short' }); 54 | const topic = ticket.topic ? '- ' + (await getTopic()) : ''; 55 | const category = emoji.hasEmoji(ticket.category.emoji) ? emoji.get(ticket.category.emoji) + ' ' + ticket.category.name : ticket.category.name; 56 | ticket._name = `${category} #${ticket.number} (${date}) ${topic}`; 57 | return ticket; 58 | }), 59 | ); 60 | } finally { 61 | await worker.terminate(); 62 | } 63 | 64 | this.cache.set(cacheKey, tickets, ms('1m')); 65 | } 66 | 67 | const options = value ? tickets.filter(t => t._name.match(new RegExp(value, 'i'))) : tickets; 68 | return options 69 | .slice(0, 25) 70 | .map(t => ({ 71 | name: t._name, 72 | value: t.id, 73 | })); 74 | } 75 | 76 | /** 77 | * @param {string} value 78 | * @param {*} command 79 | * @param {import("discord.js").AutocompleteInteraction} interaction 80 | */ 81 | async run(value, command, interaction) { 82 | const otherMember = await isStaff(interaction.guild, interaction.user.id) && interaction.options.data[1]?.value; 83 | const userId = otherMember || interaction.user.id; 84 | await interaction.respond( 85 | await this.getOptions(value, { 86 | interaction, 87 | open: ['add', 'close', 'force-close', 'remove'].includes(command.name), // false for `new`, `transcript` etc 88 | userId, 89 | }), 90 | ); 91 | } 92 | }; 93 | -------------------------------------------------------------------------------- /src/buttons/claim.js: -------------------------------------------------------------------------------- 1 | const { Button } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class ClaimButton extends Button { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'claim', 8 | }); 9 | } 10 | 11 | /** 12 | * @param {*} id 13 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 14 | */ 15 | async run(id, interaction) { 16 | /** @type {import("client")} */ 17 | const client = this.client; 18 | 19 | await client.tickets.claim(interaction); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/buttons/close.js: -------------------------------------------------------------------------------- 1 | const { Button } = require('@eartharoid/dbf'); 2 | const ExtendedEmbedBuilder = require('../lib/embed'); 3 | const { isStaff } = require('../lib/users'); 4 | const { MessageFlags } = require('discord.js'); 5 | 6 | module.exports = class CloseButton extends Button { 7 | constructor(client, options) { 8 | super(client, { 9 | ...options, 10 | id: 'close', 11 | }); 12 | } 13 | 14 | /** 15 | * @param {*} id 16 | * @param {import("discord.js").ButtonInteraction} interaction 17 | */ 18 | async run(id, interaction) { 19 | /** @type {import("client")} */ 20 | const client = this.client; 21 | 22 | if (id.accepted === undefined) { 23 | // the close button on the opening message, the same as using /close 24 | await client.tickets.beforeRequestClose(interaction); 25 | } else { 26 | const ticket = await client.tickets.getTicket(interaction.channel.id, true); // true to override cache and load new feedback 27 | const getMessage = client.i18n.getLocale(ticket.guild.locale); 28 | const staff = await isStaff(interaction.guild, interaction.user.id); 29 | 30 | if (id.expect === 'staff' && !staff) { 31 | return await interaction.reply({ 32 | embeds: [ 33 | new ExtendedEmbedBuilder() 34 | .setColor(ticket.guild.errorColour) 35 | .setDescription(getMessage('ticket.close.wait_for_staff')), 36 | ], 37 | flags: MessageFlags.Ephemeral, 38 | }); 39 | } else if (id.expect === 'user' && interaction.user.id !== ticket.createdById) { 40 | return await interaction.reply({ 41 | embeds: [ 42 | new ExtendedEmbedBuilder() 43 | .setColor(ticket.guild.errorColour) 44 | .setDescription(getMessage('ticket.close.wait_for_user')), 45 | ], 46 | flags: MessageFlags.Ephemeral, 47 | }); 48 | } else { 49 | if (id.accepted) { 50 | if ( 51 | ticket.createdById === interaction.user.id && 52 | ticket.category.enableFeedback && 53 | !ticket.feedback 54 | ) { 55 | return await interaction.showModal(client.tickets.buildFeedbackModal(ticket.guild.locale, { next: 'acceptClose' })); 56 | } else { 57 | await interaction.deferReply(); 58 | await client.tickets.acceptClose(interaction); 59 | } 60 | } else { 61 | try { 62 | await interaction.update({ 63 | components: [], 64 | embeds: [ 65 | new ExtendedEmbedBuilder({ 66 | iconURL: interaction.guild.iconURL(), 67 | text: ticket.guild.footer, 68 | }) 69 | .setColor(ticket.guild.errorColour) 70 | .setDescription(getMessage('ticket.close.rejected', { user: interaction.user.toString() })) 71 | .setFooter({ text: null }), 72 | ], 73 | }); 74 | 75 | } finally { // this should run regardless of whatever happens above 76 | client.tickets.$stale.delete(ticket.id); 77 | } 78 | } 79 | } 80 | } 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /src/buttons/create.js: -------------------------------------------------------------------------------- 1 | const { Button } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class CreateButton extends Button { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'create', 8 | }); 9 | } 10 | 11 | /** 12 | * @param {*} id 13 | * @param {import("discord.js").ButtonInteraction} interaction 14 | */ 15 | async run(id, interaction) { 16 | await this.client.tickets.create({ 17 | categoryId: id.target, 18 | interaction, 19 | topic: id.topic, 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/buttons/edit.js: -------------------------------------------------------------------------------- 1 | const { Button } = require('@eartharoid/dbf'); 2 | const { 3 | ActionRowBuilder, 4 | ModalBuilder, 5 | StringSelectMenuBuilder, 6 | StringSelectMenuOptionBuilder, 7 | TextInputBuilder, 8 | TextInputStyle, 9 | } = require('discord.js'); 10 | const { reusable } = require('../lib/threads'); 11 | const emoji = require('node-emoji'); 12 | 13 | module.exports = class EditButton extends Button { 14 | constructor(client, options) { 15 | super(client, { 16 | ...options, 17 | id: 'edit', 18 | }); 19 | } 20 | 21 | async run(id, interaction) { 22 | /** @type {import("client")} */ 23 | const client = this.client; 24 | 25 | const ticket = await client.prisma.ticket.findUnique({ 26 | select: { 27 | category: { select: { name: true } }, 28 | guild: { select: { locale: true } }, 29 | questionAnswers: { include: { question: true } }, 30 | topic: true, 31 | }, 32 | where: { id: interaction.channel.id }, 33 | }); 34 | 35 | const getMessage = client.i18n.getLocale(ticket.guild.locale); 36 | 37 | const worker = await reusable('crypto'); 38 | 39 | try { 40 | if (ticket.questionAnswers.length === 0) { 41 | const field = new TextInputBuilder() 42 | .setCustomId('topic') 43 | .setLabel(getMessage('modals.topic.label')) 44 | .setStyle(TextInputStyle.Paragraph) 45 | .setMaxLength(100) 46 | .setMinLength(5) 47 | .setPlaceholder(getMessage('modals.topic.placeholder')) 48 | .setRequired(true); 49 | if (ticket.topic) field.setValue(await worker.decrypt(ticket.topic)); 50 | await interaction.showModal( 51 | new ModalBuilder() 52 | .setCustomId(JSON.stringify({ 53 | action: 'topic', 54 | edit: true, 55 | })) 56 | .setTitle(ticket.category.name) 57 | .setComponents( 58 | new ActionRowBuilder() 59 | .setComponents(field), 60 | ), 61 | ); 62 | } else { 63 | await interaction.showModal( 64 | new ModalBuilder() 65 | .setCustomId(JSON.stringify({ 66 | action: 'questions', 67 | edit: true, 68 | })) 69 | .setTitle(ticket.category.name) 70 | .setComponents( 71 | await Promise.all( 72 | ticket.questionAnswers 73 | .filter(a => a.question.type === 'TEXT') // TODO: remove this when modals support select menus 74 | .map(async a => { 75 | if (a.question.type === 'TEXT') { 76 | const field = new TextInputBuilder() 77 | .setCustomId(String(a.id)) 78 | .setLabel(a.question.label) 79 | .setStyle(a.question.style) 80 | .setMaxLength(Math.min(a.question.maxLength, 1000)) 81 | .setMinLength(a.question.minLength) 82 | .setPlaceholder(a.question.placeholder) 83 | .setRequired(a.question.required); 84 | if (a.value) field.setValue(await worker.decrypt(a.value)); 85 | else if (a.question.value) field.setValue(a.question.value); 86 | return new ActionRowBuilder().setComponents(field); 87 | } else if (a.question.type === 'MENU') { 88 | return new ActionRowBuilder() 89 | .setComponents( 90 | new StringSelectMenuBuilder() 91 | .setCustomId(a.question.id) 92 | .setPlaceholder(a.question.placeholder || a.question.label) 93 | .setMaxValues(a.question.maxLength) 94 | .setMinValues(a.question.minLength) 95 | .setOptions( 96 | a.question.options.map((o, i) => { 97 | const builder = new StringSelectMenuOptionBuilder() 98 | .setValue(String(i)) 99 | .setLabel(o.label); 100 | if (o.description) builder.setDescription(o.description); 101 | if (o.emoji) { 102 | builder.setEmoji(emoji.hasEmoji(o.emoji) 103 | ? emoji.get(o.emoji) 104 | : { id: o.emoji }); 105 | } 106 | return builder; 107 | }), 108 | ), 109 | ); 110 | } 111 | }), 112 | ), 113 | ), 114 | ); 115 | } 116 | } finally { 117 | await worker.terminate(); 118 | } 119 | } 120 | }; 121 | -------------------------------------------------------------------------------- /src/buttons/transcript.js: -------------------------------------------------------------------------------- 1 | const { Button } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class ClaimButton extends Button { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'transcript', 8 | }); 9 | } 10 | 11 | /** 12 | * @param {*} id 13 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 14 | */ 15 | async run(id, interaction) { 16 | /** @type {import("client")} */ 17 | const client = this.client; 18 | 19 | const cmd = client.commands.commands.slash.get('transcript'); 20 | return await cmd.run(interaction, id.ticket); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/buttons/unclaim.js: -------------------------------------------------------------------------------- 1 | const { Button } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class UnclaimButton extends Button { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'unclaim', 8 | }); 9 | } 10 | 11 | /** 12 | * @param {*} id 13 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 14 | */ 15 | async run(id, interaction) { 16 | /** @type {import("client")} */ 17 | const client = this.client; 18 | 19 | await client.tickets.release(interaction); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | const { FrameworkClient } = require('@eartharoid/dbf'); 2 | const { 3 | GatewayIntentBits, 4 | Partials, 5 | } = require('discord.js'); 6 | const logger = require('./lib/logger'); 7 | const { PrismaClient } = require('@prisma/client'); 8 | const Keyv = require('keyv'); 9 | const I18n = require('@eartharoid/i18n'); 10 | const fs = require('fs'); 11 | const { join } = require('path'); 12 | const YAML = require('yaml'); 13 | const TicketManager = require('./lib/tickets/manager'); 14 | const sqliteMiddleware = require('./lib/middleware/prisma-sqlite'); 15 | const ms = require('ms'); 16 | 17 | module.exports = class Client extends FrameworkClient { 18 | constructor() { 19 | super( 20 | { 21 | intents: [ 22 | ...[ 23 | GatewayIntentBits.DirectMessages, 24 | GatewayIntentBits.DirectMessageReactions, 25 | GatewayIntentBits.DirectMessageTyping, 26 | GatewayIntentBits.MessageContent, 27 | GatewayIntentBits.Guilds, 28 | GatewayIntentBits.GuildMembers, 29 | GatewayIntentBits.GuildMessages, 30 | ], 31 | ...(process.env.PUBLIC_BOT !== 'true' ? [GatewayIntentBits.GuildPresences] : []), 32 | ], 33 | partials: [ 34 | Partials.Channel, 35 | Partials.Message, 36 | Partials.Reaction, 37 | ], 38 | shards: 'auto', 39 | waitGuildTimeout: ms('1h'), 40 | }, 41 | { baseDir: __dirname }, 42 | ); 43 | 44 | this.config = {}; 45 | this.log = {}; 46 | this.init(); 47 | } 48 | 49 | async init(reload = false) { 50 | const locales = {}; 51 | fs.readdirSync(join(__dirname, 'i18n')) 52 | .filter(file => file.endsWith('.yml')) 53 | .forEach(file => { 54 | const data = fs.readFileSync(join(__dirname, 'i18n/' + file), { encoding: 'utf8' }); 55 | const name = file.slice(0, file.length - 4); 56 | locales[name] = YAML.parse(data); 57 | }); 58 | 59 | /** @type {I18n} */ 60 | this.i18n = new I18n('en-GB', locales); 61 | 62 | // to maintain references, these shouldn't be reassigned 63 | Object.assign(this.config, YAML.parse(fs.readFileSync('./user/config.yml', 'utf8'))); 64 | Object.assign(this.log, logger(this.config)); 65 | 66 | this.banned_guilds = new Set( 67 | (() => { 68 | let array = fs.readFileSync('./user/banned-guilds.txt', 'utf8').trim().split(/\r?\n/); 69 | if (array[0] === '') array = []; 70 | return array; 71 | })(), 72 | ); 73 | this.log.info(`${this.banned_guilds.size} guilds are banned`); 74 | 75 | if (reload) { 76 | await this.initAfterLogin(); 77 | } else { 78 | this.keyv = new Keyv(); 79 | 80 | this.tickets = new TicketManager(this); 81 | 82 | this.supers = (process.env.SUPER ?? '').split(','); 83 | 84 | /** @param {import('discord.js/typings').Interaction} interaction */ 85 | this.commands.interceptor = async interaction => { 86 | if (!interaction.inGuild()) return; 87 | const id = interaction.guildId; 88 | const cacheKey = `cache/known/guild:${id}`; 89 | if (await this.keyv.has(cacheKey)) return; 90 | await this.prisma.guild.upsert({ 91 | create: { 92 | id, 93 | locale: this.i18n.locales.find(locale => locale === interaction.guild.preferredLocale), // undefined if not supported 94 | }, 95 | update: {}, 96 | where: { id }, 97 | }); 98 | await this.keyv.set(cacheKey, true); 99 | }; 100 | } 101 | } 102 | 103 | async initAfterLogin() { 104 | for (const id of this.banned_guilds) { 105 | if (this.guilds.cache.has(id)) { 106 | this.log.info(`Leaving banned guild ${id}`); 107 | await this.guilds.cache.get(id).leave(); 108 | } 109 | } 110 | } 111 | 112 | async login(token) { 113 | const levels = ['error', 'info', 'warn']; 114 | if (this.config.logs.level === 'debug') levels.push('query'); 115 | 116 | const prisma_options = { 117 | log: levels.map(level => ({ 118 | emit: 'event', 119 | level, 120 | })), 121 | }; 122 | 123 | if (process.env.DB_PROVIDER === 'sqlite' && !process.env.DB_CONNECTION_URL) { 124 | prisma_options.datasources = { db: { url: 'file:' + join(process.cwd(), './user/database.db') } }; 125 | } 126 | 127 | /** @type {PrismaClient} */ 128 | this.prisma = new PrismaClient(prisma_options); 129 | 130 | this.prisma.$on('error', e => this.log.error.prisma(`${e.target} ${e.message}`)); 131 | this.prisma.$on('info', e => this.log.info.prisma(`${e.target} ${e.message}`)); 132 | this.prisma.$on('warn', e => this.log.warn.prisma(`${e.target} ${e.message}`)); 133 | this.prisma.$on('query', e => this.log.debug.prisma(e)); 134 | 135 | if (process.env.DB_PROVIDER === 'sqlite') { 136 | // rewrite queries that use unsupported features 137 | this.prisma.$use(sqliteMiddleware); 138 | // make sqlite faster (missing parentheses are not a mistake, `$queryRaw` is a tagged template literal) 139 | this.log.debug(await this.prisma.$queryRaw`PRAGMA journal_mode=WAL;`); // https://www.sqlite.org/wal.html 140 | this.log.debug(await this.prisma.$queryRaw`PRAGMA synchronous=normal;`); // https://www.sqlite.org/pragma.html#pragma_synchronous 141 | 142 | setInterval(async () => { 143 | this.log.debug(await this.prisma.$queryRaw`PRAGMA optimize;`); // https://www.sqlite.org/pragma.html#pragma_optimize 144 | }, ms('6h')); 145 | } 146 | 147 | return super.login(token); 148 | } 149 | 150 | async destroy() { 151 | await this.prisma.$disconnect(); 152 | return super.destroy(); 153 | } 154 | }; 155 | -------------------------------------------------------------------------------- /src/commands/message/create.js: -------------------------------------------------------------------------------- 1 | const { MessageCommand } = require('@eartharoid/dbf'); 2 | const { useGuild } = require('../../lib/tickets/utils'); 3 | 4 | module.exports = class CreateMessageCommand extends MessageCommand { 5 | constructor(client, options) { 6 | const nameLocalizations = {}; 7 | client.i18n.locales.forEach(l => (nameLocalizations[l] = client.i18n.getMessage(l, 'commands.message.create.name'))); 8 | 9 | super(client, { 10 | ...options, 11 | dmPermission: false, 12 | name: nameLocalizations['en-GB'], 13 | nameLocalizations, 14 | }); 15 | } 16 | 17 | /** 18 | * @param {import("discord.js").MessageContextMenuCommandInteraction} interaction 19 | */ 20 | async run(interaction) { 21 | await useGuild(this.client, interaction, { referencesMessageId: interaction.targetId }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/commands/message/pin.js: -------------------------------------------------------------------------------- 1 | const { MessageCommand } = require('@eartharoid/dbf'); 2 | const ExtendedEmbedBuilder = require('../../lib/embed'); 3 | const { MessageFlags } = require('discord.js'); 4 | 5 | module.exports = class PinMessageCommand extends MessageCommand { 6 | constructor(client, options) { 7 | const nameLocalizations = {}; 8 | client.i18n.locales.forEach(l => (nameLocalizations[l] = client.i18n.getMessage(l, 'commands.message.pin.name'))); 9 | 10 | super(client, { 11 | ...options, 12 | dmPermission: false, 13 | name: nameLocalizations['en-GB'], 14 | nameLocalizations, 15 | }); 16 | } 17 | 18 | /** 19 | * @param {import("discord.js").MessageContextMenuCommandInteraction} interaction 20 | */ 21 | async run(interaction) { 22 | /** @type {import("client")} */ 23 | const client = this.client; 24 | 25 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 26 | const ticket = await client.prisma.ticket.findUnique({ 27 | include: { guild: true }, 28 | where: { id: interaction.channel.id }, 29 | }); 30 | 31 | if (!ticket) { 32 | const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); 33 | const getMessage = client.i18n.getLocale(settings.locale); 34 | return await interaction.editReply({ 35 | embeds: [ 36 | new ExtendedEmbedBuilder({ 37 | iconURL: interaction.guild.iconURL(), 38 | text: settings.footer, 39 | }) 40 | .setColor(settings.errorColour) 41 | .setTitle(getMessage('commands.message.pin.not_ticket.title')) 42 | .setDescription(getMessage('commands.message.pin.not_ticket.description')), 43 | ], 44 | }); 45 | } 46 | 47 | const getMessage = client.i18n.getLocale(ticket.guild.locale); 48 | 49 | if (!interaction.targetMessage.pinnable) { 50 | return await interaction.editReply({ 51 | embeds: [ 52 | new ExtendedEmbedBuilder({ 53 | iconURL: interaction.guild.iconURL(), 54 | text: ticket.guild.footer, 55 | }) 56 | .setColor(ticket.guild.errorColour) 57 | .setTitle(getMessage('commands.message.pin.not_pinnable.title')) 58 | .setDescription(getMessage('commands.message.pin.not_pinnable.description')), 59 | ], 60 | }); 61 | } 62 | 63 | await interaction.targetMessage.pin(); 64 | return await interaction.editReply({ 65 | embeds: [ 66 | new ExtendedEmbedBuilder({ 67 | iconURL: interaction.guild.iconURL(), 68 | text: ticket.guild.footer, 69 | }) 70 | .setColor(ticket.guild.successColour) 71 | .setTitle(getMessage('commands.message.pin.pinned.title')) 72 | .setDescription(getMessage('commands.message.pin.pinned.description')), 73 | ], 74 | }); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /src/commands/slash/add.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | const { 3 | ApplicationCommandOptionType, MessageFlags, 4 | } = require('discord.js'); 5 | const ExtendedEmbedBuilder = require('../../lib/embed'); 6 | const { isStaff } = require('../../lib/users'); 7 | const { logTicketEvent } = require('../../lib/logging'); 8 | 9 | module.exports = class AddSlashCommand extends SlashCommand { 10 | constructor(client, options) { 11 | const name = 'add'; 12 | super(client, { 13 | ...options, 14 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 15 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 16 | dmPermission: false, 17 | name, 18 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 19 | options: [ 20 | { 21 | name: 'member', 22 | required: true, 23 | type: ApplicationCommandOptionType.User, 24 | }, 25 | { 26 | autocomplete: true, 27 | name: 'ticket', 28 | required: false, 29 | type: ApplicationCommandOptionType.String, 30 | }, 31 | ].map(option => { 32 | option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`); 33 | option.description = option.descriptionLocalizations['en-GB']; 34 | option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`); 35 | return option; 36 | }), 37 | }); 38 | } 39 | 40 | /** 41 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 42 | */ 43 | async run(interaction) { 44 | /** @type {import("client")} */ 45 | const client = this.client; 46 | 47 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 48 | 49 | const ticket = await client.prisma.ticket.findUnique({ 50 | include: { guild: true }, 51 | where: { id: interaction.options.getString('ticket', false) || interaction.channel.id }, 52 | }); 53 | 54 | if (!ticket) { 55 | const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); 56 | const getMessage = client.i18n.getLocale(settings.locale); 57 | return await interaction.editReply({ 58 | embeds: [ 59 | new ExtendedEmbedBuilder({ 60 | iconURL: interaction.guild.iconURL(), 61 | text: settings.footer, 62 | }) 63 | .setColor(settings.errorColour) 64 | .setTitle(getMessage('misc.invalid_ticket.title')) 65 | .setDescription(getMessage('misc.invalid_ticket.description')), 66 | ], 67 | }); 68 | } 69 | 70 | const getMessage = client.i18n.getLocale(ticket.guild.locale); 71 | 72 | if ( 73 | ticket.id !== interaction.channel.id && 74 | ticket.createdById !== interaction.member.id && 75 | !(await isStaff(interaction.guild, interaction.member.id)) 76 | ) { 77 | return await interaction.editReply({ 78 | embeds: [ 79 | new ExtendedEmbedBuilder({ 80 | iconURL: interaction.guild.iconURL(), 81 | text: ticket.guild.footer, 82 | }) 83 | .setColor(ticket.guild.errorColour) 84 | .setTitle(getMessage('commands.slash.add.not_staff.title')) 85 | .setDescription(getMessage('commands.slash.add.not_staff.description')), 86 | ], 87 | }); 88 | } 89 | 90 | /** @type {import("discord.js").TextChannel} */ 91 | const ticketChannel = await interaction.guild.channels.fetch(ticket.id); 92 | const member = interaction.options.getMember('member', true); 93 | 94 | await ticketChannel.permissionOverwrites.edit( 95 | member, 96 | { 97 | AttachFiles: true, 98 | EmbedLinks: true, 99 | ReadMessageHistory: true, 100 | SendMessages: true, 101 | ViewChannel: true, 102 | }, 103 | `${interaction.user.tag} added ${member.user.tag} to the ticket`, 104 | ); 105 | 106 | await ticketChannel.send({ 107 | embeds: [ 108 | new ExtendedEmbedBuilder() 109 | .setColor(ticket.guild.primaryColour) 110 | .setDescription(getMessage('commands.slash.add.added', { 111 | added: member.toString(), 112 | by: interaction.member.toString(), 113 | })), 114 | ], 115 | }); 116 | 117 | await interaction.editReply({ 118 | embeds: [ 119 | new ExtendedEmbedBuilder({ 120 | iconURL: interaction.guild.iconURL(), 121 | text: ticket.guild.footer, 122 | }) 123 | .setColor(ticket.guild.successColour) 124 | .setTitle(getMessage('commands.slash.add.success.title')) 125 | .setDescription(getMessage('commands.slash.add.success.description', { 126 | member: member.toString(), 127 | ticket: ticketChannel.toString(), 128 | })), 129 | ], 130 | }); 131 | 132 | logTicketEvent(this.client, { 133 | action: 'update', 134 | diff: { 135 | original: {}, 136 | updated: { [getMessage('log.ticket.added')]: member.user.tag }, 137 | }, 138 | target: { 139 | id: ticket.id, 140 | name: `<#${ticket.id}>`, 141 | }, 142 | userId: interaction.user.id, 143 | }); 144 | 145 | } 146 | }; 147 | -------------------------------------------------------------------------------- /src/commands/slash/claim.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class ClaimSlashCommand extends SlashCommand { 4 | constructor(client, options) { 5 | const name = 'claim'; 6 | super(client, { 7 | ...options, 8 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 9 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 10 | dmPermission: false, 11 | name, 12 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 13 | }); 14 | } 15 | 16 | /** 17 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 18 | */ 19 | async run(interaction) { 20 | /** @type {import("client")} */ 21 | const client = this.client; 22 | 23 | await client.tickets.claim(interaction); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/commands/slash/close.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | const { ApplicationCommandOptionType } = require('discord.js'); 3 | 4 | module.exports = class CloseSlashCommand extends SlashCommand { 5 | constructor(client, options) { 6 | const name = 'close'; 7 | super(client, { 8 | ...options, 9 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 10 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 11 | dmPermission: false, 12 | name, 13 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 14 | options: [ 15 | { 16 | name: 'reason', 17 | required: false, 18 | type: ApplicationCommandOptionType.String, 19 | }, 20 | ].map(option => { 21 | option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`); 22 | option.description = option.descriptionLocalizations['en-GB']; 23 | option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`); 24 | return option; 25 | }), 26 | }); 27 | } 28 | 29 | /** 30 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 31 | */ 32 | async run(interaction) { 33 | /** @type {import("client")} */ 34 | const client = this.client; 35 | await client.tickets.beforeRequestClose(interaction); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/commands/slash/help.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | const { isStaff } = require('../../lib/users'); 3 | const ExtendedEmbedBuilder = require('../../lib/embed'); 4 | const { version } = require('../../../package.json'); 5 | const { MessageFlags } = require('discord.js'); 6 | 7 | module.exports = class ClaimSlashCommand extends SlashCommand { 8 | constructor(client, options) { 9 | const name = 'help'; 10 | super(client, { 11 | ...options, 12 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 13 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 14 | dmPermission: false, 15 | name, 16 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 17 | }); 18 | } 19 | 20 | /** 21 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 22 | */ 23 | async run(interaction) { 24 | /** @type {import("client")} */ 25 | const client = this.client; 26 | 27 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 28 | const staff = await isStaff(interaction.guild, interaction.member.id); 29 | const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); 30 | const getMessage = client.i18n.getLocale(settings.locale); 31 | const commands = client.application.commands.cache 32 | .filter(c => c.type === 1) 33 | .map(c => `> : ${c.description}`) 34 | .join('\n'); 35 | const newCommand = client.application.commands.cache.find(c => c.name === 'new'); 36 | const fields = [ 37 | { 38 | name: getMessage('commands.slash.help.response.commands'), 39 | value: commands, 40 | }, 41 | ]; 42 | 43 | if (staff) { 44 | fields.unshift( 45 | { 46 | inline: true, 47 | name: getMessage('commands.slash.help.response.links.links'), 48 | value: [ 49 | ['commands', 'https://discordtickets.app/features/commands'], 50 | ['docs', 'https://discordtickets.app'], 51 | ['feedback', 'https://lnk.earth/dsctickets-feedback'], 52 | ['support', 'https://lnk.earth/discord'], 53 | ] 54 | .map(([l, url]) => `> [${getMessage('commands.slash.help.response.links.' + l)}](${url})`) 55 | .join('\n'), 56 | }, 57 | { 58 | inline: true, 59 | name: getMessage('commands.slash.help.response.settings'), 60 | value: '> ' + process.env.HTTP_EXTERNAL + '/settings', 61 | }, 62 | ); 63 | } 64 | 65 | interaction.editReply({ 66 | embeds: [ 67 | new ExtendedEmbedBuilder({ 68 | iconURL: interaction.guild.iconURL(), 69 | text: settings.footer, 70 | }) 71 | .setColor(settings.primaryColour) 72 | .setTitle(getMessage('commands.slash.help.title')) 73 | .setDescription(staff 74 | ? `**Discord Tickets v${version} by eartharoid.**` 75 | : getMessage('commands.slash.help.response.description', { command: `` })) 76 | .setFields(fields), 77 | ], 78 | }); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/commands/slash/new.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | const { ApplicationCommandOptionType } = require('discord.js'); 3 | const { useGuild } = require('../../lib/tickets/utils'); 4 | 5 | module.exports = class NewSlashCommand extends SlashCommand { 6 | constructor(client, options) { 7 | const name = 'new'; 8 | super(client, { 9 | ...options, 10 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 11 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 12 | dmPermission: false, 13 | name, 14 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 15 | options: [ 16 | { 17 | autocomplete: true, 18 | name: 'references', 19 | required: false, 20 | type: ApplicationCommandOptionType.String, 21 | }, 22 | ].map(option => { 23 | option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`); 24 | option.description = option.descriptionLocalizations['en-GB']; 25 | option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`); 26 | return option; 27 | }), 28 | }); 29 | } 30 | 31 | /** 32 | * 33 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 34 | */ 35 | async run(interaction) { 36 | await useGuild(this.client, interaction, { referencesTicketId: interaction.options.getString('references', false) }); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/commands/slash/priority.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | const { ApplicationCommandOptionType } = require('discord.js'); 3 | const ExtendedEmbedBuilder = require('../../lib/embed'); 4 | const { logTicketEvent } = require('../../lib/logging'); 5 | const { isStaff } = require('../../lib/users'); 6 | 7 | const getEmoji = priority => { 8 | const emojis = { 9 | 'HIGH': '🔴', 10 | 'MEDIUM': '🟠', 11 | 'LOW': '🟢', // eslint-disable-line sort-keys 12 | }; 13 | return emojis[priority]; 14 | }; 15 | 16 | module.exports = class PrioritySlashCommand extends SlashCommand { 17 | constructor(client, options) { 18 | const name = 'priority'; 19 | super(client, { 20 | ...options, 21 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 22 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 23 | dmPermission: false, 24 | name, 25 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 26 | options: [ 27 | { 28 | choices: ['HIGH', 'MEDIUM', 'LOW'], 29 | name: 'priority', 30 | required: true, 31 | type: ApplicationCommandOptionType.String, 32 | }, 33 | ].map(option => { 34 | option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`); 35 | option.description = option.descriptionLocalizations['en-GB']; 36 | option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`); 37 | if (option.choices) { 38 | option.choices = option.choices.map(choice => ({ 39 | name: client.i18n.getMessage(null, `commands.slash.priority.options.${option.name}.choices.${choice}`), 40 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.priority.options.${option.name}.choices.${choice}`), 41 | value: choice, 42 | })); 43 | } 44 | return option; 45 | }), 46 | }); 47 | } 48 | 49 | /** 50 | * 51 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 52 | */ 53 | async run(interaction) { 54 | /** @type {import("client")} */ 55 | const client = this.client; 56 | 57 | await interaction.deferReply(); 58 | 59 | const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); 60 | const getMessage = client.i18n.getLocale(settings.locale); 61 | const ticket = await client.prisma.ticket.findUnique({ 62 | include: { category: { select: { channelName: true } } }, 63 | where: { id: interaction.channel.id }, 64 | }); 65 | 66 | if (!ticket) { 67 | return await interaction.editReply({ 68 | embeds: [ 69 | new ExtendedEmbedBuilder({ 70 | iconURL: interaction.guild.iconURL(), 71 | text: settings.footer, 72 | }) 73 | .setColor(settings.errorColour) 74 | .setTitle(getMessage('misc.not_ticket.title')) 75 | .setDescription(getMessage('misc.not_ticket.description')), 76 | ], 77 | }); 78 | } 79 | 80 | if (!(await isStaff(interaction.guild, interaction.user.id))) { // if user is not staff 81 | return await interaction.editReply({ 82 | embeds: [ 83 | new ExtendedEmbedBuilder({ 84 | iconURL: interaction.guild.iconURL(), 85 | text: ticket.guild.footer, 86 | }) 87 | .setColor(ticket.guild.errorColour) 88 | .setTitle(getMessage('commands.slash.move.not_staff.title')) 89 | .setDescription(getMessage('commands.slash.move.not_staff.description')), 90 | ], 91 | }); 92 | } 93 | 94 | const priority = interaction.options.getString('priority', true); 95 | let name = interaction.channel.name; 96 | if (ticket.priority) name = name.replace(getEmoji(ticket.priority), getEmoji(priority)); 97 | else name = getEmoji(priority) + name; 98 | await interaction.channel.setName(name); 99 | 100 | // don't reassign ticket because the original is used below 101 | await client.prisma.ticket.update({ 102 | data: { priority }, 103 | where: { id: interaction.channel.id }, 104 | }); 105 | 106 | logTicketEvent(this.client, { 107 | action: 'update', 108 | diff: { 109 | original: { priority: ticket.priority }, 110 | updated: { priority: priority }, 111 | }, 112 | target: { 113 | id: ticket.id, 114 | name: `<#${ticket.id}>`, 115 | }, 116 | userId: interaction.user.id, 117 | }); 118 | 119 | return await interaction.editReply({ 120 | embeds: [ 121 | new ExtendedEmbedBuilder({ 122 | iconURL: interaction.guild.iconURL(), 123 | text: settings.footer, 124 | }) 125 | .setColor(settings.successColour) 126 | .setTitle(getMessage('commands.slash.priority.success.title')) 127 | .setDescription(getMessage('commands.slash.priority.success.description', { priority: getMessage(`commands.slash.priority.options.priority.choices.${priority}`) })), 128 | ], 129 | }); 130 | 131 | } 132 | }; 133 | 134 | module.exports.getEmoji = getEmoji; 135 | -------------------------------------------------------------------------------- /src/commands/slash/release.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class ReleaseSlashCommand extends SlashCommand { 4 | constructor(client, options) { 5 | const name = 'release'; 6 | super(client, { 7 | ...options, 8 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 9 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 10 | dmPermission: false, 11 | name, 12 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 13 | }); 14 | } 15 | 16 | /** 17 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 18 | */ 19 | async run(interaction) { 20 | /** @type {import("client")} */ 21 | const client = this.client; 22 | 23 | await client.tickets.release(interaction); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/commands/slash/remove.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | const { 3 | ApplicationCommandOptionType, MessageFlags, 4 | } = require('discord.js'); 5 | const ExtendedEmbedBuilder = require('../../lib/embed'); 6 | const { isStaff } = require('../../lib/users'); 7 | const { logTicketEvent } = require('../../lib/logging'); 8 | 9 | module.exports = class RemoveSlashCommand extends SlashCommand { 10 | constructor(client, options) { 11 | const name = 'remove'; 12 | super(client, { 13 | ...options, 14 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 15 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 16 | dmPermission: false, 17 | name, 18 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 19 | options: [ 20 | { 21 | name: 'member', 22 | required: true, 23 | type: ApplicationCommandOptionType.User, 24 | }, 25 | { 26 | autocomplete: true, 27 | name: 'ticket', 28 | required: false, 29 | type: ApplicationCommandOptionType.String, 30 | }, 31 | ].map(option => { 32 | option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`); 33 | option.description = option.descriptionLocalizations['en-GB']; 34 | option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`); 35 | return option; 36 | }), 37 | }); 38 | } 39 | 40 | /** 41 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 42 | */ 43 | async run(interaction) { 44 | /** @type {import("client")} */ 45 | const client = this.client; 46 | 47 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 48 | 49 | const ticket = await client.prisma.ticket.findUnique({ 50 | include: { guild: true }, 51 | where: { id: interaction.options.getString('ticket', false) || interaction.channel.id }, 52 | }); 53 | 54 | if (!ticket) { 55 | const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); 56 | const getMessage = client.i18n.getLocale(settings.locale); 57 | return await interaction.editReply({ 58 | embeds: [ 59 | new ExtendedEmbedBuilder({ 60 | iconURL: interaction.guild.iconURL(), 61 | text: settings.footer, 62 | }) 63 | .setColor(settings.errorColour) 64 | .setTitle(getMessage('misc.invalid_ticket.title')) 65 | .setDescription(getMessage('misc.invalid_ticket.description')), 66 | ], 67 | }); 68 | } 69 | 70 | const getMessage = client.i18n.getLocale(ticket.guild.locale); 71 | 72 | if ( 73 | ticket.id !== interaction.channel.id && 74 | ticket.createdById !== interaction.member.id && 75 | !(await isStaff(interaction.guild, interaction.member.id)) 76 | ) { 77 | return await interaction.editReply({ 78 | embeds: [ 79 | new ExtendedEmbedBuilder({ 80 | iconURL: interaction.guild.iconURL(), 81 | text: ticket.guild.footer, 82 | }) 83 | .setColor(ticket.guild.errorColour) 84 | .setTitle(getMessage('commands.slash.remove.not_staff.title')) 85 | .setDescription(getMessage('commands.slash.remove.not_staff.description')), 86 | ], 87 | }); 88 | } 89 | 90 | /** @type {import("discord.js").TextChannel} */ 91 | const ticketChannel = await interaction.guild.channels.fetch(ticket.id); 92 | const member = interaction.options.getMember('member', true); 93 | 94 | if (member.id === client.user.id || member.id === ticket.createdById) { 95 | return await interaction.editReply({ 96 | embeds: [ 97 | new ExtendedEmbedBuilder() 98 | .setColor(ticket.guild.errorColour) 99 | .setTitle('❌'), 100 | ], 101 | }); 102 | } 103 | 104 | await ticketChannel.permissionOverwrites.delete(member, `${interaction.user.tag} removed ${member.user.tag} from the ticket`); 105 | 106 | await ticketChannel.send({ 107 | embeds: [ 108 | new ExtendedEmbedBuilder() 109 | .setColor(ticket.guild.primaryColour) 110 | .setDescription(getMessage('commands.slash.remove.removed', { 111 | by: interaction.member.toString(), 112 | removed: member.toString(), 113 | })), 114 | ], 115 | }); 116 | 117 | await interaction.editReply({ 118 | embeds: [ 119 | new ExtendedEmbedBuilder({ 120 | iconURL: interaction.guild.iconURL(), 121 | text: ticket.guild.footer, 122 | }) 123 | .setColor(ticket.guild.successColour) 124 | .setTitle(getMessage('commands.slash.remove.success.title')) 125 | .setDescription(getMessage('commands.slash.remove.success.description', { 126 | member: member.toString(), 127 | ticket: ticketChannel.toString(), 128 | })), 129 | ], 130 | }); 131 | 132 | logTicketEvent(this.client, { 133 | action: 'update', 134 | diff: { 135 | original: { [getMessage('log.ticket.removed')]: member.user.tag }, 136 | updated: {}, 137 | }, 138 | target: { 139 | id: ticket.id, 140 | name: `<#${ticket.id}>`, 141 | }, 142 | userId: interaction.user.id, 143 | }); 144 | } 145 | }; 146 | -------------------------------------------------------------------------------- /src/commands/slash/tag.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | const { 3 | ApplicationCommandOptionType, MessageFlags, 4 | } = require('discord.js'); 5 | const ExtendedEmbedBuilder = require('../../lib/embed'); 6 | 7 | module.exports = class TagSlashCommand extends SlashCommand { 8 | constructor(client, options) { 9 | const name = 'tag'; 10 | super(client, { 11 | ...options, 12 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 13 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 14 | dmPermission: false, 15 | name, 16 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 17 | options: [ 18 | { 19 | autocomplete: true, 20 | name: 'tag', 21 | required: true, 22 | type: ApplicationCommandOptionType.Integer, 23 | }, 24 | { 25 | name: 'for', 26 | required: false, 27 | type: ApplicationCommandOptionType.User, 28 | }, 29 | ].map(option => { 30 | option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`); 31 | option.description = option.descriptionLocalizations['en-GB']; 32 | option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`); 33 | return option; 34 | }), 35 | }); 36 | } 37 | 38 | /** 39 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 40 | */ 41 | async run(interaction) { 42 | /** @type {import("client")} */ 43 | const client = this.client; 44 | 45 | const user = interaction.options.getUser('for', false); 46 | await interaction.deferReply({ flags: user ? 0 : MessageFlags.Ephemeral }); 47 | const tag = await client.prisma.tag.findUnique({ 48 | include: { guild: true }, 49 | where: { id: interaction.options.getInteger('tag', true) }, 50 | }); 51 | 52 | await interaction.editReply({ 53 | allowedMentions: { users: user ? [user.id]: [] }, 54 | content: user?.toString(), 55 | embeds: [ 56 | new ExtendedEmbedBuilder() 57 | .setColor(tag.guild.primaryColour) 58 | .setDescription(tag.content), 59 | ], 60 | }); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /src/commands/slash/topic.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | const { 3 | ActionRowBuilder, 4 | ModalBuilder, 5 | TextInputBuilder, 6 | TextInputStyle, 7 | MessageFlags, 8 | } = require('discord.js'); 9 | const ExtendedEmbedBuilder = require('../../lib/embed'); 10 | const { quick } = require('../../lib/threads'); 11 | 12 | module.exports = class TopicSlashCommand extends SlashCommand { 13 | constructor(client, options) { 14 | const name = 'topic'; 15 | super(client, { 16 | ...options, 17 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 18 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 19 | dmPermission: false, 20 | name, 21 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 22 | }); 23 | } 24 | 25 | /** 26 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 27 | */ 28 | async run(interaction) { 29 | /** @type {import("client")} */ 30 | const client = this.client; 31 | 32 | const ticket = await client.prisma.ticket.findUnique({ 33 | select: { 34 | category: { select: { name: true } }, 35 | guild: { select: { locale: true } }, 36 | questionAnswers: { include: { question: true } }, 37 | topic: true, 38 | }, 39 | where: { id: interaction.channel.id }, 40 | }); 41 | 42 | if (!ticket) { 43 | const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); 44 | const getMessage = client.i18n.getLocale(settings.locale); 45 | return await interaction.reply({ 46 | embeds: [ 47 | new ExtendedEmbedBuilder({ 48 | iconURL: interaction.guild.iconURL(), 49 | text: settings.footer, 50 | }) 51 | .setColor(settings.errorColour) 52 | .setTitle(getMessage('misc.not_ticket.title')) 53 | .setDescription(getMessage('misc.not_ticket.description')), 54 | ], 55 | flags: MessageFlags.Ephemeral, 56 | }); 57 | } 58 | 59 | const getMessage = client.i18n.getLocale(ticket.guild.locale); 60 | 61 | const field = new TextInputBuilder() 62 | .setCustomId('topic') 63 | .setLabel(getMessage('modals.topic.label')) 64 | .setStyle(TextInputStyle.Paragraph) 65 | .setMaxLength(100) 66 | .setMinLength(5) 67 | .setPlaceholder(getMessage('modals.topic.placeholder')) 68 | .setRequired(true); 69 | 70 | // why can't discord.js accept null or undefined :( 71 | if (ticket.topic) field.setValue(await quick('crypto', w => w.decrypt(ticket.topic))); 72 | 73 | await interaction.showModal( 74 | new ModalBuilder() 75 | .setCustomId(JSON.stringify({ 76 | action: 'topic', 77 | edit: true, 78 | })) 79 | .setTitle(ticket.category.name) 80 | .setComponents( 81 | new ActionRowBuilder() 82 | .setComponents(field), 83 | ), 84 | ); 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /src/commands/slash/transfer.js: -------------------------------------------------------------------------------- 1 | const { SlashCommand } = require('@eartharoid/dbf'); 2 | const { 3 | ApplicationCommandOptionType, 4 | EmbedBuilder, 5 | } = require('discord.js'); 6 | const ExtendedEmbedBuilder = require('../../lib/embed'); 7 | const { quick } = require('../../lib/threads'); 8 | 9 | 10 | module.exports = class TransferSlashCommand extends SlashCommand { 11 | constructor(client, options) { 12 | const name = 'transfer'; 13 | super(client, { 14 | ...options, 15 | description: client.i18n.getMessage(null, `commands.slash.${name}.description`), 16 | descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`), 17 | dmPermission: false, 18 | name, 19 | nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`), 20 | options: [ 21 | { 22 | name: 'member', 23 | required: true, 24 | type: ApplicationCommandOptionType.User, 25 | }, 26 | ].map(option => { 27 | option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`); 28 | option.description = option.descriptionLocalizations['en-GB']; 29 | option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`); 30 | return option; 31 | }), 32 | }); 33 | } 34 | 35 | /** 36 | * @param {import("discord.js").ChatInputCommandInteraction} interaction 37 | */ 38 | async run(interaction) { 39 | /** @type {import("client")} */ 40 | const client = this.client; 41 | 42 | await interaction.deferReply(); 43 | 44 | const member = interaction.options.getMember('member', true); 45 | 46 | const ticket = await client.prisma.ticket.findUnique({ 47 | include: { 48 | category: true, 49 | guild: true, 50 | }, 51 | where: { id: interaction.channel.id }, 52 | }); 53 | 54 | if (!ticket) { 55 | const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); 56 | const getMessage = client.i18n.getLocale(settings.locale); 57 | return await interaction.editReply({ 58 | embeds: [ 59 | new ExtendedEmbedBuilder({ 60 | iconURL: interaction.guild.iconURL(), 61 | text: settings.footer, 62 | }) 63 | .setColor(settings.errorColour) 64 | .setTitle(getMessage('misc.not_ticket.title')) 65 | .setDescription(getMessage('misc.not_ticket.description')), 66 | ], 67 | }); 68 | } 69 | 70 | const from = ticket.createdById; 71 | 72 | const channelName = ticket.category.channelName 73 | .replace(/{+\s?(user)?name\s?}+/gi, member.user.username) 74 | .replace(/{+\s?(nick|display)(name)?\s?}+/gi, member.displayName) 75 | .replace(/{+\s?num(ber)?\s?}+/gi, ticket.number === 1488 ? '1487b' : ticket.number); 76 | 77 | await Promise.all([ 78 | client.prisma.ticket.update({ 79 | data: { 80 | createdBy: { 81 | connectOrCreate: { 82 | create: { id: member.id }, 83 | where: { id: member.id }, 84 | }, 85 | }, 86 | }, 87 | where: { id: interaction.channel.id }, 88 | }), 89 | interaction.channel.edit({ 90 | name: channelName, 91 | topic: `${member.toString()}${ticket.topic && ` | ${await quick('crypto', w => w.decrypt(ticket.topic))}`}`, 92 | }), 93 | interaction.channel.permissionOverwrites.edit( 94 | member, 95 | { 96 | AttachFiles: true, 97 | EmbedLinks: true, 98 | ReadMessageHistory: true, 99 | SendMessages: true, 100 | ViewChannel: true, 101 | }, 102 | ), 103 | ]); 104 | 105 | const $category = client.tickets.$count.categories[ticket.categoryId]; 106 | $category[from]--; 107 | $category[member.id] ||= 0; 108 | $category[member.id]++; 109 | 110 | await interaction.editReply({ 111 | embeds: [ 112 | new EmbedBuilder() 113 | .setColor(ticket.guild.primaryColour) 114 | .setDescription(client.i18n.getMessage(ticket.guild.locale, `commands.slash.transfer.transferred${interaction.member.id !== from ? '_from' : ''}`, { 115 | from: `<@${from}>`, 116 | to: member.toString(), 117 | user: interaction.user.toString(), 118 | })), 119 | 120 | ], 121 | }); 122 | 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /src/env.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | const dotenv = require('dotenv'); 4 | const { colours } = require('leeks.js'); 5 | 6 | const providers = ['mysql', 'postgresql', 'sqlite']; 7 | 8 | // ideally the defaults would be set here too, but the pre-install script may run when `src/` is not available 9 | const env = { 10 | DB_CONNECTION_URL: v => 11 | !!v || 12 | (process.env.DB_PROVIDER === 'sqlite') || 13 | new Error('must be set when "DB_PROVIDER" is not "sqlite"'), 14 | DB_PROVIDER: v => 15 | (!!v && providers.includes(v)) || 16 | new Error(`must be one of: ${providers.map(v => `"${v}"`).join(', ')}`), 17 | DISCORD_SECRET: v => 18 | !!v || 19 | new Error('is required'), 20 | DISCORD_TOKEN: v => 21 | !!v || 22 | new Error('is required'), 23 | ENCRYPTION_KEY: v => 24 | (!!v && v.length >= 48) || 25 | new Error('is required and must be at least 48 characters long; run "npm run keygen" to generate a key'), 26 | HTTP_EXTERNAL: v => { 27 | if (v?.endsWith('/')) { 28 | v = v.slice(0, -1); 29 | process.env.HTTP_EXTERNAL = v; 30 | } 31 | return (!!v && v.startsWith('http')) || 32 | new Error('must be a valid URL without a trailing slash'); 33 | }, 34 | HTTP_HOST: v => 35 | (!!v && !v.startsWith('http')) || 36 | new Error('is required and must be an address, not a URL'), 37 | HTTP_INTERNAL: () => true, // optional 38 | HTTP_PORT: v => 39 | !!v || 40 | new Error('is required'), 41 | HTTP_TRUST_PROXY: () => true, // optional 42 | INVALIDATE_TOKENS: () => true, // optional 43 | OVERRIDE_ARCHIVE: () => true, // optional 44 | PUBLIC_BOT: () => true, // optional 45 | PUBLISH_COMMANDS: () => true, // optional 46 | SUPER: () => true, // optional 47 | }; 48 | 49 | const load = options => { 50 | dotenv.config(options); 51 | Object.entries(env).forEach(([name, validate]) => { 52 | const result = validate(process.env[name]); // `true` for pass, or `Error` for fail 53 | if (result instanceof Error) { 54 | console.log('\x07' + colours.redBright(`Error: The "${name}" environment variable ${result.message}.`)); 55 | process.exit(1); 56 | } 57 | }); 58 | }; 59 | 60 | module.exports = { 61 | env, 62 | load, 63 | }; 64 | -------------------------------------------------------------------------------- /src/i18n/bg.yml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/i18n/da.yml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/i18n/el.yml: -------------------------------------------------------------------------------- 1 | buttons: 2 | accept_close_request: 3 | emoji: ✅ 4 | -------------------------------------------------------------------------------- /src/i18n/en-US.yml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/i18n/fi.yml: -------------------------------------------------------------------------------- 1 | commands: 2 | message: 3 | pin: 4 | not_ticket: 5 | title: ❌ Tämä ei ole tiketti kanava 6 | description: Voit kiinnittää viestejä vain tiketeissä. 7 | name: Kiinnitä viesti 8 | not_pinnable: 9 | title: ❌ Virhe 10 | pinned: 11 | description: Viesti on kiinnitetty. 12 | title: ✅ Kiinnitetty viesti 13 | create: 14 | name: Luo tiketti viestistä 15 | slash: 16 | add: 17 | description: Lisää jäsen tikettiin 18 | name: lisää 19 | options: 20 | ticket: 21 | name: tiketti 22 | description: Tiketti johon jäsen lisätään 23 | member: 24 | name: jäsen 25 | description: Jäsen joka lisätään tikettiin 26 | not_staff: 27 | description: Vain henkilökunta voi lisätä toisia tiketteihin. 28 | title: ❌ Virhe 29 | success: 30 | title: ✅ Lisätty 31 | description: '{member} lisättiin {ticket}' 32 | added: ➡️ {by} lisäsi {added}. 33 | force-close: 34 | not_staff: 35 | title: ❌ Virhe 36 | no_tickets: 37 | title: ❌ Ei tikettejä 38 | options: 39 | category: 40 | name: kategoria 41 | reason: 42 | name: syy 43 | ticket: 44 | name: tiketti 45 | time: 46 | name: aika 47 | confirm_multiple: 48 | title: ❓ Oletko varma? 49 | help: 50 | description: Näyttää apua valikon 51 | response: 52 | links: 53 | feedback: Palaute 54 | links: Hyödyllisiä linkkejä 55 | support: Tuki 56 | commands: Koko komento lista 57 | settings: Botin asetukset 58 | commands: Komennot 59 | title: Apua 60 | name: apua 61 | move: 62 | name: siirrä 63 | options: 64 | category: 65 | name: kategoria 66 | description: Siirrä tiketti toiseen kategoriaan 67 | remove: 68 | options: 69 | member: 70 | description: Jäsen joka poistetaan tiketistä 71 | name: jäsen 72 | ticket: 73 | name: tiketti 74 | name: poista 75 | not_staff: 76 | title: ❌ Virhe 77 | success: 78 | title: ✅ Poistettu 79 | description: Poista jäsen tiketistä 80 | close: 81 | invalid_time: 82 | title: ❌ Virheellinen 83 | name: sulje 84 | options: 85 | reason: 86 | name: syy 87 | description: Syy tiketin(t) sulkemiseen 88 | description: Pyydä tiketin sulkemista 89 | claim: 90 | description: Varaa tiketti 91 | name: varaa 92 | not_staff: 93 | title: ❌ Virhe 94 | new: 95 | description: Luo uusi tiketti 96 | name: uusi 97 | priority: 98 | options: 99 | priority: 100 | choices: 101 | LOW: 🟢 Hidas 102 | HIGH: 🔴 Kiireellinen 103 | MEDIUM: 🟠 Normaali 104 | topic: 105 | name: aihe 106 | tickets: 107 | not_staff: 108 | title: ❌ Virhe 109 | response: 110 | title: 111 | other: '{displayName} tiketit' 112 | own: Sinun tiketit 113 | fields: 114 | closed: 115 | name: Suljetut tiketit 116 | name: tiketit 117 | options: 118 | member: 119 | name: jäsen 120 | transfer: 121 | options: 122 | member: 123 | name: jäsen 124 | name: siirrä 125 | transcript: 126 | options: 127 | ticket: 128 | name: tiketti 129 | release: 130 | name: julkaise 131 | buttons: 132 | close: 133 | text: Sulje 134 | emoji: ✖️ 135 | edit: 136 | text: Muokkaa 137 | emoji: ✏️ 138 | reject_close_request: 139 | text: Hylkää 140 | emoji: ✖️ 141 | unclaim: 142 | text: Julkaise 143 | emoji: ♻️ 144 | accept_close_request: 145 | text: Hyväksy 146 | emoji: ✅ 147 | cancel: 148 | text: Peruuta 149 | emoji: ✖️ 150 | confirm_open: 151 | text: Luo tiketti 152 | emoji: ✅ 153 | create: 154 | emoji: 🎫 155 | text: Luo tiketti 156 | claim: 157 | text: Varaa 158 | emoji: 🙌 159 | ticket: 160 | opening_message: 161 | fields: 162 | topic: Aihe 163 | references_ticket: 164 | fields: 165 | topic: Aihe 166 | number: Numero 167 | feedback: Kiitos palautteestasi. 168 | -------------------------------------------------------------------------------- /src/i18n/ja.yml: -------------------------------------------------------------------------------- 1 | buttons: 2 | accept_close_request: 3 | text: 承諾 4 | emoji: ✅ 5 | cancel: 6 | emoji: ➖ 7 | text: キャンセル 8 | claim: 9 | text: 担当する 10 | emoji: 🙌 11 | close: 12 | emoji: ✖️ 13 | text: クローズ 14 | confirm_open: 15 | emoji: ✅ 16 | text: チケットを作成 17 | edit: 18 | emoji: ✏️ 19 | text: 編集 20 | reject_close_request: 21 | emoji: ✖️ 22 | text: 拒否 23 | create: 24 | text: チケットを作成 25 | emoji: 🎫 26 | unclaim: 27 | text: 担当解除 28 | emoji: ♻️ 29 | transcript: 30 | emoji: 📄 31 | text: 対応履歴 32 | commands: 33 | message: 34 | pin: 35 | not_pinnable: 36 | description: "このメッセージはピン留め出来ません。\n管理者にBotの権限を確認する様依頼してください。\n" 37 | name: メッセージをピン留め 38 | create: 39 | name: メッセージからチケットを作成 40 | -------------------------------------------------------------------------------- /src/i18n/no.yml: -------------------------------------------------------------------------------- 1 | buttons: 2 | accept_close_request: 3 | text: Aksepter 4 | cancel: 5 | text: Avbryt 6 | claim: 7 | text: Ta over 8 | close: 9 | text: Lukk 10 | confirm_open: 11 | text: Lag ticket 12 | edit: 13 | text: Endre 14 | reject_close_request: 15 | text: Avslå 16 | unclaim: 17 | text: Avbyt overtak 18 | create: 19 | text: Lag en ticket 20 | commands: 21 | message: 22 | create: 23 | name: Opprett ticket fra melding 24 | pin: 25 | name: Fest melding 26 | not_pinnable: 27 | description: "Denne meldingen kan ikke festes.\nVennligst be en administrator 28 | om å sjekke botens tillatelser.\n" 29 | not_ticket: 30 | description: Du kan bare feste meldinger i tickets. 31 | title: ❌ Dette er ikke en ticket kanal 32 | pinned: 33 | description: Meldingen er festet. 34 | title: ✅ Festet melding 35 | slash: 36 | add: 37 | options: 38 | ticket: 39 | description: Ticket å legge medlemmet til 40 | name: ticket 41 | member: 42 | description: Medlemmet å legge til ticketen 43 | name: medlem 44 | success: 45 | description: '{member} er lagt til {ticket}.' 46 | title: ✅ Addet 47 | added: ➡️ {added} er lagt til av {by}. 48 | description: Legg til et medlem på en ticket 49 | not_staff: 50 | description: Bare staff kan legge til medlemmer til andres tickets. 51 | claim: 52 | description: Overta en ticket 53 | name: overta 54 | not_staff: 55 | description: Kun staff kan overta tickets. 56 | -------------------------------------------------------------------------------- /src/i18n/sv-SE.yml: -------------------------------------------------------------------------------- 1 | buttons: 2 | accept_close_request: 3 | text: Acceptera 4 | emoji: ✅ 5 | cancel: 6 | emoji: ➖ 7 | text: Avbryt 8 | claim: 9 | emoji: 🙌 10 | text: Gör anspråk på 11 | confirm_open: 12 | emoji: ✅ 13 | text: Skapa ett ärende 14 | reject_close_request: 15 | emoji: ✖️ 16 | text: Neka 17 | unclaim: 18 | emoji: ♻️ 19 | edit: 20 | text: Redigera 21 | emoji: ✏️ 22 | close: 23 | emoji: ✖️ 24 | text: Stäng 25 | create: 26 | emoji: 🎫 27 | text: Skapa ett ärende 28 | commands: 29 | slash: 30 | add: 31 | name: läggtill 32 | options: 33 | member: 34 | name: medlem 35 | ticket: 36 | name: ärende 37 | added: ➡️ {added}har lagts till av {by}. 38 | not_staff: 39 | title: ❌ Fel 40 | success: 41 | title: ✅ Lades till 42 | description: '{member} har lagts till i {ticket}.' 43 | claim: 44 | not_staff: 45 | title: ❌ Fel 46 | description: Endast personal-medlemmar kan göra anspråk på ärenden. 47 | description: Gör anspråk på ett ärende 48 | name: claim 49 | close: 50 | invalid_time: 51 | title: ❌ Ogiltig 52 | description: '`{input}` är inte ett giltigt tidsformat.' 53 | options: 54 | reason: 55 | name: anledning 56 | description: Anledningen till att ärendet stängs 57 | name: stäng 58 | description: Begär stängning av ett ärende 59 | force-close: 60 | closed_one: 61 | description: Kanalen kommer att tas bort om några sekunder. 62 | title: ✅ Ärendet stängdes 63 | options: 64 | reason: 65 | name: anledning 66 | category: 67 | name: kategori 68 | time: 69 | name: tid 70 | confirm_multiple: 71 | title: ❓ Är du säker? 72 | confirmed_multiple: 73 | description: Kanalerna kommer att tas bort om några sekunder. 74 | not_staff: 75 | title: ❌ Fel 76 | help: 77 | response: 78 | links: 79 | feedback: Återgivning 80 | links: Användbara länkar 81 | docs: Dokumentation 82 | support: Hjälp 83 | settings: Bot-inställningar 84 | commands: Kommandon 85 | title: Hjälp 86 | description: Visa hjälp-menyn 87 | name: hjälp 88 | move: 89 | name: flytta 90 | not_staff: 91 | title: ❌ Fel 92 | options: 93 | category: 94 | name: kategori 95 | priority: 96 | options: 97 | priority: 98 | choices: 99 | HIGH: 🔴 Hög 100 | LOW: 🟢 Låg 101 | MEDIUM: 🟠 Mellan 102 | name: prioritet 103 | name: prioritet 104 | not_staff: 105 | title: ❌ Fel 106 | success: 107 | title: ✅ Prioritetens fastställdes 108 | remove: 109 | not_staff: 110 | title: ❌ Fel 111 | name: ta-bort 112 | options: 113 | member: 114 | name: medlem 115 | removed: ⬅️ {removed} har tagits bort av {by}. 116 | tag: 117 | name: tagg 118 | options: 119 | tag: 120 | description: Namnet på taggen som ska användas 121 | name: tagg 122 | for: 123 | description: Användaren som taggen ska riktas mot 124 | description: Använd en tagg 125 | tickets: 126 | options: 127 | member: 128 | name: medlem 129 | not_staff: 130 | title: ❌ Fel 131 | transcript: 132 | options: 133 | member: 134 | name: medlem 135 | not_staff: 136 | title: ❌ Fel 137 | new: 138 | options: 139 | references: 140 | name: referenser 141 | name: nytt 142 | topic: 143 | name: ämne 144 | transfer: 145 | name: överför 146 | options: 147 | member: 148 | description: Medlemmen att överföra äganderätten till 149 | name: medlem 150 | message: 151 | pin: 152 | pinned: 153 | title: ✅ Nålade fast meddelandet 154 | name: Nåla fast meddelande 155 | not_pinnable: 156 | description: "Det här meddelandet kan inte nålas fast.\nVänligen be en administratör 157 | att kontrollera behörigheterna för boten.\n" 158 | title: ❌ Fel 159 | user: 160 | create: 161 | not_staff: 162 | title: ❌ Fel 163 | log: 164 | admin: 165 | description: 166 | joined: '{user} {verb} {targetType}' 167 | target: 168 | settings: inställningarna 169 | tag: en tagg 170 | category: en kategori 171 | panel: en panel 172 | question: en fråga 173 | title: 174 | joined: '{targetType} {verb}' 175 | target: 176 | category: Kategori 177 | settings: Inställningar 178 | panel: Panel 179 | question: Fråga 180 | tag: Tagg 181 | verb: 182 | update: uppdaterades 183 | create: skapades 184 | delete: togs bort 185 | changes: Ändringar 186 | message: 187 | description: '{user} {verb} ett meddelande' 188 | message: Meddelande 189 | title: Meddelande {verb} 190 | verb: 191 | delete: togs bort 192 | misc: 193 | cooldown: 194 | title: ❌ Vänligen vänta 195 | menus: 196 | guild: 197 | placeholder: Välj en server 198 | dm: 199 | closed: 200 | fields: 201 | reason: Stängd på grund av 202 | response: Svarstid 203 | topic: Ämne 204 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Discord Tickets 3 | * Copyright (C) 2022 Isaac Saunders 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * @name discord-tickets/bot 19 | * @description An open-source Discord bot for ticket management 20 | * @copyright 2022 Isaac Saunders 21 | * @license GNU-GPLv3 22 | */ 23 | 24 | /* eslint-disable no-console */ 25 | 26 | const pkg = require('../package.json'); 27 | const banner = require('./lib/banner'); 28 | banner(pkg.version); // print big title 29 | 30 | const semver = require('semver'); 31 | const { colours } = require('leeks.js'); 32 | const path = require('path'); 33 | 34 | // check node version 35 | if (!semver.satisfies(process.versions.node, pkg.engines.node)) { 36 | console.log('\x07' + colours.redBright(`Error: Your current Node.js version, ${process.versions.node}, does not meet the requirement "${pkg.engines.node}". Please update to version ${semver.minVersion(pkg.engines.node).version} or higher.`)); 37 | process.exit(1); 38 | } 39 | 40 | // check cwd 41 | const base_dir = path.resolve(path.join(__dirname, '../')); 42 | const cwd = path.resolve(process.cwd()); 43 | if (base_dir !== cwd) { 44 | console.log('\x07' + colours.yellowBright('Warning: The current working directory is not the same as the base directory.')); 45 | if (!process.env.DOCKER) { 46 | console.log(colours.yellowBright('This may result in unexpected behaviour, particularly with missing environment variables.')); 47 | } 48 | console.log(' Base directory: ' + colours.gray(base_dir)); 49 | console.log(' Current directory: ' + colours.gray(cwd)); 50 | console.log(colours.blueBright(' Learn more at https://lnk.earth/dt-cwd.')); 51 | } 52 | 53 | process.env.NODE_ENV ??= 'production'; // make sure NODE_ENV is set 54 | require('./env').load(); // load and check environment variables 55 | 56 | const fs = require('fs'); 57 | const YAML = require('yaml'); 58 | const logger = require('./lib/logger'); 59 | 60 | // create a Logger using the default config 61 | // and set listeners as early as possible. 62 | let config = YAML.parse(fs.readFileSync(path.join(__dirname, 'user/config.yml'), 'utf8')); 63 | let log = logger(config); 64 | 65 | function exit(signal) { 66 | log.notice(`Received ${signal}`); 67 | client.destroy(); 68 | process.exit(0); 69 | } 70 | 71 | process.on('SIGTERM', () => exit('SIGTERM')); 72 | 73 | process.on('SIGINT', () => exit('SIGINT')); 74 | 75 | process.on('uncaughtException', (error, origin) => { 76 | log.notice(`Discord Tickets v${pkg.version} on Node.js ${process.version} (${process.platform})`); 77 | log.warn(origin === 'uncaughtException' ? 'Uncaught exception' : 'Unhandled promise rejection' + ` (${error.name})`); 78 | log.error(error); 79 | }); 80 | 81 | process.on('warning', warning => log.warn(warning.stack || warning)); 82 | 83 | const Client = require('./client'); 84 | const http = require('./http'); 85 | 86 | // the `user` directory may or may not exist depending on if sqlite is being used. 87 | // copy any files that don't already exist 88 | fs.cpSync(path.join(__dirname, 'user'), './user', { 89 | force: false, 90 | recursive: true, 91 | }); 92 | 93 | // initialise the framework and client, 94 | // which also loads the custom config and creates a new Logger. 95 | const client = new Client(config, log); 96 | 97 | // allow any config changes to affect the above listeners 98 | // as long as these `client` properties are not reassigned. 99 | config = client.config; 100 | log = client.log; 101 | 102 | // start the bot and then the web server 103 | client.login().then(() => { 104 | http(client); 105 | }); 106 | -------------------------------------------------------------------------------- /src/lib/banner.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { colours } = require('leeks.js'); 3 | const figlet = require('figlet'); 4 | const link = require('terminal-link'); 5 | 6 | module.exports = version => { 7 | figlet 8 | .textSync('Discord', { font: 'Banner3' }) 9 | .split('\n') 10 | .forEach(line => console.log(colours.cyan(line))); 11 | console.log(''); 12 | figlet 13 | .textSync('Tickets', { font: 'Banner3' }) 14 | .split('\n') 15 | .forEach(line => console.log(colours.cyan(line))); 16 | console.log(''); 17 | console.log(colours.cyanBright(`${link('Discord Tickets', 'https://discordtickets.app')} bot v${version} by eartharoid`)); 18 | console.log(colours.cyanBright('Sponsor this project at https://discordtickets.app/sponsor')); 19 | console.log('\n'); 20 | }; 21 | -------------------------------------------------------------------------------- /src/lib/embed.js: -------------------------------------------------------------------------------- 1 | const { EmbedBuilder } = require('discord.js'); 2 | 3 | module.exports = class ExtendedEmbedBuilder extends EmbedBuilder { 4 | constructor(footer, opts) { 5 | super(opts); 6 | if (footer && footer.text) this.setFooter(footer); 7 | } 8 | }; -------------------------------------------------------------------------------- /src/lib/error.js: -------------------------------------------------------------------------------- 1 | const { getSUID } = require('./logging'); 2 | const { 3 | EmbedBuilder, 4 | codeBlock, 5 | } = require('discord.js'); 6 | 7 | /** 8 | * 9 | * @param {Object} event 10 | * @param {import("discord.js").Interaction<"cached">} event.interaction 11 | * @param {Error} event.error 12 | * @returns 13 | */ 14 | module.exports.handleInteractionError = async event => { 15 | const { 16 | interaction, 17 | error, 18 | } = event; 19 | const { client } = interaction; 20 | 21 | const ref = getSUID(); 22 | client.log.error.buttons(ref); 23 | 24 | if (interaction.isAnySelectMenu()) { 25 | client.log.error.menus(`"${event.menu.id}" menu execution error:`, error); 26 | } else if (interaction.isButton()) { 27 | client.log.error.buttons(`"${event.button.id}" button execution error:`, error); 28 | } else if (interaction.isModalSubmit()) { 29 | client.log.error.modals(`"${event.modal.id}" modal execution error:`, error); 30 | } else if (interaction.isCommand()) { 31 | client.log.error.commands(`"${event.command.name}" command execution error:`, error); 32 | } 33 | 34 | 35 | let locale = null; 36 | if (interaction.guild) { 37 | locale = (await client.prisma.guild.findUnique({ 38 | select: { locale: true }, 39 | where: { id: interaction.guild.id }, 40 | })).locale; 41 | } 42 | const getMessage = client.i18n.getLocale(locale); 43 | 44 | const data = { 45 | components: [], 46 | embeds: [], 47 | }; 48 | 49 | if (error.code === 10011 || (error.code === 'Invalid Type' && /Role/.test(error.message))) { 50 | data.embeds.push( 51 | new EmbedBuilder() 52 | .setColor('Orange') 53 | .setTitle(getMessage('misc.role_error.title')) 54 | .setDescription(getMessage('misc.role_error.description')) 55 | .addFields([ 56 | { 57 | name: getMessage('misc.role_error.fields.for_admins.name'), 58 | value: getMessage('misc.role_error.fields.for_admins.value', { url: 'https://discordtickets.app/self-hosting/troubleshooting/#invalid-user-or-role' }), 59 | }, 60 | ]), 61 | ); 62 | } else if (/Missing (Access|Permissions)/.test(error.message)) { 63 | data.embeds.push( 64 | new EmbedBuilder() 65 | .setColor('Orange') 66 | .setTitle(getMessage('misc.permissions_error.title')) 67 | .setDescription(getMessage('misc.permissions_error.description')) 68 | .addFields([ 69 | { 70 | name: getMessage('misc.permissions_error.fields.for_admins.name'), 71 | value: getMessage('misc.permissions_error.fields.for_admins.value', { url: 'https://discordtickets.app/self-hosting/troubleshooting/#missing-permissions' }), 72 | }, 73 | ]), 74 | ); 75 | } else { 76 | data.embeds.push( 77 | new EmbedBuilder() 78 | .setColor('Orange') 79 | .setTitle(getMessage('misc.error.title')) 80 | .setDescription(getMessage('misc.error.description')) 81 | .addFields([ 82 | { 83 | name: getMessage('misc.error.fields.identifier'), 84 | value: codeBlock(ref), 85 | }, 86 | ]), 87 | ); 88 | } 89 | 90 | 91 | 92 | return interaction.reply(data).catch(() => interaction.editReply(data)); 93 | }; 94 | -------------------------------------------------------------------------------- /src/lib/logger.js: -------------------------------------------------------------------------------- 1 | const { 2 | ConsoleTransport, 3 | FileTransport, 4 | Logger, 5 | } = require('leekslazylogger'); 6 | const DTF = require('@eartharoid/dtf'); 7 | const { short } = require('leeks.js'); 8 | const { format } = require('util'); 9 | 10 | const dtf = new DTF('en-GB'); 11 | const colours = { 12 | critical: ['&!4&f', '&!4&f'], 13 | debug: ['&1', '&9'], 14 | error: ['&4', '&c'], 15 | info: ['&3', '&b'], 16 | notice: ['&!6&0', '&!6&0'], 17 | success: ['&2', '&a'], 18 | verbose: ['&7', '&f'], 19 | warn: ['&6', '&e'], 20 | }; 21 | 22 | module.exports = config => { 23 | const transports = [ 24 | new ConsoleTransport({ 25 | format: log => { 26 | const timestamp = dtf.fill('YYYY-MM-DD HH:mm:ss', log.timestamp); 27 | const colour = colours[log.level.name]; 28 | return format( 29 | short(`&f&!7 %s &r ${colour[0]}[%s]&r %s${colour[1]}%s&r`), 30 | timestamp, 31 | log.level.name.toUpperCase(), 32 | log.namespace ? short(`&d(${log.namespace.toUpperCase()})&r `) : '', 33 | log.content, 34 | ); 35 | }, 36 | level: config.logs.level, 37 | }), 38 | ]; 39 | 40 | if (config.logs.files.enabled) { 41 | transports.push( 42 | new FileTransport({ 43 | clean_directory: config.logs.files.keepFor, 44 | directory: config.logs.files.directory, 45 | format: '[{timestamp}] [{LEVEL}] ({NAMESPACE}) @{file}:{line}:{column} {content}', 46 | level: config.logs.level, 47 | name: 'Discord Tickets by eartharoid', 48 | timestamp: 'YYYY-MM-DD HH:mm:ss', 49 | }), 50 | ); 51 | } 52 | 53 | return new Logger({ 54 | namespaces: [ 55 | 'autocomplete', 56 | 'buttons', 57 | 'commands', 58 | 'http', 59 | 'listeners', 60 | 'menus', 61 | 'modals', 62 | 'prisma', 63 | 'settings', 64 | 'tickets', 65 | ], 66 | transports, 67 | }); 68 | }; 69 | 70 | -------------------------------------------------------------------------------- /src/lib/middleware/prisma-sqlite.js: -------------------------------------------------------------------------------- 1 | const jsonFields = [ 2 | 'pingRoles', 3 | 'requiredRoles', 4 | 'staffRoles', 5 | 'autoTag', 6 | 'blocklist', 7 | 'workingHours', 8 | 'options', 9 | 'pinnedMessageIds', 10 | ]; 11 | 12 | const traverse = (obj, action) => { 13 | for (let prop in obj) { 14 | if (prop === 'createMany') { 15 | obj.create = obj[prop].data; 16 | delete obj[prop]; 17 | prop = 'create'; 18 | traverse(obj[prop], action); 19 | } else if (jsonFields.includes(prop) && obj[prop] !== null && obj[prop] !== undefined) { 20 | if (action === 'SERIALISE') { 21 | if (typeof obj[prop] === 'string') { 22 | try { 23 | JSON.parse(obj[prop]); 24 | } catch { 25 | obj[prop] = JSON.stringify(obj[prop]); 26 | } 27 | } else { 28 | obj[prop] = JSON.stringify(obj[prop]); 29 | } 30 | } else if (action === 'PARSE' && typeof obj[prop] === 'string') { 31 | obj[prop] = JSON.parse(obj[prop]); 32 | } 33 | } else if (typeof obj[prop] === 'object' && obj[prop] !== null && obj[prop] !== undefined) { 34 | traverse(obj[prop], action); 35 | } 36 | } 37 | return obj; 38 | }; 39 | 40 | module.exports = async (params, next) => { 41 | if (params.args?.create) params.args.create = traverse(params.args.create, 'SERIALISE'); 42 | if (params.args?.data) params.args.data = traverse(params.args.data, 'SERIALISE'); 43 | if (params.args?.update) params.args.update = traverse(params.args.update, 'SERIALISE'); 44 | let result = await next(params); 45 | if (result) result = traverse(result, 'PARSE'); 46 | return result; 47 | }; -------------------------------------------------------------------------------- /src/lib/misc.js: -------------------------------------------------------------------------------- 1 | const { createHash } = require('crypto'); 2 | module.exports.md5 = str => createHash('md5').update(str).digest('hex'); 3 | 4 | module.exports.iconURL = guildLike => guildLike.icon 5 | ? guildLike.client.rest.cdn.icon(guildLike.id, guildLike.icon) 6 | : `https://api.dicebear.com/8.x/initials/png?seed=${encodeURIComponent(guildLike.name)}&size=96&backgroundType=gradientLinear&fontWeight=600`; 7 | -------------------------------------------------------------------------------- /src/lib/stats.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | const { version } = require('../../package.json'); 4 | const { md5 } = require('./misc'); 5 | const { 6 | quick, 7 | relativePool, 8 | } = require('./threads'); 9 | 10 | const getAverageTimes = closedTickets => quick('stats', async w => ({ 11 | avgResolutionTime: await w.getAvgResolutionTime(closedTickets), 12 | avgResponseTime: await w.getAvgResponseTime(closedTickets), 13 | })); 14 | 15 | /** 16 | * Report stats to Houston 17 | * @param {import("../client")} client 18 | */ 19 | async function sendToHouston(client) { 20 | const guilds = await client.prisma.guild.findMany({ 21 | include: { 22 | categories: { include: { _count: { select: { questions: true } } } }, 23 | tags: true, 24 | tickets: { 25 | select: { 26 | closedAt: true, 27 | createdAt: true, 28 | firstResponseAt: true, 29 | }, 30 | }, 31 | }, 32 | }); 33 | const users = await client.prisma.user.aggregate({ 34 | _count: true, 35 | _sum: { messageCount: true }, 36 | }); 37 | const messages = users._sum.messageCount; 38 | const stats = { 39 | activated_users: users._count, 40 | arch: process.arch, 41 | database: process.env.DB_PROVIDER, 42 | guilds: await relativePool(.25, 'stats', pool => Promise.all( 43 | guilds 44 | .filter(guild => client.guilds.cache.has(guild.id)) 45 | .map(guild => { 46 | guild.members = client.guilds.cache.get(guild.id).memberCount; 47 | return pool.queue(w => w.aggregateGuildForHouston(guild, messages)); 48 | }), 49 | )), 50 | id: md5(client.user.id), 51 | node: process.version, 52 | os: process.platform, 53 | version, 54 | }; 55 | const delta = guilds.length - stats.guilds.length; 56 | 57 | if (delta !== 0) { 58 | client.log.warn('%d guilds are not cached and were excluded from the stats report', delta); 59 | } 60 | 61 | try { 62 | client.log.verbose('Reporting to Houston:', stats); 63 | const res = await fetch('https://stats.discordtickets.app/api/v4/houston', { 64 | body: JSON.stringify(stats), 65 | headers: { 'content-type': 'application/json' }, 66 | method: 'POST', 67 | }); 68 | if (!res.ok) throw res; 69 | client.log.success('Posted client stats'); 70 | client.log.debug(res); 71 | } catch (res) { 72 | client.log.warn('The following error is not important and can be safely ignored'); 73 | try { 74 | const json = await res.json(); 75 | client.log.error('An error occurred whilst posting stats:', json); 76 | } catch (error) { 77 | client.log.error('An error occurred whilst posting stats and the response couldn\'t be parsed:', error.message); 78 | } 79 | client.log.debug(res); 80 | } 81 | }; 82 | 83 | module.exports = { 84 | getAverageTimes, 85 | sendToHouston, 86 | }; 87 | -------------------------------------------------------------------------------- /src/lib/sync.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {import("client")} client 3 | */ 4 | module.exports = async client => { 5 | // load ticket numbers 6 | const guilds = await client.prisma.guild.findMany({ select: { id: true } }); 7 | for (const guild of guilds) { 8 | const { _max: { number: max } } = await client.prisma.ticket.aggregate({ 9 | _max: { number: true }, 10 | where: { guildId: guild.id }, 11 | }); 12 | client.tickets.$numbers[guild.id] = max ?? 0; 13 | } 14 | 15 | // load total number of open tickets 16 | const categories = await client.prisma.category.findMany({ 17 | select: { 18 | cooldown: true, 19 | id: true, 20 | tickets: { 21 | select: { 22 | createdById: true, 23 | guildId: true, 24 | id: true, 25 | }, 26 | where: { open: true }, 27 | }, 28 | }, 29 | }); 30 | let deleted = 0; 31 | let ticketCount = 0; 32 | let cooldowns = 0; 33 | for (const category of categories) { 34 | ticketCount += category.tickets.length; 35 | client.tickets.$count.categories[category.id] = { total: category.tickets.length }; 36 | for (const ticket of category.tickets) { 37 | if (client.tickets.$count.categories[category.id][ticket.createdById]) { 38 | client.tickets.$count.categories[category.id][ticket.createdById]++; 39 | } else { 40 | client.tickets.$count.categories[category.id][ticket.createdById] = 1; 41 | } 42 | /** @type {import("discord.js").Guild} */ 43 | const guild = client.guilds.cache.get(ticket.guildId); 44 | if (guild && guild.available && !client.channels.cache.has(ticket.id)) { 45 | deleted += 0; 46 | try { 47 | await client.tickets.finallyClose(ticket.id, { reason: 'channel deleted' }); 48 | } catch (error) { 49 | client.log.warn('Failed to close ticket', ticket.id); 50 | client.log.error(error); 51 | } 52 | } 53 | 54 | } 55 | if (category.cooldown) { 56 | const recent = await client.prisma.ticket.findMany({ 57 | orderBy: { createdAt: 'asc' }, 58 | select: { 59 | createdAt: true, 60 | createdById: true, 61 | }, 62 | where: { 63 | categoryId: category.id, 64 | createdAt: { gt: new Date(Date.now() - category.cooldown) }, 65 | }, 66 | }); 67 | cooldowns += recent.length; 68 | for (const ticket of recent) { 69 | const cacheKey = `cooldowns/category-member:${category.id}-${ticket.createdById}`; 70 | const expiresAt = ticket.createdAt.getTime() + category.cooldown; 71 | const TTL = expiresAt - Date.now(); 72 | await client.keyv.set(cacheKey, expiresAt, TTL); 73 | } 74 | } 75 | } 76 | // const ticketCount = categories.reduce((total, category) => total + category.tickets.length, 0); 77 | client.log.info(`Cached ticket count of ${categories.length} categories (${ticketCount} open tickets)`); 78 | client.log.info(`Loaded ${cooldowns} active cooldowns`); 79 | client.log.info(`Closed ${deleted} deleted tickets`); 80 | return true; 81 | }; 82 | -------------------------------------------------------------------------------- /src/lib/threads.js: -------------------------------------------------------------------------------- 1 | const { 2 | spawn, 3 | Pool, 4 | Thread, 5 | Worker, 6 | } = require('threads'); 7 | const { cpus } = require('node:os'); 8 | 9 | /** 10 | * Use a thread pool of a fixed size 11 | * @param {string} name name of file in workers directory 12 | * @param {function} fun async function 13 | * @param {import('threads/dist/master/pool').PoolOptions} options 14 | * @returns {Promise} 15 | */ 16 | async function pool(name, fun, options) { 17 | const pool = Pool(() => spawn(new Worker(`./workers/${name}.js`)), options); 18 | try { 19 | return await fun(pool); 20 | } finally { 21 | pool.settled().then(() => pool.terminate()); 22 | } 23 | }; 24 | 25 | /** 26 | * Spawn one thread, do something, and terminate it 27 | * @param {string} name name of file in workers directory 28 | * @param {function} fun async function 29 | * @returns {Promise} 48 | */ 49 | function relativePool(fraction, name, fun, options) { 50 | // ! ceiL: at least 1 51 | const size = Math.ceil(fraction * cpus().length); 52 | return pool(name, fun, { 53 | ...options, 54 | size, 55 | }); 56 | } 57 | 58 | /** 59 | * Spawn one thread 60 | * @param {string} name name of file in workers directory 61 | * @returns {Promise<{terminate: function}>} 62 | */ 63 | async function reusable(name) { 64 | const thread = await spawn(new Worker(`./workers/${name}.js`)); 65 | thread.terminate = () => Thread.terminate(thread); 66 | return thread; 67 | }; 68 | 69 | module.exports = { 70 | pool, 71 | quick, 72 | relativePool, 73 | reusable, 74 | }; 75 | -------------------------------------------------------------------------------- /src/lib/tickets/archiver.js: -------------------------------------------------------------------------------- 1 | const { reusable } = require('../threads'); 2 | 3 | 4 | /** 5 | * Returns highest (roles.highest) hoisted role, or everyone 6 | * @param {import("discord.js").GuildMember} member 7 | * @returns {import("discord.js").Role} 8 | */ 9 | const hoistedRole = member => member.roles.hoist || member.guild.roles.everyone; 10 | 11 | module.exports = class TicketArchiver { 12 | constructor(client) { 13 | /** @type {import("client")} */ 14 | this.client = client; 15 | } 16 | 17 | /** Add or update a message 18 | * @param {string} ticketId 19 | * @param {import("discord.js").Message} message 20 | * @param {boolean?} external 21 | * @returns {import("@prisma/client").ArchivedMessage|boolean} 22 | */ 23 | async saveMessage(ticketId, message, external = false) { 24 | if (process.env.OVERRIDE_ARCHIVE === 'false') return false; 25 | 26 | if (!message.member) { 27 | try { 28 | message.member = await message.guild.members.fetch(message.author.id); 29 | } catch { 30 | this.client.log.verbose('Failed to fetch member %s of %s', message.author.id, message.guild.id); 31 | } 32 | } 33 | 34 | const channels = new Set(message.mentions.channels.values()); 35 | const members = new Set(message.mentions.members.values()); 36 | const roles = new Set(message.mentions.roles.values()); 37 | 38 | const worker = await reusable('crypto'); 39 | 40 | try { 41 | const queries = []; 42 | 43 | members.add(message.member); 44 | 45 | for (const member of members) { 46 | roles.add(hoistedRole(member)); 47 | } 48 | 49 | for (const role of roles) { 50 | const data = { 51 | colour: role.hexColor.slice(1), 52 | name: role.name, 53 | }; 54 | queries.push( 55 | this.client.prisma.archivedRole.upsert({ 56 | create: { 57 | ...data, 58 | roleId: role.id, 59 | ticketId, 60 | }, 61 | select: { ticketId: true }, 62 | update: data, 63 | where: { 64 | ticketId_roleId: { 65 | roleId: role.id, 66 | ticketId, 67 | }, 68 | }, 69 | }), 70 | ); 71 | } 72 | 73 | for (const member of members) { 74 | const data = { 75 | avatar: member.avatar || member.user.avatar, // TODO: save avatar in user/avatars/ 76 | bot: member.user.bot, 77 | discriminator: member.user.discriminator, 78 | displayName: member.displayName ? await worker.encrypt(member.displayName) : null, 79 | roleId: !!member && hoistedRole(member).id, 80 | username: await worker.encrypt(member.user.username), 81 | }; 82 | queries.push( 83 | this.client.prisma.archivedUser.upsert({ 84 | create: { 85 | ...data, 86 | ticketId, 87 | userId: member.user.id, 88 | }, 89 | select: { ticketId: true }, 90 | update: data, 91 | where: { 92 | ticketId_userId: { 93 | ticketId, 94 | userId: member.user.id, 95 | }, 96 | }, 97 | }), 98 | ); 99 | } 100 | 101 | for (const channel of channels) { 102 | const data = { 103 | channelId: channel.id, 104 | name: channel.name, 105 | ticketId, 106 | }; 107 | queries.push( 108 | this.client.prisma.archivedChannel.upsert({ 109 | create: data, 110 | select: { ticketId: true }, 111 | update: data, 112 | where: { 113 | ticketId_channelId: { 114 | channelId: channel.id, 115 | ticketId, 116 | }, 117 | }, 118 | }), 119 | ); 120 | } 121 | 122 | const data = { 123 | content: await worker.encrypt( 124 | JSON.stringify({ 125 | attachments: [...message.attachments.values()], 126 | components: [...message.components.values()], 127 | content: message.content, 128 | embeds: message.embeds.map(embed => ({ ...embed })), 129 | reference: message.reference?.messageId ?? null, 130 | }), 131 | ), 132 | createdAt: message.createdAt, 133 | edited: !!message.editedAt, 134 | external, 135 | }; 136 | 137 | queries.push( 138 | this.client.prisma.archivedMessage.upsert({ 139 | create: { 140 | ...data, 141 | authorId: message.author?.id || 'default', 142 | id: message.id, 143 | ticketId, 144 | }, 145 | select: { ticketId: true }, 146 | update: data, 147 | where: { id: message.id }, 148 | }), 149 | ); 150 | 151 | return await this.client.prisma.$transaction(queries); 152 | } finally { 153 | await worker.terminate(); 154 | } 155 | } 156 | }; 157 | -------------------------------------------------------------------------------- /src/lib/tickets/utils.js: -------------------------------------------------------------------------------- 1 | const { 2 | ActionRowBuilder, 3 | EmbedBuilder, 4 | StringSelectMenuBuilder, 5 | StringSelectMenuOptionBuilder, 6 | MessageFlags, 7 | } = require('discord.js'); 8 | const emoji = require('node-emoji'); 9 | 10 | module.exports = { 11 | /** 12 | * @param {import("client")} client 13 | * @param {import("discord.js").ButtonInteraction|import("discord.js").SelectMenuInteraction} interaction 14 | */ 15 | async useGuild(client, interaction, { 16 | referencesMessageId, 17 | referencesTicketId, 18 | topic, 19 | }) { 20 | const settings = await client.prisma.guild.findUnique({ 21 | select: { 22 | categories: true, 23 | errorColour: true, 24 | locale: true, 25 | primaryColour: true, 26 | }, 27 | where: { id: interaction.guild.id }, 28 | }); 29 | const getMessage = client.i18n.getLocale(settings.locale); 30 | if (settings.categories.length === 0) { 31 | interaction.reply({ 32 | components: [], 33 | embeds: [ 34 | new EmbedBuilder() 35 | .setColor(settings.errorColour) 36 | .setTitle(getMessage('misc.no_categories.title')) 37 | .setDescription(getMessage('misc.no_categories.description', { url: `${process.env.HTTP_EXTERNAL}/settings/${interaction.guildId}` })), 38 | ], 39 | flags: MessageFlags.Ephemeral, 40 | }); 41 | } else if (settings.categories.length === 1) { 42 | await client.tickets.create({ 43 | categoryId: settings.categories[0].id, 44 | interaction, 45 | referencesMessageId, 46 | referencesTicketId, 47 | topic, 48 | }); 49 | } else { 50 | await interaction.reply({ 51 | components: [ 52 | new ActionRowBuilder() 53 | .setComponents( 54 | new StringSelectMenuBuilder() 55 | .setCustomId(JSON.stringify({ 56 | action: 'create', 57 | referencesMessageId, 58 | referencesTicketId, 59 | topic, 60 | })) 61 | .setPlaceholder(getMessage('menus.category.placeholder')) 62 | .setOptions( 63 | settings.categories.map(category => 64 | new StringSelectMenuOptionBuilder() 65 | .setValue(String(category.id)) 66 | .setLabel(category.name) 67 | .setDescription(category.description) 68 | .setEmoji(emoji.hasEmoji(category.emoji) ? emoji.get(category.emoji) : { id: category.emoji }), 69 | ), 70 | ), 71 | ), 72 | ], 73 | flags: MessageFlags.Ephemeral, 74 | }); 75 | } 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /src/lib/updates.js: -------------------------------------------------------------------------------- 1 | const semver = require('semver'); 2 | const { short } = require('leeks.js'); 3 | const ExtendedEmbedBuilder = require('./embed'); 4 | const { version: currentVersion } = require('../../package.json'); 5 | 6 | /** @param {import("client")} client */ 7 | module.exports = client => { 8 | client.log.info('Checking for updates...'); 9 | fetch('https://api.github.com/repos/discord-tickets/bot/releases') 10 | .then(res => res.json()) 11 | .then(async json => { 12 | // releases are ordered by date, so a patch for an old version could be before the latest version 13 | const releases = json 14 | .filter(release => !release.prerelease) 15 | .sort((a, b) => semver.compare(semver.coerce(b.tag_name)?.version, semver.coerce(a.tag_name)?.version)); 16 | const latestRelease = releases[0]; 17 | const latestVersion = semver.coerce(latestRelease.tag_name)?.version; 18 | const compared = semver.compare(latestVersion, currentVersion); 19 | 20 | switch (compared) { 21 | case -1: { 22 | client.log.notice('You are running a pre-release version of Discord Tickets'); 23 | break; 24 | } 25 | case 0: { 26 | client.log.info('No updates available'); 27 | break; 28 | } 29 | case 1: { 30 | let currentRelease = releases.findIndex(release => semver.coerce(release.tag_name)?.version === currentVersion); 31 | if (currentRelease === -1) return client.log.warn('Failed to find current release'); 32 | const behind = currentRelease; 33 | currentRelease = releases[currentRelease]; 34 | const changelog = `https://discordtickets.app/changelogs/v${latestVersion}/`; 35 | const guide = 'https://discordtickets.app/self-hosting/updating/'; 36 | const { default: boxen } = await import('boxen'); 37 | 38 | client.log.notice( 39 | short('&r&6A new version of Discord Tickets is available (&c%s&6 -> &a%s&6)&r\n'), 40 | currentVersion, 41 | latestVersion, 42 | boxen( 43 | short([ // uses template literals to ensure boxen adds the correct padding 44 | `&6You are &f${behind}&6 version${behind === 1 ? '' : 's'} behind the latest version, &a${latestVersion}&6.&r`, 45 | `&6Changelog: &e${changelog}&r`, 46 | `&6Update guide: &e${guide}&r`, 47 | ].join('\n')), 48 | { 49 | align: 'center', 50 | borderColor: 'yellow', 51 | borderStyle: 'round', 52 | margin: 1, 53 | padding: 1, 54 | title: 'Update available', 55 | }), 56 | ); 57 | 58 | if (process.env.PUBLIC_BOT !== 'true') { 59 | const guilds = await client.prisma.guild.findMany({ where: { logChannel: { not: null } } }); 60 | for (const guild of guilds) { 61 | const getMessage = client.i18n.getLocale(guild.locale); 62 | await client.channels.cache.get(guild.logChannel).send({ 63 | embeds: [ 64 | new ExtendedEmbedBuilder() 65 | .setColor('Blurple') 66 | .setAuthor({ 67 | iconURL: latestRelease.author.avatar_url, 68 | name: latestRelease.author.login, 69 | }) 70 | .setTitle(getMessage('misc.update.title')) 71 | .setDescription(getMessage('misc.update.description', { 72 | changelog, 73 | github: latestRelease.html_url, 74 | guide, 75 | version: latestRelease.tag_name, 76 | })), 77 | ], 78 | }); 79 | } 80 | } 81 | break; 82 | } 83 | } 84 | }) 85 | .catch(error => { 86 | client.log.warn('Failed to check for updates'); 87 | client.log.error(error); 88 | }); 89 | }; 90 | -------------------------------------------------------------------------------- /src/lib/users.js: -------------------------------------------------------------------------------- 1 | const { PermissionsBitField } = require('discord.js'); 2 | 3 | /** 4 | * 5 | * @param {import("discord.js").Client} client 6 | * @param {string} userId 7 | * @returns {Promise} 8 | */ 9 | module.exports.getCommonGuilds = (client, userId) => client.guilds.cache.filter(guild => guild.members.cache.has(userId)); 10 | 11 | /** 12 | * @param {import("discord.js").Guild} guild 13 | * @returns {Promise} 14 | */ 15 | const updateStaffRoles = async guild => { 16 | const { categories } = await guild.client.prisma.guild.findUnique({ 17 | select: { categories: { select: { staffRoles: true } } }, 18 | where: { id: guild.id }, 19 | }); 20 | const staffRoles = [ 21 | ...new Set( 22 | categories.reduce((acc, c) => { 23 | acc.push(...c.staffRoles); 24 | return acc; 25 | }, []), 26 | ), 27 | ]; 28 | await guild.client.keyv.set(`cache/guild-staff:${guild.id}`, staffRoles); 29 | return staffRoles; 30 | }; 31 | 32 | module.exports.updateStaffRoles = updateStaffRoles; 33 | 34 | /** 35 | * 36 | * @param {import("discord.js").Guild} guild 37 | * @param {string} userId 38 | * @returns {Promise} 39 | */ 40 | module.exports.isStaff = async (guild, userId) => { 41 | /** @type {import("client")} */ 42 | const client = guild.client; 43 | if (client.supers.includes(userId)) return true; 44 | try { 45 | const guildMember = guild.members.cache.get(userId) || await guild.members.fetch(userId); 46 | if (guildMember.permissions.has(PermissionsBitField.Flags.ManageGuild)) return true; 47 | const staffRoles = await client.keyv.get(`cache/guild-staff:${guild.id}`) || await updateStaffRoles(guild); 48 | return staffRoles.some(r => guildMember.roles.cache.has(r)); 49 | } catch { 50 | return false; 51 | } 52 | }; 53 | 54 | /** 55 | * 56 | * @param {import("discord.js")} member 57 | * @returns {Promise} 58 | * - `4` = OPERATOR (SUPER) 59 | * - `3` = GUILD_OWNER 60 | * - `2` = GUILD_ADMIN 61 | * - `1` = GUILD_STAFF 62 | * - `0` = GUILD_MEMBER 63 | * - `-1` = NONE (NOT A MEMBER) 64 | */ 65 | module.exports.getPrivilegeLevel = async member => { 66 | if (!member) return -1; 67 | else if (member.guild.client.supers.includes(member.id)) return 4; 68 | else if (member.guild.ownerId === member.id) return 3; 69 | else if (member.permissions.has(PermissionsBitField.Flags.ManageGuild)) return 2; 70 | else if (await this.isStaff(member.guild, member.id)) return 1; 71 | else return 0; 72 | }; 73 | -------------------------------------------------------------------------------- /src/lib/workers/crypto.js: -------------------------------------------------------------------------------- 1 | const { expose } = require('threads/worker'); 2 | const Cryptr = require('cryptr'); 3 | const { 4 | encrypt, 5 | decrypt, 6 | } = new Cryptr(process.env.ENCRYPTION_KEY); 7 | 8 | expose({ 9 | decrypt, 10 | encrypt, 11 | }); 12 | -------------------------------------------------------------------------------- /src/lib/workers/export.js: -------------------------------------------------------------------------------- 1 | const { expose } = require('threads/worker'); 2 | const Cryptr = require('cryptr'); 3 | const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); 4 | 5 | expose({ 6 | exportTicket(ticket) { 7 | ticket.archivedMessages = ticket.archivedMessages.map(message => { 8 | message.content &&= decrypt(message.content); 9 | return message; 10 | }); 11 | 12 | ticket.archivedUsers = ticket.archivedUsers.map(user => { 13 | user.displayName &&= decrypt(user.displayName); 14 | user.username &&= decrypt(user.username); 15 | return user; 16 | }); 17 | 18 | if (ticket.feedback) { 19 | // why is feedback the only one with a guild relation? 😕 20 | delete ticket.feedback.guildId; 21 | ticket.feedback.comment &&= decrypt(ticket.feedback.comment); 22 | } 23 | 24 | ticket.closedReason &&= decrypt(ticket.closedReason); 25 | 26 | delete ticket.guildId; 27 | 28 | ticket.questionAnswers = ticket.questionAnswers.map(answer => { 29 | answer.value &&= decrypt(answer.value); 30 | return answer; 31 | }); 32 | 33 | ticket.topic &&= decrypt(ticket.topic); 34 | 35 | return JSON.stringify(ticket); 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /src/lib/workers/import.js: -------------------------------------------------------------------------------- 1 | const { expose } = require('threads/worker'); 2 | const Cryptr = require('cryptr'); 3 | const { encrypt } = new Cryptr(process.env.ENCRYPTION_KEY); 4 | 5 | expose({ 6 | importTicket(stringified, guildId, categoryMap) { 7 | const ticket = JSON.parse(stringified); 8 | 9 | ticket.archivedChannels = { 10 | create: ticket.archivedChannels.map(user => { 11 | delete user.ticketId; 12 | return user; 13 | }), 14 | }; 15 | 16 | ticket.archivedUsers = { 17 | create: ticket.archivedUsers.map(user => { 18 | delete user.ticketId; 19 | user.displayName &&= encrypt(user.displayName); 20 | user.username &&= encrypt(user.username); 21 | return user; 22 | }), 23 | }; 24 | 25 | ticket.archivedRoles = { 26 | create: ticket.archivedRoles.map(user => { 27 | delete user.ticketId; 28 | return user; 29 | }), 30 | }; 31 | 32 | const messages = ticket.archivedMessages.map(message => { 33 | // messages don't need to be wrapped in {create} 34 | message.content &&= encrypt(message.content); 35 | return message; 36 | }); 37 | delete ticket.archivedMessages; 38 | 39 | ticket.category = { connect: { id: categoryMap.get(ticket.categoryId) } }; 40 | delete ticket.categoryId; 41 | 42 | if (ticket.claimedById) { 43 | ticket.claimedBy = { 44 | connectOrCreate: { 45 | create: { id: ticket.claimedById }, 46 | where: { id: ticket.claimedById }, 47 | }, 48 | }; 49 | } 50 | delete ticket.claimedById; 51 | 52 | if (ticket.closedById) { 53 | ticket.closedBy = { 54 | connectOrCreate: { 55 | create: { id: ticket.closedById }, 56 | where: { id: ticket.closedById }, 57 | }, 58 | }; 59 | } 60 | delete ticket.closedById; 61 | 62 | if (ticket.createdById) { 63 | ticket.createdBy = { 64 | connectOrCreate: { 65 | create: { id: ticket.createdById }, 66 | where: { id: ticket.createdById }, 67 | }, 68 | }; 69 | } 70 | delete ticket.createdById; 71 | 72 | ticket.closedReason &&= encrypt(ticket.closedReason); 73 | 74 | if (ticket.feedback) { 75 | ticket.feedback.guild = { connect: { id: guildId } }; 76 | ticket.feedback.comment &&= encrypt(ticket.feedback.comment); 77 | if (ticket.feedback.userId) { 78 | ticket.feedback.user = { 79 | connectOrCreate: { 80 | create: { id: ticket.feedback.userId }, 81 | where: { id: ticket.feedback.userId }, 82 | }, 83 | }; 84 | delete ticket.feedback.userId; 85 | } 86 | delete ticket.feedback.ticketId; 87 | ticket.feedback = { create: ticket.feedback }; 88 | } else { 89 | ticket.feedback = undefined; 90 | } 91 | 92 | ticket.guild = { connect: { id: guildId } }; 93 | delete ticket.guildId; // shouldn't exist but make sure 94 | 95 | if (ticket.questionAnswers.length) { 96 | ticket.questionAnswers = { 97 | create: ticket.questionAnswers.map(answer => { 98 | answer.value &&= encrypt(answer.value); 99 | return answer; 100 | }), 101 | }; 102 | } else { 103 | ticket.questionAnswers = undefined; 104 | } 105 | 106 | if (ticket.referencesTicketId) { 107 | ticket.referencesTicket = { connect: { id: ticket.referencesTicketId } }; 108 | } 109 | delete ticket.referencesTicketId; 110 | 111 | ticket.topic &&= encrypt(ticket.topic); 112 | 113 | return [ticket, messages]; 114 | 115 | }, 116 | }); 117 | -------------------------------------------------------------------------------- /src/lib/workers/stats.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | const { expose } = require('threads/worker'); 4 | const { createHash } = require('crypto'); 5 | 6 | const md5 = str => createHash('md5').update(str).digest('hex'); 7 | 8 | const msToMins = ms => Number((ms / 1000 / 60).toFixed(2)); 9 | 10 | const reduce = (closedTickets, prop) => closedTickets.reduce((total, ticket) => total + (ticket[prop] - ticket.createdAt), 0) || 1; 11 | 12 | const getAvgResolutionTime = closedTickets => reduce(closedTickets, 'closedAt') / Math.max(closedTickets.length, 1); 13 | 14 | const getAvgResponseTime = closedTickets => reduce(closedTickets, 'firstResponseAt') / Math.max(closedTickets.length, 1); 15 | 16 | const sum = numbers => numbers.reduce((t, n) => t + n, 0); 17 | 18 | expose({ 19 | aggregateGuildForHouston(guild, messages) { 20 | const closedTickets = guild.tickets.filter(t => t.firstResponseAt && t.closedAt); 21 | return { 22 | avg_resolution_time: msToMins(getAvgResolutionTime(closedTickets)), 23 | avg_response_time: msToMins(getAvgResponseTime(closedTickets)), 24 | categories: guild.categories.length, 25 | features: { 26 | auto_close: msToMins(guild.autoClose), 27 | claiming: guild.categories.filter(c => c.claiming).length, 28 | feedback: guild.categories.filter(c => c.enableFeedback).length, 29 | logs: !!guild.logChannel, 30 | questions: guild.categories.filter(c => c._count.questions).length, 31 | tags: guild.tags.length, 32 | tags_regex: guild.tags.filter(t => t.regex).length, 33 | topic: guild.categories.filter(c => c.requireTopic).length, 34 | }, 35 | id: md5(guild.id), 36 | locale: guild.locale, 37 | members: guild.members, 38 | messages, // * global not guild, don't count archivedMessage table rows, they can be deleted 39 | tickets: guild.tickets.length, 40 | }; 41 | }, 42 | getAvgResolutionTime, 43 | getAvgResponseTime, 44 | sum, 45 | }); 46 | -------------------------------------------------------------------------------- /src/lib/workers/transcript.js: -------------------------------------------------------------------------------- 1 | const { expose } = require('threads/worker'); 2 | const Cryptr = require('cryptr'); 3 | const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); 4 | 5 | function getTranscript(ticket) { 6 | ticket.claimedBy = ticket.archivedUsers.find(u => u.userId === ticket.claimedById); 7 | ticket.closedBy = ticket.archivedUsers.find(u => u.userId === ticket.closedById); 8 | ticket.createdBy = ticket.archivedUsers.find(u => u.userId === ticket.createdById); 9 | 10 | if (ticket.closedReason) ticket.closedReason = decrypt(ticket.closedReason); 11 | if (ticket.feedback?.comment) ticket.feedback.comment = decrypt(ticket.feedback.comment); 12 | if (ticket.topic) ticket.topic = decrypt(ticket.topic).replace(/\n/g, '\n\t'); 13 | 14 | ticket.archivedUsers.forEach((user, i) => { 15 | if (user.displayName) user.displayName = decrypt(user.displayName); 16 | user.username = decrypt(user.username); 17 | ticket.archivedUsers[i] = user; 18 | }); 19 | 20 | ticket.archivedMessages.forEach((message, i) => { 21 | message.author = ticket.archivedUsers.find(u => u.userId === message.authorId); 22 | message.content = JSON.parse(decrypt(message.content)); 23 | message.text = message.content.content?.replace(/\n/g, '\n\t') ?? ''; 24 | message.content.attachments?.forEach(a => (message.text += '\n\t' + a.url)); 25 | message.content.embeds?.forEach(() => (message.text += '\n\t[embedded content]')); 26 | message.number = 'M' + String(i + 1).padStart(ticket.archivedMessages.length.toString().length, '0'); 27 | ticket.archivedMessages[i] = message; 28 | }); 29 | 30 | ticket.questionAnswers = ticket.questionAnswers.map(answer => { 31 | answer.value &&= decrypt(answer.value); 32 | return answer; 33 | }); 34 | 35 | ticket.pinnedMessageIds = ticket.pinnedMessageIds.map(id => ticket.archivedMessages.find(message => message.id === id)?.number); 36 | 37 | return ticket; 38 | } 39 | 40 | expose(getTranscript); 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/listeners/autocomplete/componentLoad.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.autocomplete, 8 | event: 'componentLoad', 9 | }); 10 | } 11 | 12 | run(autocompleter) { 13 | this.client.log.info.autocomplete(`Loaded "${autocompleter.id}" autocompleter`); 14 | return true; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/listeners/autocomplete/error.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.autocomplete, 8 | event: 'error', 9 | }); 10 | } 11 | 12 | async run({ 13 | completer, 14 | error, 15 | interaction, 16 | }) { 17 | this.client.log.error.autocomplete(`"${completer.id}" autocomplete execution error:`, { 18 | error, 19 | interaction, 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/listeners/autocomplete/run.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.autocomplete, 8 | event: 'run', 9 | }); 10 | } 11 | 12 | run({ 13 | completer, 14 | interaction, 15 | }) { 16 | this.client.log.verbose.autocomplete(`${interaction.user.tag} used the "${completer.id}" autocompleter`); 17 | return true; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/listeners/buttons/componentLoad.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.buttons, 8 | event: 'componentLoad', 9 | }); 10 | } 11 | 12 | run(button) { 13 | this.client.log.info.buttons(`Loaded "${button.id}" button`); 14 | return true; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/listeners/buttons/error.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | const { handleInteractionError } = require('../../lib/error'); 3 | 4 | module.exports = class extends Listener { 5 | constructor(client, options) { 6 | super(client, { 7 | ...options, 8 | emitter: client.buttons, 9 | event: 'error', 10 | }); 11 | } 12 | 13 | async run(...params) { 14 | return handleInteractionError(...params); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/listeners/buttons/run.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.buttons, 8 | event: 'run', 9 | }); 10 | } 11 | 12 | run({ 13 | button, 14 | interaction, 15 | }) { 16 | this.client.log.info.buttons(`${interaction.user.tag} used the "${button.id}" button`); 17 | return true; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/listeners/client/channelDelete.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client, 8 | event: 'channelDelete', 9 | }); 10 | } 11 | 12 | async run(channel) { 13 | /** @type {import("client")} */ 14 | const client = this.client; 15 | 16 | const ticket = await client.prisma.ticket.findUnique({ 17 | include: { guild: true }, 18 | where: { id: channel.id }, 19 | }); 20 | 21 | if (ticket?.open) { 22 | await client.tickets.finallyClose(ticket.id, { reason: 'channel deleted' }); 23 | this.client.log.info.tickets(`Closed ticket ${ticket.id} because the channel was deleted`); 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/listeners/client/error.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client, 8 | event: 'error', 9 | }); 10 | } 11 | 12 | run(error) { 13 | this.client.log.error(error); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/listeners/client/guildCreate.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client, 8 | event: 'guildCreate', 9 | }); 10 | } 11 | 12 | /** 13 | * @param {import("discord.js").Guild} guild 14 | */ 15 | async run(guild) { 16 | /** @type {import("client")} */ 17 | const client = this.client; 18 | 19 | this.client.log.success(`Added to guild "${guild.name}"`); 20 | let settings = await client.prisma.guild.findUnique({ where: { id: guild.id } }); 21 | if (!settings) { 22 | settings = await client.prisma.guild.create({ 23 | data: { 24 | id: guild.id, 25 | locale: client.i18n.locales.includes(guild.preferredLocale) ? guild.preferredLocale : 'en-GB', 26 | }, 27 | }); 28 | } 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/listeners/client/guildDelete.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client, 8 | event: 'guildDelete', 9 | }); 10 | } 11 | 12 | run(guild) { 13 | this.client.log.info(`Removed from guild ${guild.id} (${guild.name})`); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/listeners/client/guildMemberRemove.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client, 8 | event: 'guildMemberRemove', 9 | }); 10 | } 11 | 12 | /** 13 | * 14 | * @param {import("discord.js").GuildMember} member 15 | */ 16 | async run(member) { 17 | /** @type {import("client")} */ 18 | const client = this.client; 19 | 20 | const tickets = await client.prisma.ticket.findMany({ 21 | where: { 22 | createdById: member.id, 23 | guildId: member.guild.id, 24 | open: true, 25 | }, 26 | }); 27 | 28 | for (const ticket of tickets) { 29 | await client.tickets.finallyClose(ticket.id, { reason: 'user left server' }); 30 | } 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/listeners/client/messageDelete.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | const { 3 | AuditLogEvent, MessageFlags, 4 | } = require('discord.js'); 5 | const { logMessageEvent } = require('../../lib/logging'); 6 | const { quick } = require('../../lib/threads'); 7 | 8 | module.exports = class extends Listener { 9 | constructor(client, options) { 10 | super(client, { 11 | ...options, 12 | emitter: client, 13 | event: 'messageDelete', 14 | }); 15 | } 16 | 17 | /** 18 | * @param {import("discord.js").Message} message 19 | */ 20 | async run(message) { 21 | /** @type {import("client")} */ 22 | const client = this.client; 23 | 24 | if (!message.guild) return; 25 | 26 | const ticket = await client.prisma.ticket.findUnique({ 27 | include: { guild: true }, 28 | where: { id: message.channel.id }, 29 | }); 30 | if (!ticket) return; 31 | 32 | let content = message.cleanContent; 33 | 34 | const logEvent = (await message.guild.fetchAuditLogs({ 35 | limit: 1, 36 | type: AuditLogEvent.MessageDelete, 37 | })).entries.first(); 38 | 39 | if (ticket.guild.archive) { 40 | try { 41 | await client.prisma.archivedMessage.update({ 42 | data: { deleted: true }, 43 | where: { id: message.id }, 44 | }); 45 | const archived = await client.prisma.archivedMessage.findUnique({ where: { id: message.id } }); 46 | if (archived?.content) { 47 | if (!content) { 48 | const string = await quick('crypto', worker => worker.decrypt(archived.content)); 49 | content = JSON.parse(string).content; // won't be cleaned 50 | } 51 | } 52 | } catch (error) { 53 | if ((error.meta?.cause || error.cause) === 'Record to update not found.') { 54 | client.log.warn(`Archived message ${message.id} can't be marked as deleted because it doesn't exist`); 55 | } else { 56 | client.log.warn('Failed to "delete" archived message', message.id); 57 | client.log.error(error); 58 | } 59 | } 60 | } 61 | 62 | let { 63 | executor, 64 | target, 65 | } = logEvent ?? {}; 66 | 67 | executor ||= undefined; 68 | if (target?.id !== message.author?.id) executor = undefined; 69 | 70 | if (executor) { 71 | try { 72 | executor = await message.guild.members.fetch(executor.id); 73 | } catch (error) { 74 | client.log.error(error); 75 | } 76 | } 77 | 78 | if (message.author.id !== client.user.id && !message.flags.has(MessageFlags.Ephemeral)) { 79 | await logMessageEvent(this.client, { 80 | action: 'delete', 81 | diff: { 82 | original: { content }, 83 | updated: { content: '' }, 84 | }, 85 | executor, 86 | target: message, 87 | ticket, 88 | }); 89 | } 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/listeners/client/messageUpdate.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | const { MessageFlags } = require('discord.js'); 3 | const { logMessageEvent } = require('../../lib/logging'); 4 | 5 | module.exports = class extends Listener { 6 | constructor(client, options) { 7 | super(client, { 8 | ...options, 9 | emitter: client, 10 | event: 'messageUpdate', 11 | }); 12 | } 13 | 14 | 15 | /** 16 | * @param {import("discord.js").Message} oldMessage 17 | * @param {import("discord.js").Message} newMessage 18 | */ 19 | async run(oldMessage, newMessage) { 20 | /** @type {import("client")} */ 21 | const client = this.client; 22 | 23 | if (newMessage.partial) { 24 | try { 25 | newMessage = await newMessage.fetch(); 26 | } catch (error) { 27 | client.log.error(error); 28 | } 29 | } 30 | 31 | if (!newMessage.guild) return; 32 | if (newMessage.flags.has(MessageFlags.Ephemeral)) return; 33 | if (!newMessage.editedAt) return; 34 | 35 | const ticket = await client.prisma.ticket.findUnique({ 36 | include: { guild: true }, 37 | where: { id: newMessage.channel.id }, 38 | }); 39 | if (!ticket) return; 40 | 41 | if (ticket.guild.archive) { 42 | try { 43 | await client.tickets.archiver.saveMessage(ticket.id, newMessage); 44 | } catch (error) { 45 | client.log.warn('Failed to update archived message', newMessage.id); 46 | client.log.error(error); 47 | newMessage.react('❌').catch(client.log.error); 48 | } 49 | } 50 | 51 | if (newMessage.author.id === client.user.id) return; 52 | 53 | await logMessageEvent(this.client, { 54 | action: 'update', 55 | diff: { 56 | original: { content: oldMessage.cleanContent }, 57 | updated: { content: newMessage.cleanContent }, 58 | }, 59 | target: newMessage, 60 | ticket, 61 | }); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/listeners/client/warn.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client, 8 | event: 'warn', 9 | }); 10 | } 11 | 12 | run(warn) { 13 | this.client.log.warn(warn); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/listeners/commands/componentLoad.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.commands, 8 | event: 'componentLoad', 9 | }); 10 | } 11 | 12 | run(command) { 13 | const types = { 14 | 1: 'slash', 15 | 2: 'user', 16 | 3: 'message', 17 | }; 18 | this.client.log.info.commands(`Loaded "${command.name}" ${types[command.type]} command`); 19 | return true; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/listeners/commands/error.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | const { handleInteractionError } = require('../../lib/error'); 3 | 4 | module.exports = class extends Listener { 5 | constructor(client, options) { 6 | super(client, { 7 | ...options, 8 | emitter: client.commands, 9 | event: 'error', 10 | }); 11 | } 12 | 13 | async run(...params) { 14 | return handleInteractionError(...params); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/listeners/commands/run.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.commands, 8 | event: 'run', 9 | }); 10 | } 11 | 12 | run({ 13 | command, 14 | interaction, 15 | }) { 16 | const types = { 17 | 1: 'slash', 18 | 2: 'user', 19 | 3: 'message', 20 | }; 21 | this.client.log.info.commands(`${interaction.user.tag} used the "${command.name}" ${types[command.type]} command`); 22 | return true; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/listeners/menus/componentLoad.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.menus, 8 | event: 'componentLoad', 9 | }); 10 | } 11 | 12 | run(menu) { 13 | this.client.log.info.menus(`Loaded "${menu.id}" menu`); 14 | return true; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/listeners/menus/error.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | const { handleInteractionError } = require('../../lib/error'); 3 | 4 | module.exports = class extends Listener { 5 | constructor(client, options) { 6 | super(client, { 7 | ...options, 8 | emitter: client.menus, 9 | event: 'error', 10 | }); 11 | } 12 | 13 | async run(...params) { 14 | return handleInteractionError(...params); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/listeners/menus/run.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.menus, 8 | event: 'run', 9 | }); 10 | } 11 | 12 | run({ 13 | menu, 14 | interaction, 15 | }) { 16 | this.client.log.info.menus(`${interaction.user.tag} used the "${menu.id}" menu`); 17 | return true; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/listeners/modals/componentLoad.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.modals, 8 | event: 'componentLoad', 9 | }); 10 | } 11 | 12 | run(modal) { 13 | this.client.log.info.modals(`Loaded "${modal.id}" modal`); 14 | return true; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/listeners/modals/error.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | const { handleInteractionError } = require('../../lib/error'); 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.modals, 8 | event: 'error', 9 | }); 10 | } 11 | 12 | async run(...params) { 13 | return handleInteractionError(...params); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/listeners/modals/run.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.modals, 8 | event: 'run', 9 | }); 10 | } 11 | 12 | run({ 13 | modal, 14 | interaction, 15 | }) { 16 | this.client.log.info.modals(`${interaction.user.tag} used the "${modal.id}" modal`); 17 | return true; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/listeners/stdin/unknown.js: -------------------------------------------------------------------------------- 1 | const { Listener } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends Listener { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | emitter: client.stdin, 8 | event: 'unknown', 9 | }); 10 | } 11 | 12 | run(commandName) { 13 | this.client.log.warn(`Unknown command: "${commandName}"; type "help" for a list of commands`); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/menus/create.js: -------------------------------------------------------------------------------- 1 | const { Menu } = require('@eartharoid/dbf'); 2 | const { MessageFlags } = require('discord.js'); 3 | 4 | module.exports = class CreateMenu extends Menu { 5 | constructor(client, options) { 6 | super(client, { 7 | ...options, 8 | id: 'create', 9 | }); 10 | } 11 | 12 | /** 13 | * @param {*} id 14 | * @param {import("discord.js").SelectMenuInteraction} interaction 15 | */ 16 | async run(id, interaction) { 17 | if (!interaction.message.flags.has(MessageFlags.Ephemeral)) { 18 | // reset the select menu (to fix a UI issue) 19 | interaction.message.edit({ components: interaction.message.components }).catch(() => { }); 20 | } 21 | await this.client.tickets.create({ 22 | ...id, 23 | categoryId: interaction.values[0], 24 | interaction, 25 | }); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/modals/feedback.js: -------------------------------------------------------------------------------- 1 | const { Modal } = require('@eartharoid/dbf'); 2 | const ExtendedEmbedBuilder = require('../lib/embed'); 3 | const { quick } = require('../lib/threads'); 4 | const { MessageFlags } = require('discord.js'); 5 | 6 | module.exports = class FeedbackModal extends Modal { 7 | constructor(client, options) { 8 | super(client, { 9 | ...options, 10 | id: 'feedback', 11 | }); 12 | } 13 | 14 | /** 15 | * @param {*} id 16 | * @param {import("discord.js").ModalSubmitInteraction} interaction 17 | */ 18 | async run(id, interaction) { 19 | /** @type {import("client")} */ 20 | const client = this.client; 21 | 22 | await interaction.deferReply(); 23 | 24 | const comment = interaction.fields.getTextInputValue('comment'); 25 | let rating = parseInt(interaction.fields.getTextInputValue('rating')) || null; // any integer, or null if NaN 26 | rating = Math.min(Math.max(rating, 1), 5); // clamp between 1 and 5 (0 and null become 1, 6 becomes 5) 27 | 28 | const data = { 29 | comment: comment?.length > 0 ? await quick('crypto', worker => worker.encrypt(comment)) : null, 30 | guild: { connect: { id: interaction.guild.id } }, 31 | rating, 32 | user: { connect: { id: interaction.user.id } }, 33 | }; 34 | const ticket = await client.prisma.ticket.update({ 35 | data: { 36 | feedback: { 37 | upsert: { 38 | create: data, 39 | update: data, 40 | }, 41 | }, 42 | }, 43 | include: { guild: true }, 44 | where: { id: interaction.channel.id }, 45 | }); 46 | 47 | 48 | if (id.next === 'requestClose') await client.tickets.requestClose(interaction, id.reason); 49 | else if (id.next === 'acceptClose') await client.tickets.acceptClose(interaction); 50 | 51 | const getMessage = client.i18n.getLocale(ticket.guild.locale); 52 | 53 | // `followUp` must go after `reply`/`editReply` (the above) 54 | if (comment?.length > 0 && rating !== null) { 55 | await interaction.followUp({ 56 | embeds: [ 57 | new ExtendedEmbedBuilder({ 58 | iconURL: interaction.guild.iconURL(), 59 | text: ticket.guild.footer, 60 | }) 61 | .setColor(ticket.guild.primaryColour) 62 | .setDescription(getMessage('ticket.feedback')), 63 | ], 64 | flags: MessageFlags.Ephemeral, 65 | }); 66 | } 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /src/modals/topic.js: -------------------------------------------------------------------------------- 1 | const { Modal } = require('@eartharoid/dbf'); 2 | const { 3 | EmbedBuilder, MessageFlags, 4 | } = require('discord.js'); 5 | const ExtendedEmbedBuilder = require('../lib/embed'); 6 | const { logTicketEvent } = require('../lib/logging'); 7 | const { reusable } = require('../lib/threads'); 8 | 9 | module.exports = class TopicModal extends Modal { 10 | constructor(client, options) { 11 | super(client, { 12 | ...options, 13 | id: 'topic', 14 | }); 15 | } 16 | 17 | async run(id, interaction) { 18 | /** @type {import("client")} */ 19 | const client = this.client; 20 | 21 | if (id.edit) { 22 | const worker = await reusable('crypto'); 23 | try { 24 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 25 | const topic = interaction.fields.getTextInputValue('topic'); 26 | const select = { 27 | createdById: true, 28 | guild: { 29 | select: { 30 | footer: true, 31 | locale: true, 32 | successColour: true, 33 | }, 34 | }, 35 | id: true, 36 | openingMessageId: true, 37 | topic: true, 38 | }; 39 | const original = await client.prisma.ticket.findUnique({ 40 | select, 41 | where: { id: interaction.channel.id }, 42 | }); 43 | const ticket = await client.prisma.ticket.update({ 44 | data: { topic: topic ? await worker.encrypt(topic) : null }, 45 | select, 46 | where: { id: interaction.channel.id }, 47 | }); 48 | const getMessage = client.i18n.getLocale(ticket.guild.locale); 49 | 50 | if (topic) interaction.channel.setTopic(`<@${ticket.createdById}> | ${topic}`); 51 | 52 | const opening = await interaction.channel.messages.fetch(ticket.openingMessageId); 53 | if (opening && opening.embeds.length >= 2) { 54 | const embeds = [...opening.embeds]; 55 | embeds[1] = new EmbedBuilder(embeds[1].data) 56 | .setFields({ 57 | name: getMessage('ticket.opening_message.fields.topic'), 58 | value: topic, 59 | }); 60 | await opening.edit({ embeds }); 61 | } 62 | 63 | await interaction.editReply({ 64 | embeds: [ 65 | new ExtendedEmbedBuilder({ 66 | iconURL: interaction.guild.iconURL(), 67 | text: ticket.guild.footer, 68 | }) 69 | .setColor(ticket.guild.successColour) 70 | .setTitle(getMessage('ticket.edited.title')) 71 | .setDescription(getMessage('ticket.edited.description')), 72 | ], 73 | }); 74 | 75 | /** @param {ticket} ticket */ 76 | const makeDiff = async ticket => { 77 | const diff = {}; 78 | diff[getMessage('ticket.opening_message.fields.topic')] = ticket.topic ? await worker.decrypt(ticket.topic) : ' '; 79 | return diff; 80 | }; 81 | 82 | logTicketEvent(this.client, { 83 | action: 'update', 84 | diff: { 85 | original: await makeDiff(original), 86 | updated: await makeDiff(ticket), 87 | }, 88 | target: { 89 | id: ticket.id, 90 | name: `<#${ticket.id}>`, 91 | }, 92 | userId: interaction.user.id, 93 | }); 94 | 95 | } finally { 96 | await worker.terminate(); 97 | } 98 | } else { 99 | await this.client.tickets.postQuestions({ 100 | ...id, 101 | interaction, 102 | }); 103 | } 104 | } 105 | }; 106 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/[guild]/categories/[category]/questions/[question].js: -------------------------------------------------------------------------------- 1 | const { logAdminEvent } = require('../../../../../../../../lib/logging'); 2 | 3 | module.exports.delete = fastify => ({ 4 | handler: async (req, res) => { 5 | /** @type {import('client')} */ 6 | const client = req.routeOptions.config.client; 7 | const guildId = req.params.guild; 8 | const categoryId = Number(req.params.category); 9 | const questionId = req.params.question; 10 | const original = questionId && await client.prisma.question.findUnique({ where: { id: questionId } }); 11 | const category = categoryId && await client.prisma.category.findUnique({ where: { id: categoryId } }); 12 | if (original?.categoryId !== categoryId || category.guildId !== guildId) return res.status(400).send(new Error('Bad Request')); 13 | const question = await client.prisma.question.delete({ where: { id: questionId } }); 14 | 15 | logAdminEvent(client, { 16 | action: 'delete', 17 | guildId: req.params.guild, 18 | target: { 19 | id: question.id, 20 | name: question.label, 21 | type: 'question', 22 | }, 23 | userId: req.user.id, 24 | }); 25 | 26 | return question; 27 | }, 28 | onRequest: [fastify.authenticate, fastify.isAdmin], 29 | }); 30 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/[guild]/categories/index.js: -------------------------------------------------------------------------------- 1 | const { logAdminEvent } = require('../../../../../../lib/logging'); 2 | const { updateStaffRoles } = require('../../../../../../lib/users'); 3 | const emoji = require('node-emoji'); 4 | const { 5 | ApplicationCommandPermissionType, 6 | ChannelType: { GuildCategory }, 7 | } = require('discord.js'); 8 | const ms = require('ms'); 9 | const { getAverageTimes } = require('../../../../../../lib/stats'); 10 | 11 | module.exports.get = fastify => ({ 12 | handler: async req => { 13 | /** @type {import('client')} */ 14 | const client = req.routeOptions.config.client; 15 | 16 | let { categories } = await client.prisma.guild.findUnique({ 17 | select: { 18 | categories: { 19 | select: { 20 | createdAt: true, 21 | description: true, 22 | discordCategory: true, 23 | emoji: true, 24 | id: true, 25 | image: true, 26 | name: true, 27 | requiredRoles: true, 28 | staffRoles: true, 29 | tickets: { 30 | select: { 31 | closedAt: true, 32 | createdAt: true, 33 | firstResponseAt: true, 34 | }, 35 | where: { 36 | firstResponseAt: { not: null }, 37 | open: false, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | where: { id: req.params.guild }, 44 | }); 45 | 46 | categories = await Promise.all( 47 | categories.map(async category => { 48 | const { 49 | avgResolutionTime, 50 | avgResponseTime, 51 | } = await getAverageTimes(category.tickets); 52 | category = { 53 | ...category, 54 | stats: { 55 | avgResolutionTime: ms(avgResolutionTime), 56 | avgResponseTime: ms(avgResponseTime), 57 | }, 58 | }; 59 | delete category.tickets; 60 | return category; 61 | }), 62 | ); 63 | 64 | return categories; 65 | }, 66 | onRequest: [fastify.authenticate, fastify.isAdmin], 67 | }); 68 | 69 | module.exports.post = fastify => ({ 70 | handler: async req => { 71 | /** @type {import('client')} */ 72 | const client = req.routeOptions.config.client; 73 | 74 | const user = await client.users.fetch(req.user.id); 75 | const guild = client.guilds.cache.get(req.params.guild); 76 | const data = req.body; 77 | const allow = ['ViewChannel', 'ReadMessageHistory', 'SendMessages', 'EmbedLinks', 'AttachFiles']; 78 | 79 | if (!data.discordCategory) { 80 | let name = data.name; 81 | if (emoji.hasEmoji(data.emoji)) name = `${emoji.get(data.emoji)} ${name}`; 82 | const channel = await guild.channels.create({ 83 | name, 84 | permissionOverwrites: [ 85 | ...[ 86 | { 87 | deny: ['ViewChannel'], 88 | id: guild.roles.everyone, 89 | }, 90 | { 91 | allow: allow, 92 | id: client.user.id, 93 | }, 94 | ], 95 | ...data.staffRoles.map(id => ({ 96 | allow: allow, 97 | id, 98 | })), 99 | ], 100 | position: 1, 101 | reason: `Tickets category created by ${user.tag}`, 102 | type: GuildCategory, 103 | }); 104 | data.discordCategory = channel.id; 105 | } 106 | 107 | data.channelName ||= 'ticket-{num}'; // not ??=, expect empty string 108 | 109 | const category = await client.prisma.category.create({ 110 | data: { 111 | guild: { connect: { id: guild.id } }, 112 | ...data, 113 | questions: { createMany: { data: data.questions ?? [] } }, 114 | }, 115 | }); 116 | 117 | // update caches 118 | await client.tickets.getCategory(category.id, true); 119 | await updateStaffRoles(guild); 120 | 121 | if (req.user.accessToken) { 122 | Promise.all([ 123 | 'Create ticket for user', 124 | 'claim', 125 | 'force-close', 126 | 'move', 127 | 'priority', 128 | 'release', 129 | ].map(name => 130 | client.application.commands.permissions.set({ 131 | command: client.application.commands.cache.find(cmd => cmd.name === name), 132 | guild, 133 | permissions: [ 134 | { 135 | id: guild.id, // @everyone 136 | permission: false, 137 | type: ApplicationCommandPermissionType.Role, 138 | }, 139 | ...category.staffRoles.map(id => ({ 140 | id, 141 | permission: true, 142 | type: ApplicationCommandPermissionType.Role, 143 | })), 144 | ], 145 | token: req.user.accessToken, 146 | }), 147 | )) 148 | .then(() => client.log.success('Updated application command permissions in "%s"', guild.name)) 149 | .catch(error => client.log.error(error)); 150 | } 151 | 152 | logAdminEvent(client, { 153 | action: 'create', 154 | guildId: guild.id, 155 | target: { 156 | id: category.id, 157 | name: category.name, 158 | type: 'category', 159 | }, 160 | userId: req.user.id, 161 | }); 162 | 163 | return category; 164 | }, 165 | onRequest: [fastify.authenticate, fastify.isAdmin], 166 | }); 167 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/[guild]/data.js: -------------------------------------------------------------------------------- 1 | module.exports.get = fastify => ({ 2 | handler: async req => { 3 | /** @type {import('client')} */ 4 | const client = req.routeOptions.config.client; 5 | const id = req.params.guild; 6 | const guild = client.guilds.cache.get(id) ?? {}; 7 | const { query } = req.query; 8 | if (!query) return {}; 9 | const data = query.split(/\./g).reduce((acc, part) => acc && acc[part], guild); 10 | return data; 11 | }, 12 | onRequest: [fastify.authenticate, fastify.isAdmin], 13 | }); 14 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/[guild]/export.js: -------------------------------------------------------------------------------- 1 | const { 2 | spawn, 3 | Pool, 4 | Worker, 5 | } = require('threads'); 6 | const { Readable } = require('node:stream'); 7 | const { cpus } = require('node:os'); 8 | const archiver = require('archiver'); 9 | const { iconURL } = require('../../../../../lib/misc'); 10 | const pkg = require('../../../../../../package.json'); 11 | 12 | // a single persistent pool shared across all exports 13 | const poolSize = Math.ceil(cpus().length / 4); // ! ceiL: at least 1 14 | const pool = Pool(() => spawn(new Worker('../../../../../lib/workers/export.js')), { size: poolSize }); 15 | 16 | module.exports.get = fastify => ({ 17 | /** 18 | * 19 | * @param {import('fastify').FastifyRequest} req 20 | * @param {import('fastify').FastifyReply} res 21 | */ 22 | handler: async (req, res) => { 23 | /** @type {import('client')} */ 24 | const client = req.routeOptions.config.client; 25 | const id = req.params.guild; 26 | const guild = client.guilds.cache.get(id); 27 | const member = await guild.members.fetch(req.user.id); 28 | 29 | client.log.info(`${member.user.username} requested an export of "${guild.name}"`); 30 | 31 | // TODO: sign so the importer can ensure files haven't been added (important for attachments) 32 | const archive = archiver('zip', { 33 | comment: JSON.stringify({ 34 | exportedAt: new Date().toISOString(), 35 | exportedFromClientId: client.user.id, 36 | originalGuildId: id, 37 | originalGuildName: guild.name, 38 | version: pkg.version, 39 | }), 40 | }); 41 | 42 | archive.on('warning', err => { 43 | if (err.code === 'ENOENT') client.log.warn(err); 44 | else throw err; 45 | }); 46 | 47 | archive.on('error', err => { 48 | throw err; 49 | }); 50 | 51 | const settings = await client.prisma.guild.findUnique({ 52 | include: { 53 | categories: { include: { questions: true } }, 54 | tags: true, 55 | }, 56 | where: { id }, 57 | }); 58 | 59 | delete settings.id; 60 | 61 | settings.categories = settings.categories.map(c => { 62 | delete c.guildId; 63 | return c; 64 | }); 65 | 66 | settings.tags = settings.tags.map(t => { 67 | delete t.guildId; 68 | return t; 69 | }); 70 | 71 | const ticketsStream = Readable.from(ticketsGenerator()); 72 | async function* ticketsGenerator() { 73 | try { 74 | let done = false; 75 | const take = 50; 76 | const findOptions = { 77 | include: { 78 | archivedChannels: true, 79 | archivedMessages: true, 80 | archivedRoles: true, 81 | archivedUsers: true, 82 | feedback: true, 83 | questionAnswers: true, 84 | }, 85 | orderBy: { id: 'asc' }, 86 | take, 87 | where: { guildId: id }, 88 | }; 89 | do { 90 | const batch = await client.prisma.ticket.findMany(findOptions); 91 | if (batch.length < take) { 92 | done = true; 93 | } else { 94 | findOptions.skip = 1; 95 | findOptions.cursor = { id: batch[take - 1].id }; 96 | } 97 | // ! map (parallel) not for...of (serial) 98 | yield* batch.map(async ticket => (await pool.queue(worker => worker.exportTicket(ticket)) + '\n')); 99 | // Readable.from(AsyncGenerator) seems to be faster than pushing to a Readable with an empty `read()` function 100 | // for (const ticket of batch) { 101 | // pool 102 | // .queue(worker => worker.exportTicket(ticket)) 103 | // .then(string => ticketsStream.push(string + '\n')); 104 | // } 105 | } while (!done); 106 | } finally { 107 | ticketsStream.push(null); // ! extremely important 108 | } 109 | } 110 | 111 | const icon = await fetch(iconURL(guild)); 112 | archive.append(Readable.from(icon.body), { name: 'icon.png' }); 113 | archive.append(JSON.stringify(settings), { name: 'settings.json' }); 114 | archive.append(ticketsStream, { name: 'tickets.jsonl' }); 115 | archive.finalize(); // ! do not await 116 | 117 | const cleanGuildName = guild.name.replace(/\W/g, '_').replace(/_+/g, '_'); 118 | const fileName = `tickets-${cleanGuildName}-${new Date().toISOString().slice(0, 10)}.zip`; 119 | 120 | res 121 | .type('application/zip') 122 | .header('content-disposition', `attachment; filename="${fileName}"`) 123 | .send(archive); 124 | }, 125 | onRequest: [fastify.authenticate, fastify.isAdmin], 126 | }); 127 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/[guild]/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | const { logAdminEvent } = require('../../../../../lib/logging.js'); 3 | const { iconURL } = require('../../../../../lib/misc'); 4 | const { getAverageTimes } = require('../../../../../lib/stats'); 5 | const ms = require('ms'); 6 | 7 | module.exports.delete = fastify => ({ 8 | handler: async req => { 9 | /** @type {import('client')} */ 10 | const client = req.routeOptions.config.client; 11 | const id = req.params.guild; 12 | client.keyv.delete(`cache/stats/guild:${id}`); 13 | await client.prisma.$transaction([ 14 | client.prisma.guild.delete({ where: { id } }), 15 | client.prisma.guild.create({ data: { id } }), 16 | ]); 17 | logAdminEvent(client, { 18 | action: 'delete', 19 | guildId: id, 20 | target: { 21 | id, 22 | name: client.guilds.cache.get(id), 23 | type: 'settings', 24 | }, 25 | userId: req.user.id, 26 | }); 27 | return true; 28 | }, 29 | onRequest: [fastify.authenticate, fastify.isAdmin], 30 | }); 31 | 32 | module.exports.get = fastify => ({ 33 | handler: async req => { 34 | /** @type {import("client")} */ 35 | const client = req.routeOptions.config.client; 36 | const id = req.params.guild; 37 | const cacheKey = `cache/stats/guild:${id}`; 38 | let cached = await client.keyv.get(cacheKey); 39 | 40 | if (!cached) { 41 | const guild = client.guilds.cache.get(id); 42 | const settings = 43 | await client.prisma.guild.findUnique({ where: { id } }) ?? 44 | await client.prisma.guild.create({ data: { id } }); 45 | const [ 46 | categories, 47 | tags, 48 | tickets, 49 | closedTickets, 50 | ] = await Promise.all([ 51 | client.prisma.category.findMany({ 52 | select: { 53 | _count: { select: { tickets: true } }, 54 | id: true, 55 | name: true, 56 | }, 57 | where: { guildId: id }, 58 | }), 59 | client.prisma.tag.count({ where: { guildId: id } }), 60 | client.prisma.ticket.count({ where: { guildId: id } }), 61 | client.prisma.ticket.findMany({ 62 | select: { 63 | closedAt: true, 64 | createdAt: true, 65 | firstResponseAt: true, 66 | }, 67 | where: { 68 | firstResponseAt: { not: null }, 69 | guildId: id, 70 | open: false, 71 | }, 72 | }), 73 | client.prisma.user.aggregate({ 74 | _count: true, 75 | _sum: { messageCount: true }, 76 | }), 77 | ]); 78 | const { 79 | avgResolutionTime, 80 | avgResponseTime, 81 | } = await getAverageTimes(closedTickets); 82 | 83 | cached = { 84 | createdAt: settings.createdAt, 85 | id: guild.id, 86 | logo: iconURL(guild), 87 | name: guild.name, 88 | stats: { 89 | avgResolutionTime: ms(avgResolutionTime), 90 | avgResponseTime: ms(avgResponseTime), 91 | categories: categories.map(c => ({ 92 | id: c.id, 93 | name: c.name, 94 | tickets: c._count.tickets, 95 | })), 96 | tags, 97 | tickets, 98 | }, 99 | }; 100 | await client.keyv.set(cacheKey, cached, ms('5m')); 101 | } 102 | 103 | return cached; 104 | }, 105 | onRequest: [fastify.authenticate, fastify.isAdmin], 106 | }); 107 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/[guild]/problems.js: -------------------------------------------------------------------------------- 1 | const { PermissionsBitField } = require('discord.js'); 2 | 3 | module.exports.get = fastify => ({ 4 | handler: async req => { 5 | /** @type {import('client')} */ 6 | const client = req.routeOptions.config.client; 7 | const id = req.params.guild; 8 | const guild = client.guilds.cache.get(id); 9 | const settings = await client.prisma.guild.findUnique({ where: { id } }) ?? 10 | await client.prisma.guild.create({ data: { id } }); 11 | const problems = []; 12 | 13 | if (settings.logChannel) { 14 | const permissions = guild.members.me.permissionsIn(settings.logChannel); 15 | 16 | if (!permissions.has(PermissionsBitField.Flags.SendMessages)) { 17 | problems.push({ 18 | id: 'logChannelMissingPermission', 19 | permission: 'SendMessages', 20 | }); 21 | } 22 | 23 | if (!permissions.has(PermissionsBitField.Flags.EmbedLinks)) { 24 | problems.push({ 25 | id: 'logChannelMissingPermission', 26 | permission: 'EmbedLinks', 27 | }); 28 | } 29 | 30 | if (process.env.PUBLIC_BOT !== 'true' && client.application.botPublic) { 31 | problems.push({ id: 'botPublic' }); 32 | } 33 | } 34 | 35 | return problems; 36 | }, 37 | onRequest: [fastify.authenticate, fastify.isAdmin], 38 | }); 39 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/[guild]/settings.js: -------------------------------------------------------------------------------- 1 | const { logAdminEvent } = require('../../../../../lib/logging.js'); 2 | const { Colors } = require('discord.js'); 3 | 4 | module.exports.get = fastify => ({ 5 | handler: async req => { 6 | /** @type {import('client')} */ 7 | const client = req.routeOptions.config.client; 8 | const id = req.params.guild; 9 | const settings = await client.prisma.guild.findUnique({ where: { id } }) ?? 10 | await client.prisma.guild.create({ data: { id } }); 11 | 12 | return settings; 13 | }, 14 | onRequest: [fastify.authenticate, fastify.isAdmin], 15 | }); 16 | 17 | module.exports.patch = fastify => ({ 18 | handler: async req => { 19 | const data = req.body; 20 | if (Object.prototype.hasOwnProperty.call(data, 'id')) delete data.id; 21 | if (Object.prototype.hasOwnProperty.call(data, 'createdAt')) delete data.createdAt; 22 | const colours = ['errorColour', 'primaryColour', 'successColour']; 23 | for (const c of colours) { 24 | if (data[c] && !data[c].startsWith('#') && !(data[c] in Colors)) { // if not null/empty and not hex 25 | throw new Error(`${data[c]} is not a valid colour. Valid colours are HEX and: ${Object.keys(Colors).join(', ')}`); 26 | } 27 | } 28 | 29 | /** @type {import('client')} */ 30 | const client = req.routeOptions.config.client; 31 | const id = req.params.guild; 32 | const original = await client.prisma.guild.findUnique({ where: { id } }); 33 | const settings = await client.prisma.guild.update({ 34 | data: data, 35 | include: { categories: { select: { id: true } } }, 36 | where: { id }, 37 | }); 38 | 39 | // Update cached categories, which include guild settings 40 | for (const { id } of settings.categories) await client.tickets.getCategory(id, true); 41 | 42 | // don't log the categories 43 | delete settings.categories; 44 | 45 | logAdminEvent(client, { 46 | action: 'update', 47 | diff: { 48 | original, 49 | updated: settings, 50 | }, 51 | guildId: id, 52 | target: { 53 | id, 54 | name: client.guilds.cache.get(id).name, 55 | type: 'settings', 56 | }, 57 | userId: req.user.id, 58 | }); 59 | return settings; 60 | }, 61 | onRequest: [fastify.authenticate, fastify.isAdmin], 62 | }); 63 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/[guild]/tags/[tag].js: -------------------------------------------------------------------------------- 1 | const ms = require('ms'); 2 | const { logAdminEvent } = require('../../../../../../lib/logging'); 3 | 4 | module.exports.delete = fastify => ({ 5 | handler: async (req, res) => { 6 | /** @type {import('client')} */ 7 | const client = req.routeOptions.config.client; 8 | const guildId = req.params.guild; 9 | const tagId = Number(req.params.tag); 10 | const original = tagId && await client.prisma.tag.findUnique({ where: { id: tagId } }); 11 | if (original.guildId !== guildId) return res.status(400).send(new Error('Bad Request')); 12 | const tag = await client.prisma.tag.delete({ where: { id: tagId } }); 13 | 14 | const cacheKey = `cache/guild-tags:${guildId}`; 15 | client.keyv.set(cacheKey, await client.prisma.tag.findMany({ 16 | select: { 17 | content: true, 18 | id: true, 19 | name: true, 20 | regex: true, 21 | }, 22 | where: { guildId: guildId }, 23 | }), ms('1h')); 24 | 25 | logAdminEvent(client, { 26 | action: 'delete', 27 | guildId: req.params.guild, 28 | target: { 29 | id: tag.id, 30 | name: tag.name, 31 | type: 'tag', 32 | }, 33 | userId: req.user.id, 34 | }); 35 | 36 | return tag; 37 | }, 38 | onRequest: [fastify.authenticate, fastify.isAdmin], 39 | }); 40 | 41 | module.exports.get = fastify => ({ 42 | handler: async (req, res) => { 43 | /** @type {import('client')} */ 44 | const client = req.routeOptions.config.client; 45 | const guildId = req.params.guild; 46 | const tagId = Number(req.params.tag); 47 | const tag = await client.prisma.tag.findUnique({ where: { id: tagId } }); 48 | 49 | if (!tag || tag.guildId !== guildId) return res.status(400).send(new Error('Bad Request')); 50 | 51 | return tag; 52 | }, 53 | onRequest: [fastify.authenticate, fastify.isAdmin], 54 | }); 55 | 56 | module.exports.patch = fastify => ({ 57 | handler: async (req, res) => { 58 | /** @type {import('client')} */ 59 | const client = req.routeOptions.config.client; 60 | const guildId = req.params.guild; 61 | const tagId = Number(req.params.tag); 62 | const guild = client.guilds.cache.get(req.params.guild); 63 | const data = req.body; 64 | 65 | const original = req.params.tag && await client.prisma.tag.findUnique({ where: { id: tagId } }); 66 | 67 | if (!original || original.guildId !== guildId) return res.status(400).send(new Error('Bad Request')); 68 | 69 | if (Object.prototype.hasOwnProperty.call(data, 'id')) delete data.id; 70 | if (Object.prototype.hasOwnProperty.call(data, 'createdAt')) delete data.createdAt; 71 | 72 | const tag = await client.prisma.tag.update({ 73 | data, 74 | where: { id: tagId }, 75 | }); 76 | 77 | const cacheKey = `cache/guild-tags:${guildId}`; 78 | client.keyv.set(cacheKey, await client.prisma.tag.findMany({ 79 | select: { 80 | content: true, 81 | id: true, 82 | name: true, 83 | regex: true, 84 | }, 85 | where: { guildId: guildId }, 86 | }), ms('1h')); 87 | 88 | logAdminEvent(client, { 89 | action: 'update', 90 | diff: { 91 | original, 92 | updated: tag, 93 | }, 94 | guildId: guild.id, 95 | target: { 96 | id: tag.id, 97 | name: tag.name, 98 | type: 'tag', 99 | }, 100 | userId: req.user.id, 101 | }); 102 | 103 | return tag; 104 | }, 105 | onRequest: [fastify.authenticate, fastify.isAdmin], 106 | }); 107 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/[guild]/tags/index.js: -------------------------------------------------------------------------------- 1 | const ms = require('ms'); 2 | const { logAdminEvent } = require('../../../../../../lib/logging'); 3 | 4 | module.exports.get = fastify => ({ 5 | handler: async req => { 6 | /** @type {import('client')} */ 7 | const client = req.routeOptions.config.client; 8 | 9 | const { tags } = await client.prisma.guild.findUnique({ 10 | select: { tags: true }, 11 | where: { id: req.params.guild }, 12 | }); 13 | 14 | return tags; 15 | }, 16 | onRequest: [fastify.authenticate, fastify.isAdmin], 17 | }); 18 | 19 | 20 | module.exports.post = fastify => ({ 21 | handler: async req => { 22 | /** @type {import('client')} */ 23 | const client = req.routeOptions.config.client; 24 | const guild = client.guilds.cache.get(req.params.guild); 25 | const data = req.body; 26 | const tag = await client.prisma.tag.create({ 27 | data: { 28 | guild: { connect: { id: guild.id } }, 29 | ...data, 30 | }, 31 | }); 32 | 33 | const cacheKey = `cache/guild-tags:${guild.id}`; 34 | let tags = await client.keyv.get(cacheKey); 35 | if (!tags) { 36 | tags = await client.prisma.tag.findMany({ 37 | select: { 38 | content: true, 39 | id: true, 40 | name: true, 41 | regex: true, 42 | }, 43 | where: { guildId: guild.id }, 44 | }); 45 | client.keyv.set(cacheKey, tags, ms('1h')); 46 | } else { 47 | tags.push(tag); 48 | client.keyv.set(cacheKey, tags, ms('1h')); 49 | } 50 | 51 | logAdminEvent(client, { 52 | action: 'create', 53 | guildId: guild.id, 54 | target: { 55 | id: tag.id, 56 | name: tag.name, 57 | type: 'tag', 58 | }, 59 | userId: req.user.id, 60 | }); 61 | 62 | return tag; 63 | }, 64 | onRequest: [fastify.authenticate, fastify.isAdmin], 65 | }); 66 | -------------------------------------------------------------------------------- /src/routes/api/admin/guilds/index.js: -------------------------------------------------------------------------------- 1 | const { PermissionsBitField } = require('discord.js'); 2 | const { iconURL } = require('../../../../lib/misc'); 3 | 4 | module.exports.get = fastify => ({ 5 | handler: async (req, res) => { 6 | const { client } = req.routeOptions.config; 7 | const guilds = await (await fetch('https://discordapp.com/api/users/@me/guilds', { headers: { 'Authorization': `Bearer ${req.user.accessToken}` } })).json(); 8 | res.send( 9 | guilds 10 | .filter(guild => guild.owner || new PermissionsBitField(guild.permissions.toString()).has(PermissionsBitField.Flags.ManageGuild)) 11 | .map(guild => ({ 12 | added: client.guilds.cache.has(guild.id), 13 | id: guild.id, 14 | logo: iconURL( 15 | client.guilds.cache.get(guild.id) || 16 | { 17 | client, 18 | icon: guild.icon, 19 | id: guild.id, 20 | }, 21 | ), 22 | name: guild.name, 23 | })), 24 | ); 25 | }, 26 | onRequest: [fastify.authenticate], 27 | }); 28 | -------------------------------------------------------------------------------- /src/routes/api/client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | const ms = require('ms'); 4 | const pkg = require('../../../package.json'); 5 | const { getAverageTimes } = require('../../lib/stats'); 6 | const { quick } = require('../../lib/threads'); 7 | 8 | module.exports.get = () => ({ 9 | handler: async req => { 10 | /** @type {import("client")} */ 11 | const client = req.routeOptions.config.client; 12 | const cacheKey = 'cache/stats/client'; 13 | let cached = await client.keyv.get(cacheKey); 14 | if (!cached) { 15 | const [ 16 | categories, 17 | members, 18 | tags, 19 | tickets, 20 | closedTickets, 21 | users, 22 | ] = await Promise.all([ 23 | client.prisma.category.count(), 24 | quick('stats', w => w.sum(client.guilds.cache.map(g => g.memberCount))), 25 | client.prisma.tag.count(), 26 | client.prisma.ticket.count(), 27 | client.prisma.ticket.findMany({ 28 | select: { 29 | closedAt: true, 30 | createdAt: true, 31 | firstResponseAt: true, 32 | }, 33 | where: { 34 | firstResponseAt: { not: null }, 35 | open: false, 36 | }, 37 | }), 38 | client.prisma.user.aggregate({ 39 | _count: true, 40 | _sum: { messageCount: true }, 41 | }), 42 | ]); 43 | const { 44 | avgResolutionTime, 45 | avgResponseTime, 46 | } = await getAverageTimes(closedTickets); 47 | 48 | cached = { 49 | avatar: client.user.avatarURL(), 50 | discriminator: client.user.discriminator, 51 | id: client.user.id, 52 | public: (process.env.PUBLIC_BOT === 'true'), 53 | stats: { 54 | activatedUsers: users._count, 55 | archivedMessages: users._sum.messageCount, 56 | avgResolutionTime: ms(avgResolutionTime), 57 | avgResponseTime: ms(avgResponseTime), 58 | categories, 59 | guilds: client.guilds.cache.size, 60 | members, 61 | tags, 62 | tickets, 63 | }, 64 | username: client.user.username, 65 | version: pkg.version, 66 | }; 67 | await client.keyv.set(cacheKey, cached, ms('15m')); 68 | } 69 | return cached; 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /src/routes/api/guilds/[guild]/index.js: -------------------------------------------------------------------------------- 1 | const { getPrivilegeLevel } = require('../../../../lib/users'); 2 | const { iconURL } = require('../../../../lib/misc'); 3 | 4 | module.exports.get = fastify => ({ 5 | handler: async (req, res) => { 6 | const { client } = req.routeOptions.config; 7 | const guild = client.guilds.cache.get(req.params.guild); 8 | res.send({ 9 | id: guild.id, 10 | logo: iconURL(guild), 11 | name: guild.name, 12 | privilegeLevel: await getPrivilegeLevel(await guild.members.fetch(req.user.id)), 13 | }); 14 | }, 15 | onRequest: [fastify.authenticate, fastify.isMember], 16 | }); 17 | 18 | -------------------------------------------------------------------------------- /src/routes/api/guilds/[guild]/tickets/@me.js: -------------------------------------------------------------------------------- 1 | module.exports.get = fastify => ({ 2 | handler: async (req, res) => { 3 | const { client } = req.routeOptions.config; 4 | /** @type {import("@prisma/client").PrismaClient} */ 5 | const prisma = client.prisma; 6 | const guild = client.guilds.cache.get(req.params.guild); 7 | res.send( 8 | await prisma.ticket.findMany({ 9 | where: { 10 | createdById: req.user.id, 11 | guildId: guild.id, 12 | }, 13 | }), 14 | ); 15 | }, 16 | onRequest: [fastify.authenticate, fastify.isMember], 17 | }); 18 | 19 | -------------------------------------------------------------------------------- /src/routes/api/guilds/index.js: -------------------------------------------------------------------------------- 1 | const { getPrivilegeLevel } = require('../../../lib/users'); 2 | const { iconURL } = require('../../../lib/misc'); 3 | 4 | module.exports.get = fastify => ({ 5 | handler: async (req, res) => { 6 | const { client } = req.routeOptions.config; 7 | const guilds = await (await fetch('https://discordapp.com/api/users/@me/guilds', { headers: { 'Authorization': `Bearer ${req.user.accessToken}` } })).json(); 8 | res.send( 9 | await Promise.all( 10 | guilds 11 | .filter(partialGuild => client.guilds.cache.has(partialGuild.id)) 12 | .map(async partialGuild => { 13 | const guild = client.guilds.cache.get(partialGuild.id); 14 | return { 15 | id: guild.id, 16 | logo: iconURL(guild), 17 | name: guild.name, 18 | privilegeLevel: await getPrivilegeLevel(await guild.members.fetch(req.user.id)), 19 | }; 20 | }), 21 | ), 22 | ); 23 | }, 24 | onRequest: [fastify.authenticate], 25 | }); 26 | -------------------------------------------------------------------------------- /src/routes/api/locales.js: -------------------------------------------------------------------------------- 1 | module.exports.get = () => ({ 2 | handler: async req => { 3 | /** @type {import("client")} */ 4 | const client = req.routeOptions.config.client; 5 | return client.i18n.locales; 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /src/routes/api/users/@me/index.js: -------------------------------------------------------------------------------- 1 | module.exports.get = fastify => ({ 2 | handler: req => req.user, 3 | onRequest: [fastify.authenticate], 4 | }); 5 | -------------------------------------------------------------------------------- /src/routes/api/users/@me/key.js: -------------------------------------------------------------------------------- 1 | module.exports.get = fastify => ({ 2 | handler: async function (req, res) { // MUST NOT use arrow function syntax 3 | if (process.env.PUBLIC_BOT === 'true') { 4 | return res.code(400).send({ 5 | error: 'Bad Request', 6 | message: 'API keys are not available on public bots.', 7 | statusCode: 400, 8 | }); 9 | } else { 10 | return { 11 | token: this.jwt.sign({ 12 | createdAt: Date.now(), 13 | id: req.user.id, 14 | service: true, 15 | }), 16 | }; 17 | } 18 | }, 19 | onRequest: [fastify.authenticate], 20 | }); 21 | -------------------------------------------------------------------------------- /src/routes/auth/callback.js: -------------------------------------------------------------------------------- 1 | module.exports.get = () => ({ 2 | handler: async function (req, res) { 3 | const cookie = req.cookies['oauth2-state']; 4 | if (!cookie) { 5 | return res.code(400).send({ 6 | error: 'Bad Request', 7 | message: 'State is missing.', 8 | statusCode: 400, 9 | 10 | }); 11 | } 12 | 13 | const state = new URLSearchParams(cookie); 14 | if (state.get('secret') !== req.query.state) { 15 | return res.code(400).send({ 16 | error: 'Bad Request', 17 | message: 'Invalid state.', 18 | statusCode: 400, 19 | 20 | }); 21 | } 22 | 23 | // TODO: check if req.query.permissions are correct 24 | 25 | const data = await (await fetch('https://discord.com/api/oauth2/token', { 26 | body: new URLSearchParams({ 27 | client_id: req.routeOptions.config.client.user.id, 28 | client_secret: process.env.DISCORD_SECRET, 29 | code: req.query.code, 30 | grant_type: 'authorization_code', 31 | redirect_uri: `${process.env.HTTP_EXTERNAL}/auth/callback`, 32 | }).toString(), 33 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 34 | method: 'POST', 35 | })).json(); 36 | 37 | const redirect = (data.guild?.id && `/settings/${data.guild?.id}`) || state.get('redirect') || '/'; 38 | 39 | const bearerOptions = { headers: { 'Authorization': `Bearer ${data.access_token}` } }; 40 | const user = await (await fetch('https://discordapp.com/api/users/@me', bearerOptions)).json(); 41 | 42 | let scopes; 43 | if (data.scope) { 44 | scopes = data.scope.split(' '); 45 | } else { 46 | const auth = await (await fetch('https://discordapp.com/api/oauth2/@me', bearerOptions)).json(); 47 | scopes = auth.scopes; 48 | } 49 | 50 | const token = this.jwt.sign({ 51 | accessToken: data.access_token, 52 | avatar: user.avatar, 53 | expiresAt: Date.now() + (data.expires_in * 1000), 54 | id: user.id, 55 | locale: user.locale, 56 | scopes, 57 | username: user.username, 58 | }); 59 | 60 | res.setCookie('token', token, { 61 | httpOnly: true, 62 | maxAge: data.expires_in, 63 | path: '/', 64 | sameSite: 'Strict', 65 | secure: false, 66 | }); 67 | res.header('Content-Type', 'text/html'); 68 | return res.send(` 69 | 70 | 71 | 72 | 73 | 74 | `); 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /src/routes/auth/login.js: -------------------------------------------------------------------------------- 1 | const { randomBytes } = require('crypto'); 2 | 3 | module.exports.get = () => ({ 4 | handler: async function (req, res) { 5 | const { client } = req.routeOptions.config; 6 | 7 | const state = new URLSearchParams({ 8 | redirect: req.query.r ?? '', 9 | secret: randomBytes(8).toString('hex'), 10 | }); 11 | 12 | res.setCookie('oauth2-state', state.toString(), { 13 | httpOnly: true, 14 | sameSite: 'lax', 15 | }); 16 | 17 | const params = { 18 | client_id: client.user.id, 19 | prompt: 'none', 20 | redirect_uri: `${process.env.HTTP_EXTERNAL}/auth/callback`, // if not set defaults to first allowed 21 | response_type: 'code', 22 | scope: 'guilds identify', 23 | state: state.get('secret'), 24 | }; 25 | 26 | if (req.query.invite !== undefined) { 27 | params.prompt = 'consent'; // already implied by the bot scope 28 | params.scope = 'applications.commands applications.commands.permissions.update bot ' + params.scope; 29 | params.integration_type = '0'; 30 | params.permissions = '268561488'; 31 | if (req.query.guild) { 32 | params.guild_id = req.query.guild; 33 | params.disable_guild_select = 'true'; 34 | } 35 | } else if (req.query.role === 'admin') { // invite implies admin already 36 | params.scope = 'applications.commands.permissions.update ' + params.scope; 37 | } 38 | 39 | const url = new URL('https://discord.com/oauth2/authorize'); 40 | url.search = new URLSearchParams(params); 41 | 42 | res.redirect(url.toString()); 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /src/routes/auth/logout.js: -------------------------------------------------------------------------------- 1 | module.exports.get = fastify => ({ 2 | handler: async function (req, res) { 3 | const { accessToken } = req.user; 4 | 5 | await fetch('https://discord.com/api/oauth2/token/revoke', { 6 | body: new URLSearchParams({ 7 | client_id: req.routeOptions.config.client.user.id, 8 | client_secret: process.env.DISCORD_SECRET, 9 | token: accessToken, 10 | }).toString(), 11 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 12 | method: 'POST', 13 | }); 14 | 15 | res.clearCookie('token', { 16 | httpOnly: true, 17 | path: '/', 18 | sameSite: 'Strict', 19 | secure: false, 20 | }); 21 | res.header('Content-Type', 'text/html'); 22 | return res.send(` 23 | 24 | 25 | 26 | 27 | 28 | `); 29 | }, 30 | onRequest: [fastify.authenticate], 31 | }); 32 | -------------------------------------------------------------------------------- /src/routes/status.js: -------------------------------------------------------------------------------- 1 | module.exports.get = () => ({ 2 | handler: async (req, res) => { 3 | const { client } = req.routeOptions.config; 4 | res 5 | .code(client.ws.status === 0 ? 200 : 503) 6 | .send({ 7 | ping: client.ws.ping, 8 | shards: client.ws.shards.map(shard => ({ 9 | id: shard.id, 10 | ping: shard.ping, 11 | status: shard.status, 12 | })), 13 | status: client.ws.status, 14 | }); 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/schemas/settings.js: -------------------------------------------------------------------------------- 1 | // TODO 2 | /* eslint-disable no-undef */ 3 | module.exports = joi.object({ 4 | archive: joi.boolean().optional(), 5 | autoClose: joi.number().min(3_600_000).optional(), 6 | autoTag: [joi.array(), joi.string().valid('ticket', '!ticket', 'all')].optional(), 7 | blocklist: joi.array().optional(), 8 | createdAt: joi.string().optional(), 9 | errorColour: joi.string().optional(), 10 | footer: joi.string().optional(), 11 | id: joi.string().optional(), 12 | logChannel: joi.string().optional(), 13 | primaryColour: joi.string().optional(), 14 | staleAfter: joi.number().min(60_000).optional(), 15 | successColour: joi.string().optional(), 16 | workingHours: joi.array().length(8).items( 17 | joi.string(), 18 | joi.array().items(joi.string().required(), joi.string().required()), 19 | joi.array().items(joi.string().required(), joi.string().required()), 20 | joi.array().items(joi.string().required(), joi.string().required()), 21 | joi.array().items(joi.string().required(), joi.string().required()), 22 | joi.array().items(joi.string().required(), joi.string().required()), 23 | joi.array().items(joi.string().required(), joi.string().required()), 24 | joi.array().items(joi.string().required(), joi.string().required()), 25 | ).optional(), 26 | }); -------------------------------------------------------------------------------- /src/stdin/commands.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | const { inspect } = require('util'); 3 | 4 | module.exports = class Commands extends StdinCommand { 5 | constructor(client, options) { 6 | super(client, { 7 | ...options, 8 | id: 'commands', 9 | }); 10 | } 11 | 12 | async run(args) { 13 | switch (args[0]) { 14 | case 'publish': { 15 | this.client.commands.publish() 16 | .then(commands => this.client.log.success('Published %d commands', commands?.size)) 17 | .catch(error => { 18 | this.client.log.warn('Failed to publish commands'); 19 | this.client.log.error(error); 20 | this.client.log.error(inspect(error.rawError?.errors, { depth: Infinity })); 21 | }); 22 | break; 23 | } 24 | default: { 25 | this.client.log.info('subcommands: \n' + [ 26 | '> commands publish', 27 | ].join('\n')); 28 | } 29 | } 30 | } 31 | }; -------------------------------------------------------------------------------- /src/stdin/eval.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends StdinCommand { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'eval', 8 | }); 9 | } 10 | 11 | async run(input) { 12 | const toEval = input.join(' '); 13 | try { 14 | const res = await eval(toEval); 15 | console.log(res); // eslint-disable-line no-console 16 | return true; 17 | } catch (error) { 18 | this.client.log.error(error); 19 | return false; 20 | } 21 | } 22 | }; -------------------------------------------------------------------------------- /src/stdin/exit.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends StdinCommand { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'exit', 8 | }); 9 | } 10 | 11 | async run() { 12 | this.client.log.info('Exiting'); 13 | process.exit(); 14 | } 15 | }; -------------------------------------------------------------------------------- /src/stdin/help.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | const { homepage } = require('../../package.json'); 3 | 4 | module.exports = class extends StdinCommand { 5 | constructor(client, options) { 6 | super(client, { 7 | ...options, 8 | id: 'help', 9 | }); 10 | } 11 | 12 | async run() { 13 | this.client.log.info('Documentation:', homepage); 14 | this.client.log.info('Support: https://lnk.earth/discord'); 15 | this.client.log.info('stdin commands:\n' + this.client.stdin.components.map(c => `> ${c.id}`).join('\n')); 16 | } 17 | }; -------------------------------------------------------------------------------- /src/stdin/npx.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | const { spawn } = require('child_process'); 3 | 4 | module.exports = class extends StdinCommand { 5 | constructor(client, options) { 6 | super(client, { 7 | ...options, 8 | id: 'npx', 9 | }); 10 | } 11 | 12 | async run(input) { 13 | if (!input[0]) return this.client.log.warn('Usage: npx [args]'); 14 | const child = spawn('npx', input, { shell: true }); 15 | for await (const data of child.stdout) this.client.log.info('npx:', data.toString()); 16 | for await (const data of child.stderr) this.client.log.warn('npx:', data.toString()); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/stdin/reload.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends StdinCommand { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'reload', 8 | }); 9 | } 10 | 11 | async run() { 12 | this.client.log.warn('Reloading is not the same as restarting!'); 13 | this.client.log.info('Reinitialising client...'); 14 | await this.client.init(true); 15 | this.client.log.success('Client reinitialised'); 16 | // TODO: fix this 17 | // this.client.log.info('Reloading module components...'); 18 | // this.client.mods.forEach(mod => mod.components.forEach(component => component.reload())); 19 | // this.client.log.success('Components reloaded'); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/stdin/settings.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | 3 | module.exports = class extends StdinCommand { 4 | constructor(client, options) { 5 | super(client, { 6 | ...options, 7 | id: 'settings', 8 | }); 9 | } 10 | 11 | async run() { 12 | this.client.log.info.settings(process.env.HTTP_EXTERNAL + '/settings'); 13 | } 14 | }; -------------------------------------------------------------------------------- /src/stdin/suid-time.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | const ShortUniqueId = require('short-unique-id'); 3 | const uid = new ShortUniqueId(); 4 | 5 | module.exports = class extends StdinCommand { 6 | constructor(client, options) { 7 | super(client, { 8 | ...options, 9 | id: 'suid-time', 10 | }); 11 | } 12 | 13 | async run(input) { 14 | try { 15 | input = input.filter(str => str.length > 0); 16 | this.client.log.info('Timestamp:', uid.parseStamp(input[0])); 17 | } catch (error) { 18 | this.client.log.error(error); 19 | } 20 | } 21 | }; 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/stdin/sync.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | const sync = require('../lib/sync'); 3 | 4 | module.exports = class extends StdinCommand { 5 | constructor(client, options) { 6 | super(client, { 7 | ...options, 8 | id: 'sync', 9 | }); 10 | } 11 | 12 | async run() { 13 | await sync(this.client); 14 | } 15 | }; -------------------------------------------------------------------------------- /src/stdin/version.js: -------------------------------------------------------------------------------- 1 | const { StdinCommand } = require('@eartharoid/dbf'); 2 | const { version } = require('../../package.json'); 3 | const checkForUpdates = require('../lib/updates'); 4 | 5 | module.exports = class extends StdinCommand { 6 | constructor(client, options) { 7 | super(client, { 8 | ...options, 9 | id: 'version', 10 | }); 11 | } 12 | 13 | async run() { 14 | this.client.log.info('Current version:', version); 15 | checkForUpdates(this.client); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/user/avatars/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord-tickets/bot/5e81acb7fec4bea040f80365577334279f10583f/src/user/avatars/.gitkeep -------------------------------------------------------------------------------- /src/user/banned-guilds.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord-tickets/bot/5e81acb7fec4bea040f80365577334279f10583f/src/user/banned-guilds.txt -------------------------------------------------------------------------------- /src/user/config.yml: -------------------------------------------------------------------------------- 1 | ##################################################### 2 | ## ____ _ ## 3 | ## | _ \ (_) ___ ___ ___ _ __ __| | ## 4 | ## | | | | | | / __| / __| / _ \ | '__| / _` | ## 5 | ## | |_| | | | \__ \ | (__ | (_) | | | | (_| | ## 6 | ## |____/ |_| |___/ \___| \___/ |_| \__,_| ## 7 | ## _____ _ _ ## 8 | ## |_ _| (_) ___ | | __ ___ | |_ ___ ## 9 | ## | | | | / __| | |/ / / _ \ | __| / __| ## 10 | ## | | | | | (__ | < | __/ | |_ \__ \ ## 11 | ## |_| |_| \___| |_|\_\ \___| \__| |___/ ## 12 | ## ## 13 | ## Documentation: https://discordtickets.app ## 14 | ## Support: https://lnk.earth/discord ## 15 | ##################################################### 16 | 17 | logs: 18 | files: 19 | directory: ./logs 20 | enabled: true 21 | keepFor: 30 22 | level: info 23 | presence: 24 | activities: 25 | - name: /new 26 | - name: with {totalTickets} tickets 27 | - name: "{openTickets} tickets" 28 | type: 3 29 | - name: "{avgResponseTime} response time" 30 | type: 3 31 | interval: 20 32 | status: online 33 | stats: true 34 | templates: 35 | transcript: transcript.md 36 | updates: true 37 | -------------------------------------------------------------------------------- /src/user/templates/transcript.md.mustache: -------------------------------------------------------------------------------- 1 | #{{ channelName }} ticket transcript 2 | 3 | --- 4 | 5 | * ID: {{ ticket.id }} ({{ guildName }}) 6 | * Number: {{ ticket.category.name }} #{{ ticket.number }} 7 | * Topic: {{ #ticket.topic }}{{ . }}{{ /ticket.topic }}{{ ^ticket.topic }}(no topic){{ /ticket.topic }} 8 | * Created on: {{ #ticket }}{{ createdAtFull }}{{ /ticket }} 9 | * Created by: {{ #ticket.createdBy }}"{{ displayName }}" @{{ username }}#{{ discriminator }}{{ /ticket.createdBy }} 10 | * Closed on: {{ #ticket }}{{ closedAtFull }}{{ /ticket }} 11 | * Closed by: {{ #ticket.closedBy }}"{{ displayName }}" @{{ username }}#{{ discriminator }}{{ /ticket.closedBy }}{{ ^ticket.closedBy }}(automated){{ /ticket.closedBy }} 12 | * Closed because: {{ #ticket.closedReason }}{{ ticket.closedReason }}{{ /ticket.closedReason }}{{ ^ticket.closedReason }}(no reason){{ /ticket.closedReason }} 13 | * Claimed by: {{ #ticket.claimedBy }}"{{ displayName }}" @{{ username }}#{{ discriminator }}{{ /ticket.claimedBy }}{{ ^ticket.claimedBy }}(not claimed){{ /ticket.claimedBy }} 14 | {{ #ticket.feedback }} 15 | * Feedback: 16 | * Rating: {{ rating }}/5 17 | * Comment: {{ comment }}{{ ^comment }}(no comment){{ /comment }} 18 | {{ /ticket.feedback }} 19 | * Participants: 20 | {{ #ticket.archivedUsers }} 21 | * "{{ displayName }}" @{{ username }}#{{ discriminator }} ({{ userId }}) 22 | {{ /ticket.archivedUsers }} 23 | * Pinned messages: {{ #pinned }}{{ . }}{{ /pinned }}{{ ^pinned }}(none){{ /pinned }} 24 | 25 | --- 26 | 27 | ## Questions 28 | 29 | {{ #ticket.questionAnswers }} 30 | ### **{{ question.label }}** 31 | > {{ value }}{{ ^value }}(no answer){{ /value }} 32 | 33 | {{ /ticket.questionAnswers }}{{ ^ticket.questionAnswers }}(none) 34 | 35 | {{ /ticket.questionAnswers }} 36 | ## Messages 37 | 38 | {{ #ticket.archivedMessages }} 39 | <{{ number }}> [{{ createdAtTimestamp }}] {{author.displayName}}: {{ text }} 40 | 41 | {{ /ticket.archivedMessages }} 42 | -------------------------------------------------------------------------------- /src/user/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord-tickets/bot/5e81acb7fec4bea040f80365577334279f10583f/src/user/uploads/.gitkeep --------------------------------------------------------------------------------