feat!: v4 (merge pull request #425 from discord-tickets/v4)

This commit is contained in:
Isaac 2023-05-30 01:03:32 +01:00 committed by GitHub
commit 12647decda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
228 changed files with 14788 additions and 18094 deletions

View File

@ -4,13 +4,11 @@
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"CONTRIBUTORS.md",
"README.md"
"CONTRIBUTORS.md"
],
"imageSize": 100,
"commit": false,
"commit": true,
"commitConvention": "angular",
"badgeTemplate": "<a href=\"https://github.com/discord-tickets/bot/blob/main/CONTRIBUTORS.md\">\n<img src=\"https://img.shields.io/github/all-contributors/discord-tickets/bot?color=ee8449&style=flat-square\"alt=\"All contributors\">\n</a>",
"contributors": [
{
"login": "eartharoid",

1
.commitlintrc.js Normal file
View File

@ -0,0 +1 @@
module.exports = { extends: ['@commitlint/config-conventional'] };

View File

@ -1 +1,19 @@
# only db, scripts, src, and user directories are needed
.github
.husky
.vscode
logs
node_modules
# prisma will generated by postinstall script
prisma
.all-contributorsrc
.commitlintrc.js
.dockerignore
.env
.eslintrc.json
.gitattributes
.gitignore
CONTRIBUTORS.md
Dockerfile
jsconfig.json
README.md

15
.editorconfig Normal file
View File

@ -0,0 +1,15 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
indent_style = space
indent_size = 4

View File

@ -1,189 +0,0 @@
module.exports = {
'env': {
'commonjs': true,
'es2021': true,
'node': true
},
'extends': 'eslint:recommended',
'parserOptions': { 'ecmaVersion': 12 },
'rules': {
'array-bracket-newline': [
'error',
'consistent'
],
'array-bracket-spacing': [
'error',
'never'
],
'array-element-newline': [
'error',
'consistent'
],
'arrow-body-style': [
'error',
'as-needed'
],
'arrow-parens': [
'error',
'as-needed'
],
'block-spacing': [
'error',
'always'
],
'brace-style': [
'error',
'1tbs'
],
'comma-dangle': [
'error',
'never'
],
'comma-spacing': [
'error',
{
'after': true,
'before': false
}
],
'comma-style': [
'error',
'last'
],
'computed-property-spacing': [
'error',
'never'
],
'curly': [
'error',
'multi-line', // 'multi'
'consistent'
],
'default-case-last': [
'error'
],
'dot-location': [
'error',
'property'
],
'dot-notation': [
'error'
],
'eqeqeq': [
'error'
],
'func-call-spacing': [
'error',
'never'
],
'indent': [
'error',
'tab'
],
'linebreak-style': [
'off',
'windows'
],
'max-depth': [
'warn',
{ 'max': 5 }
],
'max-len': [
'warn',
{
'code': 150,
'ignoreRegExpLiterals': true,
'ignoreStrings': true,
'ignoreTemplateLiterals': true,
'ignoreTrailingComments': true,
'ignoreUrls': true
}
],
'max-lines': [
'warn',
500
],
'max-statements-per-line': [
'error'
],
'multiline-comment-style': [
'warn'
],
'no-console': [
'warn'
],
'no-return-assign': [
'error'
],
'no-template-curly-in-string': [
'warn'
],
'no-trailing-spaces': [
'error'
],
'no-underscore-dangle': [
'error'
],
'no-unneeded-ternary': [
'error'
],
'no-var': [
'error'
],
'no-whitespace-before-property': [
'error'
],
'object-curly-newline': [
'error',
{
'minProperties': 2,
'multiline': true
}
],
'object-curly-spacing': [
'error',
'always'
],
'object-property-newline': [
'error'
],
'operator-linebreak': [
'error'
],
'prefer-arrow-callback': [
'error'
],
'prefer-const': [
'error',
{
'destructuring': 'all',
'ignoreReadBeforeAssign': false
}
],
'quotes': [
'error',
'single'
],
'rest-spread-spacing': [
'error',
'never'
],
'semi': [
'error',
'always'
],
'sort-keys': [
'error',
'asc',
{ 'natural': true }
],
'space-in-parens': [
'error',
'never'
],
'spaced-comment': [
'error',
'always'
]
}
};

228
.eslintrc.json Normal file
View File

@ -0,0 +1,228 @@
{
"env": {
"browser": false,
"commonjs": false,
"es6": true,
"node": true
},
"extends": [
"eslint:recommended"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"unused-imports"
],
"root": true,
"rules": {
"array-bracket-newline": [
"error",
"consistent"
],
"array-bracket-spacing": [
"error",
"never"
],
"array-element-newline": [
"error",
"consistent"
],
"arrow-body-style": [
"error",
"as-needed"
],
"arrow-parens": [
"error",
"as-needed"
],
"block-spacing": [
"error",
"always"
],
"brace-style": [
"error",
"1tbs"
],
"comma-dangle": [
"error",
{
"arrays": "always-multiline",
"exports": "always-multiline",
"functions": "always-multiline",
"imports": "always-multiline",
"objects": "always-multiline"
}
],
"comma-spacing": [
"error",
{
"after": true,
"before": false
}
],
"comma-style": [
"error",
"last"
],
"computed-property-spacing": [
"error",
"never"
],
"curly": [
"error",
"multi-line", // "multi"
"consistent"
],
"default-case-last": [
"error"
],
"dot-location": [
"error",
"property"
],
"dot-notation": [
"error"
],
"eqeqeq": [
"error"
],
"func-call-spacing": [
"error",
"never"
],
"indent": [
"error",
"tab"
],
"linebreak-style": [
"off",
"windows"
],
"max-depth": [
"warn",
{
"max": 5
}
],
"max-len": [
"warn",
{
"code": 180,
"ignoreRegExpLiterals": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreTrailingComments": true,
"ignoreUrls": true
}
],
"max-lines": [
"warn"
],
"max-statements-per-line": [
"error"
],
"multiline-comment-style": [
"off"
],
"no-console": [
"warn"
],
"no-prototype-builtins": [
"off"
],
"no-return-assign": [
"error"
],
"no-template-curly-in-string": [
"warn"
],
"no-trailing-spaces": [
"error"
],
"no-underscore-dangle": [
"warn",
{
"allowAfterThis": true,
"allowFunctionParams": true
}
],
"no-unneeded-ternary": [
"error"
],
"no-unused-expressions": [
"error"
],
"no-var": [
"error"
],
"no-whitespace-before-property": [
"error"
],
"object-curly-newline": [
"error",
{
"minProperties": 2,
"multiline": true
}
],
"object-curly-spacing": [
"error",
"always"
],
"object-property-newline": [
"error"
],
"operator-linebreak": [
"error"
],
"prefer-arrow-callback": [
"error"
],
"prefer-const": [
"error",
{
"destructuring": "all",
"ignoreReadBeforeAssign": false
}
],
"quotes": [
"error",
"single"
],
"rest-spread-spacing": [
"error",
"never"
],
"semi": [
"error",
"always"
],
"sort-keys": [
"warn",
"asc",
{
"natural": true
}
],
"space-in-parens": [
"error",
"never"
],
"spaced-comment": [
"error",
"always"
],
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}

6
.github/SECURITY.md vendored
View File

@ -6,10 +6,10 @@ Release versions that will receive security updates.
| Version | Supported |
| ------- | --------- |
| 4.x | ✅ |
| 3.x | ✅ |
| 2.x | ❌ |
| 1.x | ❌ |
| 2.x | ❌ |
| 3.x | ❌ |
| 4.x | ✅ |
## Reporting a vulnerability

58
.github/workflows/docker.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: Publish Docker image
on:
push:
branches:
- 'main'
tags:
- 'v*'
pull_request:
branches:
- 'main'
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to the Container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
eartharoid/discord-tickets
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push Docker images
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -1,30 +1,32 @@
name: eslint
on:
push:
branches:
- main
name: Lint
on: [push, pull_request]
# push:
# branches:
# - main
# pull_request:
# branches:
# - main
jobs:
lint:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- name: Cache pnpm modules
uses: actions/cache@v2
env:
cache-name: cache-pnpm-modules
- uses: actions/checkout@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/package.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-
- uses: pnpm/action-setup@v2.0.1
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v5
with:
version: 6.3.0
run_install: false
- run: pnpm install eslint
configFile: .commitlintrc.js
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 7
- uses: actions/setup-node@v3
with:
node-version: 18
cache: 'pnpm'
- run: pnpm install
- run: pnpm run lint

16
.gitignore vendored
View File

@ -1,12 +1,14 @@
# directories
.history/
.vscode/
node_modules/
logs/
site/
prisma/
# files
.env
version
user/config.js
user/database.sqlite
user/plugins/*/
*.env*
*.db*
*.log
user/config.yml
user/**/*.*
!user/**/.gitkeep
!user/templates/*

4
.husky/commit-msg Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit ${1}

4
.husky/pre-commit Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
auto-install-peers=true
engine-strict=true

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
lts/*

3
Caddyfile Normal file
View File

@ -0,0 +1,3 @@
tickets.example.com {
reverse_proxy 127.0.0.1:8169
}

View File

@ -1,20 +1,23 @@
# Use the alpine image of node 16
FROM node:16-alpine
# syntax=docker/dockerfile:1
# Create a dir for the app and make it owned by a non-root user (node)
RUN mkdir /tickets && \
chown -R 1000:1000 /tickets
WORKDIR /tickets
FROM node:18-alpine AS builder
WORKDIR /build
# install python etc so node-gyp works for the optional dependencies
RUN apk add --no-cache make gcc g++ python3
# install pnpm to make dependency installation faster (because it has a lockfile)
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml ./
COPY --link scripts scripts
RUN chmod +x ./scripts/start.sh
# install dependencies, CI=true to skip pre/postinstall scripts
RUN CI=true pnpm install --prod --frozen-lockfile
COPY --link . .
# Change user to node
USER node
# Install packages
COPY --chown=1000:1000 package.json pnpm-lock.yaml ./
RUN npx pnpm install --prod --frozen-lockfile
# Copy src folder
COPY src ./src
# Set the command
CMD ["node", "src/"]
FROM node:18-alpine AS runner
ENV NODE_ENV=production \
HTTP_HOST=0.0.0.0 \
HTTP_PORT=80
WORKDIR /usr/bot
COPY --from=builder /build ./
EXPOSE ${HTTP_PORT}
ENTRYPOINT [ "/usr/bot/scripts/start.sh" ]

306
README.md
View File

@ -1,244 +1,156 @@
<div align="center">
<img
src="https://img.eartharoid.me/insecure/plain/https://static.eartharoid.me/discord-tickets/logo/wordmark/gradient-by-eartharoid.png@png"
alt="Discord Tickets"
/>
<b>
An open-source ticket management bot for Discord - a free alternative to the premium and white-label plans of other popular ticketing bots.
</b>
<br>
<br>
<div>
<p>
<a href="https://github.com/discord-tickets/bot/stargazers">
<img
src="https://img.shields.io/github/stars/discord-tickets/bot?style=flat-square"
alt="GitHub stars">
</a>
<img
src="https://img.shields.io/badge/dynamic/json?color=5865F2&label=bots&query=clients.total&url=https%3A%2F%2Fstats.discordtickets.app%2Fapi%2Fv3%2Fcurrent&logo=discord&logoColor=white&style=flat-square"
alt="">
<img
src="https://img.shields.io/badge/dynamic/json?color=5865F2&label=tickets&query=tickets&url=https%3A%2F%2Fstats.discordtickets.app%2Fapi%2Fv3%2Fcurrent&logo=discord&logoColor=white&style=flat-square"
alt="">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="https://github.com/discord-tickets/bot/blob/main/CONTRIBUTORS.md">
<img src="https://img.shields.io/github/all-contributors/discord-tickets/bot?color=ee8449&style=flat-square"alt="All contributors">
</a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
<a
href="https://www.codacy.com/gh/discord-tickets/bot/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=discord-tickets/bot&amp;utm_campaign=Badge_Grade"><img
src="https://img.shields.io/codacy/grade/b974eb5f984c40868e07d82c968bd02d?logo=codacy&amp;style=flat-square"
alt="Codacy">
</a>
<a
href="https://lnk.earth/discord"><img
src="https://img.shields.io/discord/451745464480432129?label=discord&amp;color=7289DA&amp;style=flat-square"
alt="Discord">
</a>
</p>
</div>
<br>
<div>
<a
![bots](https://img.shields.io/badge/dynamic/json?color=5865F2&label=bots&query=clients.total&url=https%3A%2F%2Fstats.discordtickets.app%2Fapi%2Fv3%2Fcurrent&logo=discord&logoColor=white&style=for-the-badge)
![tickets](https://img.shields.io/badge/dynamic/json?color=5865F2&label=tickets&query=tickets&url=https%3A%2F%2Fstats.discordtickets.app%2Fapi%2Fv3%2Fcurrent&logo=discord&logoColor=white&style=for-the-badge)
[![GitHub stars](https://img.shields.io/github/stars/discord-tickets/bot?style=for-the-badge)](https://github.com/discord-tickets/bot/stargazers)
[![Codacy](https://img.shields.io/codacy/grade/b974eb5f984c40868e07d82c968bd02d?logo=codacy&amp;style=for-the-badge)](https://www.codacy.com/gh/discord-tickets/bot/dashboard)
[![All Contributors](https://img.shields.io/github/all-contributors/discord-tickets/bot?color=ee8449&style=for-the-badge)](https://github.com/discord-tickets/bot/blob/main/CONTRIBUTORS.md)
[![Discord](https://img.shields.io/discord/451745464480432129?label=discord&amp;color=7289DA&amp;style=for-the-badge)](https://lnk.earth/discord)
<br>
![Discord Tickets](https://static.eartharoid.me/discord-tickets/logo/wordmark/gradient-by-eartharoid.png)
**A free alternative to the premium and white-label plans of other ticket management bots.**
<br>
<a
href="https://www.producthunt.com/posts/discord-tickets?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-discord&#0045;tickets"
target="_blank">
<img
<img
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=321112&theme=light"
alt="Discord&#0032;Tickets - A&#0032;free&#0032;ticketing&#0032;solution | Product Hunt"
style="width: 250px; height: 54px;"
width="250"
height="54"
/>
</a>
</div>
</div>
</a>
<br>
---
<div align="center">
<a href="https://discordtickets.app/getting-started#pebblehost">
<img src="https://img.eartharoid.me/insecure/rs:auto:180/plain/s3://eartharoid/sharex/21/10/pebblehost.webp"/>
</a>
<br />
<br />
<b><a href="https://discordtickets.app/getting-started#pebblehost">Partnered with PebbleHost</a></b>
<br>
<sub>
for affordable bot hosting
</sub>
</div>
[![PebbleHost](https://img.eartharoid.me/insecure/rs:auto:180/plain/s3://eartharoid/sharex/21/10/pebblehost.webp)](https://pebble.host/discordtickets)
<a href="https://pebble.host/discordtickets">Partnered with PebbleHost</a>
<br>
<sub>for affordable bot hosting</sub>
---
<br>
</div>
## 📖 Contents
- [📖 Contents](#-contents)
- [✨ Features](#-features)
- [Want to learn more?](#want-to-learn-more)
- [⚡ Getting started](#-getting-started)
- [🤑 Sponsors](#-sponsors)
- [🎖️ Contributors](#-contributors)
- [💻 Contributing](#-contributing)
- [🌎 Translating](#-translating)
- [😕 Support](#-support)
- [⭐ Star History](#-star-history)
- [🥱 License](#-license)
## What is this?
## ✨ Features
Discord Tickets is a Discord bot for creating and managing ticket channels. It is a free and open-source alternative to the paid "premium" and "white-label" plans of popular ticketing bots, such as [Ticket Tool](https://tickettool.xyz/), [TicketsBot](https://ticketsbot.net/), [Tickety](https://tickety.net/), [Helper.gg](https://helper.gg/), [Helper](https://helper.wtf), and others.
- 📖 [**Documentation**](https://discordtickets.app/getting-started/) - comprehensive documentation and guides to help you get started
- ⚙️ **Simple settings** - configure your bot with the included and easy-to-use dashboard
- 🎨 **Highly customisable** - tweak features, colours, messages, and more to your liking
- 🛸 **Modern features** - including slash commands, buttons, select menus, and modals
- 🤖 **Automation** - ease your staff team's workload with configurable automation
- 🏷️ [**Tags**](https://v4--discordtickets.netlify.app/features/#tags) - resolve members' problems without escalating to tickets
- 🎫 **Tickets** - close inactive tickets automatically
- 📜 **Archiving** - store messages in the database and view transcripts later
- ❓ **Context** - ask for a topic or up to 5 custom questions before creating a ticket, and see references to a message or previous ticket at a glance
- 🗃️ **Organisation** - claim, release, move and transfer tickets between members and categories
- 🌎 [**Internationalisation**](#-translating) - available in more than 10 languages
- ⏱️ **Statistics** - analyse your staff members' performance
- 🪓 **Battle-tested** - trusted by thousands of servers, with over [half a million tickets](https://stats.discordtickets.app/) created since 2019
- 🐳 [**Docker**](https://discordtickets.app/self-hosting/installation/docker/) - reliable and quick deployment with Docker
Discord Tickets is feature-rich and much more customisable than many of the bots mentioned above. As it is intended for self-hosting, the bot can have your community/company's logo, for free.
[*...and more!*](https://discordtickets.app/features/)
Although intended for use in a single Discord server, the bot can also function in multiple servers at once if you run more than one community.
### Want to learn more?
### Features
**Visit [the website](https://discordtickets.app/) for more features, details, and screenshots**,
or skip to the [full feature tour](https://discordtickets.app/features/).
Discord Tickets is packed full of features, many of which were suggested by its users. If it's missing a feature you want, you can:
## ⚡ Getting started
- Create a plugin for it, if you can code JavaScript
- Request someone else to make a plugin
- [Submit a feature request](https://github.com/discord-tickets/.github/blob/main//CONTRIBUTING.md#submitting-a-feature-request) if you think many other users would benefit from it
> *🙏 Please read the [documentation](https://discordtickets.app/self-hosting/installation/) before you start.*
Here's some of the things that makes Discord Tickets awesome:
There are 3 ways to get started with Discord Tickets:
1. **Highly customisable**
Some messages can be configured for each server and for each ticket category. Every other message is set in the locale files, making it relatively easy to override the default messages.
You can also configure the functionality of the bot to your liking and add commands with plugins.
- ☁️ [Add the public bot](https://discordtickets.app/public/) - instant setup, but branded *(great for testing)*
- 😴 [Get a managed bot](https://discordtickets.app/managed/) - your own bot, hosted by me *(easy and cheap)*
- 🧑‍💻 [Install your own bot](https://discordtickets.app/self-hosting/) - host it yourself *(experience recommended)*
2. **Localisable**
If the bot hasn't already been translated to your (community's) language, you can [translate](https://github.com/discord-tickets/.github/blob/main//CONTRIBUTING.md#translating) it yourself.
Plugin authors are encouraged to support multiple languages as well.
**[Read the documentation](https://discordtickets.app/getting-started/)** for more information.
> This button is here because it looks nice, but I will cry if you click it before reading the [documentation](https://discordtickets.app/getting-started/). :)
<details>
<summary>Translation status</summary>
<a href="https://i18n.capestar.net/engage/discord-tickets/">
<img src="https://i18n.capestar.net/widgets/discord-tickets/-/bot/multi-auto.svg" alt="Weblate" />
</a>
</details>
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/eB6TkX?referralCode=Z3aYd2)
3. **Multiple ticket categories**
Each ticket category has its own settings for messages and the support team roles. There's also multiple methods of creating a ticket.
<!-- [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/discord-tickets/bot) -->
4. **A beautiful ticket archives portal**
**\[COMING SOON\] :** Add the official [Discord Tickets Portal](https://github.com/discord-tickets/portal) plugin for an instant ticket archives website.
## 🤑 Sponsors
You can use [text transcripts](https://discordtickets.app/plugins/official/text-transcripts/) whilst you wait for the portal.
Discord Tickets is made possible by these awesome people and organisations:
5. **Open-source and self-hosted**
It's yours, you have full control.
![Sponsors](https://cdn.jsdelivr.net/gh/eartharoid/sponsors/sponsorkit/sponsors.svg)
Please consider sponsoring the project if it adds value to your business/community.
## Getting started
| [**Host it yourself**](https://discordtickets.app/getting-started) | [**Managed bot**](https://discordtickets.app/getting-started#managed-hosting) | [**Public test bot**](https://discord.com/oauth2/authorize?permissions=8&scope=applications.commands%20bot&client_id=475371285531066368) |
|:-:|:-:|:-:|
| Recommended if you have some experience. | Recommended if you want a bot without hosting it. | Try out the bot for a few days. |
| [Go to the docs »](https://discordtickets.app/getting-started) | [Learn more »](https://discordtickets.app/getting-started#managed-hosting) | [Add to Discord »](https://discord.com/oauth2/authorize?permissions=8&scope=applications.commands%20bot&client_id=475371285531066368) |
Discord Tickets is partnered with [PebbleHost](https://discordtickets.app/getting-started#pebblehost). Click on the logo below if you want to self host but you don't have a server.
[![PebbleHost](https://img.eartharoid.me/insecure/rs:auto:180/plain/s3://eartharoid/sharex/21/10/pebblehost.webp)](https://discordtickets.app/getting-started#pebblehost)
## Documentation
You will find most of information you need at [discordtickets.app](https://discordtickets.app).
## Support
If the [documentation](https://discordtickets.app) leaves you with questions, you can ask for help in the [discussions](https://github.com/discord-tickets/bot/discussions/categories/support-q-a) or join the support server on Discord.
[![Join the Discord server](https://img.eartharoid.me/insecure/rs:auto:440:200/plain/s3://eartharoid/images/join-discord.png@png)](https://lnk.earth/discord)
## Contributing
For contributing instructions, or to find out all of the ways you can contribute, read [CONTRIBUTING.md](https://github.com/discord-tickets/.github/blob/main//CONTRIBUTING.md). All contributions are welcome and encouraged, but please [read the information](https://github.com/discord-tickets/.github/blob/main//CONTRIBUTING.md) given before doing so.
## Contributors
Thank you to everyone to has contributed to Discord Tickets, including everyone who has:
- Contributed code
- Translated
- Improved documentation
- Reported bugs
- Requested a feature
- Given help or support to other users
- Created a public plugin
- Created resources such as tutorials
**A full list of contributors can be found in [CONTRIBUTORS.md](https://github.com/discord-tickets/bot/blob/main/CONTRIBUTORS.md).**
## Sponsors
*Does your community or company use Discord Tickets? [Sponsor the project](https://github.com/discord-tickets/bot/?sponsor=1) to get your logo shown here.*
**These awesome people and communities sponsor Discord Tickets:**
<table>
<tr>
<td align="center">
<a href="https://reskybounds.com">
<img
src="https://img.eartharoid.me/insecure/rs:auto:256/plain/s3://eartharoid/k/22/05/reskybounds.png"
height="128px;"
alt="" />
<br />
<sub><b>reSkybounds</b></sub>
</a>
</td>
<td align="center">
<a href="https://darkhosting.club">
<img
src="https://cdn.discordapp.com/attachments/920423855636496387/943574596777549894/attachment.png"
height="128px;"
alt="" />
<br />
<sub><b>DarkHosting™</b></sub>
</a>
</td>
<td align="center">
<a href="https://simplyvanilla.net">
<img
src="https://i.imghut.com/2022/04/26/sv-2022-discord-static.png"
height="128px;"
alt="" />
<br />
<sub><b>Simply Vanilla</b></sub>
</a>
</td>
<td align="center">
<a href="https://urhost.io/">
<img
src="https://static.eartharoid.me/k/22/05/urhost.png"
height="128px;"
alt="" />
<br />
<sub><b>URHOST</b></sub>
</a>
</td>
<td align="center">
<a href="https://sunrisenode.com">
<img
src="https://i.imgur.com/0gHlN7L.png"
height="128px;"
alt="" />
<br />
<sub><b>SunriseNode</b></sub>
</a>
</td>
</tr>
</table>
### Donate
**[Sponsor on GitHub](https://github.com/discord-tickets/bot/?sponsor=1)**, or
[![Donate at ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/eartharoid)
## Star History
> **Note**
>
> Your logo will only appear here if you sponsor through GitHub Sponsors.
> [Create an organisation](https://github.com/account/organizations/new?plan=free) if you want to use your business/community logo.
[![Star History Chart](https://api.star-history.com/svg?repos=discord-tickets/bot&type=Date)](https://star-history.com/#discord-tickets/bot&Date)
## 🎖️ Contributors
<!-- [![Contributors](https://contrib.rocks/image?repo=discord-tickets/bot)](https://github.com/discord-tickets/bot/graphs/contributors) -->
Discord Tickets is made possible by all of the people listed in [CONTRIBUTORS.md](https://github.com/discord-tickets/bot/blob/main/CONTRIBUTORS.md).
## License
### 💻 Contributing
Discord Tickets is licensed under the [GPLv3 license](https://github.com/discord-tickets/bot/blob/main/LICENSE).
If you want to help translate, suggest a feature, submit a bug report,
or contribute in any other way, please read the [contributing guidelines](https://github.com/discord-tickets/.github/blob/main//CONTRIBUTING.md).
> **Note**
> You can add yourself to the list of contributors [here](https://github.com/discord-tickets/bot/issues/new/choose).
#### 🌎 Translating
[![Translation status](https://hosted.weblate.org/widgets/discord-tickets/-/open-graph.png)](https://hosted.weblate.org/engage/discord-tickets/)
## 😕 Support
[![Discord](https://discordapp.com/api/guilds/451745464480432129/widget.png?style=banner4)](https://lnk.earth/discord)
## ⭐ Star History
<details>
<summary>Show graph</summary>
[![Star History Chart](https://api.star-history.com/svg?repos=discord-tickets/bot&type=Date)](https://star-history.com/#discord-tickets/bot&Date)
</details>
## 🥱 License
Discord Tickets by eartharoid™ is licensed under the [GPLv3 license](https://github.com/discord-tickets/bot/blob/main/LICENSE).
This is not an official Discord product. It is not affiliated with nor endorsed by Discord Inc.
© 2021 Isaac Saunders
© 2023 Isaac Saunders

View File

@ -1,20 +0,0 @@
# Use the alpine image of node 16
FROM node:16-alpine
# Create a dir for the app and make it owned by a non-root user (node)
RUN mkdir /tickets && \
chown -R 1000:1000 /tickets
WORKDIR /tickets
# Change user to node
USER node
# Install packages
COPY --chown=1000:1000 package.json pnpm-lock.yaml ./
RUN npx pnpm install --prod --frozen-lockfile --no-optional && \
# Currently WIP since pnpm installs dev deps automatically when I don't want it to.
# Quick fix is to add to main deps
npx pnpm install mysql2
# Set the command
CMD ["node", "src/"]

View File

@ -0,0 +1,257 @@
-- CreateTable
CREATE TABLE `archivedChannels` (
`channelId` VARCHAR(19) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`name` VARCHAR(191) NOT NULL,
`ticketId` VARCHAR(19) NOT NULL,
UNIQUE INDEX `archivedChannels_ticketId_channelId_key`(`ticketId`, `channelId`),
PRIMARY KEY (`ticketId`, `channelId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `archivedMessages` (
`authorId` VARCHAR(19) NOT NULL,
`content` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`deleted` BOOLEAN NOT NULL DEFAULT false,
`edited` BOOLEAN NOT NULL DEFAULT false,
`external` BOOLEAN NOT NULL DEFAULT false,
`id` VARCHAR(19) NOT NULL,
`ticketId` VARCHAR(19) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `archivedRoles` (
`colour` CHAR(6) NOT NULL DEFAULT '5865F2',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`name` VARCHAR(191) NOT NULL,
`roleId` VARCHAR(19) NOT NULL,
`ticketId` VARCHAR(19) NOT NULL,
UNIQUE INDEX `archivedRoles_ticketId_roleId_key`(`ticketId`, `roleId`),
PRIMARY KEY (`ticketId`, `roleId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `archivedUsers` (
`avatar` VARCHAR(191) NULL,
`bot` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`discriminator` CHAR(4) NULL,
`displayName` TEXT NULL,
`roleId` VARCHAR(19) NULL,
`ticketId` VARCHAR(19) NOT NULL,
`userId` VARCHAR(19) NOT NULL,
`username` TEXT NULL,
UNIQUE INDEX `archivedUsers_ticketId_userId_key`(`ticketId`, `userId`),
PRIMARY KEY (`ticketId`, `userId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `categories` (
`channelName` VARCHAR(191) NOT NULL,
`claiming` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`cooldown` INTEGER NULL,
`customTopic` VARCHAR(191) NULL,
`description` VARCHAR(191) NOT NULL,
`discordCategory` VARCHAR(19) NOT NULL,
`emoji` VARCHAR(191) NOT NULL,
`enableFeedback` BOOLEAN NOT NULL DEFAULT false,
`guildId` VARCHAR(19) NOT NULL,
`id` INTEGER NOT NULL AUTO_INCREMENT,
`image` VARCHAR(191) NULL,
`memberLimit` INTEGER NOT NULL DEFAULT 1,
`name` VARCHAR(191) NOT NULL,
`openingMessage` TEXT NOT NULL,
`pingRoles` JSON NOT NULL,
`ratelimit` INTEGER NULL,
`requiredRoles` JSON NOT NULL,
`requireTopic` BOOLEAN NOT NULL DEFAULT false,
`staffRoles` JSON NOT NULL,
`totalLimit` INTEGER NOT NULL DEFAULT 50,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `feedback` (
`comment` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`guildId` VARCHAR(19) NOT NULL,
`rating` INTEGER NOT NULL,
`ticketId` VARCHAR(19) NOT NULL,
`userId` VARCHAR(19) NULL,
PRIMARY KEY (`ticketId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `guilds` (
`autoClose` INTEGER NOT NULL DEFAULT 43200000,
`autoTag` JSON NOT NULL,
`archive` BOOLEAN NOT NULL DEFAULT true,
`blocklist` JSON NOT NULL,
`claimButton` BOOLEAN NOT NULL DEFAULT false,
`closeButton` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`errorColour` VARCHAR(191) NOT NULL DEFAULT 'Red',
`footer` VARCHAR(191) NULL DEFAULT 'Discord Tickets by eartharoid',
`id` VARCHAR(19) NOT NULL,
`locale` VARCHAR(191) NOT NULL DEFAULT 'en-GB',
`logChannel` VARCHAR(19) NULL,
`primaryColour` VARCHAR(191) NOT NULL DEFAULT '#009999',
`staleAfter` INTEGER NULL,
`successColour` VARCHAR(191) NOT NULL DEFAULT 'Green',
`workingHours` JSON NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `questions` (
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`id` VARCHAR(191) NOT NULL,
`categoryId` INTEGER NOT NULL,
`label` VARCHAR(45) NOT NULL,
`maxLength` INTEGER NULL DEFAULT 4000,
`minLength` INTEGER NULL DEFAULT 0,
`options` JSON NOT NULL,
`order` INTEGER NOT NULL,
`placeholder` VARCHAR(100) NULL,
`required` BOOLEAN NOT NULL DEFAULT true,
`style` INTEGER NOT NULL DEFAULT 2,
`type` ENUM('MENU', 'TEXT') NOT NULL DEFAULT 'TEXT',
`value` TEXT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `questionAnswers` (
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`id` INTEGER NOT NULL AUTO_INCREMENT,
`ticketId` VARCHAR(19) NOT NULL,
`questionId` VARCHAR(191) NOT NULL,
`userId` VARCHAR(19) NOT NULL,
`value` TEXT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `tags` (
`content` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`guildId` VARCHAR(19) NOT NULL,
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`regex` VARCHAR(191) NULL,
UNIQUE INDEX `tags_guildId_name_key`(`guildId`, `name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `tickets` (
`categoryId` INTEGER NULL,
`claimedById` VARCHAR(19) NULL,
`closedAt` DATETIME(3) NULL,
`closedById` VARCHAR(19) NULL,
`closedReason` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`createdById` VARCHAR(19) NOT NULL,
`deleted` BOOLEAN NOT NULL DEFAULT false,
`firstResponseAt` DATETIME(3) NULL,
`guildId` VARCHAR(19) NOT NULL,
`id` VARCHAR(19) NOT NULL,
`lastMessageAt` DATETIME(3) NULL,
`messageCount` INTEGER NULL,
`number` INTEGER NOT NULL,
`open` BOOLEAN NOT NULL DEFAULT true,
`openingMessageId` VARCHAR(19) NOT NULL,
`pinnedMessageIds` JSON NOT NULL,
`priority` ENUM('LOW', 'MEDIUM', 'HIGH') NULL,
`referencesMessageId` VARCHAR(19) NULL,
`referencesTicketId` VARCHAR(19) NULL,
`topic` TEXT NULL,
UNIQUE INDEX `tickets_guildId_number_key`(`guildId`, `number`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `users` (
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`id` VARCHAR(19) NOT NULL,
`messageCount` INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `archivedChannels` ADD CONSTRAINT `archivedChannels_ticketId_fkey` FOREIGN KEY (`ticketId`) REFERENCES `tickets`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `archivedMessages` ADD CONSTRAINT `archivedMessages_ticketId_authorId_fkey` FOREIGN KEY (`ticketId`, `authorId`) REFERENCES `archivedUsers`(`ticketId`, `userId`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `archivedMessages` ADD CONSTRAINT `archivedMessages_ticketId_fkey` FOREIGN KEY (`ticketId`) REFERENCES `tickets`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `archivedRoles` ADD CONSTRAINT `archivedRoles_ticketId_fkey` FOREIGN KEY (`ticketId`) REFERENCES `tickets`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `archivedUsers` ADD CONSTRAINT `archivedUsers_ticketId_roleId_fkey` FOREIGN KEY (`ticketId`, `roleId`) REFERENCES `archivedRoles`(`ticketId`, `roleId`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `archivedUsers` ADD CONSTRAINT `archivedUsers_ticketId_fkey` FOREIGN KEY (`ticketId`) REFERENCES `tickets`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `categories` ADD CONSTRAINT `categories_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `guilds`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `feedback` ADD CONSTRAINT `feedback_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `guilds`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `feedback` ADD CONSTRAINT `feedback_ticketId_fkey` FOREIGN KEY (`ticketId`) REFERENCES `tickets`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `feedback` ADD CONSTRAINT `feedback_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `questions` ADD CONSTRAINT `questions_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `categories`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `questionAnswers` ADD CONSTRAINT `questionAnswers_ticketId_fkey` FOREIGN KEY (`ticketId`) REFERENCES `tickets`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `questionAnswers` ADD CONSTRAINT `questionAnswers_questionId_fkey` FOREIGN KEY (`questionId`) REFERENCES `questions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `questionAnswers` ADD CONSTRAINT `questionAnswers_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `tags` ADD CONSTRAINT `tags_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `guilds`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `tickets` ADD CONSTRAINT `tickets_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `categories`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `tickets` ADD CONSTRAINT `tickets_claimedById_fkey` FOREIGN KEY (`claimedById`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `tickets` ADD CONSTRAINT `tickets_closedById_fkey` FOREIGN KEY (`closedById`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `tickets` ADD CONSTRAINT `tickets_createdById_fkey` FOREIGN KEY (`createdById`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `tickets` ADD CONSTRAINT `tickets_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `guilds`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `tickets` ADD CONSTRAINT `tickets_referencesTicketId_fkey` FOREIGN KEY (`referencesTicketId`) REFERENCES `tickets`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

248
db/mysql/schema.prisma Normal file
View File

@ -0,0 +1,248 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DB_CONNECTION_URL")
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}
model ArchivedChannel {
channelId String @db.VarChar(19)
createdAt DateTime @default(now())
name String
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
@@id([ticketId, channelId])
@@unique([ticketId, channelId])
@@map("archivedChannels")
}
model ArchivedMessage {
author ArchivedUser @relation(fields: [ticketId, authorId], references: [ticketId, userId], onDelete: Cascade)
authorId String @db.VarChar(19)
content String @db.Text
createdAt DateTime @default(now())
deleted Boolean @default(false)
edited Boolean @default(false)
external Boolean @default(false)
id String @id @db.VarChar(19)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
@@map("archivedMessages")
}
model ArchivedRole {
archivedUsers ArchivedUser[]
colour String @default("5865F2") @db.Char(6) // 7289DA
createdAt DateTime @default(now())
name String
roleId String @db.VarChar(19)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
@@id([ticketId, roleId])
@@unique([ticketId, roleId])
@@map("archivedRoles")
}
model ArchivedUser {
archivedMessages ArchivedMessage[]
avatar String?
bot Boolean @default(false)
createdAt DateTime @default(now())
discriminator String? @db.Char(4)
displayName String? @db.Text
role ArchivedRole? @relation(fields: [ticketId, roleId], references: [ticketId, roleId], onDelete: Cascade)
roleId String? @db.VarChar(19)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
userId String @db.VarChar(19)
username String? @db.Text
@@id([ticketId, userId])
@@unique([ticketId, userId])
@@map("archivedUsers")
}
model Category {
channelName String
claiming Boolean @default(false)
createdAt DateTime @default(now())
cooldown Int?
customTopic String?
description String
discordCategory String @db.VarChar(19)
emoji String
enableFeedback Boolean @default(false)
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id Int @id @default(autoincrement())
image String?
memberLimit Int @default(1)
name String
openingMessage String @db.Text
pingRoles Json @default("[]")
questions Question[]
ratelimit Int?
requiredRoles Json @default("[]")
requireTopic Boolean @default(false)
staffRoles Json
tickets Ticket[]
totalLimit Int @default(50)
@@map("categories")
}
model Feedback {
comment String? @db.Text
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
rating Int
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @id @db.VarChar(19)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String? @db.VarChar(19)
@@map("feedback")
}
model Guild {
autoClose Int @default(43200000)
autoTag Json @default("[]")
archive Boolean @default(true)
blocklist Json @default("[]")
categories Category[]
claimButton Boolean @default(false)
closeButton Boolean @default(false)
createdAt DateTime @default(now())
errorColour String @default("Red")
feedback Feedback[]
footer String? @default("Discord Tickets by eartharoid")
id String @id @db.VarChar(19)
locale String @default("en-GB")
logChannel String? @db.VarChar(19)
primaryColour String @default("#009999")
staleAfter Int?
successColour String @default("Green")
tags Tag[]
tickets Ticket[]
workingHours Json @default("[\"UTC\", [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"]]")
@@map("guilds")
}
model Question {
answers QuestionAnswer[]
createdAt DateTime @default(now())
id String @id @default(uuid())
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId Int
label String @db.VarChar(45)
maxLength Int? @default(4000)
minLength Int? @default(0)
options Json @default("[]")
order Int
placeholder String? @db.VarChar(100)
required Boolean @default(true)
style Int @default(2)
type QuestionType @default(TEXT)
value String? @db.Text
@@map("questions")
}
model QuestionAnswer {
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
questionId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.VarChar(19)
value String? @db.Text
@@map("questionAnswers")
}
model Tag {
content String @db.Text
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id Int @id @default(autoincrement())
name String
regex String?
@@unique([guildId, name])
@@map("tags")
}
model Ticket {
archivedChannels ArchivedChannel[]
archivedMessages ArchivedMessage[]
archivedRoles ArchivedRole[]
archivedUsers ArchivedUser[]
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId Int?
claimedBy User? @relation(name: "TicketsClaimedByUser", fields: [claimedById], references: [id])
claimedById String? @db.VarChar(19)
closedAt DateTime?
closedBy User? @relation(name: "TicketsClosedByUser", fields: [closedById], references: [id])
closedById String? @db.VarChar(19)
closedReason String? @db.Text
createdAt DateTime @default(now())
createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id])
createdById String @db.VarChar(19)
deleted Boolean @default(false)
feedback Feedback?
firstResponseAt DateTime?
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id String @id @db.VarChar(19)
lastMessageAt DateTime?
messageCount Int?
number Int
open Boolean @default(true)
openingMessageId String @db.VarChar(19)
pinnedMessageIds Json @default("[]")
priority TicketPriority?
referencedBy Ticket[] @relation("TicketsReferencedByTicket")
referencesMessageId String? @db.VarChar(19)
referencesTicket Ticket? @relation(name: "TicketsReferencedByTicket", fields: [referencesTicketId], references: [id], onDelete: SetNull)
referencesTicketId String? @db.VarChar(19)
topic String? @db.Text
questionAnswers QuestionAnswer[]
@@unique([guildId, number])
@@map("tickets")
}
model User {
createdAt DateTime @default(now())
feedback Feedback[]
id String @id @db.VarChar(19)
messageCount Int @default(0)
ticketsCreated Ticket[] @relation("TicketsCreatedByUser")
ticketsClosed Ticket[] @relation("TicketsClosedByUser")
ticketsClaimed Ticket[] @relation("TicketsClaimedByUser")
questionAnswers QuestionAnswer[]
@@map("users")
}
enum TicketPriority {
LOW
MEDIUM
HIGH
}
enum QuestionType {
MENU
TEXT
}

View File

@ -0,0 +1,273 @@
-- CreateEnum
CREATE TYPE "TicketPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
-- CreateEnum
CREATE TYPE "QuestionType" AS ENUM ('MENU', 'TEXT');
-- CreateTable
CREATE TABLE "archivedChannels" (
"channelId" VARCHAR(19) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"ticketId" VARCHAR(19) NOT NULL,
CONSTRAINT "archivedChannels_pkey" PRIMARY KEY ("ticketId","channelId")
);
-- CreateTable
CREATE TABLE "archivedMessages" (
"authorId" VARCHAR(19) NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"edited" BOOLEAN NOT NULL DEFAULT false,
"external" BOOLEAN NOT NULL DEFAULT false,
"id" VARCHAR(19) NOT NULL,
"ticketId" VARCHAR(19) NOT NULL,
CONSTRAINT "archivedMessages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "archivedRoles" (
"colour" CHAR(6) NOT NULL DEFAULT '5865F2',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"roleId" VARCHAR(19) NOT NULL,
"ticketId" VARCHAR(19) NOT NULL,
CONSTRAINT "archivedRoles_pkey" PRIMARY KEY ("ticketId","roleId")
);
-- CreateTable
CREATE TABLE "archivedUsers" (
"avatar" TEXT,
"bot" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"discriminator" CHAR(4),
"displayName" TEXT,
"roleId" VARCHAR(19),
"ticketId" VARCHAR(19) NOT NULL,
"userId" VARCHAR(19) NOT NULL,
"username" TEXT,
CONSTRAINT "archivedUsers_pkey" PRIMARY KEY ("ticketId","userId")
);
-- CreateTable
CREATE TABLE "categories" (
"channelName" TEXT NOT NULL,
"claiming" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cooldown" INTEGER,
"customTopic" TEXT,
"description" TEXT NOT NULL,
"discordCategory" VARCHAR(19) NOT NULL,
"emoji" TEXT NOT NULL,
"enableFeedback" BOOLEAN NOT NULL DEFAULT false,
"guildId" VARCHAR(19) NOT NULL,
"id" SERIAL NOT NULL,
"image" TEXT,
"memberLimit" INTEGER NOT NULL DEFAULT 1,
"name" TEXT NOT NULL,
"openingMessage" TEXT NOT NULL,
"pingRoles" JSONB NOT NULL DEFAULT '[]',
"ratelimit" INTEGER,
"requiredRoles" JSONB NOT NULL DEFAULT '[]',
"requireTopic" BOOLEAN NOT NULL DEFAULT false,
"staffRoles" JSONB NOT NULL,
"totalLimit" INTEGER NOT NULL DEFAULT 50,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "feedback" (
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"guildId" VARCHAR(19) NOT NULL,
"rating" INTEGER NOT NULL,
"ticketId" VARCHAR(19) NOT NULL,
"userId" VARCHAR(19),
CONSTRAINT "feedback_pkey" PRIMARY KEY ("ticketId")
);
-- CreateTable
CREATE TABLE "guilds" (
"autoClose" INTEGER NOT NULL DEFAULT 43200000,
"autoTag" JSONB NOT NULL DEFAULT '[]',
"archive" BOOLEAN NOT NULL DEFAULT true,
"blocklist" JSONB NOT NULL DEFAULT '[]',
"claimButton" BOOLEAN NOT NULL DEFAULT false,
"closeButton" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"errorColour" TEXT NOT NULL DEFAULT 'Red',
"footer" TEXT DEFAULT 'Discord Tickets by eartharoid',
"id" VARCHAR(19) NOT NULL,
"locale" TEXT NOT NULL DEFAULT 'en-GB',
"logChannel" VARCHAR(19),
"primaryColour" TEXT NOT NULL DEFAULT '#009999',
"staleAfter" INTEGER,
"successColour" TEXT NOT NULL DEFAULT 'Green',
"workingHours" JSONB NOT NULL DEFAULT '["UTC", ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"]]',
CONSTRAINT "guilds_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "questions" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"id" TEXT NOT NULL,
"categoryId" INTEGER NOT NULL,
"label" VARCHAR(45) NOT NULL,
"maxLength" INTEGER DEFAULT 4000,
"minLength" INTEGER DEFAULT 0,
"options" JSONB NOT NULL DEFAULT '[]',
"order" INTEGER NOT NULL,
"placeholder" VARCHAR(100),
"required" BOOLEAN NOT NULL DEFAULT true,
"style" INTEGER NOT NULL DEFAULT 2,
"type" "QuestionType" NOT NULL DEFAULT 'TEXT',
"value" TEXT,
CONSTRAINT "questions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "questionAnswers" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"id" SERIAL NOT NULL,
"ticketId" VARCHAR(19) NOT NULL,
"questionId" TEXT NOT NULL,
"userId" VARCHAR(19) NOT NULL,
"value" TEXT,
CONSTRAINT "questionAnswers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tags" (
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"guildId" VARCHAR(19) NOT NULL,
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"regex" TEXT,
CONSTRAINT "tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tickets" (
"categoryId" INTEGER,
"claimedById" VARCHAR(19),
"closedAt" TIMESTAMP(3),
"closedById" VARCHAR(19),
"closedReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdById" VARCHAR(19) NOT NULL,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"firstResponseAt" TIMESTAMP(3),
"guildId" VARCHAR(19) NOT NULL,
"id" VARCHAR(19) NOT NULL,
"lastMessageAt" TIMESTAMP(3),
"messageCount" INTEGER,
"number" INTEGER NOT NULL,
"open" BOOLEAN NOT NULL DEFAULT true,
"openingMessageId" VARCHAR(19) NOT NULL,
"pinnedMessageIds" JSONB NOT NULL DEFAULT '[]',
"priority" "TicketPriority",
"referencesMessageId" VARCHAR(19),
"referencesTicketId" VARCHAR(19),
"topic" TEXT,
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"id" VARCHAR(19) NOT NULL,
"messageCount" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "archivedChannels_ticketId_channelId_key" ON "archivedChannels"("ticketId", "channelId");
-- CreateIndex
CREATE UNIQUE INDEX "archivedRoles_ticketId_roleId_key" ON "archivedRoles"("ticketId", "roleId");
-- CreateIndex
CREATE UNIQUE INDEX "archivedUsers_ticketId_userId_key" ON "archivedUsers"("ticketId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "tags_guildId_name_key" ON "tags"("guildId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "tickets_guildId_number_key" ON "tickets"("guildId", "number");
-- AddForeignKey
ALTER TABLE "archivedChannels" ADD CONSTRAINT "archivedChannels_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "archivedMessages" ADD CONSTRAINT "archivedMessages_ticketId_authorId_fkey" FOREIGN KEY ("ticketId", "authorId") REFERENCES "archivedUsers"("ticketId", "userId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "archivedMessages" ADD CONSTRAINT "archivedMessages_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "archivedRoles" ADD CONSTRAINT "archivedRoles_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "archivedUsers" ADD CONSTRAINT "archivedUsers_ticketId_roleId_fkey" FOREIGN KEY ("ticketId", "roleId") REFERENCES "archivedRoles"("ticketId", "roleId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "archivedUsers" ADD CONSTRAINT "archivedUsers_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback" ADD CONSTRAINT "feedback_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback" ADD CONSTRAINT "feedback_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "feedback" ADD CONSTRAINT "feedback_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "questions" ADD CONSTRAINT "questions_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "questionAnswers" ADD CONSTRAINT "questionAnswers_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "questionAnswers" ADD CONSTRAINT "questionAnswers_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "questions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "questionAnswers" ADD CONSTRAINT "questionAnswers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tags" ADD CONSTRAINT "tags_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_claimedById_fkey" FOREIGN KEY ("claimedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_referencesTicketId_fkey" FOREIGN KEY ("referencesTicketId") REFERENCES "tickets"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

247
db/postgresql/schema.prisma Normal file
View File

@ -0,0 +1,247 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DB_CONNECTION_URL")
}
model ArchivedChannel {
channelId String @db.VarChar(19)
createdAt DateTime @default(now())
name String
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
@@id([ticketId, channelId])
@@unique([ticketId, channelId])
@@map("archivedChannels")
}
model ArchivedMessage {
author ArchivedUser @relation(fields: [ticketId, authorId], references: [ticketId, userId], onDelete: Cascade)
authorId String @db.VarChar(19)
content String @db.Text
createdAt DateTime @default(now())
deleted Boolean @default(false)
edited Boolean @default(false)
external Boolean @default(false)
id String @id @db.VarChar(19)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
@@map("archivedMessages")
}
model ArchivedRole {
archivedUsers ArchivedUser[]
colour String @default("5865F2") @db.Char(6) // 7289DA
createdAt DateTime @default(now())
name String
roleId String @db.VarChar(19)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
@@id([ticketId, roleId])
@@unique([ticketId, roleId])
@@map("archivedRoles")
}
model ArchivedUser {
archivedMessages ArchivedMessage[]
avatar String?
bot Boolean @default(false)
createdAt DateTime @default(now())
discriminator String? @db.Char(4)
displayName String? @db.Text
role ArchivedRole? @relation(fields: [ticketId, roleId], references: [ticketId, roleId], onDelete: Cascade)
roleId String? @db.VarChar(19)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
userId String @db.VarChar(19)
username String? @db.Text
@@id([ticketId, userId])
@@unique([ticketId, userId])
@@map("archivedUsers")
}
model Category {
channelName String
claiming Boolean @default(false)
createdAt DateTime @default(now())
cooldown Int?
customTopic String?
description String
discordCategory String @db.VarChar(19)
emoji String
enableFeedback Boolean @default(false)
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id Int @id @default(autoincrement())
image String?
memberLimit Int @default(1)
name String
openingMessage String @db.Text
pingRoles Json @default("[]")
questions Question[]
ratelimit Int?
requiredRoles Json @default("[]")
requireTopic Boolean @default(false)
staffRoles Json
tickets Ticket[]
totalLimit Int @default(50)
@@map("categories")
}
model Feedback {
comment String? @db.Text
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
rating Int
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @id @db.VarChar(19)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String? @db.VarChar(19)
@@map("feedback")
}
model Guild {
autoClose Int @default(43200000)
autoTag Json @default("[]")
archive Boolean @default(true)
blocklist Json @default("[]")
categories Category[]
claimButton Boolean @default(false)
closeButton Boolean @default(false)
createdAt DateTime @default(now())
errorColour String @default("Red")
feedback Feedback[]
footer String? @default("Discord Tickets by eartharoid")
id String @id @db.VarChar(19)
locale String @default("en-GB")
logChannel String? @db.VarChar(19)
primaryColour String @default("#009999")
staleAfter Int?
successColour String @default("Green")
tags Tag[]
tickets Ticket[]
workingHours Json @default("[\"UTC\", [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"]]")
@@map("guilds")
}
model Question {
answers QuestionAnswer[]
createdAt DateTime @default(now())
id String @id @default(uuid())
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId Int
label String @db.VarChar(45)
maxLength Int? @default(4000)
minLength Int? @default(0)
options Json @default("[]")
order Int
placeholder String? @db.VarChar(100)
required Boolean @default(true)
style Int @default(2)
type QuestionType @default(TEXT)
value String? @db.Text
@@map("questions")
}
model QuestionAnswer {
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @db.VarChar(19)
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
questionId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.VarChar(19)
value String? @db.Text
@@map("questionAnswers")
}
model Tag {
content String @db.Text
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id Int @id @default(autoincrement())
name String
regex String?
@@unique([guildId, name])
@@map("tags")
}
model Ticket {
archivedChannels ArchivedChannel[]
archivedMessages ArchivedMessage[]
archivedRoles ArchivedRole[]
archivedUsers ArchivedUser[]
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId Int?
claimedBy User? @relation(name: "TicketsClaimedByUser", fields: [claimedById], references: [id])
claimedById String? @db.VarChar(19)
closedAt DateTime?
closedBy User? @relation(name: "TicketsClosedByUser", fields: [closedById], references: [id])
closedById String? @db.VarChar(19)
closedReason String? @db.Text
createdAt DateTime @default(now())
createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id])
createdById String @db.VarChar(19)
deleted Boolean @default(false)
feedback Feedback?
firstResponseAt DateTime?
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id String @id @db.VarChar(19)
lastMessageAt DateTime?
messageCount Int?
number Int
open Boolean @default(true)
openingMessageId String @db.VarChar(19)
pinnedMessageIds Json @default("[]")
priority TicketPriority?
referencedBy Ticket[] @relation("TicketsReferencedByTicket")
referencesMessageId String? @db.VarChar(19)
referencesTicket Ticket? @relation(name: "TicketsReferencedByTicket", fields: [referencesTicketId], references: [id], onDelete: SetNull)
referencesTicketId String? @db.VarChar(19)
topic String? @db.Text
questionAnswers QuestionAnswer[]
@@unique([guildId, number])
@@map("tickets")
}
model User {
createdAt DateTime @default(now())
feedback Feedback[]
id String @id @db.VarChar(19)
messageCount Int @default(0)
ticketsCreated Ticket[] @relation("TicketsCreatedByUser")
ticketsClosed Ticket[] @relation("TicketsClosedByUser")
ticketsClaimed Ticket[] @relation("TicketsClaimedByUser")
questionAnswers QuestionAnswer[]
@@map("users")
}
enum TicketPriority {
LOW
MEDIUM
HIGH
}
enum QuestionType {
MENU
TEXT
}

View File

@ -0,0 +1,207 @@
-- CreateTable
CREATE TABLE "archivedChannels" (
"channelId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
PRIMARY KEY ("ticketId", "channelId"),
CONSTRAINT "archivedChannels_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "archivedMessages" (
"authorId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"edited" BOOLEAN NOT NULL DEFAULT false,
"id" TEXT NOT NULL PRIMARY KEY,
"external" BOOLEAN NOT NULL DEFAULT false,
"ticketId" TEXT NOT NULL,
CONSTRAINT "archivedMessages_ticketId_authorId_fkey" FOREIGN KEY ("ticketId", "authorId") REFERENCES "archivedUsers" ("ticketId", "userId") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "archivedMessages_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "archivedRoles" (
"colour" TEXT NOT NULL DEFAULT '5865F2',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
PRIMARY KEY ("ticketId", "roleId"),
CONSTRAINT "archivedRoles_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "archivedUsers" (
"avatar" TEXT,
"bot" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"discriminator" TEXT,
"displayName" TEXT,
"roleId" TEXT,
"ticketId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"username" TEXT,
PRIMARY KEY ("ticketId", "userId"),
CONSTRAINT "archivedUsers_ticketId_roleId_fkey" FOREIGN KEY ("ticketId", "roleId") REFERENCES "archivedRoles" ("ticketId", "roleId") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "archivedUsers_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "categories" (
"channelName" TEXT NOT NULL,
"claiming" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cooldown" INTEGER,
"customTopic" TEXT,
"description" TEXT NOT NULL,
"discordCategory" TEXT NOT NULL,
"emoji" TEXT NOT NULL,
"enableFeedback" BOOLEAN NOT NULL DEFAULT false,
"guildId" TEXT NOT NULL,
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"image" TEXT,
"memberLimit" INTEGER NOT NULL DEFAULT 1,
"name" TEXT NOT NULL,
"openingMessage" TEXT NOT NULL,
"pingRoles" TEXT NOT NULL DEFAULT '[]',
"ratelimit" INTEGER,
"requiredRoles" TEXT NOT NULL DEFAULT '[]',
"requireTopic" BOOLEAN NOT NULL DEFAULT false,
"staffRoles" TEXT NOT NULL,
"totalLimit" INTEGER NOT NULL DEFAULT 50,
CONSTRAINT "categories_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "feedback" (
"comment" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"guildId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"ticketId" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT,
CONSTRAINT "feedback_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "feedback_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "feedback_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "guilds" (
"autoClose" INTEGER NOT NULL DEFAULT 43200000,
"autoTag" TEXT NOT NULL DEFAULT '[]',
"archive" BOOLEAN NOT NULL DEFAULT true,
"blocklist" TEXT NOT NULL DEFAULT '[]',
"claimButton" BOOLEAN NOT NULL DEFAULT false,
"closeButton" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"errorColour" TEXT NOT NULL DEFAULT 'Red',
"footer" TEXT DEFAULT 'Discord Tickets by eartharoid',
"id" TEXT NOT NULL PRIMARY KEY,
"locale" TEXT NOT NULL DEFAULT 'en-GB',
"logChannel" TEXT,
"primaryColour" TEXT NOT NULL DEFAULT '#009999',
"staleAfter" INTEGER,
"successColour" TEXT NOT NULL DEFAULT 'Green',
"workingHours" TEXT NOT NULL DEFAULT '["UTC", ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"], ["00:00","23:59"]]'
);
-- CreateTable
CREATE TABLE "questions" (
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"id" TEXT NOT NULL PRIMARY KEY,
"categoryId" INTEGER NOT NULL,
"label" TEXT NOT NULL,
"maxLength" INTEGER DEFAULT 4000,
"minLength" INTEGER DEFAULT 0,
"options" TEXT NOT NULL DEFAULT '[]',
"order" INTEGER NOT NULL,
"placeholder" TEXT,
"required" BOOLEAN NOT NULL DEFAULT true,
"style" INTEGER NOT NULL DEFAULT 2,
"type" TEXT NOT NULL DEFAULT 'TEXT',
"value" TEXT,
CONSTRAINT "questions_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "questionAnswers" (
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"ticketId" TEXT NOT NULL,
"questionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"value" TEXT,
CONSTRAINT "questionAnswers_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "questionAnswers_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "questions" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "questionAnswers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "tags" (
"content" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"guildId" TEXT NOT NULL,
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"regex" TEXT,
CONSTRAINT "tags_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "tickets" (
"categoryId" INTEGER,
"claimedById" TEXT,
"closedAt" DATETIME,
"closedById" TEXT,
"closedReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdById" TEXT NOT NULL,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"firstResponseAt" DATETIME,
"guildId" TEXT NOT NULL,
"id" TEXT NOT NULL PRIMARY KEY,
"lastMessageAt" DATETIME,
"messageCount" INTEGER,
"number" INTEGER NOT NULL,
"open" BOOLEAN NOT NULL DEFAULT true,
"openingMessageId" TEXT NOT NULL,
"pinnedMessageIds" TEXT NOT NULL DEFAULT '[]',
"priority" TEXT,
"referencesMessageId" TEXT,
"referencesTicketId" TEXT,
"topic" TEXT,
CONSTRAINT "tickets_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "tickets_claimedById_fkey" FOREIGN KEY ("claimedById") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "tickets_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "tickets_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "tickets_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "tickets_referencesTicketId_fkey" FOREIGN KEY ("referencesTicketId") REFERENCES "tickets" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "users" (
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"id" TEXT NOT NULL PRIMARY KEY,
"messageCount" INTEGER NOT NULL DEFAULT 0
);
-- CreateIndex
CREATE UNIQUE INDEX "archivedChannels_ticketId_channelId_key" ON "archivedChannels"("ticketId", "channelId");
-- CreateIndex
CREATE UNIQUE INDEX "archivedRoles_ticketId_roleId_key" ON "archivedRoles"("ticketId", "roleId");
-- CreateIndex
CREATE UNIQUE INDEX "archivedUsers_ticketId_userId_key" ON "archivedUsers"("ticketId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "tags_guildId_name_key" ON "tags"("guildId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "tickets_guildId_number_key" ON "tickets"("guildId", "number");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

236
db/sqlite/schema.prisma Normal file
View File

@ -0,0 +1,236 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:../user/database.db"
}
model ArchivedChannel {
channelId String
createdAt DateTime @default(now())
name String
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String
@@id([ticketId, channelId])
@@unique([ticketId, channelId])
@@map("archivedChannels")
}
model ArchivedMessage {
author ArchivedUser @relation(fields: [ticketId, authorId], references: [ticketId, userId], onDelete: Cascade)
authorId String
content String
createdAt DateTime @default(now())
deleted Boolean @default(false)
edited Boolean @default(false)
id String @id
external Boolean @default(false)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String
@@map("archivedMessages")
}
model ArchivedRole {
archivedUsers ArchivedUser[]
colour String @default("5865F2") // 7289DA
createdAt DateTime @default(now())
name String
roleId String
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String
@@id([ticketId, roleId])
@@unique([ticketId, roleId])
@@map("archivedRoles")
}
model ArchivedUser {
archivedMessages ArchivedMessage[]
avatar String?
bot Boolean @default(false)
createdAt DateTime @default(now())
discriminator String?
displayName String?
role ArchivedRole? @relation(fields: [ticketId, roleId], references: [ticketId, roleId], onDelete: Cascade)
roleId String?
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String
userId String
username String?
@@id([ticketId, userId])
@@unique([ticketId, userId])
@@map("archivedUsers")
}
model Category {
channelName String
claiming Boolean @default(false)
createdAt DateTime @default(now())
cooldown Int?
customTopic String?
description String
discordCategory String
emoji String
enableFeedback Boolean @default(false)
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String
id Int @id @default(autoincrement())
image String?
memberLimit Int @default(1)
name String
openingMessage String
pingRoles String @default("[]")
questions Question[]
ratelimit Int?
requiredRoles String @default("[]")
requireTopic Boolean @default(false)
staffRoles String
tickets Ticket[]
totalLimit Int @default(50)
@@map("categories")
}
model Feedback {
comment String?
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String
rating Int
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String @id
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String?
@@map("feedback")
}
model Guild {
autoClose Int @default(43200000)
autoTag String @default("[]")
archive Boolean @default(true)
blocklist String @default("[]")
categories Category[]
claimButton Boolean @default(false)
closeButton Boolean @default(false)
createdAt DateTime @default(now())
errorColour String @default("Red")
feedback Feedback[]
footer String? @default("Discord Tickets by eartharoid")
id String @id
locale String @default("en-GB")
logChannel String?
primaryColour String @default("#009999")
staleAfter Int?
successColour String @default("Green")
tags Tag[]
tickets Ticket[]
workingHours String @default("[\"UTC\", [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"], [\"00:00\",\"23:59\"]]")
@@map("guilds")
}
model Question {
answers QuestionAnswer[]
createdAt DateTime @default(now())
id String @id @default(uuid())
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId Int
label String
maxLength Int? @default(4000)
minLength Int? @default(0)
options String @default("[]")
order Int
placeholder String?
required Boolean @default(true)
style Int @default(2)
type String @default("TEXT")
value String?
@@map("questions")
}
model QuestionAnswer {
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
ticketId String
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
questionId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
value String?
@@map("questionAnswers")
}
model Tag {
content String
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String
id Int @id @default(autoincrement())
name String
regex String?
@@unique([guildId, name])
@@map("tags")
}
model Ticket {
archivedChannels ArchivedChannel[]
archivedMessages ArchivedMessage[]
archivedRoles ArchivedRole[]
archivedUsers ArchivedUser[]
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId Int?
claimedBy User? @relation(name: "TicketsClaimedByUser", fields: [claimedById], references: [id])
claimedById String?
closedAt DateTime?
closedBy User? @relation(name: "TicketsClosedByUser", fields: [closedById], references: [id])
closedById String?
closedReason String?
createdAt DateTime @default(now())
createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id])
createdById String
deleted Boolean @default(false)
feedback Feedback?
firstResponseAt DateTime?
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String
id String @id
lastMessageAt DateTime?
messageCount Int?
number Int
open Boolean @default(true)
openingMessageId String
pinnedMessageIds String @default("[]")
priority String?
referencedBy Ticket[] @relation("TicketsReferencedByTicket")
referencesMessageId String?
referencesTicket Ticket? @relation(name: "TicketsReferencedByTicket", fields: [referencesTicketId], references: [id], onDelete: SetNull)
referencesTicketId String?
topic String?
questionAnswers QuestionAnswer[]
@@unique([guildId, number])
@@map("tickets")
}
model User {
createdAt DateTime @default(now())
feedback Feedback[]
id String @id
messageCount Int @default(0)
ticketsCreated Ticket[] @relation("TicketsCreatedByUser")
ticketsClosed Ticket[] @relation("TicketsClosedByUser")
ticketsClaimed Ticket[] @relation("TicketsClaimedByUser")
questionAnswers QuestionAnswer[]
@@map("users")
}

View File

@ -1,38 +1,54 @@
version: "3.8"
version: "3.9"
services:
mysql:
image: mysql:8
restart: unless-stopped
hostname: mysql
networks:
- discord-tickets
volumes:
- tickets-mysql:/var/lib/mysql
environment:
MYSQL_DATABASE: tickets
MYSQL_PASSWORD: insecure # change this to a secure password
MYSQL_ROOT_PASSWORD: insecure # change this to a (different) secure password
MYSQL_USER: tickets
bot:
build:
context: .
dockerfile: compose.Dockerfile
restart: unless-stopped
volumes:
- ./src:/tickets/src
- ./user:/tickets/user
- ./logs:/tickets/logs
- ./.env:/tickets/.env:ro
environment:
- DB_TYPE=mysql
- DB_HOST=db
- DB_PORT=3306
- DB_NAME=tickets
- DB_USER=tickets
- DB_PASS=tickets
- DB_TABLE_PREFIX=dsctickets_
image: eartharoid/discord-tickets:4.0
depends_on:
- db
db:
image: mariadb:10.6
- mysql
restart: unless-stopped
environment:
- "MYSQL_DATABASE=tickets"
- "MYSQL_USER=tickets"
- "MYSQL_PASSWORD=tickets"
- "MYSQL_RANDOM_ROOT_PASSWORD=yes"
hostname: bot
networks:
- discord-tickets
ports:
- 8169:8169
volumes:
- db:/var/lib/mysql
- tickets-bot:/usr/bot/user
tty: true
stdin_open: true
# Please refer to the documentation:
# https://discordtickets.app/self-hosting/configuration/#environment-variables
environment:
DB_CONNECTION_URL: mysql://tickets:insecure@mysql/tickets # change `insecure` to the MYSQL_PASSWORD you set above
DISCORD_SECRET: # required
DISCORD_TOKEN: # required
ENCRYPTION_KEY: # required
DB_PROVIDER: mysql
HTTP_EXTERNAL: http://127.0.0.1:8169 # change this to your server's external IP (or domain)
HTTP_HOST: 0.0.0.0
HTTP_PORT: 8169
HTTP_TRUST_PROXY: false # set to true if you're using a reverse proxy
OVERRIDE_ARCHIVE: null
PUBLIC_BOT: false
PUBLISH_COMMANDS: false
SUPER: 319467558166069248 # optionally add `,youruseridhere`
networks:
discord-tickets:
volumes:
db:
tickets-mysql:
tickets-bot:

View File

@ -1,9 +0,0 @@
DISCORD_TOKEN=
DB_ENCRYPTION_KEY=
DB_TYPE=sqlite
DB_HOST=
DB_PORT=
DB_NAME=
DB_USER=
DB_PASS=
DB_TABLE_PREFIX=dsctickets_

13
jsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Node",
"baseUrl": "src",
"resolveJsonModule": true,
"checkJs": false,
},
"include": [
"src/**/*.js"
]
}

View File

@ -1,18 +1,25 @@
{
"name": "@eartharoid/discord-tickets",
"version": "3.1.3",
"private": true,
"name": "discord-tickets",
"version": "4.0.0-beta.12",
"private": "true",
"description": "An open-source Discord bot for ticket management",
"main": "src/index.js",
"main": "src/",
"scripts": {
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"contributors:add": "all-contributors add",
"contributors:generate": "all-contributors generate",
"lint": "eslint src --fix",
"start": "node src/",
"test": "echo \"Nothing to test! Run with 'npm start'\" && exit 1"
"keygen": "node scripts/keygen",
"lint": "eslint src scripts --fix",
"preinstall": "node scripts/preinstall",
"postinstall": "node scripts/postinstall",
"start": "node .",
"studio": "npx prisma studio",
"test": "echo \"There's nothing to test\" && exit 1"
},
"engines": {
"node": ">=16.6"
"lint-staged": {
"**/*.js": [
"npm run lint --"
]
},
"repository": {
"type": "git",
@ -29,41 +36,54 @@
"url": "https://github.com/discord-tickets/bot/issues"
},
"homepage": "https://discordtickets.app",
"funding": "https://github.com/discord-tickets/bot/?sponsor=1",
"engines": {
"node": ">=18"
},
"dependencies": {
"@eartharoid/i18n": "^1.0.1",
"boxen": "^5.1.2",
"cryptr": "^6.0.2",
"discord.js": "^13.14.0",
"dotenv": "^8.6.0",
"keyv": "^4.0.3",
"leeks.js": "^0.2.4",
"leekslazylogger": "^3.0.2",
"@discord-tickets/settings": "^2.1.4",
"@eartharoid/dbf": "^0.4.0",
"@eartharoid/dtf": "^2.0.1",
"@eartharoid/i18n": "^1.2.1",
"@fastify/cookie": "^6.0.0",
"@fastify/jwt": "^5.0.1",
"@fastify/oauth2": "^5.1.0",
"@prisma/client": "^4.14.1",
"boxen": "^7.1.0",
"cryptr": "^6.2.0",
"discord.js": "^14.11.0",
"dotenv": "^16.0.3",
"fastify": "^4.17.0",
"figlet": "^1.6.0",
"fs-extra": "^10.1.0",
"keyv": "^4.5.2",
"leeks.js": "^0.3.0",
"leekslazylogger": "^5.0.1",
"ms": "^2.1.3",
"mustache": "^4.2.0",
"node-fetch": "^2.6.5",
"semver": "^7.3.5",
"sequelize": "^6.6.5",
"terminal-link": "^2.1.1"
"node-dir": "^0.1.17",
"node-emoji": "^1.11.0",
"object-diffy": "^1.0.4",
"prisma": "^4.14.1",
"semver": "^7.5.1",
"spacetime": "^7.4.4",
"terminal-link": "^2.1.1",
"yaml": "^1.10.2"
},
"devDependencies": {
"all-contributors-cli": "^6.20.0",
"eslint": "^7.32.0",
"mariadb": "^2.5.4",
"mysql2": "^2.3.0",
"nodemon": "^2.0.13",
"pg": "^8.7.1",
"pg-hstore": "^2.3.4",
"tedious": "^11.8.0"
"@commitlint/cli": "^17.6.3",
"@commitlint/config-conventional": "^17.6.3",
"all-contributors-cli": "^6.26.0",
"conventional-changelog-cli": "^2.2.2",
"eslint": "^8.41.0",
"eslint-plugin-unused-imports": "^2.0.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.2",
"nodemon": "^2.0.22"
},
"optionalDependencies": {
"sqlite3": "^5.0.2"
},
"peerDependencies": {
"mariadb": "^2.5.4",
"mysql2": "^2.3.0",
"pg": "^8.7.1",
"pg-hstore": "^2.3.4",
"tedious": "^11.4.0"
"bufferutil": "^4.0.7",
"erlpack": "github:discord/erlpack",
"utf-8-validate": "^5.0.10",
"zlib-sync": "^0.1.8"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,50 +0,0 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v1",
"update_url": null
},
"exported_at": "2021-05-26T22:41:44+02:00",
"name": "Discord Tickets",
"author": "contact@discordtickets.app",
"description": "https:\/\/discordtickets.app",
"features": null,
"images": [
"ghcr.io\/parkervcp\/yolks:nodejs_16"
],
"file_denylist": [],
"startup": "if [[ ! -z ${VERSION} ]]; then\r\n echo -e \\\"Using version ${VERSION}\\\";\r\nelse\r\n echo -e \\\"Please set the VERSION variable \\(e.g. v3.1.1\\)\\\";\r\n exit 0;\r\nfi;\r\n\r\nif [[ -d .git ]]; then\r\n git fetch --all --tags;\r\n git checkout tags\/${VERSION};\r\nfi;\r\n\r\nif [ -f \/home\/container\/package.json ]; then\r\n \/usr\/local\/bin\/npm install --production;\r\nfi;\r\n\r\nif [[ ! -z ${PLUGINS} ]]; then\r\n \/usr\/local\/bin\/npm install ${PLUGINS} --no-save;\r\nfi;\r\n\r\n\/usr\/local\/bin\/npm start",
"config": {
"files": "{}",
"startup": "{\r\n \"done\": \"Connected to Discord as \",\r\n \"userInteraction\": [\r\n \"Please set your bot's \\\"DISCORD_TOKEN\\\" in \\\".\/.env\\\".\"\r\n ]\r\n}",
"logs": "{}",
"stop": "^C^C"
},
"scripts": {
"installation": {
"script": "#!\/bin\/bash\r\n\r\napt update\r\napt install -y git curl jq file unzip make gcc g++ python python-dev libtool\r\n\r\nmkdir -p \/mnt\/server\r\ncd \/mnt\/server\r\n\r\nif [[ ! -z ${VERSION} ]]; then\r\n echo -e \\\"Using version ${VERSION}\\\"\r\nelse\r\n echo -e \\\"Please set the VERSION variable \\(e.g. v3.0.0\\)\\\"\r\n exit 1\r\nfi\r\n\r\nif [ \\\"$(ls -A \/mnt\/server)\\\" ] && [[ -d .git ]]; then\r\n echo -e \\\".git directory exists\\\"\r\n if [ -f .git\\\/config ]; then\r\n echo \\\"Upating...\\\"\r\n git fetch --all --tags\r\n git checkout tags\/${VERSION}\r\n else\r\n echo -e \\\"files found with no git config\\\"\r\n echo -e \\\"closing out without touching things to not break anything\\\"\r\n exit 1\r\n fi\r\nelse\r\n echo -e \\\"Cloning...\\\"\r\n git clone https:\/\/github.com\/discord-tickets\/bot.git .\r\n\tgit checkout tags\/${VERSION}\r\nfi\r\n\r\necho \\\"Installing dependencies\\\"\r\n\r\nif [[ ! -z ${PUGINS} ]]; then\r\n \/usr\/local\/bin\/npm install ${PUGINS}\r\nfi\r\n\r\nif [ -f \/mnt\/server\/package.json ]; then\r\n \/usr\/local\/bin\/npm install --production\r\nfi\r\n\r\necho -e \\\"Installed\\\"\r\nexit 0",
"container": "node:16-bullseye-slim",
"entrypoint": "bash"
}
},
"variables": [
{
"name": "Version",
"description": "The version of the bot to use.",
"env_variable": "VERSION",
"default_value": "v3.1.3",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:20"
},
{
"name": "Plugins",
"description": "A list of extra NPM package names to install as plugins, separated by a space: \"example1 exmaple2 example3\"",
"env_variable": "PLUGINS",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string"
}
]
}

9
scripts/keygen.js Normal file
View File

@ -0,0 +1,9 @@
/* eslint-disable no-console */
const { randomBytes } = require('crypto');
const { short } = require('leeks.js');
console.log(short(
'Set the "ENCRYPTION_KEY" environment variable to: \n&!b ' +
randomBytes(24).toString('hex') +
' &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.',
));

42
scripts/postinstall.js Normal file
View File

@ -0,0 +1,42 @@
/* eslint-disable no-console */
require('dotenv').config();
const fs = require('fs-extra');
const util = require('util');
const exec = util.promisify(require('child_process').exec);
const { short } = require('leeks.js');
function log(...strings) {
console.log(short('&9[postinstall]&r'), ...strings);
}
async function npx(cmd) {
log(`> ${cmd}`);
const {
stderr,
stdout,
} = await exec('npx ' + cmd);
if (stdout) console.log(stdout.toString());
if (stderr) console.log(stderr.toString());
}
const providers = ['mysql', 'postgresql', 'sqlite'];
const provider = process.env.DB_PROVIDER;
if (!provider) {
log('environment not set, exiting.');
process.exit(0);
}
if (!providers.includes(provider)) throw new Error(`DB_PROVIDER must be one of: ${providers}`);
log(`provider=${provider}`);
log(`copying ${provider} schema & migrations`);
if (!fs.existsSync('./prisma')) fs.mkdirSync('./prisma');
fs.copySync(`./db/${provider}`, './prisma'); // copy schema & migrations
(async () => {
await npx('prisma generate');
await npx('prisma migrate deploy');
})();

40
scripts/preinstall.js Normal file
View File

@ -0,0 +1,40 @@
/* eslint-disable no-console */
const { randomBytes } = require('crypto');
const fs = require('fs');
const { short } = require('leeks.js');
function log (...strings) {
console.log(short('&9[preinstall]&r'), ...strings);
}
if (process.env.CI) {
log('CI detected, skipping');
process.exit(0);
}
const env = {
DB_CONNECTION_URL: '',
DB_PROVIDER: '', // don't default to sqlite, postinstall checks if empty
DISCORD_SECRET: '',
DISCORD_TOKEN: '',
ENCRYPTION_KEY: randomBytes(24).toString('hex'),
HTTP_EXTERNAL: 'http://127.0.0.1:8169',
HTTP_HOST: '0.0.0.0',
HTTP_PORT: 8169,
HTTP_TRUST_PROXY: false,
NODE_ENV: 'production', // not bot-specific
OVERRIDE_ARCHIVE: '',
PUBLIC_BOT: false,
PUBLISH_COMMANDS: false,
SUPER: '319467558166069248',
};
// check ENCRYPTION_KEY because we don't want to force use of the .env file
if (!process.env.ENCRYPTION_KEY && !fs.existsSync('./.env')) {
log('generating ENCRYPTION_KEY');
fs.writeFileSync('./.env', Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n'));
log('created .env file');
log(short('&r&0&!e WARNING &r &e&lkeep your environment variables safe, don\'t lose your encryption key or you will lose data'));
} else {
log('nothing to do');
}

10
scripts/start.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
echo "Checking environment..."
node scripts/preinstall
echo "Preparing the database..."
node scripts/postinstall
echo "Starting..."
node src/

View File

@ -0,0 +1,37 @@
const { Autocompleter } = require('@eartharoid/dbf');
module.exports = class CategoryCompleter extends Autocompleter {
constructor(client, options) {
super(client, {
...options,
id: 'category',
});
}
/**
* @param {string} value
* @param {*} command
* @param {import("discord.js").AutocompleteInteraction} interaction
*/
async run(value, command, interaction) {
/** @type {import("client")} */
const client = this.client;
let categories = await client.prisma.category.findMany({ where: { guildId: interaction.guild.id } });
if (command.name === 'move') {
const ticket = await client.prisma.ticket.findUnique({ where: { id: interaction.channel.id } });
if (ticket) categories = categories.filter(category => ticket.categoryId !== category.id);
}
const options = value ? categories.filter(category => category.name.match(new RegExp(value, 'i'))) : categories;
await interaction.respond(
options
.slice(0, 25)
.map(category => ({
name: category.name,
value: category.id,
})),
);
}
};

View File

@ -0,0 +1,26 @@
const { Autocompleter } = require('@eartharoid/dbf');
module.exports = class ReferencesCompleter extends Autocompleter {
constructor(client, options) {
super(client, {
...options,
id: 'references',
});
}
/**
* @param {string} value
* @param {*} comamnd
* @param {import("discord.js").AutocompleteInteraction} interaction
*/
async run(value, comamnd, interaction) {
await interaction.respond(
await this.client.autocomplete.components.get('ticket').getOptions(value, {
guildId: interaction.guild.id,
open: false,
userId: interaction.user.id,
}),
);
}
};

50
src/autocomplete/tag.js Normal file
View File

@ -0,0 +1,50 @@
const { Autocompleter } = require('@eartharoid/dbf');
const ms = require('ms');
module.exports = class TagCompleter extends Autocompleter {
constructor(client, options) {
super(client, {
...options,
id: 'tag',
});
}
/**
* @param {string} value
* @param {*} command
* @param {import("discord.js").AutocompleteInteraction} interaction
*/
async run(value, command, interaction) {
/** @type {import("client")} */
const client = this.client;
const cacheKey = `cache/guild-tags:${interaction.guild.id}`;
let tags = await client.keyv.get(cacheKey);
if (!tags) {
tags = await client.prisma.tag.findMany({
select: {
content: true,
id: true,
name: true,
regex: true,
},
where: { guildId: interaction.guild.id },
});
client.keyv.set(cacheKey, tags, ms('1h'));
}
const options = value ? tags.filter(tag =>
tag.name.match(new RegExp(value, 'i')) ||
tag.content.match(new RegExp(value, 'i')) ||
tag.regex?.match(new RegExp(value, 'i')),
) : tags;
await interaction.respond(
options
.slice(0, 25)
.map(tag => ({
name: tag.name,
value: tag.id,
})),
);
}
};

View File

@ -0,0 +1,86 @@
/* eslint-disable no-underscore-dangle */
const { Autocompleter } = require('@eartharoid/dbf');
const emoji = require('node-emoji');
const Cryptr = require('cryptr');
const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY);
const Keyv = require('keyv');
const ms = require('ms');
const { isStaff } = require('../lib/users');
module.exports = class TicketCompleter extends Autocompleter {
constructor(client, options) {
super(client, {
...options,
id: 'ticket',
});
this.cache = new Keyv();
}
async getOptions(value, {
guildId,
open,
userId,
}) {
/** @type {import("client")} */
const client = this.client;
const cacheKey = [guildId, userId, open].join('/');
let tickets = await this.cache.get(cacheKey);
if (!tickets) {
const { locale } = await client.prisma.guild.findUnique({
select: { locale: true },
where: { id: guildId },
});
tickets = await client.prisma.ticket.findMany({
include: {
category: {
select: {
emoji: true,
name: true,
},
},
},
where: {
createdById: userId,
guildId,
open,
},
});
tickets = tickets.map(ticket => {
const date = new Date(ticket.createdAt).toLocaleString([locale, 'en-GB'], { dateStyle: 'short' });
const topic = ticket.topic ? '- ' + decrypt(ticket.topic).replace(/\n/g, ' ').substring(0, 50) : '';
const category = emoji.hasEmoji(ticket.category.emoji) ? emoji.get(ticket.category.emoji) + ' ' + ticket.category.name : ticket.category.name;
ticket._name = `${category} #${ticket.number} (${date}) ${topic}`;
return ticket;
});
this.cache.set(cacheKey, tickets, ms('1m'));
}
const options = value ? tickets.filter(t => t._name.match(new RegExp(value, 'i'))) : tickets;
return options
.slice(0, 25)
.map(t => ({
name: t._name,
value: t.id,
}));
}
/**
* @param {string} value
* @param {*} command
* @param {import("discord.js").AutocompleteInteraction} interaction
*/
async run(value, command, interaction) {
const otherMember = await isStaff(interaction.guild, interaction.user.id) && interaction.options.data[1]?.value;
const userId = otherMember || interaction.user.id;
await interaction.respond(
await this.getOptions(value, {
guildId: interaction.guild.id,
open: ['add', 'close', 'force-close', 'remove'].includes(command.name), // false for `new`, `transcript` etc
userId,
}),
);
}
};

View File

@ -1,30 +0,0 @@
/* eslint-disable no-console */
const link = require('terminal-link');
const leeks = require('leeks.js');
const {
version, homepage
} = require('../package.json');
module.exports = () => {
console.log(leeks.colours.cyan(`
######## #### ###### ###### ####### ######## ########
## ## ## ## ## ## ## ## ## ## ## ## ##
## ## ## ## ## ## ## ## ## ## ##
## ## ## ###### ## ## ## ######## ## ##
## ## ## ## ## ## ## ## ## ## ##
## ## ## ## ## ## ## ## ## ## ## ## ##
######## #### ###### ###### ####### ## ## ########
######## #### ###### ## ## ######## ######## ######
## ## ## ## ## ## ## ## ## ##
## ## ## ## ## ## ## ##
## ## ## ##### ###### ## ######
## ## ## ## ## ## ## ##
## ## ## ## ## ## ## ## ## ##
## #### ###### ## ## ######## ## ######
`));
console.log(leeks.colours.cyanBright(`Discord Tickets bot v${version} by eartharoid`));
console.log(leeks.colours.cyanBright(homepage + '\n'));
console.log(leeks.colours.cyanBright(link('Sponsor this project', 'https://discordtickets.app/sponsor') + '\n'));
};

22
src/buttons/claim.js Normal file
View File

@ -0,0 +1,22 @@
const { Button } = require('@eartharoid/dbf');
module.exports = class ClaimButton extends Button {
constructor(client, options) {
super(client, {
...options,
id: 'claim',
});
}
/**
* @param {*} id
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(id, interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: false });
await client.tickets.claim(interaction);
}
};

81
src/buttons/close.js Normal file
View File

@ -0,0 +1,81 @@
const { Button } = require('@eartharoid/dbf');
const ExtendedEmbedBuilder = require('../lib/embed');
const { isStaff } = require('../lib/users');
module.exports = class CloseButton extends Button {
constructor(client, options) {
super(client, {
...options,
id: 'close',
});
}
/**
* @param {*} id
* @param {import("discord.js").ButtonInteraction} interaction
*/
async run(id, interaction) {
/** @type {import("client")} */
const client = this.client;
if (id.accepted === undefined) {
// the close button on the opening message, the same as using /close
await client.tickets.beforeRequestClose(interaction);
} else {
const ticket = await client.tickets.getTicket(interaction.channel.id, true); // true to override cache and load new feedback
const getMessage = client.i18n.getLocale(ticket.guild.locale);
const staff = await isStaff(interaction.guild, interaction.user.id);
if (id.expect === 'staff' && !staff) {
return await interaction.reply({
embeds: [
new ExtendedEmbedBuilder()
.setColor(ticket.guild.errorColour)
.setDescription(getMessage('ticket.close.wait_for_staff')),
],
ephemeral: true,
});
} else if (id.expect === 'user' && interaction.user.id !== ticket.createdById) {
return await interaction.reply({
embeds: [
new ExtendedEmbedBuilder()
.setColor(ticket.guild.errorColour)
.setDescription(getMessage('ticket.close.wait_for_user')),
],
ephemeral: true,
});
} else {
if (id.accepted) {
if (
ticket.createdById === interaction.user.id &&
ticket.category.enableFeedback &&
!ticket.feedback
) {
return await interaction.showModal(client.tickets.buildFeedbackModal(ticket.guild.locale, { next: 'acceptClose' }));
} else {
await interaction.deferReply();
await client.tickets.acceptClose(interaction);
}
} else {
try {
await interaction.update({
components: [],
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.errorColour)
.setDescription(getMessage('ticket.close.rejected', { user: interaction.user.toString() }))
.setFooter({ text: null }),
],
});
} finally { // this should run regardless of whatever happens above
client.tickets.$stale.delete(ticket.id);
}
}
}
}
}
};

23
src/buttons/create.js Normal file
View File

@ -0,0 +1,23 @@
const { Button } = require('@eartharoid/dbf');
module.exports = class CreateButton extends Button {
constructor(client, options) {
super(client, {
...options,
id: 'create',
});
}
/**
* @param {*} id
* @param {import("discord.js").ButtonInteraction} interaction
*/
async run(id, interaction) {
if (id.targetUser && id.targetUser !== interaction.user.id) return;
await this.client.tickets.create({
categoryId: id.target,
interaction,
topic: id.topic,
});
}
};

111
src/buttons/edit.js Normal file
View File

@ -0,0 +1,111 @@
const { Button } = require('@eartharoid/dbf');
const {
ActionRowBuilder,
ModalBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
TextInputBuilder,
TextInputStyle,
} = require('discord.js');
const emoji = require('node-emoji');
const Cryptr = require('cryptr');
const cryptr = new Cryptr(process.env.ENCRYPTION_KEY);
module.exports = class EditButton extends Button {
constructor(client, options) {
super(client, {
...options,
id: 'edit',
});
}
async run(id, interaction) {
/** @type {import("client")} */
const client = this.client;
const ticket = await client.prisma.ticket.findUnique({
select: {
category: { select: { name: true } },
guild: { select: { locale: true } },
questionAnswers: { include: { question: true } },
topic: true,
},
where: { id: interaction.channel.id },
});
const getMessage = client.i18n.getLocale(ticket.guild.locale);
if (ticket.questionAnswers.length === 0) {
await interaction.showModal(
new ModalBuilder()
.setCustomId(JSON.stringify({
action: 'topic',
edit: true,
}))
.setTitle(ticket.category.name)
.setComponents(
new ActionRowBuilder()
.setComponents(
new TextInputBuilder()
.setCustomId('topic')
.setLabel(getMessage('modals.topic.label'))
.setStyle(TextInputStyle.Paragraph)
.setMaxLength(1000)
.setMinLength(5)
.setPlaceholder(getMessage('modals.topic.placeholder'))
.setRequired(true)
.setValue(ticket.topic ? cryptr.decrypt(ticket.topic) : ''),
),
),
);
} else {
await interaction.showModal(
new ModalBuilder()
.setCustomId(JSON.stringify({
action: 'questions',
edit: true,
}))
.setTitle(ticket.category.name)
.setComponents(
ticket.questionAnswers
.filter(a => a.question.type === 'TEXT') // TODO: remove this when modals support select menus
.map(a => {
if (a.question.type === 'TEXT') {
return new ActionRowBuilder()
.setComponents(
new TextInputBuilder()
.setCustomId(String(a.id))
.setLabel(a.question.label)
.setStyle(a.question.style)
.setMaxLength(Math.min(a.question.maxLength, 1000))
.setMinLength(a.question.minLength)
.setPlaceholder(a.question.placeholder)
.setRequired(a.question.required)
.setValue(a.value ? cryptr.decrypt(a.value) : a.question.value),
);
} else if (a.question.type === 'MENU') {
return new ActionRowBuilder()
.setComponents(
new StringSelectMenuBuilder()
.setCustomId(a.question.id)
.setPlaceholder(a.question.placeholder || a.question.label)
.setMaxValues(a.question.maxLength)
.setMinValues(a.question.minLength)
.setOptions(
a.question.options.map((o, i) => {
const builder = new StringSelectMenuOptionBuilder()
.setValue(String(i))
.setLabel(o.label);
if (o.description) builder.setDescription(o.description);
if (o.emoji) builder.setEmoji(emoji.hasEmoji(o.emoji) ? emoji.get(o.emoji) : { id: o.emoji });
return builder;
}),
),
);
}
}),
),
);
}
}
};

22
src/buttons/unclaim.js Normal file
View File

@ -0,0 +1,22 @@
const { Button } = require('@eartharoid/dbf');
module.exports = class UnclaimButton extends Button {
constructor(client, options) {
super(client, {
...options,
id: 'unclaim',
});
}
/**
* @param {*} id
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(id, interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: false });
await client.tickets.release(interaction);
}
};

70
src/client.js Normal file
View File

@ -0,0 +1,70 @@
const { FrameworkClient } = require('@eartharoid/dbf');
const {
GatewayIntentBits,
Partials,
} = require('discord.js');
const { PrismaClient } = require('@prisma/client');
const Keyv = require('keyv');
const I18n = require('@eartharoid/i18n');
const fs = require('fs');
const { join } = require('path');
const YAML = require('yaml');
const TicketManager = require('./lib/tickets/manager');
const sqliteMiddleware = require('./lib/middleware/prisma-sqlite');
module.exports = class Client extends FrameworkClient {
constructor(config, log) {
super({
intents: [
GatewayIntentBits.DirectMessages,
GatewayIntentBits.DirectMessageReactions,
GatewayIntentBits.DirectMessageTyping,
GatewayIntentBits.MessageContent,
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildPresences,
],
partials: [
Partials.Channel,
Partials.Message,
Partials.Reaction,
],
});
const locales = {};
fs.readdirSync(join(__dirname, 'i18n'))
.filter(file => file.endsWith('.yml'))
.forEach(file => {
const data = fs.readFileSync(join(__dirname, 'i18n/' + file), { encoding: 'utf8' });
const name = file.slice(0, file.length - 4);
locales[name] = YAML.parse(data);
});
/** @type {I18n} */
this.i18n = new I18n('en-GB', locales);
/** @type {TicketManager} */
this.tickets = new TicketManager(this);
this.config = config;
this.log = log;
this.supers = (process.env.SUPER ?? '').split(',');
}
async login(token) {
/** @type {PrismaClient} */
this.prisma = new PrismaClient();
if (process.env.DB_PROVIDER === 'sqlite') {
this.prisma.$use(sqliteMiddleware);
// make sqlite faster (https://www.sqlite.org/wal.html),
// and the missing parentheses are not a mistake, `$queryRaw` is a tagged template literal
this.log.debug(await this.prisma.$queryRaw`PRAGMA journal_mode=WAL;`);
}
this.keyv = new Keyv();
return super.login(token);
}
async destroy() {
await this.prisma.$disconnect();
return super.destroy();
}
};

View File

@ -1,118 +0,0 @@
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageEmbed
} = require('discord.js');
module.exports = class AddCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.add.description'),
internal: true,
name: i18n('commands.add.name'),
options: [
{
description: i18n('commands.add.options.member.description'),
name: i18n('commands.add.options.member.name'),
required: true,
type: Command.option_types.USER
},
{
description: i18n('commands.add.options.ticket.description'),
name: i18n('commands.add.options.ticket.name'),
required: false,
type: Command.option_types.CHANNEL
}
]
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const default_i18n = this.client.i18n.getLocale(this.client.config.defaults.locale); // command properties could be in a different locale
const i18n = this.client.i18n.getLocale(settings.locale);
const channel = interaction.options.getChannel(default_i18n('commands.add.options.ticket.name')) ?? interaction.channel;
const t_row = await this.client.tickets.resolve(channel.id, interaction.guild.id);
if (!t_row) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.add.response.not_a_ticket.title'))
.setDescription(i18n('commands.add.response.not_a_ticket.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
const member = interaction.options.getMember(default_i18n('commands.add.options.member.name'));
if (!member) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.add.response.no_member.title'))
.setDescription(i18n('commands.add.response.no_member.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
if (t_row.creator !== interaction.member.id && !await this.client.utils.isStaff(interaction.member)) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.add.response.no_permission.title'))
.setDescription(i18n('commands.add.response.no_permission.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setAuthor(member.user.username, member.user.displayAvatarURL())
.setTitle(i18n('commands.add.response.added.title'))
.setDescription(i18n('commands.add.response.added.description', member.toString(), channel.toString()))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
await channel.send({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setAuthor(member.user.username, member.user.displayAvatarURL())
.setTitle(i18n('ticket.member_added.title'))
.setDescription(i18n('ticket.member_added.description', member.toString(), interaction.user.toString()))
.setFooter(settings.footer, interaction.guild.iconURL())
]
});
await channel.permissionOverwrites.edit(member, {
ATTACH_FILES: true,
READ_MESSAGE_HISTORY: true,
SEND_MESSAGES: true,
VIEW_CHANNEL: true
}, `${interaction.user.tag} added ${member.user.tag} to the ticket`);
await this.client.tickets.archives.updateMember(channel.id, member);
this.client.log.info(`${interaction.user.tag} added ${member.user.tag} to ${channel.id}`);
}
};

View File

@ -1,156 +0,0 @@
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageEmbed,
Role
} = require('discord.js');
module.exports = class BlacklistCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.blacklist.description'),
internal: true,
name: i18n('commands.blacklist.name'),
options: [
{
description: i18n('commands.blacklist.options.add.description'),
name: i18n('commands.blacklist.options.add.name'),
options: [
{
description: i18n('commands.blacklist.options.add.options.member_or_role.description'),
name: i18n('commands.blacklist.options.add.options.member_or_role.name'),
required: true,
type: Command.option_types.MENTIONABLE
}
],
type: Command.option_types.SUB_COMMAND
},
{
description: i18n('commands.blacklist.options.remove.description'),
name: i18n('commands.blacklist.options.remove.name'),
options: [
{
description: i18n('commands.blacklist.options.remove.options.member_or_role.description'),
name: i18n('commands.blacklist.options.remove.options.member_or_role.name'),
required: true,
type: Command.option_types.MENTIONABLE
}
],
type: Command.option_types.SUB_COMMAND
},
{
description: i18n('commands.blacklist.options.show.description'),
name: i18n('commands.blacklist.options.show.name'),
type: Command.option_types.SUB_COMMAND
}
],
staff_only: true
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const default_i18n = this.client.i18n.getLocale(this.client.config.defaults.locale); // command properties could be in a different locale
const i18n = this.client.i18n.getLocale(settings.locale);
const blacklist = JSON.parse(JSON.stringify(settings.blacklist)); // not the same as `const blacklist = { ...settings.blacklist };` ..?
switch (interaction.options.getSubcommand()) {
case default_i18n('commands.blacklist.options.add.name'): {
const member_or_role = interaction.options.getMentionable(default_i18n('commands.blacklist.options.add.options.member_or_role.name'));
const type = member_or_role instanceof Role ? 'role' : 'member';
if (type === 'member' && await this.client.utils.isStaff(member_or_role)) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.blacklist.response.illegal_action.title'))
.setDescription(i18n('commands.blacklist.response.illegal_action.description', member_or_role.toString()))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
blacklist[type + 's'].push(member_or_role.id);
await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setTitle(i18n(`commands.blacklist.response.${type}_added.title`))
.setDescription(i18n(`commands.blacklist.response.${type}_added.description`, member_or_role.id))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
await settings.update({ blacklist });
break;
}
case default_i18n('commands.blacklist.options.remove.name'): {
const member_or_role = interaction.options.getMentionable(default_i18n('commands.blacklist.options.remove.options.member_or_role.name'));
const type = member_or_role instanceof Role ? 'role' : 'member';
const index = blacklist[type + 's'].findIndex(element => element === member_or_role.id);
if (index === -1) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.blacklist.response.invalid.title'))
.setDescription(i18n('commands.blacklist.response.invalid.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
blacklist[type + 's'].splice(index, 1);
await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setTitle(i18n(`commands.blacklist.response.${type}_removed.title`))
.setDescription(i18n(`commands.blacklist.response.${type}_removed.description`, member_or_role.id))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
await settings.update({ blacklist });
break;
}
case default_i18n('commands.blacklist.options.show.name'): {
if (blacklist.members.length === 0 && blacklist.roles.length === 0) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.blacklist.response.empty_list.title'))
.setDescription(i18n('commands.blacklist.response.empty_list.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
} else {
const members = blacklist.members.map(id => `**·** <@${id}>`);
const roles = blacklist.roles.map(id => `**·** <@&${id}>`);
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.blacklist.response.list.title'))
.addField(i18n('commands.blacklist.response.list.fields.members'), members.join('\n') || 'none')
.addField(i18n('commands.blacklist.response.list.fields.roles'), roles.join('\n') || 'none')
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
}
}
}
};

View File

@ -1,322 +0,0 @@
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageActionRow,
MessageButton,
MessageEmbed
} = require('discord.js');
const { Op } = require('sequelize');
const ms = require('ms');
module.exports = class CloseCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.close.description'),
internal: true,
name: i18n('commands.close.name'),
options: [
{
description: i18n('commands.close.options.reason.description'),
name: i18n('commands.close.options.reason.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.close.options.ticket.description'),
name: i18n('commands.close.options.ticket.name'),
required: false,
type: Command.option_types.INTEGER
},
{
description: i18n('commands.close.options.time.description'),
name: i18n('commands.close.options.time.name'),
required: false,
type: Command.option_types.STRING
}
]
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const default_i18n = this.client.i18n.getLocale(this.client.config.defaults.locale); // command properties could be in a different locale
const i18n = this.client.i18n.getLocale(settings.locale);
const reason = interaction.options.getString(default_i18n('commands.close.options.reason.name'));
const ticket = interaction.options.getInteger(default_i18n('commands.close.options.ticket.name'));
const time = interaction.options.getString(default_i18n('commands.close.options.time.name'));
if (time) {
if (!await this.client.utils.isStaff(interaction.member)) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.close.response.no_permission.title'))
.setDescription(i18n('commands.close.response.no_permission.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
let period;
try {
period = ms(time);
} catch {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.close.response.invalid_time.title'))
.setDescription(i18n('commands.close.response.invalid_time.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
const tickets = await this.client.db.models.Ticket.findAndCountAll({
where: {
guild: interaction.guild.id,
last_message: { [Op.lte]: new Date(Date.now() - period) },
open: true
}
});
if (tickets.count === 0) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.close.response.no_tickets.title'))
.setDescription(i18n('commands.close.response.no_tickets.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
} else {
await interaction.reply({
components: [
new MessageActionRow()
.addComponents(
new MessageButton()
.setCustomId(`confirm_close_multiple:${interaction.id}`)
.setLabel(i18n('commands.close.response.confirm_multiple.buttons.confirm', tickets.count, tickets.count))
.setEmoji('✅')
.setStyle('SUCCESS')
)
.addComponents(
new MessageButton()
.setCustomId(`cancel_close_multiple:${interaction.id}`)
.setLabel(i18n('commands.close.response.confirm_multiple.buttons.cancel'))
.setEmoji('❌')
.setStyle('SECONDARY')
)
],
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.close.response.confirm_multiple.title'))
.setDescription(i18n('commands.close.response.confirm_multiple.description', tickets.count, tickets.count))
.setFooter(this.client.utils.footer(settings.footer, i18n('collector_expires_in', 30)), interaction.guild.iconURL())
],
ephemeral: true
});
const filter = i => i.user.id === interaction.user.id && i.customId.includes(interaction.id);
const collector = interaction.channel.createMessageComponentCollector({
filter,
time: 30000
});
collector.on('collect', async i => {
await i.deferUpdate();
if (i.customId === `confirm_close_multiple:${interaction.id}`) {
for (const ticket of tickets.rows) {
await this.client.tickets.close(ticket.id, interaction.user.id, interaction.guild.id, reason);
}
await i.editReply({
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setTitle(i18n('commands.close.response.closed_multiple.title', tickets.count, tickets.count))
.setDescription(i18n('commands.close.response.closed_multiple.description', tickets.count, tickets.count))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
} else {
await i.editReply({
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.close.response.canceled.title'))
.setDescription(i18n('commands.close.response.canceled.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
collector.stop();
});
collector.on('end', async collected => {
if (collected.size === 0) {
await interaction.editReply({
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.close.response.confirmation_timeout.title'))
.setDescription(i18n('commands.close.response.confirmation_timeout.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
});
}
} else {
let t_row;
if (ticket) {
t_row = await this.client.tickets.resolve(ticket, interaction.guild.id);
if (!t_row) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.close.response.unresolvable.title'))
.setDescription(i18n('commands.close.response.unresolvable.description', ticket))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
} else {
t_row = await this.client.db.models.Ticket.findOne({ where: { id: interaction.channel.id } });
if (!t_row) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.close.response.not_a_ticket.title'))
.setDescription(i18n('commands.close.response.not_a_ticket.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
}
if (t_row.creator !== interaction.member.id && !await this.client.utils.isStaff(interaction.member)) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.close.response.no_permission.title'))
.setDescription(i18n('commands.close.response.no_permission.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
await interaction.reply({
components: [
new MessageActionRow()
.addComponents(
new MessageButton()
.setCustomId(`confirm_close:${interaction.id}`)
.setLabel(i18n('commands.close.response.confirm.buttons.confirm'))
.setEmoji('✅')
.setStyle('SUCCESS')
)
.addComponents(
new MessageButton()
.setCustomId(`cancel_close:${interaction.id}`)
.setLabel(i18n('commands.close.response.confirm.buttons.cancel'))
.setEmoji('❌')
.setStyle('SECONDARY')
)
],
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.close.response.confirm.title'))
.setDescription(settings.log_messages ? i18n('commands.close.response.confirm.description_with_archive') : i18n('commands.close.response.confirm.description'))
.setFooter(this.client.utils.footer(settings.footer, i18n('collector_expires_in', 30)), interaction.guild.iconURL())
],
ephemeral: true
});
const filter = i => i.user.id === interaction.user.id && i.customId.includes(interaction.id);
const collector = interaction.channel.createMessageComponentCollector({
filter,
time: 30000
});
collector.on('collect', async i => {
await i.deferUpdate();
if (i.customId === `confirm_close:${interaction.id}`) {
await this.client.tickets.close(t_row.id, interaction.user.id, interaction.guild.id, reason);
await i.editReply({
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setTitle(i18n('commands.close.response.closed.title', t_row.number))
.setDescription(i18n('commands.close.response.closed.description', t_row.number))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
} else {
await i.editReply({
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.close.response.canceled.title'))
.setDescription(i18n('commands.close.response.canceled.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
collector.stop();
});
collector.on('end', async collected => {
if (collected.size === 0) {
await interaction.editReply({
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.close.response.confirmation_timeout.title'))
.setDescription(i18n('commands.close.response.confirmation_timeout.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
});
}
}
};

View File

@ -1,67 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>{{survey}} Survey Responses | Discord Tickets</title>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css'>
<link rel='stylesheet' href='https://jenil.github.io/bulmaswatch/darkly/bulmaswatch.min.css'>
</head>
<body>
<section class='section'>
<container class='container box has-text-centered'>
<div class='content'>
<h1>{{survey}} survey responses</h1>
</div>
<div class='level'>
<div class='level-item has-text-centered'>
<div class='box'>
<p class='title'>{{count.responses}}</p>
<p class='heading'>Responses</p>
</div>
</div>
<div class='level-item has-text-centered'>
<div class='box'>
<p class='title'>{{count.users}}</p>
<p class='heading'>Users</p>
</div>
</div>
</div>
<div class='table-container'>
<table class='table is-bordered is-striped is-hoverable is-fullwidth'>
<thead>
<tr>
{{#columns}}
<th>{{.}}</th>
{{/columns}}
</tr>
</thead>
<tbody>
{{#responses}}
<tr>
{{#.}}
<td>{{.}}</td>
{{/.}}
</tr>
{{/responses}}
</tbody>
<tfoot>
<tr>
{{#columns}}
<th>{{.}}</th>
{{/columns}}
</tr>
</tfoot>
</table>
</div>
</container>
</section>
</body>
</html>

View File

@ -1,49 +0,0 @@
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageEmbed
} = require('discord.js');
module.exports = class HelpCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.help.description'),
internal: true,
name: i18n('commands.help.name')
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const i18n = this.client.i18n.getLocale(settings.locale);
const is_staff = await this.client.utils.isStaff(interaction.member);
const commands = this.manager.commands.filter(command => {
if (command.permissions.length >= 1) return interaction.member.permissions.has(command.permissions);
else if (command.staff_only) return is_staff;
else return true;
});
const list = commands.map(command => {
const description = command.description.length > 50
? command.description.substring(0, 50) + '...'
: command.description;
return `**\`/${command.name}\` ·** ${description}`;
});
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.help.response.list.title'))
.setDescription(i18n('commands.help.response.list.description'))
.addField(i18n('commands.help.response.list.fields.commands'), list.join('\n'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
};

View File

@ -0,0 +1,23 @@
const { MessageCommand } = require('@eartharoid/dbf');
const { useGuild } = require('../../lib/tickets/utils');
module.exports = class CreateMessageCommand extends MessageCommand {
constructor(client, options) {
const nameLocalizations = {};
client.i18n.locales.forEach(l => (nameLocalizations[l] = client.i18n.getMessage(l, 'commands.message.create.name')));
super(client, {
...options,
dmPermission: false,
name: nameLocalizations['en-GB'],
nameLocalizations,
});
}
/**
* @param {import("discord.js").MessageContextMenuCommandInteraction} interaction
*/
async run(interaction) {
await useGuild(this.client, interaction, { referencesMessage: interaction.targetMessage.channelId + '/' + interaction.targetId });
}
};

View File

@ -0,0 +1,75 @@
const { MessageCommand } = require('@eartharoid/dbf');
const ExtendedEmbedBuilder = require('../../lib/embed');
module.exports = class PinMessageCommand extends MessageCommand {
constructor(client, options) {
const nameLocalizations = {};
client.i18n.locales.forEach(l => (nameLocalizations[l] = client.i18n.getMessage(l, 'commands.message.pin.name')));
super(client, {
...options,
dmPermission: false,
name: nameLocalizations['en-GB'],
nameLocalizations,
});
}
/**
* @param {import("discord.js").MessageContextMenuCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: true });
const ticket = await client.prisma.ticket.findUnique({
include: { guild: true },
where: { id: interaction.channel.id },
});
if (!ticket) {
const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
const getMessage = client.i18n.getLocale(settings.locale);
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('commands.message.pin.not_ticket.title'))
.setDescription(getMessage('commands.message.pin.not_ticket.description')),
],
});
}
const getMessage = client.i18n.getLocale(ticket.guild.locale);
if (!interaction.targetMessage.pinnable) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.errorColour)
.setTitle(getMessage('commands.message.pin.not_pinnable.title'))
.setDescription(getMessage('commands.message.pin.not_pinnable.description')),
],
});
}
await interaction.targetMessage.pin();
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.successColour)
.setTitle(getMessage('commands.message.pin.pinned.title'))
.setDescription(getMessage('commands.message.pin.pinned.description')),
],
});
}
};

View File

@ -1,190 +0,0 @@
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars,
MessageActionRow,
MessageEmbed,
MessageSelectMenu
} = require('discord.js');
module.exports = class NewCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.new.description'),
internal: true,
name: i18n('commands.new.name'),
options: [
{
description: i18n('commands.new.options.topic.description'),
name: i18n('commands.new.options.topic.name'),
required: false,
type: Command.option_types.STRING
}
]
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const default_i18n = this.client.i18n.getLocale(this.client.config.defaults.locale); // command properties could be in a different locale
const i18n = this.client.i18n.getLocale(settings.locale);
const topic = interaction.options.getString(default_i18n('commands.new.options.topic.name'));
const create = async (cat_row, i) => {
const tickets = await this.client.db.models.Ticket.findAndCountAll({
where: {
category: cat_row.id,
creator: interaction.user.id,
open: true
}
});
if (tickets.count >= cat_row.max_per_member) {
if (cat_row.max_per_member === 1) {
const response = {
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.new.response.has_a_ticket.title'))
.setDescription(i18n('commands.new.response.has_a_ticket.description', tickets.rows[0].id))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
};
await i ? i.editReply(response) : interaction.reply(response);
} else {
const list = tickets.rows.map(row => {
if (row.topic) {
const description = row.topic.substring(0, 30);
const ellipses = row.topic.length > 30 ? '...' : '';
return `<#${row.id}>: \`${description}${ellipses}\``;
} else {
return `<#${row.id}>`;
}
});
const response = {
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.new.response.max_tickets.title', tickets.count))
.setDescription(i18n('commands.new.response.max_tickets.description', list.join('\n')))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
};
await i ? i.editReply(response) : interaction.reply(response);
}
} else {
try {
const t_row = await this.client.tickets.create(interaction.guild.id, interaction.user.id, cat_row.id, topic);
const response = {
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.new.response.created.title'))
.setDescription(i18n('commands.new.response.created.description', `<#${t_row.id}>`))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
};
await i ? i.editReply(response) : interaction.reply(response);
} catch (error) {
const response = {
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.new.response.error.title'))
.setDescription(error.message)
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
};
await i ? i.editReply(response) : interaction.reply(response);
}
}
};
const categories = await this.client.db.models.Category.findAndCountAll({ where: { guild: interaction.guild.id } });
if (categories.count === 0) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.new.response.no_categories.title'))
.setDescription(i18n('commands.new.response.no_categories.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
]
});
} else if (categories.count === 1) {
create(categories.rows[0]); // skip the category selection
} else {
await interaction.reply({
components: [
new MessageActionRow()
.addComponents(
new MessageSelectMenu()
.setCustomId(`select_category:${interaction.id}`)
.setPlaceholder('Select a category')
.addOptions(categories.rows.map(row => ({
label: row.name,
value: row.id
})))
)
],
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.new.response.select_category.title'))
.setDescription(i18n('commands.new.response.select_category.description'))
.setFooter(this.client.utils.footer(settings.footer, i18n('collector_expires_in', 30)), interaction.guild.iconURL())
],
ephemeral: true
});
const filter = i => i.user.id === interaction.user.id && i.customId.includes(interaction.id);
const collector = interaction.channel.createMessageComponentCollector({
filter,
time: 30000
});
collector.on('collect', async i => {
await i.deferUpdate();
create(categories.rows.find(row => row.id === i.values[0]), i);
collector.stop();
});
collector.on('end', async collected => {
if (collected.size === 0) {
await interaction.editReply({
components: [],
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.new.response.select_category_timeout.title'))
.setDescription(i18n('commands.new.response.select_category_timeout.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
});
}
}
};

View File

@ -1,210 +0,0 @@
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageActionRow,
MessageButton,
MessageEmbed,
MessageSelectMenu
} = require('discord.js');
const { some } = require('../utils');
module.exports = class PanelCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.panel.description'),
internal: true,
name: i18n('commands.panel.name'),
options: [
{
description: i18n('commands.panel.options.categories.description'),
multiple: true,
name: i18n('commands.panel.options.categories.name'),
required: true,
type: Command.option_types.STRING
},
{
description: i18n('commands.panel.options.description.description'),
name: i18n('commands.panel.options.description.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.panel.options.image.description'),
name: i18n('commands.panel.options.image.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.panel.options.just_type.description') + ' (false)',
name: i18n('commands.panel.options.just_type.name'),
required: false,
type: Command.option_types.BOOLEAN
},
{
description: i18n('commands.panel.options.title.description'),
name: i18n('commands.panel.options.title.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.panel.options.thumbnail.description'),
name: i18n('commands.panel.options.thumbnail.name'),
required: false,
type: Command.option_types.STRING
}
],
staff_only: true
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const default_i18n = this.client.i18n.getLocale(this.client.config.defaults.locale); // command properties could be in a different locale
const i18n = this.client.i18n.getLocale(settings.locale);
const categories = interaction.options.getString(default_i18n('commands.panel.options.categories.name')).match(/\d{17,19}/g) ?? [];
const description = interaction.options.getString(default_i18n('commands.panel.options.description.name'))?.replace(/\\n/g, '\n');
const image = interaction.options.getString(default_i18n('commands.panel.options.image.name'));
const just_type = interaction.options.getBoolean(default_i18n('commands.panel.options.just_type.name'));
const title = interaction.options.getString(default_i18n('commands.panel.options.title.name'));
const thumbnail = interaction.options.getString(default_i18n('commands.panel.options.thumbnail.name'));
if (just_type && categories.length > 1) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.panel.response.too_many_categories.title'))
.setDescription(i18n('commands.panel.response.too_many_categories.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
const invalid_category = await some(categories, async id => {
const cat_row = await this.client.db.models.Category.findOne({
where: {
guild: interaction.guild.id,
id
}
});
return !cat_row;
});
if (invalid_category) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.panel.response.invalid_category.title'))
.setDescription(i18n('commands.panel.response.invalid_category.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
let panel_channel;
const embed = new MessageEmbed()
.setColor(settings.colour)
.setFooter(settings.footer, interaction.guild.iconURL());
if (description) embed.setDescription(description);
if (image) embed.setImage(image);
if (title) embed.setTitle(title);
if (thumbnail) embed.setThumbnail(thumbnail);
if (just_type) {
panel_channel = await interaction.guild.channels.create('create-a-ticket', {
permissionOverwrites: [
{
allow: ['VIEW_CHANNEL', 'SEND_MESSAGES', 'READ_MESSAGE_HISTORY'],
deny: ['ATTACH_FILES', 'EMBED_LINKS', 'ADD_REACTIONS'],
id: interaction.guild.roles.everyone
},
{
allow: ['SEND_MESSAGES', 'EMBED_LINKS', 'ADD_REACTIONS'],
id: this.client.user.id
}
],
position: 1,
rateLimitPerUser: 30,
reason: `${interaction.user.tag} created a new message panel`,
type: 'GUILD_TEXT'
});
await panel_channel.send({ embeds: [embed] });
this.client.log.info(`${interaction.user.tag} has created a new message panel`);
} else {
panel_channel = await interaction.guild.channels.create('create-a-ticket', {
permissionOverwrites: [
{
allow: ['VIEW_CHANNEL', 'READ_MESSAGE_HISTORY'],
deny: ['SEND_MESSAGES', 'ADD_REACTIONS'],
id: interaction.guild.roles.everyone
},
{
allow: ['SEND_MESSAGES', 'EMBED_LINKS', 'ADD_REACTIONS'],
id: this.client.user.id
}
],
position: 1,
reason: `${interaction.user.tag} created a new panel`,
type: 'GUILD_TEXT'
});
if (categories.length === 1) {
// single category
await panel_channel.send({
components: [
new MessageActionRow()
.addComponents(
new MessageButton()
.setCustomId(`panel.single:${categories[0]}`)
.setLabel(i18n('panel.create_ticket'))
.setStyle('PRIMARY')
)
],
embeds: [embed]
});
this.client.log.info(`${interaction.user.tag} has created a new button panel`);
} else {
// multi category
const rows = (await this.client.db.models.Category.findAll({ where: { guild: interaction.guild.id } })).filter(row => categories.includes(row.id));
await panel_channel.send({
components: [
new MessageActionRow()
.addComponents(
new MessageSelectMenu()
.setCustomId(`panel.multiple:${panel_channel.id}`)
.setPlaceholder('Select a category')
.addOptions(rows.map(row => ({
label: row.name,
value: row.id
})))
)
],
embeds: [embed]
});
this.client.log.info(`${interaction.user.tag} has created a new select panel`);
}
}
interaction.reply({
content: `${panel_channel}`,
ephemeral: true
});
await this.client.db.models.Panel.create({
category: categories.length === 1 ? categories[0] : null,
channel: panel_channel.id,
guild: interaction.guild.id
});
}
};

View File

@ -1,111 +0,0 @@
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageEmbed
} = require('discord.js');
module.exports = class RemoveCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.remove.description'),
internal: true,
name: i18n('commands.remove.name'),
options: [
{
description: i18n('commands.remove.options.member.description'),
name: i18n('commands.remove.options.member.name'),
required: true,
type: Command.option_types.USER
},
{
description: i18n('commands.remove.options.ticket.description'),
name: i18n('commands.remove.options.ticket.name'),
required: false,
type: Command.option_types.CHANNEL
}
]
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const default_i18n = this.client.i18n.getLocale(this.client.config.defaults.locale); // command properties could be in a different locale
const i18n = this.client.i18n.getLocale(settings.locale);
const channel = interaction.options.getChannel(default_i18n('commands.remove.options.channel.name')) ?? interaction.channel;
const t_row = await this.client.tickets.resolve(channel.id, interaction.guild.id);
if (!t_row) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.remove.response.not_a_channel.title'))
.setDescription(i18n('commands.remove.response.not_a_channel.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
const member = interaction.options.getMember(default_i18n('commands.remove.options.member.name'));
if (!member) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.remove.response.no_member.title'))
.setDescription(i18n('commands.remove.response.no_member.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
if (t_row.creator !== interaction.user.id && !await this.client.utils.isStaff(interaction.member)) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.remove.response.no_permission.title'))
.setDescription(i18n('commands.remove.response.no_permission.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setAuthor(member.user.username, member.user.displayAvatarURL())
.setTitle(i18n('commands.remove.response.removed.title'))
.setDescription(i18n('commands.remove.response.removed.description', member.toString(), channel.toString()))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
await channel.send({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setAuthor(member.user.username, member.user.displayAvatarURL())
.setTitle(i18n('ticket.member_removed.title'))
.setDescription(i18n('ticket.member_removed.description', member.toString(), interaction.user.toString()))
.setFooter(settings.footer, interaction.guild.iconURL())
]
});
await channel.permissionOverwrites.delete(member.user.id, `${interaction.user.tag} removed ${member.user.tag} from the ticket`);
this.client.log.info(`${interaction.user.tag} removed ${member.user.tag} from ${channel.id}`);
}
};

View File

@ -1,358 +0,0 @@
/* eslint-disable max-lines */
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageEmbed
} = require('discord.js');
module.exports = class SettingsCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.settings.description'),
internal: true,
name: i18n('commands.settings.name'),
options: [
{
description: i18n('commands.settings.options.categories.description'),
name: i18n('commands.settings.options.categories.name'),
options: [
{
description: i18n('commands.settings.options.categories.options.create.description'),
name: i18n('commands.settings.options.categories.options.create.name'),
options: [
{
description: i18n('commands.settings.options.categories.options.create.options.name.description'),
name: i18n('commands.settings.options.categories.options.create.options.name.name'),
required: true,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.categories.options.create.options.roles.description'),
name: i18n('commands.settings.options.categories.options.create.options.roles.name'),
required: true,
type: Command.option_types.STRING
}
],
type: Command.option_types.SUB_COMMAND
},
{
description: i18n('commands.settings.options.categories.options.delete.description'),
name: i18n('commands.settings.options.categories.options.delete.name'),
options: [
{
description: i18n('commands.settings.options.categories.options.delete.options.id.description'),
name: i18n('commands.settings.options.categories.options.delete.options.id.name'),
required: true,
type: Command.option_types.STRING
}
],
type: Command.option_types.SUB_COMMAND
},
{
description: i18n('commands.settings.options.categories.options.edit.description'),
name: i18n('commands.settings.options.categories.options.edit.name'),
options: [
{
description: i18n('commands.settings.options.categories.options.edit.options.id.description'),
name: i18n('commands.settings.options.categories.options.edit.options.id.name'),
required: true,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.categories.options.edit.options.claiming.description'),
name: i18n('commands.settings.options.categories.options.edit.options.claiming.name'),
required: false,
type: Command.option_types.BOOLEAN
},
{
description: i18n('commands.settings.options.categories.options.edit.options.image.description'),
name: i18n('commands.settings.options.categories.options.edit.options.image.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.categories.options.edit.options.max_per_member.description'),
name: i18n('commands.settings.options.categories.options.edit.options.max_per_member.name'),
required: false,
type: Command.option_types.INTEGER
},
{
description: i18n('commands.settings.options.categories.options.edit.options.name.description'),
name: i18n('commands.settings.options.categories.options.edit.options.name.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.categories.options.edit.options.name_format.description'),
name: i18n('commands.settings.options.categories.options.edit.options.name_format.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.categories.options.edit.options.opening_message.description'),
name: i18n('commands.settings.options.categories.options.edit.options.opening_message.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.categories.options.edit.options.opening_questions.description'),
name: i18n('commands.settings.options.categories.options.edit.options.opening_questions.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.categories.options.edit.options.ping.description'),
name: i18n('commands.settings.options.categories.options.edit.options.ping.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.categories.options.edit.options.require_topic.description'),
name: i18n('commands.settings.options.categories.options.edit.options.require_topic.name'),
required: false,
type: Command.option_types.BOOLEAN
},
{
description: i18n('commands.settings.options.categories.options.edit.options.roles.description'),
name: i18n('commands.settings.options.categories.options.edit.options.roles.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.categories.options.edit.options.survey.description'),
name: i18n('commands.settings.options.categories.options.edit.options.survey.name'),
required: false,
type: Command.option_types.STRING
}
],
type: Command.option_types.SUB_COMMAND
},
{
description: i18n('commands.settings.options.categories.options.list.description'),
name: i18n('commands.settings.options.categories.options.list.name'),
type: Command.option_types.SUB_COMMAND
}
],
type: Command.option_types.SUB_COMMAND_GROUP
},
{
description: i18n('commands.settings.options.set.description'),
name: i18n('commands.settings.options.set.name'),
options: [
{
description: i18n('commands.settings.options.set.options.close_button.description'),
name: i18n('commands.settings.options.set.options.close_button.name'),
required: false,
type: Command.option_types.BOOLEAN
},
{
description: i18n('commands.settings.options.set.options.colour.description'),
name: i18n('commands.settings.options.set.options.colour.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.set.options.error_colour.description'),
name: i18n('commands.settings.options.set.options.error_colour.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.set.options.footer.description'),
name: i18n('commands.settings.options.set.options.footer.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.set.options.locale.description'),
name: i18n('commands.settings.options.set.options.locale.name'),
required: false,
type: Command.option_types.STRING
},
{
description: i18n('commands.settings.options.set.options.log_messages.description'),
name: i18n('commands.settings.options.set.options.log_messages.name'),
required: false,
type: Command.option_types.BOOLEAN
},
{
description: i18n('commands.settings.options.set.options.success_colour.description'),
name: i18n('commands.settings.options.set.options.success_colour.name'),
required: false,
type: Command.option_types.STRING
}
],
type: Command.option_types.SUB_COMMAND
}
],
permissions: ['MANAGE_GUILD']
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const default_i18n = this.client.i18n.getLocale(this.client.config.defaults.locale); // command properties could be in a different locale
const i18n = this.client.i18n.getLocale(settings.locale);
switch (interaction.options.getSubcommand()) {
case default_i18n('commands.settings.options.categories.options.create.name'): {
const name = interaction.options.getString(default_i18n('commands.settings.options.categories.options.create.options.name.name'));
const roles = interaction.options.getString(default_i18n('commands.settings.options.categories.options.create.options.roles.name'))?.match(/\d{17,19}/g) ?? [];
const allowed_permissions = ['VIEW_CHANNEL', 'READ_MESSAGE_HISTORY', 'SEND_MESSAGES', 'EMBED_LINKS', 'ATTACH_FILES'];
const cat_channel = await interaction.guild.channels.create(name, {
permissionOverwrites: [
...[
{
deny: ['VIEW_CHANNEL'],
id: interaction.guild.roles.everyone
},
{
allow: allowed_permissions,
id: this.client.user.id
}
],
...roles.map(r => ({
allow: allowed_permissions,
id: r
}))
],
position: 1,
reason: `Tickets category created by ${interaction.user.tag}`,
type: 'GUILD_CATEGORY'
});
await this.client.db.models.Category.create({
guild: interaction.guild.id,
id: cat_channel.id,
name,
roles
});
await this.client.commands.updatePermissions(interaction.guild);
interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setTitle(i18n('commands.settings.response.category_created', name))
],
ephemeral: true
});
break;
}
case default_i18n('commands.settings.options.categories.options.delete.name'): {
const category = await this.client.db.models.Category.findOne({ where: { id: interaction.options.getString(default_i18n('commands.settings.options.categories.options.delete.options.id.name')) } });
if (category) {
const channel = this.client.channels.cache.get(interaction.options.getString(default_i18n('commands.settings.options.categories.options.delete.options.id.name')));
if (channel) channel.delete();
await category.destroy();
interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setTitle(i18n('commands.settings.response.category_deleted', category.name))
],
ephemeral: true
});
} else {
interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.settings.response.category_does_not_exist'))
],
ephemeral: true
});
}
break;
}
case default_i18n('commands.settings.options.categories.options.edit.name'): {
const category = await this.client.db.models.Category.findOne({ where: { id: interaction.options.getString(default_i18n('commands.settings.options.categories.options.delete.options.id.name')) } });
if (!category) {
return interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.settings.response.category_does_not_exist'))
],
ephemeral: true
});
}
const claiming = interaction.options.getBoolean(default_i18n('commands.settings.options.categories.options.edit.options.claiming.name'));
const image = interaction.options.getString(default_i18n('commands.settings.options.categories.options.edit.options.image.name'));
const max_per_member = interaction.options.getInteger(default_i18n('commands.settings.options.categories.options.edit.options.max_per_member.name'));
const name = interaction.options.getString(default_i18n('commands.settings.options.categories.options.edit.options.name.name'));
const name_format = interaction.options.getString(default_i18n('commands.settings.options.categories.options.edit.options.name_format.name'));
const opening_message = interaction.options.getString(default_i18n('commands.settings.options.categories.options.edit.options.opening_message.name'));
const opening_questions = interaction.options.getString(default_i18n('commands.settings.options.categories.options.edit.options.opening_questions.name'));
const ping = interaction.options.getString(default_i18n('commands.settings.options.categories.options.edit.options.ping.name'));
const require_topic = interaction.options.getBoolean(default_i18n('commands.settings.options.categories.options.edit.options.require_topic.name'));
const roles = interaction.options.getString(default_i18n('commands.settings.options.categories.options.edit.options.roles.name'));
const survey = interaction.options.getString(default_i18n('commands.settings.options.categories.options.edit.options.survey.name'));
if (claiming !== null) category.set('claiming', claiming);
if (max_per_member !== null) category.set('max_per_member', max_per_member);
if (image !== null) category.set('image', image);
if (name !== null) category.set('name', name);
if (name_format !== null) category.set('name_format', name_format);
if (opening_message !== null) category.set('opening_message', opening_message.replace(/\\n/g, '\n'));
if (opening_questions !== null) category.set('opening_questions', JSON.parse(opening_questions));
if (ping !== null) category.set('ping', ping.match(/\d{17,19}/g) ?? []);
if (require_topic !== null) category.set('require_topic', require_topic);
if (roles !== null) category.set('roles', roles.match(/\d{17,19}/g) ?? []);
if (survey !== null) category.set('survey', survey);
await category.save();
interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setTitle(i18n('commands.settings.response.category_updated', category.name))
],
ephemeral: true
});
break;
}
case default_i18n('commands.settings.options.categories.options.list.name'): {
const categories = await this.client.db.models.Category.findAll({ where: { guild: interaction.guild.id } });
await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.settings.response.category_list'))
.setDescription(categories.map(c => `- ${c.name} (\`${c.id}\`)`).join('\n'))
],
ephemeral: true
});
break;
}
case default_i18n('commands.settings.options.set.name'): {
const close_button = interaction.options.getBoolean(default_i18n('commands.settings.options.set.options.close_button.name'));
const colour = interaction.options.getString(default_i18n('commands.settings.options.set.options.colour.name'));
const error_colour = interaction.options.getString(default_i18n('commands.settings.options.set.options.error_colour.name'));
const footer = interaction.options.getString(default_i18n('commands.settings.options.set.options.footer.name'));
const locale = interaction.options.getString(default_i18n('commands.settings.options.set.options.locale.name'));
const log_messages = interaction.options.getBoolean(default_i18n('commands.settings.options.set.options.log_messages.name'));
const success_colour = interaction.options.getString(default_i18n('commands.settings.options.set.options.success_colour.name'));
if (close_button !== null) settings.set('close_button', close_button);
if (colour !== null) settings.set('colour', colour.toUpperCase());
if (error_colour !== null) settings.set('error_colour', error_colour.toUpperCase());
if (footer !== null) settings.set('footer', footer);
if (locale !== null) settings.set('locale', locale);
if (log_messages !== null) settings.set('log_messages', log_messages);
if (success_colour !== null) settings.set('success_colour', success_colour.toUpperCase());
await settings.save();
interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setTitle(i18n('commands.settings.response.settings_updated'))
],
ephemeral: true
});
break;
}
}
}
};

144
src/commands/slash/add.js Normal file
View File

@ -0,0 +1,144 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { ApplicationCommandOptionType } = require('discord.js');
const ExtendedEmbedBuilder = require('../../lib/embed');
const { isStaff } = require('../../lib/users');
const { logTicketEvent } = require('../../lib/logging');
module.exports = class AddSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'add';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
name: 'member',
required: true,
type: ApplicationCommandOptionType.User,
},
{
autocomplete: true,
name: 'ticket',
required: false,
type: ApplicationCommandOptionType.String,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: true });
const ticket = await client.prisma.ticket.findUnique({
include: { guild: true },
where: { id: interaction.options.getString('ticket', false) || interaction.channel.id },
});
if (!ticket) {
const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
const getMessage = client.i18n.getLocale(settings.locale);
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('misc.invalid_ticket.title'))
.setDescription(getMessage('misc.invalid_ticket.description')),
],
});
}
const getMessage = client.i18n.getLocale(ticket.guild.locale);
if (
ticket.id !== interaction.channel.id &&
ticket.createdById !== interaction.member.id &&
!(await isStaff(interaction.guild, interaction.member.id))
) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.errorColour)
.setTitle(getMessage('commands.slash.add.not_staff.title'))
.setDescription(getMessage('commands.slash.add.not_staff.description')),
],
});
}
/** @type {import("discord.js").TextChannel} */
const ticketChannel = await interaction.guild.channels.fetch(ticket.id);
const member = interaction.options.getMember('member', true);
await ticketChannel.permissionOverwrites.edit(
member,
{
AttachFiles: true,
EmbedLinks: true,
ReadMessageHistory: true,
SendMessages: true,
ViewChannel: true,
},
`${interaction.user.tag} added ${member.user.tag} to the ticket`,
);
await ticketChannel.send({
embeds: [
new ExtendedEmbedBuilder()
.setColor(ticket.guild.primaryColour)
.setDescription(getMessage('commands.slash.add.added', {
added: member.toString(),
by: interaction.member.toString(),
})),
],
});
await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.successColour)
.setTitle(getMessage('commands.slash.add.success.title'))
.setDescription(getMessage('commands.slash.add.success.description', {
member: member.toString(),
ticket: ticketChannel.toString(),
})),
],
});
logTicketEvent(this.client, {
action: 'update',
diff: {
original: {},
updated: { [getMessage('log.ticket.added')]: member.user.tag },
},
target: {
id: ticket.id,
name: `<#${ticket.id}>`,
},
userId: interaction.user.id,
});
}
};

View File

@ -0,0 +1,26 @@
const { SlashCommand } = require('@eartharoid/dbf');
module.exports = class ClaimSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'claim';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: false });
await client.tickets.claim(interaction);
}
};

View File

@ -0,0 +1,37 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { ApplicationCommandOptionType } = require('discord.js');
module.exports = class CloseSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'close';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
name: 'reason',
required: false,
type: ApplicationCommandOptionType.String,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await client.tickets.beforeRequestClose(interaction);
}
};

View File

@ -0,0 +1,286 @@
const { SlashCommand } = require('@eartharoid/dbf');
const {
ApplicationCommandOptionType,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ComponentType,
} = require('discord.js');
const ExtendedEmbedBuilder = require('../../lib/embed');
const { isStaff } = require('../../lib/users');
const ms = require('ms');
module.exports = class ForceCloseSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'force-close';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
autocomplete: true,
name: 'category',
required: false,
type: ApplicationCommandOptionType.Integer,
},
{
name: 'reason',
required: false,
type: ApplicationCommandOptionType.String,
},
{
autocomplete: true,
name: 'ticket',
required: false,
type: ApplicationCommandOptionType.String,
},
{
name: 'time',
required: false,
type: ApplicationCommandOptionType.String,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: true });
const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
const getMessage = client.i18n.getLocale(settings.locale);
let ticket;
if (!(await isStaff(interaction.guild, interaction.user.id))) { // if user is not staff
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('commands.slash.force-close.not_staff.title'))
.setDescription(getMessage('commands.slash.force-close.not_staff.description')),
],
});
}
if (interaction.options.getString('ticket', false)) { // if ticket option is passed
ticket = await client.prisma.ticket.findUnique({
include: { category: true },
where: { id: interaction.options.getString('ticket') },
});
if (!ticket) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('misc.invalid_ticket.title'))
.setDescription(getMessage('misc.invalid_ticket.description')),
],
});
}
await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.successColour)
.setTitle(getMessage('commands.slash.force-close.closed_one.title'))
.setDescription(getMessage('commands.slash.force-close.closed_one.description', { ticket: ticket.id })),
],
});
setTimeout(async () => {
await client.tickets.finallyClose(ticket.id, {
closedBy: interaction.user.id,
reason: interaction.options.getString('reason', false),
});
}, ms('3s'));
} else if (interaction.options.getString('time', false)) { // if time option is passed
const time = ms(interaction.options.getString('time', false));
if (!time) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('commands.slash.close.invalid_time.title'))
.setDescription(getMessage('commands.slash.close.invalid_time.description', { input: interaction.options.getString('time', false) })),
],
});
}
const categoryId = interaction.options.getInteger('category', false);
const tickets = await client.prisma.ticket.findMany({
where: {
categoryId: categoryId ?? undefined, // must be undefined not null
lastMessageAt: { lte: new Date(Date.now() - time) },
open: true,
},
});
if (tickets.length === 0) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('commands.slash.force-close.no_tickets.title'))
.setDescription(getMessage('commands.slash.force-close.no_tickets.description', { time: ms(time, { long: true }) })),
],
});
}
const collectorTime = ms('15s');
const confirmationM = await interaction.editReply({
components: [
new ActionRowBuilder()
.addComponents([
new ButtonBuilder()
.setCustomId(JSON.stringify({
action: 'custom',
id: 'close',
}))
.setStyle(ButtonStyle.Danger)
.setEmoji(getMessage('buttons.close.emoji'))
.setLabel(getMessage('buttons.close.text')),
new ButtonBuilder()
.setCustomId(JSON.stringify({
action: 'custom',
id: 'cancel',
}))
.setStyle(ButtonStyle.Secondary)
.setEmoji(getMessage('buttons.cancel.emoji'))
.setLabel(getMessage('buttons.cancel.text')),
]),
],
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: getMessage('misc.expires_in', { time: ms(collectorTime, { long: true }) }),
})
.setColor(settings.primaryColour)
.setTitle(getMessage('commands.slash.force-close.confirm_multiple.title'))
.setDescription(getMessage('commands.slash.force-close.confirm_multiple.description', {
count: tickets.length,
tickets: tickets.map(t => `> <#${t.id}>`).join('\n'),
time: ms(time, { long: true }),
})),
],
});
confirmationM.awaitMessageComponent({
componentType: ComponentType.Button,
filter: i => i.user.id === interaction.user.id,
time: collectorTime,
})
.then(async i => {
if (JSON.parse(i.customId).id === 'close') {
await i.reply({
components: [],
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.successColour)
.setTitle(getMessage('commands.slash.force-close.confirmed_multiple.title', tickets.length, tickets.length))
.setDescription(getMessage('commands.slash.force-close.confirmed_multiple.description')),
],
ephemeral: true,
});
setTimeout(async () => {
for (const ticket of tickets) {
await client.tickets.finallyClose(ticket.id, {
closedBy: interaction.user.id,
reason: interaction.options.getString('reason', false),
});
}
}, ms('3s'));
} else {
await interaction.deleteReply();
}
})
.catch(async error => {
client.log.error(error);
await interaction.reply({
components: [],
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('misc.expired.title'))
.setDescription(getMessage('misc.expired.description', { time: ms(time, { long: true }) })),
],
});
});
} else {
ticket = await client.prisma.ticket.findUnique({
include: { category: true },
where: { id: interaction.channel.id },
});
if (!ticket) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('misc.not_ticket.title'))
.setDescription(getMessage('misc.not_ticket.description')),
],
});
}
await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.successColour)
.setTitle(getMessage('commands.slash.force-close.closed_one.title'))
.setDescription(getMessage('commands.slash.force-close.closed_one.description', { ticket: ticket.id })),
],
});
setTimeout(async () => {
await client.tickets.finallyClose(ticket.id, {
closedBy: interaction.user.id,
reason: interaction.options.getString('reason', false),
});
}, ms('3s'));
}
}
};

View File

@ -0,0 +1,79 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { isStaff } = require('../../lib/users');
const ExtendedEmbedBuilder = require('../../lib/embed');
const { version } = require('../../../package.json');
module.exports = class ClaimSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'help';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: true });
const staff = await isStaff(interaction.guild, interaction.member.id);
const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
const getMessage = client.i18n.getLocale(settings.locale);
const commands = client.application.commands.cache
.filter(c => c.type === 1)
.map(c => `> </${c.name}:${c.id}>: ${c.description}`)
.join('\n');
const newCommand = client.application.commands.cache.find(c => c.name === 'new');
const fields = [
{
name: getMessage('commands.slash.help.response.commands'),
value: commands,
},
];
if (staff) {
fields.unshift(
{
inline: true,
name: getMessage('commands.slash.help.response.links.links'),
value: [
['commands', 'https://discordtickets.app/features/commands'],
['docs', 'https://discordtickets.app'],
['feedback', 'https://lnk.earth/dsctickets-feedback'],
['support', 'https://lnk.earth/discord'],
]
.map(([l, url]) => `> [${getMessage('commands.slash.help.response.links.' + l)}](${url})`)
.join('\n'),
},
{
inline: true,
name: getMessage('commands.slash.help.response.settings'),
value: '> ' + process.env.HTTP_EXTERNAL + '/settings',
},
);
}
interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.primaryColour)
.setTitle(getMessage('commands.slash.help.title'))
.setDescription(staff
? `**Discord Tickets v${version} by eartharoid.**`
: getMessage('commands.slash.help.response.description', { command: `</${newCommand.name}:${newCommand.id}>` }))
.setFields(fields),
],
});
}
};

170
src/commands/slash/move.js Normal file
View File

@ -0,0 +1,170 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { ApplicationCommandOptionType } = require('discord.js');
const ExtendedEmbedBuilder = require('../../lib/embed');
const { isStaff } = require('../../lib/users');
module.exports = class MoveSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'move';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
autocomplete: true,
name: 'category',
required: true,
type: ApplicationCommandOptionType.Integer,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: false });
const ticket = await client.prisma.ticket.findUnique({
include: {
category: true,
guild: true,
},
where: { id: interaction.channel.id },
});
if (!ticket) {
const { locale } = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
const getMessage = client.i18n.getLocale(locale);
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.errorColour)
.setTitle(getMessage('misc.not_ticket.title'))
.setDescription(getMessage('misc.not_ticket.description')),
],
ephemeral: true,
});
}
const getMessage = client.i18n.getLocale(ticket.guild.locale);
if (!(await isStaff(interaction.guild, interaction.user.id))) { // if user is not staff
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.errorColour)
.setTitle(getMessage('commands.slash.move.not_staff.title'))
.setDescription(getMessage('commands.slash.move.not_staff.description')),
],
});
}
const creator = await interaction.guild.members.fetch(ticket.createdById);
const newCategory = await client.prisma.category.findUnique({ where: { id: interaction.options.getInteger('category', true) } });
const discordCategory = await interaction.guild.channels.fetch(newCategory.discordCategory);
if (discordCategory.children.cache.size === 50) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.errorColour)
.setTitle(getMessage('misc.category_full.title'))
.setDescription(getMessage('misc.category_full.description')),
],
ephemeral: true,
});
} else {
// don't reassign `ticket`, the previous value is used below
await client.prisma.ticket.update({
data: { category: { connect: { id: newCategory.id } } },
where: { id: ticket.id },
});
const $oldCategory = client.tickets.$count.categories[ticket.categoryId];
const $newCategory = client.tickets.$count.categories[newCategory.id];
$oldCategory.total--;
$oldCategory[ticket.createdById]--;
$newCategory.total ||= 0;
$newCategory.total++;
$newCategory[ticket.createdById] ||= 0;
$newCategory[ticket.createdById]++;
// these 3 could be done separately,
// but using `setParent`, `setName` etc instead of a single `edit` call increases the number of API requests
if (
newCategory.staffRoles !== ticket.category.staffRoles ||
newCategory.channelName !== ticket.category.channelName ||
newCategory.discordCategory !== ticket.category.discordCategory
) {
const allow = ['ViewChannel', 'ReadMessageHistory', 'SendMessages', 'EmbedLinks', 'AttachFiles'];
const channelName = newCategory.channelName
.replace(/{+\s?(user)?name\s?}+/gi, creator.user.username)
.replace(/{+\s?(nick|display)(name)?\s?}+/gi, creator.displayName)
.replace(/{+\s?num(ber)?\s?}+/gi, ticket.number === 1488 ? '1487b' : ticket.number);
await interaction.channel.edit({
lockPermissions: false,
name: channelName,
parent: discordCategory,
permissionOverwrites: [
{
deny: ['ViewChannel'],
id: interaction.guild.roles.everyone,
},
{
allow,
id: this.client.user.id,
},
{
allow,
id: creator.id,
},
...newCategory.staffRoles.map(id => ({
allow,
id,
})),
],
reason: `Moved by ${interaction.user.tag}`,
});
}
await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder()
.setColor(ticket.guild.primaryColour)
.setDescription(getMessage('commands.slash.move.moved', {
by: interaction.user.toString(),
from: ticket.category.name,
to: newCategory.name,
})),
],
});
}
}
};

38
src/commands/slash/new.js Normal file
View File

@ -0,0 +1,38 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { ApplicationCommandOptionType } = require('discord.js');
const { useGuild } = require('../../lib/tickets/utils');
module.exports = class NewSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'new';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
autocomplete: true,
name: 'references',
required: false,
type: ApplicationCommandOptionType.String,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
}
/**
*
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
await useGuild(this.client, interaction, { referencesTicketId: interaction.options.getString('references', false) });
}
};

View File

@ -0,0 +1,142 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { ApplicationCommandOptionType } = require('discord.js');
const ExtendedEmbedBuilder = require('../../lib/embed');
const { logTicketEvent } = require('../../lib/logging');
const { isStaff } = require('../../lib/users');
module.exports = class PrioritySlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'priority';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
choices: ['HIGH', 'MEDIUM', 'LOW'],
name: 'priority',
required: true,
type: ApplicationCommandOptionType.String,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
if (option.choices) {
option.choices = option.choices.map(choice => ({
name: client.i18n.getMessage(null, `commands.slash.priority.options.${option.name}.choices.${choice}`),
nameLocalizations: client.i18n.getAllMessages(`commands.slash.priority.options.${option.name}.choices.${choice}`),
value: choice,
}));
}
return option;
}),
});
}
getEmoji(priority) {
let emoji;
switch (priority) {
case 'HIGH': {
emoji = '🔴';
break;
}
case 'MEDIUM': {
emoji = '🟠';
break;
}
case 'LOW': {
emoji = '🟢';
break;
}
}
return emoji;
}
/**
*
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply();
const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
const getMessage = client.i18n.getLocale(settings.locale);
const ticket = await client.prisma.ticket.findUnique({
include: { category: { select: { channelName: true } } },
where: { id: interaction.channel.id },
});
if (!ticket) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('misc.not_ticket.title'))
.setDescription(getMessage('misc.not_ticket.description')),
],
});
}
if (!(await isStaff(interaction.guild, interaction.user.id))) { // if user is not staff
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.errorColour)
.setTitle(getMessage('commands.slash.move.not_staff.title'))
.setDescription(getMessage('commands.slash.move.not_staff.description')),
],
});
}
const priority = interaction.options.getString('priority', true);
let name = interaction.channel.name;
if (ticket.priority) name = name.replace(this.getEmoji(ticket.priority), this.getEmoji(priority));
else name = this.getEmoji(priority) + name;
await interaction.channel.setName(name);
// don't reassign ticket because the original is used below
await client.prisma.ticket.update({
data: { priority },
where: { id: interaction.channel.id },
});
logTicketEvent(this.client, {
action: 'update',
diff: {
original: { priority: ticket.priority },
updated: { priority: priority },
},
target: {
id: ticket.id,
name: `<#${ticket.id}>`,
},
userId: interaction.user.id,
});
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.successColour)
.setTitle(getMessage('commands.slash.priority.success.title'))
.setDescription(getMessage('commands.slash.priority.success.description', { priority: getMessage(`commands.slash.priority.options.priority.choices.${priority}`) })),
],
});
}
};

View File

@ -0,0 +1,26 @@
const { SlashCommand } = require('@eartharoid/dbf');
module.exports = class ReleaseSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'release';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: false });
await client.tickets.release(interaction);
}
};

View File

@ -0,0 +1,143 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { ApplicationCommandOptionType } = require('discord.js');
const ExtendedEmbedBuilder = require('../../lib/embed');
const { isStaff } = require('../../lib/users');
const { logTicketEvent } = require('../../lib/logging');
module.exports = class RemoveSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'remove';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
name: 'member',
required: true,
type: ApplicationCommandOptionType.User,
},
{
autocomplete: true,
name: 'ticket',
required: false,
type: ApplicationCommandOptionType.String,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: true });
const ticket = await client.prisma.ticket.findUnique({
include: { guild: true },
where: { id: interaction.options.getString('ticket', false) || interaction.channel.id },
});
if (!ticket) {
const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
const getMessage = client.i18n.getLocale(settings.locale);
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('misc.invalid_ticket.title'))
.setDescription(getMessage('misc.invalid_ticket.description')),
],
});
}
const getMessage = client.i18n.getLocale(ticket.guild.locale);
if (
ticket.id !== interaction.channel.id &&
ticket.createdById !== interaction.member.id &&
!(await isStaff(interaction.guild, interaction.member.id))
) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.errorColour)
.setTitle(getMessage('commands.slash.remove.not_staff.title'))
.setDescription(getMessage('commands.slash.remove.not_staff.description')),
],
});
}
/** @type {import("discord.js").TextChannel} */
const ticketChannel = await interaction.guild.channels.fetch(ticket.id);
const member = interaction.options.getMember('member', true);
if (member.id === client.user.id || member.id === ticket.createdById) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder()
.setColor(ticket.guild.errorColour)
.setTitle('❌'),
],
});
}
await ticketChannel.permissionOverwrites.delete(member, `${interaction.user.tag} removed ${member.user.tag} from the ticket`);
await ticketChannel.send({
embeds: [
new ExtendedEmbedBuilder()
.setColor(ticket.guild.primaryColour)
.setDescription(getMessage('commands.slash.remove.removed', {
by: interaction.member.toString(),
removed: member.toString(),
})),
],
});
await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: ticket.guild.footer,
})
.setColor(ticket.guild.successColour)
.setTitle(getMessage('commands.slash.remove.success.title'))
.setDescription(getMessage('commands.slash.remove.success.description', {
member: member.toString(),
ticket: ticketChannel.toString(),
})),
],
});
logTicketEvent(this.client, {
action: 'update',
diff: {
original: { [getMessage('log.ticket.removed')]: member.user.tag },
updated: {},
},
target: {
id: ticket.id,
name: `<#${ticket.id}>`,
},
userId: interaction.user.id,
});
}
};

60
src/commands/slash/tag.js Normal file
View File

@ -0,0 +1,60 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { ApplicationCommandOptionType } = require('discord.js');
const ExtendedEmbedBuilder = require('../../lib/embed');
module.exports = class TagSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'tag';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
autocomplete: true,
name: 'tag',
required: true,
type: ApplicationCommandOptionType.Integer,
},
{
name: 'for',
required: false,
type: ApplicationCommandOptionType.User,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
const user = interaction.options.getUser('for', false);
await interaction.deferReply({ ephemeral: !user });
const tag = await client.prisma.tag.findUnique({
include: { guild: true },
where: { id: interaction.options.getInteger('tag', true) },
});
await interaction.editReply({
allowedMentions: { users: user ? [user.id]: [] },
content: user?.toString(),
embeds: [
new ExtendedEmbedBuilder()
.setColor(tag.guild.primaryColour)
.setDescription(tag.content),
],
});
}
};

View File

@ -0,0 +1,133 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { ApplicationCommandOptionType } = require('discord.js');
const { isStaff } = require('../../lib/users');
const ExtendedEmbedBuilder = require('../../lib/embed');
const Cryptr = require('cryptr');
const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY);
module.exports = class TicketsSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'tickets';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
name: 'member',
required: false,
type: ApplicationCommandOptionType.User,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: true });
await client.application.commands.fetch();
const member = interaction.options.getMember('member', false) ?? interaction.member;
const ownOrOther = member.id === interaction.member.id ? 'own' : 'other';
const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
const getMessage = client.i18n.getLocale(settings.locale);
if (member.id !== interaction.member.id && !(await isStaff(interaction.guild, interaction.member.id))) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('commands.slash.tickets.not_staff.title'))
.setDescription(getMessage('commands.slash.tickets.not_staff.description')),
],
});
}
const fields = [];
const open = await client.prisma.ticket.findMany({
include: { category: true },
where: {
createdById: member.id,
guildId: interaction.guild.id,
open: true,
},
});
const closed = await client.prisma.ticket.findMany({
include: { category: true },
orderBy: { createdAt: 'desc' },
take: 10, // max 10 rows
where: {
createdById: member.id,
guildId: interaction.guild.id,
open: false,
},
});
if (open.length >= 1) {
fields.push({
name: getMessage('commands.slash.tickets.response.fields.open.name'),
value: open.map(ticket =>{
const topic = ticket.topic ? `- \`${decrypt(ticket.topic).replace(/\n/g, ' ').slice(0, 30) }\`` : '';
return `> <#${ticket.id}> ${topic}`;
}).join('\n'),
});
}
if (closed.length === 0) {
const newCommand = client.application.commands.cache.find(c => c.name === 'new');
fields.push({
name: getMessage('commands.slash.tickets.response.fields.closed.name'),
value: getMessage(`commands.slash.tickets.response.fields.closed.none.${ownOrOther}`, {
new: `</${newCommand.name}:${newCommand.id}>`,
user: member.user.toString(),
}),
});
} else {
fields.push({
name: getMessage('commands.slash.tickets.response.fields.closed.name'),
value: closed.map(ticket => {
const topic = ticket.topic ? `- \`${decrypt(ticket.topic).replace(/\n/g, ' ').slice(0, 30)}\`` : '';
return `> ${ticket.category.name} #${ticket.number} ${topic}`;
}).join('\n'),
});
}
// TODO: add portal URL to view all (this list is limited to the last 10)
const embed = new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.primaryColour)
.setAuthor({
iconURL: member.displayAvatarURL(),
name: member.displayName,
})
.setTitle(getMessage(`commands.slash.tickets.response.title.${ownOrOther}`, { displayName: member.displayName }))
.setFields(fields);
if (settings.archive && process.env.OVERRIDE_ARCHIVE !== 'false') {
const transcriptCommand = client.application.commands.cache.find(c => c.name === 'transcript');
embed.setDescription(getMessage('commands.slash.tickets.response.description', { transcript: `</${transcriptCommand.name}:${transcriptCommand.id}>` }));
}
return await interaction.editReply({ embeds: [embed] });
}
};

View File

@ -0,0 +1,84 @@
const { SlashCommand } = require('@eartharoid/dbf');
const {
ActionRowBuilder,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
} = require('discord.js');
const Cryptr = require('cryptr');
const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY);
const ExtendedEmbedBuilder = require('../../lib/embed');
module.exports = class TopicSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'topic';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
const ticket = await client.prisma.ticket.findUnique({
select: {
category: { select: { name: true } },
guild: { select: { locale: true } },
questionAnswers: { include: { question: true } },
topic: true,
},
where: { id: interaction.channel.id },
});
if (!ticket) {
const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
const getMessage = client.i18n.getLocale(settings.locale);
return await interaction.reply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('misc.not_ticket.title'))
.setDescription(getMessage('misc.not_ticket.description')),
],
});
}
const getMessage = client.i18n.getLocale(ticket.guild.locale);
const field = new TextInputBuilder()
.setCustomId('topic')
.setLabel(getMessage('modals.topic.label'))
.setStyle(TextInputStyle.Paragraph)
.setMaxLength(1000)
.setMinLength(5)
.setPlaceholder(getMessage('modals.topic.placeholder'))
.setRequired(true);
if (ticket.topic) field.setValue(decrypt(ticket.topic)); // why can't discord.js accept null or undefined :(
await interaction.showModal(
new ModalBuilder()
.setCustomId(JSON.stringify({
action: 'topic',
edit: true,
}))
.setTitle(ticket.category.name)
.setComponents(
new ActionRowBuilder()
.setComponents(field),
),
);
}
};

View File

@ -0,0 +1,151 @@
const { SlashCommand } = require('@eartharoid/dbf');
const { ApplicationCommandOptionType } = require('discord.js');
const fs = require('fs');
const { join } = require('path');
const Mustache = require('mustache');
const { AttachmentBuilder } = require('discord.js');
const Cryptr = require('cryptr');
const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY);
module.exports = class TranscriptSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'transcript';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
autocomplete: true,
name: 'ticket',
required: true,
type: ApplicationCommandOptionType.String,
},
{
name: 'member',
required: false,
type: ApplicationCommandOptionType.User,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
Mustache.escape = text => text; // don't HTML-escape
this.template = fs.readFileSync(
join('./user/templates/', this.client.config.templates.transcript + '.mustache'),
{ encoding: 'utf8' },
);
}
async fillTemplate(ticketId) {
/** @type {import("client")} */
const client = this.client;
const ticket = await client.prisma.ticket.findUnique({
include: {
archivedChannels: true,
archivedMessages: {
orderBy: { createdAt: 'asc' },
where: { external: false },
},
archivedRoles: true,
archivedUsers: true,
category: true,
claimedBy: true,
closedBy: true,
createdBy: true,
feedback: true,
guild: true,
questionAnswers: true,
},
where: { id: ticketId },
});
if (!ticket) throw new Error(`Ticket ${ticketId} does not exist`);
ticket.claimedBy = ticket.archivedUsers.find(u => u.userId === ticket.claimedById);
ticket.closedBy = ticket.archivedUsers.find(u => u.userId === ticket.closedById);
ticket.createdBy = ticket.archivedUsers.find(u => u.userId === ticket.createdById);
if (ticket.closedReason) ticket.closedReason = decrypt(ticket.closedReason);
if (ticket.feedback?.comment) ticket.feedback.comment = decrypt(ticket.feedback.comment);
if (ticket.topic) ticket.topic = decrypt(ticket.topic).replace(/\n/g, '\n\t');
ticket.archivedUsers.forEach((user, i) => {
if (user.displayName) user.displayName = decrypt(user.displayName);
user.username = decrypt(user.username);
ticket.archivedUsers[i] = user;
});
ticket.archivedMessages.forEach((message, i) => {
message.author = ticket.archivedUsers.find(u => u.userId === message.authorId);
message.content = JSON.parse(decrypt(message.content));
message.text = message.content.content?.replace(/\n/g, '\n\t') ?? '';
message.content.attachments?.forEach(a => (message.text += '\n\t' + a.url));
message.content.embeds?.forEach(() => (message.text += '\n\t[embedded content]'));
message.number = 'M' + String(i + 1).padStart(ticket.archivedMessages.length.toString().length, '0');
ticket.archivedMessages[i] = message;
});
ticket.pinnedMessageIds = ticket.pinnedMessageIds.map(id => ticket.archivedMessages.find(message => message.id === id)?.number);
const channelName = ticket.category.channelName
.replace(/{+\s?(user)?name\s?}+/gi, ticket.createdBy?.username)
.replace(/{+\s?(nick|display)(name)?\s?}+/gi, ticket.createdBy?.displayName)
.replace(/{+\s?num(ber)?\s?}+/gi, ticket.number);
const fileName = `${channelName}.${this.client.config.templates.transcript.split('.').slice(-1)[0]}`;
const transcript = Mustache.render(this.template, {
channelName,
closedAtFull: function () {
return new Intl.DateTimeFormat([ticket.guild.locale, 'en-GB'], {
dateStyle: 'full',
timeStyle: 'long',
timeZone: 'Etc/UTC',
}).format(this.closedAt);
},
createdAtFull: function () {
return new Intl.DateTimeFormat([ticket.guild.locale, 'en-GB'], {
dateStyle: 'full',
timeStyle: 'long',
timeZone: 'Etc/UTC',
}).format(this.createdAt);
},
createdAtTimestamp: function () {
return new Intl.DateTimeFormat([ticket.guild.locale, 'en-GB'], {
dateStyle: 'short',
timeStyle: 'long',
timeZone: 'Etc/UTC',
}).format(this.createdAt);
},
guildName: client.guilds.cache.get(ticket.guildId)?.name,
pinned: ticket.pinnedMessageIds.join(', '),
ticket,
});
return {
fileName,
transcript,
};
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
await interaction.deferReply({ ephemeral: true });
const {
fileName,
transcript,
} = await this.fillTemplate(interaction.options.getString('ticket', true));
const attachment = new AttachmentBuilder()
.setFile(Buffer.from(transcript))
.setName(fileName);
await interaction.editReply({ files: [attachment] });
// TODO: add portal link
}
};

View File

@ -0,0 +1,87 @@
const { SlashCommand } = require('@eartharoid/dbf');
const {
ApplicationCommandOptionType,
EmbedBuilder,
} = require('discord.js');
const Cryptr = require('cryptr');
const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY);
module.exports = class TransferSlashCommand extends SlashCommand {
constructor(client, options) {
const name = 'transfer';
super(client, {
...options,
description: client.i18n.getMessage(null, `commands.slash.${name}.description`),
descriptionLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.description`),
dmPermission: false,
name,
nameLocalizations: client.i18n.getAllMessages(`commands.slash.${name}.name`),
options: [
{
name: 'member',
required: true,
type: ApplicationCommandOptionType.User,
},
].map(option => {
option.descriptionLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.description`);
option.description = option.descriptionLocalizations['en-GB'];
option.nameLocalizations = client.i18n.getAllMessages(`commands.slash.${name}.options.${option.name}.name`);
return option;
}),
});
}
/**
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: false });
const member = interaction.options.getMember('member', true);
let ticket = await client.prisma.ticket.findUnique({ where: { id: interaction.channel.id } });
const from = ticket.createdById;
ticket = await client.prisma.ticket.update({
data: {
createdBy: {
connectOrCreate: {
create: { id: member.id },
where: { id: member.id },
},
},
},
include: { guild: true },
where: { id: interaction.channel.id },
});
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(ticket.guild.primaryColour)
.setDescription(client.i18n.getMessage(ticket.guild.locale, `commands.slash.transfer.transferred${interaction.member.id !== from ? '_from' : ''}`, {
from: `<@${from}>`,
to: member.toString(),
user: interaction.user.toString(),
})),
],
});
await interaction.channel.setTopic(`${member.toString()}${ticket.topic?.length > 0 ? ` | ${decrypt(ticket.topic)}` : ''}`);
await interaction.channel.permissionOverwrites.edit(
member,
{
AttachFiles: true,
EmbedLinks: true,
ReadMessageHistory: true,
SendMessages: true,
ViewChannel: true,
},
);
}
};

View File

@ -1,95 +0,0 @@
const Command = require('../modules/commands/command');
const Keyv = require('keyv');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageEmbed
} = require('discord.js');
module.exports = class StatsCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.stats.description'),
internal: true,
name: i18n('commands.stats.name'),
staff_only: true
});
this.cache = new Keyv({ namespace: 'cache.commands.stats' });
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const i18n = this.client.i18n.getLocale(settings.locale);
const messages = await this.client.db.models.Message.findAndCountAll();
let stats = await this.cache.get(interaction.guild.id);
if (!stats) {
const tickets = await this.client.db.models.Ticket.findAndCountAll({ where: { guild: interaction.guild.id } });
stats = { // maths
messages: settings.log_messages
? await messages.rows
.reduce(async (acc, row) => (await this.client.db.models.Ticket.findOne({ where: { id: row.ticket } }))
.guild === interaction.guild.id
? await acc + 1
: await acc, 0)
: null,
response_time: Math.floor(tickets.rows.reduce((acc, row) => row.first_response
? acc + ((Math.abs(new Date(row.createdAt) - new Date(row.first_response)) / 1000) / 60)
: acc, 0) / tickets.count),
tickets: tickets.count
};
await this.cache.set(interaction.guild.id, stats, 60 * 60 * 1000); // cache for an hour
}
const guild_embed = new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.stats.response.guild.title'))
.setDescription(i18n('commands.stats.response.guild.description'))
.addField(i18n('commands.stats.fields.tickets'), String(stats.tickets), true)
.addField(i18n('commands.stats.fields.response_time.title'), i18n('commands.stats.fields.response_time.minutes', stats.response_time), true)
.setFooter(settings.footer, interaction.guild.iconURL());
if (stats.messages) guild_embed.addField(i18n('commands.stats.fields.messages'), String(stats.messages), true);
const embeds = [guild_embed];
if (this.client.guilds.cache.size > 1) {
let global = await this.cache.get('global');
if (!global) {
const tickets = await this.client.db.models.Ticket.findAndCountAll();
global = { // maths
messages: settings.log_messages
? await messages.count
: null,
response_time: Math.floor(tickets.rows.reduce((acc, row) => row.first_response
? acc + ((Math.abs(new Date(row.createdAt) - new Date(row.first_response)) / 1000) / 60)
: acc, 0) / tickets.count),
tickets: tickets.count
};
await this.cache.set('global', global, 60 * 60 * 1000); // cache for an hour
}
const global_embed = new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.stats.response.global.title'))
.setDescription(i18n('commands.stats.response.global.description'))
.addField(i18n('commands.stats.fields.tickets'), String(global.tickets), true)
.addField(i18n('commands.stats.fields.response_time.title'), i18n('commands.stats.fields.response_time.minutes', global.response_time), true)
.setFooter(settings.footer, interaction.guild.iconURL());
if (stats.messages) global_embed.addField(i18n('commands.stats.fields.messages'), String(global.messages), true);
embeds.push(global_embed);
}
await interaction.reply({ embeds });
}
};

View File

@ -1,113 +0,0 @@
const Command = require('../modules/commands/command');
// eslint-disable-next-line no-unused-vars
const {
Message, // eslint-disable-line no-unused-vars
MessageAttachment,
MessageEmbed
} = require('discord.js');
const fsp = require('fs').promises;
const { path } = require('../utils/fs');
const mustache = require('mustache');
module.exports = class SurveyCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.survey.description'),
internal: true,
name: i18n('commands.survey.name'),
options: async guild => {
const surveys = await this.client.db.models.Survey.findAll({ where: { guild: guild.id } });
return [
{
choices: surveys.map(survey => ({
name: survey.name,
value: survey.name
})),
description: i18n('commands.survey.options.survey.description'),
name: i18n('commands.survey.options.survey.name'),
required: true,
type: Command.option_types.STRING
}
];
},
staff_only: true
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const default_i18n = this.client.i18n.getLocale(this.client.config.defaults.locale); // command properties could be in a different locale
const i18n = this.client.i18n.getLocale(settings.locale);
const name = interaction.options.getString(default_i18n('commands.survey.options.survey.name'));
const survey = await this.client.db.models.Survey.findOne({
where: {
guild: interaction.guild.id,
name
}
});
if (survey) {
const {
rows: responses, count
} = await this.client.db.models.SurveyResponse.findAndCountAll({ where: { survey: survey.id } });
const users = new Set();
for (const i in responses) {
const ticket = await this.client.db.models.Ticket.findOne({ where: { id: responses[i].ticket } });
users.add(ticket.creator);
const answers = responses[i].answers.map(a => this.client.cryptr.decrypt(a));
answers.unshift(ticket.number);
responses[i] = answers;
}
let template = await fsp.readFile(path('./src/commands/extra/survey.template.html'), { encoding: 'utf8' });
template = template.replace(/[\r\n\t]/g, '');
survey.questions.unshift('Ticket #');
const html = mustache.render(template, {
columns: survey.questions,
count: {
responses: count,
users: users.size
},
responses,
survey: survey.name.charAt(0).toUpperCase() + survey.name.slice(1)
});
const attachment = new MessageAttachment(
Buffer.from(html),
`${survey.name}.html`
);
return await interaction.reply({
ephemeral: true,
files: [attachment]
});
} else {
const surveys = await this.client.db.models.Survey.findAll({ where: { guild: interaction.guild.id } });
const list = surveys.map(s => ` **\`${s.name}\`**`);
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.survey.response.list.title'))
.setDescription(list.join('\n'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
}
};

View File

@ -1,71 +0,0 @@
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageEmbed
} = require('discord.js');
module.exports = class TagCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.tag.description'),
internal: true,
name: i18n('commands.tag.name'),
options: async guild => {
const settings = await client.utils.getSettings(guild.id);
return Object.keys(settings.tags).map(tag => ({
description: settings.tags[tag].substring(0, 100),
name: tag,
options: [...settings.tags[tag].matchAll(/(?<!\\){{1,2}\s?([A-Za-z0-9._:]+)\s?(?<!\\)}{1,2}/gi)]
.map(match => ({
description: match[1],
name: match[1],
required: true,
type: Command.option_types.STRING
})),
type: Command.option_types.SUB_COMMAND
}));
},
staff_only: true
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const i18n = this.client.i18n.getLocale(settings.locale);
try {
const tag_name = interaction.options.getSubcommand();
const tag = settings.tags[tag_name];
const args = interaction.options.data[0]?.options;
const text = tag.replace(/(?<!\\){{1,2}\s?([A-Za-z0-9._:]+)\s?(?<!\\)}{1,2}/gi, ($, $1) => {
const arg = args.find(arg => arg.name === $1);
return arg ? arg.value : $;
});
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setDescription(text)
],
ephemeral: false
});
} catch {
const list = Object.keys(settings.tags).map(t => ` **\`${t}\`**`);
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setTitle(i18n('commands.tag.response.list.title'))
.setDescription(list.join('\n'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
}
};

View File

@ -1,87 +0,0 @@
const Command = require('../modules/commands/command');
const {
Interaction, // eslint-disable-line no-unused-vars
MessageEmbed
} = require('discord.js');
module.exports = class TopicCommand extends Command {
constructor(client) {
const i18n = client.i18n.getLocale(client.config.locale);
super(client, {
description: i18n('commands.topic.description'),
internal: true,
name: i18n('commands.topic.name'),
options: [
{
description: i18n('commands.topic.options.new_topic.description'),
name: i18n('commands.topic.options.new_topic.name'),
required: true,
type: Command.option_types.STRING
}
]
});
}
/**
* @param {Interaction} interaction
* @returns {Promise<void|any>}
*/
async execute(interaction) {
const settings = await this.client.utils.getSettings(interaction.guild.id);
const default_i18n = this.client.i18n.getLocale(this.client.config.defaults.locale); // command properties could be in a different locale
const i18n = this.client.i18n.getLocale(settings.locale);
const topic = interaction.options.getString(default_i18n('commands.topic.options.new_topic.name'));
const t_row = await this.client.db.models.Ticket.findOne({ where: { id: interaction.channel.id } });
if (!t_row) {
return await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.error_colour)
.setTitle(i18n('commands.topic.response.not_a_ticket.title'))
.setDescription(i18n('commands.topic.response.not_a_ticket.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: true
});
}
await t_row.update({ topic: this.client.cryptr.encrypt(topic) });
const member = await interaction.guild.members.fetch(t_row.creator);
interaction.channel.setTopic(`${member} | ${topic}`, { reason: 'User updated ticket topic' });
const cat_row = await this.client.db.models.Category.findOne({ where: { id: t_row.category } });
const description = cat_row.opening_message
.replace(/{+\s?(user)?name\s?}+/gi, member.displayName)
.replace(/{+\s?(tag|ping|mention)?\s?}+/gi, member.user.toString());
const opening_message = await interaction.channel.messages.fetch(t_row.opening_message);
await opening_message.edit({
embeds: [
new MessageEmbed()
.setColor(settings.colour)
.setAuthor(member.user.username, member.user.displayAvatarURL())
.setDescription(description)
.addField(i18n('ticket.opening_message.fields.topic'), topic)
.setFooter(settings.footer, interaction.guild.iconURL())
]
});
await interaction.reply({
embeds: [
new MessageEmbed()
.setColor(settings.success_colour)
.setAuthor(interaction.user.username, interaction.user.displayAvatarURL())
.setTitle(i18n('commands.topic.response.changed.title'))
.setDescription(i18n('commands.topic.response.changed.description'))
.setFooter(settings.footer, interaction.guild.iconURL())
],
ephemeral: false
});
this.client.log.info(`${interaction.user.tag} changed the topic of #${interaction.channel.name}`);
}
};

168
src/commands/user/create.js Normal file
View File

@ -0,0 +1,168 @@
const { UserCommand } = require('@eartharoid/dbf');
const { isStaff } = require('../../lib/users');
const ExtendedEmbedBuilder = require('../../lib/embed');
const ms = require('ms');
const {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ComponentType,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
} = require('discord.js');
const emoji = require('node-emoji');
module.exports = class CreateUserCommand extends UserCommand {
constructor(client, options) {
const nameLocalizations = {};
client.i18n.locales.forEach(l => (nameLocalizations[l] = client.i18n.getMessage(l, 'commands.user.create.name')));
super(client, {
...options,
dmPermission: false,
name: nameLocalizations['en-GB'],
nameLocalizations,
});
}
/**
* @param {import("discord.js").UserContextMenuCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply({ ephemeral: true });
const settings = await client.prisma.guild.findUnique({
include: { categories:true },
where: { id: interaction.guild.id },
});
const getMessage = client.i18n.getLocale(settings.locale);
if (!await isStaff(interaction.guild, interaction.user.id)) {
return await interaction.editReply({
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('commands.user.create.not_staff.title'))
.setDescription(getMessage('commands.user.create.not_staff.description')),
],
});
}
const prompt = async categoryId => {
interaction.followUp({
components: [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(JSON.stringify({
action: 'create',
target: categoryId,
targetUser: interaction.targetId,
}))
.setStyle(ButtonStyle.Primary)
.setEmoji(getMessage('buttons.create.emoji')) // emoji.get('ticket')
.setLabel(getMessage('buttons.create.text')),
),
],
content: interaction.targetUser.toString(),
embeds: [
new ExtendedEmbedBuilder()
.setColor(settings.primaryColour)
.setAuthor({
iconURL: interaction.member.displayAvatarURL(),
name: interaction.member.displayName,
})
.setTitle(getMessage('commands.user.create.prompt.title'))
.setDescription(getMessage('commands.user.create.prompt.description')),
],
ephemeral: false,
});
};
if (settings.categories.length === 0) {
interaction.reply({
components: [],
embeds: [
new ExtendedEmbedBuilder()
.setColor(settings.errorColour)
.setTitle(getMessage('misc.no_categories.title'))
.setDescription(getMessage('misc.no_categories.description')),
],
ephemeral: true,
});
} else if (settings.categories.length === 1) {
await prompt(settings.categories[0].id);
} else {
const collectorTime = ms('15s');
const confirmationM = await interaction.editReply({
components: [
new ActionRowBuilder()
.setComponents(
new StringSelectMenuBuilder()
.setCustomId(JSON.stringify({
action: 'promptCreate',
user: interaction.targetId,
}))
.setPlaceholder(getMessage('menus.category.placeholder'))
.setOptions(
settings.categories.map(category =>
new StringSelectMenuOptionBuilder()
.setValue(String(category.id))
.setLabel(category.name)
.setDescription(category.description)
.setEmoji(emoji.hasEmoji(category.emoji) ? emoji.get(category.emoji) : { id: category.emoji }),
),
),
),
],
});
confirmationM.awaitMessageComponent({
componentType: ComponentType.StringSelect,
filter: i => i.user.id === interaction.user.id,
time: collectorTime,
})
.then(async i => {
const category = settings.categories.find(c => c.id === Number(i.values[0]));
await i.update({
components: [],
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.successColour)
.setTitle(getMessage('commands.user.create.sent.title'))
.setDescription(getMessage('commands.user.create.sent.description', {
category: category.name,
user: interaction.targetUser.toString(),
})),
],
ephemeral: true,
});
await prompt(category.id);
})
.catch(async error => {
client.log.error(error);
await interaction.reply({
components: [],
embeds: [
new ExtendedEmbedBuilder({
iconURL: interaction.guild.iconURL(),
text: settings.footer,
})
.setColor(settings.errorColour)
.setTitle(getMessage('misc.expired.title'))
.setDescription(getMessage('misc.expired.description')),
],
});
});
}
}
};

View File

@ -1,42 +0,0 @@
module.exports = {
maria: {
dialect: 'mariadb',
name: 'MariaDB',
packages: ['mariadb']
},
mariadb: {
dialect: 'mariadb',
name: 'MariaDB',
packages: ['mariadb']
},
microsoft: {
dialect: 'mssql',
name: 'Microsoft SQL',
packages: ['tedious']
},
mysql: {
dialect: 'mysql',
name: 'MySQL',
packages: ['mysql2']
},
postgre: { // this is wrong
dialect: 'postgres',
name: 'PostgreSQL',
packages: ['pg', 'pg-hstore']
},
postgres: {
dialect: 'postgres',
name: 'PostgreSQL',
packages: ['pg', 'pg-hstore']
},
postgresql: {
dialect: 'postgres',
name: 'PostgreSQL',
packages: ['pg', 'pg-hstore']
},
sqlite: {
dialect: 'sqlite',
name: 'SQLite',
packages: ['sqlite3']
}
};

View File

@ -1,73 +0,0 @@
const { Sequelize } = require('sequelize');
const fs = require('fs');
const { path } = require('../utils/fs');
const types = require('./dialects');
module.exports = async client => {
const {
DB_TYPE,
DB_HOST,
DB_PORT,
DB_USER,
DB_PASS,
DB_NAME
} = process.env;
const type = (DB_TYPE || 'sqlite').toLowerCase();
const supported = Object.keys(types);
if (!supported.includes(type)) {
client.log.error(new Error(`DB_TYPE (${type}) is not a valid type`));
return process.exit();
}
try {
types[type].packages.forEach(pkg => require(pkg));
} catch {
const required = types[type].packages.map(i => `"${i}"`).join(' and ');
client.log.error(new Error(`Please install the package(s) for your selected database type: ${required}`));
return process.exit();
}
let sequelize;
if (type === 'sqlite') {
client.log.info('Using SQLite storage');
sequelize = new Sequelize({
dialect: types[type].dialect,
logging: text => client.log.debug(text),
storage: path('./user/database.sqlite')
});
client.config.defaults.log_messages = false;
client.log.warn('Message logging is disabled due to insufficient database');
} else {
client.log.info(`Connecting to ${types[type].name} database...`);
sequelize = new Sequelize(DB_NAME, DB_USER, DB_PASS, {
dialect: types[type].dialect,
host: DB_HOST,
logging: text => client.log.debug(text),
port: DB_PORT
});
}
try {
await sequelize.authenticate();
client.log.success('Connected to database successfully');
} catch (error) {
client.log.warn('Failed to connect to database');
client.log.error(error);
return process.exit();
}
const models = fs.readdirSync(path('./src/database/models'))
.filter(filename => filename.endsWith('.model.js'));
for (const model of models) {
require(`./models/${model}`)(client, sequelize);
}
await sequelize.sync({ alter: false });
return sequelize;
};

View File

@ -1,93 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = ({ config }, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('Category', {
claiming: {
defaultValue: false,
type: DataTypes.BOOLEAN
},
guild: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'guilds'
},
type: DataTypes.CHAR(19),
unique: 'name-guild'
},
id: {
allowNull: false,
primaryKey: true,
type: DataTypes.CHAR(19)
},
image: {
allowNull: true,
type: DataTypes.STRING
},
max_per_member: {
defaultValue: 1,
type: DataTypes.INTEGER
},
name: {
allowNull: false,
type: DataTypes.STRING,
unique: 'name-guild'
},
name_format: {
allowNull: false,
defaultValue: config.defaults.name_format,
type: DataTypes.STRING
},
opening_message: {
defaultValue: config.defaults.opening_message,
type: DataTypes.TEXT
},
opening_questions: {
allowNull: true,
get() {
const raw_value = this.getDataValue('opening_questions');
return raw_value
? typeof raw_value === 'string'
? JSON.parse(raw_value)
: raw_value
: null;
},
type: DataTypes.JSON
},
ping: {
defaultValue: [],
get() {
const raw_value = this.getDataValue('ping');
return raw_value
? typeof raw_value === 'string'
? JSON.parse(raw_value)
: raw_value
: null;
},
type: DataTypes.JSON
},
require_topic: {
defaultValue: false,
type: DataTypes.BOOLEAN
},
roles: {
allowNull: false,
get() {
const raw_value = this.getDataValue('roles');
return raw_value
? typeof raw_value === 'string'
? JSON.parse(raw_value)
: raw_value
: null;
},
type: DataTypes.JSON
},
survey: {
allowNull: true,
type: DataTypes.STRING
}
}, {
paranoid: true,
tableName: DB_TABLE_PREFIX + 'categories'
});
};

View File

@ -1,21 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = (_client, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('ChannelEntity', {
channel: {
allowNull: false,
type: DataTypes.CHAR(19),
unique: 'channel-ticket'
},
name: DataTypes.TEXT,
ticket: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'tickets'
},
type: DataTypes.CHAR(19),
unique: 'channel-ticket'
}
}, { tableName: DB_TABLE_PREFIX + 'channel_entities' });
};

View File

@ -1,66 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = ({ config }, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('Guild', {
blacklist: {
defaultValue: {
members: [],
roles: []
},
get() {
const raw_value = this.getDataValue('blacklist');
return raw_value
? typeof raw_value === 'string'
? JSON.parse(raw_value)
: raw_value
: null;
},
type: DataTypes.JSON
},
close_button: {
defaultValue: false,
type: DataTypes.BOOLEAN
},
colour: {
defaultValue: config.defaults.colour,
type: DataTypes.STRING
},
error_colour: {
defaultValue: 'RED',
type: DataTypes.STRING
},
footer: {
defaultValue: 'Discord Tickets by eartharoid',
type: DataTypes.STRING
},
id: {
allowNull: false,
primaryKey: true,
type: DataTypes.CHAR(19)
},
locale: {
defaultValue: config.locale,
type: DataTypes.STRING
},
log_messages: {
defaultValue: config.defaults.log_messages,
type: DataTypes.BOOLEAN
},
success_colour: {
defaultValue: 'GREEN',
type: DataTypes.STRING
},
tags: {
defaultValue: {},
get() {
const raw_value = this.getDataValue('tags');
return raw_value
? typeof raw_value === 'string'
? JSON.parse(raw_value)
: raw_value
: null;
},
type: DataTypes.JSON
}
}, { tableName: DB_TABLE_PREFIX + 'guilds' });
};

View File

@ -1,35 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = (_client, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('Message', {
author: {
allowNull: false,
type: DataTypes.CHAR(19)
},
data: {
allowNull: false,
type: DataTypes.TEXT
},
deleted: {
defaultValue: false,
type: DataTypes.BOOLEAN
},
edited: {
defaultValue: false,
type: DataTypes.BOOLEAN
},
id: {
allowNull: false,
primaryKey: true,
type: DataTypes.CHAR(19)
},
ticket: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'tickets'
},
type: DataTypes.CHAR(19)
}
}, { tableName: DB_TABLE_PREFIX + 'messages' });
};

View File

@ -1,22 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = (client, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('Panel', {
category: {
allowNull: true,
type: DataTypes.CHAR(19)
},
channel: {
allowNull: false,
type: DataTypes.CHAR(19)
},
guild: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'guilds'
},
type: DataTypes.CHAR(19)
}
}, { tableName: DB_TABLE_PREFIX + 'panels' });
};

View File

@ -1,25 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = (client, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('RoleEntity', {
colour: {
defaultValue: '7289DA',
type: DataTypes.CHAR(6)
},
name: DataTypes.TEXT,
role: {
allowNull: false,
type: DataTypes.CHAR(19),
unique: 'role-ticket'
},
ticket: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'tickets'
},
type: DataTypes.CHAR(19),
unique: 'role-ticket'
}
}, { tableName: DB_TABLE_PREFIX + 'role_entities' });
};

View File

@ -1,32 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = (client, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('Survey', {
guild: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'guilds'
},
type: DataTypes.CHAR(19),
unique: 'name-guild'
},
name: {
allowNull: false,
type: DataTypes.STRING,
unique: 'name-guild'
},
questions: {
allowNull: true,
get() {
const raw_value = this.getDataValue('questions');
return raw_value
? typeof raw_value === 'string'
? JSON.parse(raw_value)
: raw_value
: null;
},
type: DataTypes.JSON
}
}, { tableName: DB_TABLE_PREFIX + 'surveys' });
};

View File

@ -1,36 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = (client, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('SurveyResponse', {
answers: {
allowNull: true,
get() {
const raw_value = this.getDataValue('answers');
return raw_value
? typeof raw_value === 'string'
? JSON.parse(raw_value)
: raw_value
: null;
},
type: DataTypes.JSON
},
survey: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'surveys'
},
type: DataTypes.INTEGER,
unique: 'survey-ticket'
},
ticket: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'tickets'
},
type: DataTypes.CHAR(19),
unique: 'survey-ticket'
}
}, { tableName: DB_TABLE_PREFIX + 'survey_responses' });
};

View File

@ -1,81 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = (_client, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('Ticket', {
category: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'categories'
},
type: DataTypes.CHAR(19)
},
claimed_by: {
allowNull: true,
type: DataTypes.CHAR(19)
},
closed_by: {
allowNull: true,
type: DataTypes.CHAR(19)
},
closed_reason: {
allowNull: true,
type: DataTypes.STRING
},
creator: {
allowNull: false,
type: DataTypes.CHAR(19)
},
first_response: {
allowNull: true,
type: DataTypes.DATE
},
guild: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'guilds'
},
type: DataTypes.CHAR(19),
unique: 'number-guild'
},
id: {
allowNull: false,
primaryKey: true,
type: DataTypes.CHAR(19)
},
last_message: {
allowNull: true,
type: DataTypes.DATE
},
number: {
allowNull: false,
type: DataTypes.INTEGER,
unique: 'number-guild'
},
open: {
defaultValue: true,
type: DataTypes.BOOLEAN
},
opening_message: {
allowNull: true,
type: DataTypes.CHAR(19)
},
pinned_messages: {
defaultValue: [],
get() {
const raw_value = this.getDataValue('pinned_messages');
return raw_value
? typeof raw_value === 'string'
? JSON.parse(raw_value)
: raw_value
: null;
},
type: DataTypes.JSON
},
topic: {
allowNull: true,
type: DataTypes.TEXT
}
}, { tableName: DB_TABLE_PREFIX + 'tickets' });
};

View File

@ -1,34 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = (client, sequelize) => {
const { DB_TABLE_PREFIX } = process.env;
sequelize.define('UserEntity', {
avatar: DataTypes.STRING,
bot: DataTypes.BOOLEAN,
discriminator: DataTypes.STRING,
display_name: DataTypes.TEXT,
role: {
allowNull: false,
references: {
key: 'role',
model: DB_TABLE_PREFIX + 'role_entities'
},
type: DataTypes.CHAR(19)
},
ticket: {
allowNull: false,
references: {
key: 'id',
model: DB_TABLE_PREFIX + 'tickets'
},
type: DataTypes.CHAR(19),
unique: 'user-ticket'
},
user: {
allowNull: false,
type: DataTypes.CHAR(19),
unique: 'user-ticket'
},
username: DataTypes.TEXT
}, { tableName: DB_TABLE_PREFIX + 'user_entities' });
};

57
src/env.js Normal file
View File

@ -0,0 +1,57 @@
/* eslint-disable no-console */
const dotenv = require('dotenv');
const { colours } = require('leeks.js');
const providers = ['mysql', 'postgresql', 'sqlite'];
// ideally the defaults would be set here too, but the pre-install script may run when `src/` is not available
const env = {
DB_CONNECTION_URL: v =>
!!v ||
(process.env.DB_PROVIDER === 'sqlite') ||
new Error('must be set when "DB_PROVIDER" is not "sqlite"'),
DB_PROVIDER: v =>
(!!v && providers.includes(v)) ||
new Error(`must be one of: ${providers.map(v => `"${v}"`).join(', ')}`),
DISCORD_SECRET: v =>
!!v ||
new Error('is required'),
DISCORD_TOKEN: v =>
!!v ||
new Error('is required'),
ENCRYPTION_KEY: v =>
(!!v && v.length >= 48) ||
new Error('is required and must be at least 48 characters long; run "npm run keygen" to generate a key'),
HTTP_EXTERNAL: v =>
(!!v && v.startsWith('http') && !v.endsWith('/')) ||
new Error('must be a valid URL without a trailing slash'),
HTTP_HOST: v =>
(!!v && !v.startsWith('http')) ||
new Error('is required and must be an address, not a URL'),
HTTP_PORT: v =>
!!v ||
new Error('is required'),
HTTP_TRUST_PROXY: () => true, // optional
INVALIDATE_TOKENS: () => true, // optional
OVERRIDE_ARCHIVE: () => true, // optional
PUBLIC_BOT: () => true, // optional
PUBLISH_COMMANDS: () => true, // optional
SUPER: () => true, // optional
};
const load = options => {
dotenv.config(options);
Object.entries(env).forEach(([name, validate]) => {
const result = validate(process.env[name]); // `true` for pass, or `Error` for fail
if (result instanceof Error) {
console.log('\x07' + colours.redBright(`Error: The "${name}" environment variable ${result.message}.`));
process.exit(1);
}
});
};
module.exports = {
env,
load,
};

176
src/http.js Normal file
View File

@ -0,0 +1,176 @@
const fastify = require('fastify')({ trustProxy: process.env.HTTP_TRUST_PROXY === 'true' });
const oauth = require('@fastify/oauth2');
const { randomBytes } = require('crypto');
const { short } = require('leeks.js');
const { join } = require('path');
const { files } = require('node-dir');
const { PermissionsBitField } = require('discord.js');
process.env.ORIGIN = process.env.HTTP_EXTERNAL;
module.exports = async client => {
// oauth2 plugin
fastify.states = new Map();
fastify.register(oauth, {
callbackUri: `${process.env.HTTP_EXTERNAL}/auth/callback`,
checkStateFunction: (state, callback) => {
if (fastify.states.has(state)) {
callback();
return;
}
callback(new Error('Invalid state'));
},
credentials: {
auth: oauth.DISCORD_CONFIGURATION,
client: {
id: client.user.id,
secret: process.env.DISCORD_SECRET,
},
},
generateStateFunction: req => {
const state = randomBytes(12).toString('hex');
fastify.states.set(state, req.query.r);
return state;
},
name: 'discord',
scope: ['applications.commands.permissions.update', 'guilds', 'identify'],
startRedirectPath: '/auth/login',
});
// cookies plugin
fastify.register(require('@fastify/cookie'));
// jwt plugin
fastify.register(require('@fastify/jwt'), {
cookie: {
cookieName: 'token',
signed: false,
},
secret: process.env.ENCRYPTION_KEY,
});
// auth
fastify.decorate('authenticate', async (req, res) => {
try {
const data = await req.jwtVerify();
if (data.expiresAt < Date.now()) throw 'expired';
if (data.createdAt < new Date(process.env.INVALIDATE_TOKENS).getTime()) throw 'expired';
} catch (error) {
return res.code(401).send({
error: 'Unauthorised',
message: error === 'expired' ? 'Your token has expired; please re-authenticate.' : 'You are not authenticated.',
statusCode: 401,
});
}
});
fastify.decorate('isAdmin', async (req, res) => {
try {
const userId = req.user.id;
const guildId = req.params.guild;
const guild = client.guilds.cache.get(guildId);
if (!guild) {
return res.code(404).send({
error: 'Not Found',
message: 'The requested resource could not be found.',
statusCode: 404,
});
}
const guildMember = await guild.members.fetch(userId);
const isAdmin = guildMember?.permissions.has(PermissionsBitField.Flags.ManageGuild) || client.supers.includes(userId);
if (!isAdmin) {
return res.code(403).send({
error: 'Forbidden',
message: 'You are not permitted for this action.',
statusCode: 403,
});
}
} catch (err) {
res.send(err);
}
});
// body processing
fastify.addHook('preHandler', (req, res, done) => {
if (req.body && typeof req.body === 'object') {
for (const prop in req.body) {
if (typeof req.body[prop] === 'string') {
req.body[prop] = req.body[prop].trim();
}
}
}
done();
});
// logging
fastify.addHook('onResponse', (req, res, done) => {
done();
const status = (res.statusCode >= 500
? '&4'
: res.statusCode >= 400
? '&6'
: res.statusCode >= 300
? '&3'
: res.statusCode >= 200
? '&2'
: '&f') + res.statusCode;
let responseTime = res.getResponseTime().toFixed(2);
responseTime = (responseTime >= 100
? '&c'
: responseTime >= 10
? '&e'
: '&a') + responseTime + 'ms';
const level = req.routerPath === '/*' ? 'verbose' : 'info';
client.log[level].http(short(`${req.id} ${req.ip} ${req.method} ${req.routerPath ?? '*'} &m-+>&r ${status}&b in ${responseTime}`));
if (!req.routerPath) client.log.verbose.http(`${req.id} ${req.method} ${req.url}`);
done();
});
fastify.addHook('onError', async (req, res, err) => client.log.error.http(req.id, err));
// route loading
const dir = join(__dirname, '/routes');
files(dir, {
exclude: /^\./,
match: /.js$/,
sync: true,
}).forEach(file => {
const path = file
.substring(0, file.length - 3) // remove `.js`
.substring(dir.length) // remove higher directories
.replace(/\\/g, '/') // replace `\` with `/` because Windows is stupid
.replace(/\[(\w+)\]/gi, ':$1') // convert [] to :
.replace('/index', '') || '/'; // remove index
const route = require(file);
Object.keys(route).forEach(method => fastify.route({
config: { client },
method: method.toUpperCase(),
path,
...route[method](fastify),
})); // register route
});
const { handler } = await import('@discord-tickets/settings/build/handler.js');
// https://stackoverflow.com/questions/72317071/how-to-set-up-fastify-correctly-so-that-sveltekit-works-fine
fastify.all('/*', {}, (req, res) => handler(req.raw, res.raw, () => { }));
// start the fastify server
fastify.listen({
host: process.env.HTTP_HOST,
port: process.env.HTTP_PORT,
}, (err, addr) => {
if (err) client.log.error.http(err);
else client.log.success.http(`Listening at ${addr}`);
});
process.on('sveltekit:error', ({
error,
errorId,
}) => {
client.log.error.http(`SvelteKit ${errorId} ${error}`);
});
};

231
src/i18n/cs.yml Normal file
View File

@ -0,0 +1,231 @@
commands:
message:
pin:
name: Připnout zprávu
pinned:
description: Zpráva byla připnuta.
title: ✅ Zpráva připnuta
not_pinnable:
description: "Tato zpráva nemůže být připnuta.\nProsím zeptejte se admininistrátora,\
\ jestli má robot dostatečné oprávnění.\n"
title: ❌ Chyba
not_ticket:
description: Zprávy můžeš připnout jen v ticketech.
title: ❌ Tento kanál není ticket
create:
name: Vytvořit ticket ze zprávy
slash:
add:
name: přidat
options:
ticket:
name: tiket
description: Ticket na přidání člena
member:
name: člen
description: Člen, kterého chcete přidat do ticketu
added: ➡️{by} přidal {added}.
description: Přidat člena do ticketu
not_staff:
description: Pouze členové staff teamu můžou přidávat členy do ticketů ostatních.
title: ❌ Chyba
success:
description: '{member} byl přidán do {ticket}.'
title: ✅ Přidán
claim:
name: převzít
description: Převzít ticket
close:
invalid_time:
title: ❌ Neplatný
description: '`{input}` není platný formát času.'
description: Zažádat o uzavření ticketu
name: uzavřít
options:
reason:
description: Důvod pro uzavření ticketu
name: důvod
remove:
options:
ticket:
name: tiket
description: Tiket pro odebrání člena
member:
description: Člen, který má být odstraněn z tiketu
name: člen
not_staff:
description: Pouze členové staff teamu mohou odstranit členy z ostatních tiketů.
title: ❌ Chyba
success:
description: '{member} byl odstraněn z {ticket}.'
title: ✅ Odstraněn
description: Odstranit člena z tiketu
name: odstranit
removed: ⬅️ {removed} byl odstraněn uživatelem {by}.
help:
response:
links:
feedback: Zpětná vazba
commands: Celý list příkazů
docs: Dokumentace
links: Užitečné odkazy
support: Podpora
commands: Příkazy
settings: Nastavení bota
description: '**Použijte {command} pro vytvoření ticketu a získání podpory.**'
description: Zobrazit pomocné menu
name: pomoc
title: Pomoc
force-close:
options:
category:
name: kategorie
description: Uzavřít všechny tickety v zadané kategorii (lze použít s `time`).
reason:
description: Důvod pro uzavření ticketu(ů)
name: důvod
ticket:
description: Ticket k uzavření
name: tiket
time:
name: čas
description: Zavřít všechny tickety, které byly po zadanou dobu neaktivní
(lze použít s `category`).
confirm_multiple:
title: ❓Jste si jist?
description: "Chystáte se uzavřít **{count}** ticketů, které jsou neaktivní\
\ déle než `{time}`:\n{tickets}\n"
name: uzavřít-silou
description: Násilně uzavřít tiketu
no_tickets:
title: ❌Žádné tikety
description: Neexistují žádné otevřené tickety, které by byly neaktivní déle
než `{time}`.
not_staff:
title: ❌Chyba
description: Tickety mohou násilně uzavírat pouze zaměstnanci.
move:
description: Přesunout ticket do jiné kategorie
name: přesunout
options:
category:
description: Kategorie, do které se má ticket přesunout
name: kategorie
moved: 🗃️ {by} přesunul tento ticket z **{from}** do **{to}**.
new:
description: Vytvořit nový ticket
name: nový
options:
references:
description: Číslo souvisejícího tiketu
name: reference
priority:
description: Nastavit důležitost ticketu
name: důležitost
options:
priority:
choices:
HIGH: 🔴 Vysoká
LOW: 🟢 Malá
MEDIUM: 🟠 Střední
description: Priorita tiketu
name: priorita
success:
description: Priorita tiketu byla nastavena na `{priority}`.
title: ✅ Priorita nastavena
release:
name: uvolnit
description: Uvolnit (vrátit) ticket
tag:
description: Použijte tag
name: tag
options:
for:
description: Uživatel, na kterého má cílit tag
name: pro
tag:
description: Jméno tagu pro použití
name: tag
tickets:
name: tickety
response:
fields:
closed:
name: Uzavřené tickety
open:
name: Otevřít tickety
title:
other: Tickety uživatele {displayName}
not_staff:
title: ❌ Chyba
topic:
name: téma
buttons:
accept_close_request:
emoji:
text: Přijmout
create:
text: Vytvořit ticket
emoji: 🎫
cancel:
emoji: ✖️
text: Zrušit
claim:
emoji: 🙌
text: Převzít
edit:
emoji: ✏️
text: Upravit
close:
emoji: ✖️
text: Zavřít
confirm_open:
emoji:
text: Vytvořit ticket
reject_close_request:
emoji: ✖️
text: Odmítnut
unclaim:
emoji: ♻️
text: Vydat
dm:
closed:
archived: Chcete-li zobrazit archivované zprávy, zadejte `/transcript` do **{guild}**.
title: Váš ticket byl uzavřen
fields:
closed_by: Zavřeno uživatelem
ticket: Ticket
closed:
name: Zavřeno v
value: '{timestamp} (po {duration})'
created: Vytvořeno v
feedback: Vaše zpětná vazba
reason: Zavřeno, protože
response: Doba odezvy
topic: Téma
confirm_open:
title: Chcete otevřít ticket s následujícím tématem?
log:
admin:
description:
target:
tag: značka
settings: nastavení
category: kategorie
panel: panel
question: otázka
joined: '{user} {verb} {targetType}'
verb:
create: vytvořeno
title:
target:
panel: Panel
category: Kategorie
question: Otázka
settings: Nastavení
tag: Štítek
joined: '{targetType} {verb}'
changes: Změny
misc:
error:
title: ⚠️ Něco se pokazilo

405
src/i18n/de.yml Normal file
View File

@ -0,0 +1,405 @@
buttons:
cancel:
emoji: ✖️
text: Abbrechen
close:
emoji: ✖️
text: Schließen
create:
text: Ticket erstellen
emoji: 🎫
accept_close_request:
emoji:
text: Akzeptieren
claim:
emoji: 🙌
text: Übernehmen
confirm_open:
emoji:
text: Ticket erstellen
reject_close_request:
emoji: ✖️
text: Ablehnen
edit:
emoji: ✏️
text: Bearbeiten
unclaim:
text: Freigeben
emoji: ♻️
commands:
message:
pin:
name: Nachricht anheften
not_pinnable:
description: "Diese Nachricht kann nicht angeheftet werden.\nBitte einen Administrator,\
\ die Berechtigungen des Bots zu überprüfen.\n"
title: ❌ Fehler
not_ticket:
description: Du kannst Nachrichten nur in Tickets anheften.
title: ❌ Dies ist kein Ticketkanal
pinned:
description: Die Nachricht wurde angeheftet.
title: ✅ Nachricht angeheftet
create:
name: Ticket aus Nachricht erstellen
slash:
add:
added: ➡️ {added} wurde von {by} hinzugefügt.
not_staff:
title: ❌ Fehler
description: Nur Teammitglieder können Benutzer zu Tickets anderer hinzufügen.
options:
member:
name: mitglied
description: Das Mitglied, das dem Ticket hinzugefügt werden soll
ticket:
name: ticket
description: Das Ticket, zu dem das Mitglied hinzugefügt werden soll
success:
description: '{member} wurde zu {ticket} hinzugefügt.'
title: ✅ Hinzugefügt
description: Benutzer zu einem Ticket hinzufügen
name: hinzufügen
help:
response:
links:
support: Unterstützung
links: Nützliche Links
docs: Dokumentation
commands: Vollständige Befehlsliste
feedback: Feedback
commands: Befehle
description: '**Verwende {command} um ein Ticket zu erstellen und Hilfe zu
erhalten.**'
settings: Bot-Einstellungen
name: hilfe
title: hilfe
description: Zeigt das Hilfemenü an
force-close:
options:
reason:
name: grund
description: Der Grund für das Schließen des/der Tickets
category:
name: kategorie
description: Schließe alle Tickets einer Kategorie (kann mit `time` benutzt
werden)
time:
name: zeit
description: Alle Tickets schließen, die für die angegebene Zeit inaktiv
waren (mit `Kategorie` verwendbar)
ticket:
description: Das Ticket welches geschlossen werden soll
name: ticket
no_tickets:
description: Es gibt keine offenen Tickets, die länger als `{time}` inaktiv
waren.
title: ❌ Keine Tickets
not_staff:
title: ❌ Fehler
description: Nur Teammitglieder können Tickets zwangsweise abschliessen.
confirm_multiple:
description: "Du bist dabei **{count}** Tickets zu schließen die länger als\
\ `{time}`:\n{tickets} inaktiv waren.\n"
title: ❓Bist du dir sicher?
description: Ticket zwangsweise schließen
name: force-close
close:
invalid_time:
title: ❌ Ungültig
description: '`{input}` ist kein gültiges Zeitformat.'
options:
reason:
name: grund
description: Der Grund für das Schließen des Tickets
description: Beantragen, dass ein Ticket geschlossen wird
name: schließen
move:
name: verschieben
description: Verschiebe ein Ticket in eine andere Kategorie
moved: 🗃️ {by} hat dieses Ticket von **{from}** nach **{to}** verschoben.
options:
category:
description: Die Kategorie in welche das Ticket verschoben werden soll
name: kategorie
not_staff:
description: Nur Mitarbeiter können Tickets verschieben.
title: ❌ Fehler
new:
name: neu
description: Neues Ticket erstellen
options:
references:
name: verweise
description: Die Nummer eines zugehörigen Tickets
claim:
description: Ticket übernehmen
name: übernehmen
not_staff:
title: ❌ Fehler
priority:
options:
priority:
description: Die Priorität des Tickets
name: priorität
choices:
HIGH: 🔴 Hoch
LOW: 🟢 Niedrig
MEDIUM: 🟠 Mittel
success:
title: ✅ Priorität gesetzt
description: Die Priorität dieses Tickets wurde auf `{priority}` gesetzt.
name: priorität
description: Legt die Priorität eines Tickets fest
not_staff:
title: ❌ Fehler
tickets:
not_staff:
title: ❌ Fehler
description: Nur Teammitglieder können Tickets anderer einsehen.
options:
member:
description: Das Mitglied, dessen Tickets aufgelistet werden sollen
name: mitglied
name: tickets
response:
title:
own: Deine Tickets
other: '{displayName}s Tickets'
fields:
closed:
none:
own: "Du hast noch keine Tickets erstellt\nBenutze {new} um ein Ticket\
\ zu öffnen.\n"
other: '{user} hat noch keine Tickets erstellt.'
name: Geschlossene Tickets
open:
name: Offene Tickets
description: Verwende {transcript}, um die Abschrift eines Tickets herunterzuladen.
description: Eigene oder fremde Tickets auflisten
remove:
not_staff:
description: Nur Mitarbeiter können Mitglieder aus den Tickets anderer entfernen.
title: ❌ Fehler
options:
member:
name: mitglied
description: Das Mitglied, das aus dem Ticket entfernt werden soll
ticket:
name: ticket
description: Das Ticket, aus dem das Mitglied entfernt werden soll
success:
title: ✅ Entfernt
description: '{member} wurde von {ticket} entfernt.'
description: Entfernt ein Mitglied aus einem Ticket
name: entfernen
removed: ⬅️ {removed} wurde von {by} entfernt.
topic:
description: Thema eines Tickets ändern
name: thema
transcript:
name: abschrift
options:
ticket:
name: ticket
description: Die Nummer des Tickets von welchem die Abschrift erstellt werden
soll
description: Abschrift eines Tickets erhalten
transfer:
name: übertragen
transferred_from: 📨 {user} hat dieses Ticket von {from} auf {to} übertragen.
options:
member:
name: mitglied
description: Das Mitglied, an das die Eigentümerschaft übertragen werden
soll
transferred: 📨 {user} hat dieses Ticket an {to} übertragen.
description: Ein Ticket an ein anderes Mitglied übertragen
tag:
options:
for:
name: für
description: Der Benutzer, an den das Tag gerichtet werden soll
tag:
name: tag
description: Der Name des zu verwendenden Tags
name: tag
description: Verwende einen Tag
release:
description: Ein Ticket freigeben (unclaim)
name: freigeben
user:
create:
name: Erstelle Ticket für Benutzer
log:
admin:
changes: Änderungen
description:
target:
question: eine Frage
settings: Einstellungen
category: eine Kategorie
panel: ein Panel
tag: ein Tag
joined: '{user} {verb} {targetType}'
title:
target:
category: Kategorie
panel: Panel
settings: Einstellungen
question: Frage
tag: Tag
joined: '{targetType} {verb}'
verb:
delete: gelöscht
create: erstellt
update: Aktualisiert
message:
description: '{user} {verb} eine Nachricht'
verb:
delete: gelöscht
update: Aktualisiert
message: Nachricht
title: Nachricht {verb}
ticket:
title: Ticket {verb}
description: '{user} {verb} ein Ticket'
ticket: Ticket
verb:
claim: übernommen
close: Geschlossen
update: Aktualisiert
create: erstellt
unclaim: freigegeben
added: Mitglieder hinzugefügt
removed: Entfernte Mitglieder
menus:
category:
placeholder: Wähle eine Ticketkategorie
guild:
placeholder: Wähle einen Server
misc:
blocked:
title: ❌ Gesperrt
description: Du darfst keine Tickets erstellen.
cooldown:
description: Bitte warte {time}, bevor du ein weiteres Ticket in dieser Kategorie
erstellst.
title: ❌Bitte warten
error:
description: "Entschuldigung, ein unerwarteter Fehler ist aufgetreten.\nBitte\
\ gib diese Informationen an einen Administrator weiter.\n"
fields:
code: Fehlercode
identifier: Kennung
title: ⚠️ Etwas ist schief gelaufen
expired:
description: Du hast nicht rechtzeitig geantwortet. Bitte versuche es erneut.
title: ⏰ Abgelaufen
expires_in: Läuft in {time} ab
invalid_ticket:
description: Bitte gib ein gültiges Ticket an.
title: ❌ Ungültiges Ticket
member_limit:
description:
- Bitte verwende dein vorhandenes Ticket oder schließe es ab, bevor du ein neues
erstellst.
- "Bitte schließe ein Ticket, bevor du ein neues erstellst.\nVerwende `/Tickets`,\
\ um deine bestehenden Tickets anzuzeigen.\n"
title:
- ❌ Du hast bereits ein Ticket
- ❌ Du hast bereits %d offene Tickets
not_ticket:
title: ❌ Dies ist kein Ticketkanal
description: Du kannst diesen Befehl nur in Tickets verwenden.
ratelimited:
description: Versuche es in ein paar Sekunden erneut.
title: 🐢 Bitte langsamer
unknown_category:
description: Bitte versuche es mit einer anderen Kategorie.
title: ❌ Diese Ticketkategorie existiert nicht
no_categories:
title: ❌ Es gibt keine Ticketkategorien
description: Es wurden keine Ticketkategorien konfiguriert.
missing_roles:
description: Du hast nicht die erforderlichen Rollen, um ein Ticket in dieser
Kategorie erstellen zu können.
title: ❌ Unzureichende Rollen
category_full:
description: "Die Kategorie hat ihre maximale Kapazität erreicht.\nBitte versuche\
\ es später erneut.\n"
title: ❌ Kategorie voll
modals:
feedback:
comment:
placeholder: Hast du zusätzliches Feedback?
label: Kommentar
rating:
label: Bewertung
placeholder: 1-5
title: Wie haben wir das gemacht?
topic:
label: Thema
placeholder: Worum geht es in diesem Ticket?
ticket:
answers:
no_value: '*Keine Antwort*'
close:
closed:
description: Dieser Kanal wird in wenigen Sekunden gelöscht…
title: ✅ Ticket geschlossen
forbidden:
title: ❌ Fehler
description: Du bist nicht berechtigt, dieses Ticket zu schließen.
rejected: ✋ {user} hat eine Anfrage zum Schließen dieses Tickets abgelehnt.
staff_request:
title: ❓ Kann dieses Ticket geschlossen werden?
archived: "\nDie Nachrichten in diesem Kanal werden zum späteren Nachschlagen\
\ archiviert.\n"
description: "{requestedBy} möchte dieses Ticket schließen.\nKlicke auf \"Akzeptieren\"\
, um es jetzt zu schließen, oder auf \"Ablehnen\", wenn du noch Hilfe benötigst.\n"
user_request:
title: ❓ {requestedBy} möchte dieses Ticket schließen
wait_for_user: ✋ Bitte warte bis der Benutzer antwortet.
wait_for_staff: ✋ Bitte wartee, bis das Personal dieses Ticket schließt.
created:
title: ✅ Ticket erstellt
description: 'Dein Ticketkanal wurde erstellt: {channel}.'
edited:
description: Deine Änderungen wurden gespeichert.
title: ✅ Ticket aktualisiert
feedback: Danke für deine Rückmeldung.
opening_message:
content: "{staff}\n{creator} hat ein neues Ticket erstellt\n"
fields:
topic: Thema
references_message:
title: Referenz
description: Verweist auf [eine Nachricht]({url}), die {timestamp} von {author}
gesendet wurde.
references_ticket:
description: 'Dieses Ticket bezieht sich auf ein vorheriges Ticket:'
fields:
date: Erstellt am
number: Nummer
topic: Thema
title: Referenz
claimed: 🙌 {user} hat dieses Ticket übernommen.
released: ♻️ {user} hat das ticket freigegeben.
dm:
closed:
title: Dein Ticket wurde geschlossen
fields:
closed:
name: Geschlossen um
value: '{timestamp} (nach {duration})'
closed_by: Geschlossen durch
created: Erstellt um
feedback: Deine Rückmeldung
reason: Grund des Schließens
response: Reaktionszeit
ticket: Ticket
topic: Thema
archived: Benutze `/transcript` in **{guild}** um archivierte Nachrichten einzusehen.
confirm_open:
title: Möchtest du ein Ticket mit dem folgendem Thema eröffnen?

1
src/i18n/el.yml Normal file
View File

@ -0,0 +1 @@
{}

467
src/i18n/en-GB.yml Normal file
View File

@ -0,0 +1,467 @@
buttons:
accept_close_request:
emoji:
text: Accept
cancel:
emoji:
text: Cancel
claim:
emoji: 🙌
text: Claim
close:
emoji: ✖️
text: Close
confirm_open:
emoji:
text: Create ticket
create:
emoji: 🎫
text: Create a ticket
edit:
emoji: ✏️
text: Edit
reject_close_request:
emoji: ✖️
text: Reject
unclaim:
emoji: ♻️
text: Release
commands:
message:
create:
name: Create ticket from message
pin:
name: Pin message
not_pinnable:
description: |
This message can't be pinned.
Please ask an administrator to check the bot's permissions.
title: ❌ Error
not_ticket:
description: You can only pin messages in tickets.
title: ❌ This isn't a ticket channel
pinned:
description: The message has been pinned.
title: ✅ Pinned message
slash:
add:
added: ➡️ {added} has been added by {by}.
description: Add a member to a ticket
name: add
not_staff:
description: Only staff members can add members to others' tickets.
title: ❌ Error
options:
member:
description: The member to add to the ticket
name: member
ticket:
description: The ticket to add the member to
name: ticket
success:
description: "{member} has been added to {ticket}."
title: ✅ Added
claim:
description: Claim a ticket
name: claim
not_staff:
description: Only staff members can claim tickets.
title: ❌ Error
close:
description: Request a ticket to be closed
invalid_time:
description: "`{input}` is not a valid time format."
title: ❌ Invalid
name: close
options:
reason:
description: The reason for closing the ticket
name: reason
force-close:
closed_one:
description: The channel will be deleted in a few seconds.
title: ✅ Ticket closed
confirm_multiple:
description: >
You are about to close **{count}** tickets that have been inactive
for more than `{time}`:
{tickets}
title: ❓ Are you sure?
confirmed_multiple:
description: The channels will be deleted in a few seconds.
title:
- ✅ Closing %d ticket
- ✅ Closing %d tickets
description: Forcibly close a ticket
name: force-close
no_tickets:
description: >-
There are no open tickets that have been inactive for more than
`{time}`.
title: ❌ No tickets
not_staff:
description: Only staff members can force-close tickets.
title: ❌ Error
options:
category:
description:
Close all tickets in the specified category (must be used with
`time`)
name: category
reason:
description: The reason for closing the ticket(s)
name: reason
ticket:
description: The ticket to close
name: ticket
time:
description: Close all tickets that have been inactive for the specified time
name: time
help:
description: Show the help menu
name: help
response:
commands: Commands
description: "**Use {command} to create a ticket and get support.**"
links:
commands: Full command list
docs: Documentation
feedback: Feedback
links: Useful links
support: Support
settings: Bot settings
title: Help
move:
description: Move a ticket to another category
moved: 🗃️ {by} has moved this ticket from **{from}** to **{to}**.
name: move
not_staff:
description: Only staff members can move tickets.
title: ❌ Error
options:
category:
description: The category to move the ticket to
name: category
new:
description: Create a new ticket
name: new
options:
references:
description: The number of a related ticket
name: references
priority:
description: Set the priority of a ticket
name: priority
not_staff:
description: Only staff members can change the priority of tickets.
title: ❌ Error
options:
priority:
choices:
HIGH: 🔴 High
LOW: 🟢 Low
MEDIUM: 🟠 Medium
description: The priority of the ticket
name: priority
success:
description: This ticket's priority has been set to `{priority}`.
title: ✅ Priority set
release:
description: Release (unclaim) a ticket
name: release
remove:
description: Remove a member from a ticket
name: remove
not_staff:
description: Only staff members can remove members from others' tickets.
title: ❌ Error
options:
member:
description: The member to remove from the ticket
name: member
ticket:
description: The ticket to remove the member from
name: ticket
removed: ⬅️ {removed} has been removed by {by}.
success:
description: "{member} has been removed from {ticket}."
title: ✅ Removed
tag:
description: Use a tag
name: tag
options:
for:
description: The user to target the tag to
name: for
tag:
description: The name of the tag to use
name: tag
tickets:
description: List your own or someone else's tickets
name: tickets
not_staff:
description: Only staff members can view others' tickets.
title: ❌ Error
options:
member:
description: The member to list the tickets of
name: member
response:
description: Use {transcript} to download the transcript of a ticket.
fields:
closed:
name: Closed tickets
none:
other: "{user} hasn't made any tickets."
own: |
You haven't made any tickets.
Use {new} to open a ticket.
open:
name: Open tickets
title:
other: "{displayName}'s tickets"
own: Your tickets
topic:
description: Change the topic of a ticket
name: topic
transcript:
description: Get the transcript of a ticket
name: transcript
options:
member:
description: The member to search for tickets of
name: member
ticket:
description: The ticket to get the transcript of
name: ticket
transfer:
description: Transfer ownership of a ticket to another member
name: transfer
options:
member:
description: The member to transfer ownership to
name: member
transferred: 📨 {user} has transferred this ticket to {to}.
transferred_from: 📨 {user} has transferred this ticket from {from} to {to}.
user:
create:
name: Create ticket for user
not_staff:
description: Only staff members can open tickets for other members.
title: ❌ Error
prompt:
description: Click the button below to create a ticket.
title: Please create a ticket
sent:
description: "{user} has been invited to create a ticket in **{category}**."
title: ✅ Prompt sent
dm:
closed:
archived: Use the `/transcript` command in **{guild}** to view the archived messages.
fields:
closed:
name: Closed at
value: "{timestamp} (after {duration})"
closed_by: Closed by
created: Created at
feedback: Your feedback
reason: Closed because
response: Response time
ticket: Ticket
topic: Topic
title: Your ticket has been closed
confirm_open:
title: Do you want to open a ticket with the following topic?
log:
admin:
changes: Changes
description:
joined: "{user} {verb} {targetType}"
target:
category: a category
panel: a panel
question: a question
settings: the settings
tag: a tag
title:
joined: "{targetType} {verb}"
target:
category: Category
panel: Panel
question: Question
settings: Settings
tag: Tag
verb:
create: created
delete: deleted
update: updated
message:
description: "{user} {verb} a message"
message: Message
title: Message {verb}
verb:
delete: deleted
update: updated
ticket:
added: Added members
description: "{user} {verb} a ticket"
removed: Removed members
ticket: Ticket
title: Ticket {verb}
verb:
claim: claimed
close: closed
create: created
unclaim: released
update: updated
menus:
category:
placeholder: Select a ticket category
guild:
placeholder: Select a server
misc:
blocked:
description: You are not allowed to create tickets.
title: ❌ Blocked
category_full:
description: |
The category has reached its maximum capacity.
Please try again later.
title: ❌ Category full
cooldown:
description: Please wait {time} before creating another ticket in this category.
title: ❌ Please wait
error:
description: |
Sorry, an unexpected error occurred.
Please give this information to an administrator.
fields:
code: Error code
identifier: Identifier
title: ⚠️ Something went wrong
expired:
description: You didn't respond in time. Please try again.
title: ⏰ Expired
expires_in: Expires in {time}
invalid_ticket:
description: Please specify a valid ticket.
title: ❌ Invalid ticket
member_limit:
description:
- Please use your existing ticket or close it before creating another.
- |
Please close a ticket before creating another.
Use `/tickets` to view your existing tickets.
title:
- ❌ You already have a ticket
- ❌ You already have %d open tickets
missing_roles:
description: >-
You do not have the roles required to be able to create a ticket in
this category.
title: ❌ Insufficient roles
no_categories:
description: No ticket categories have been configured.
title: ❌ There are no ticket categories
not_ticket:
description: You can only use this command in tickets.
title: ❌ This isn't a ticket channel
ratelimited:
description: Try again in a few seconds.
title: 🐢 Please slow down
unknown_category:
description: Please try a different category.
title: ❌ That ticket category doesn't exist
update:
description: |
> [View `{version}` on GitHub]({github})
> [Changelog]({changelog})
> [Update guide]({guide})
title: An update is available
modals:
feedback:
comment:
label: Comment
placeholder: Do you have any additional feedback?
rating:
label: Rating
placeholder: 1-5
title: How did we do?
topic:
label: Topic
placeholder: What is this ticket about?
ticket:
answers:
no_value: "*No response*"
claimed: 🙌 {user} has claimed this ticket.
close:
closed:
description: This channel will be deleted in a few seconds…
title: ✅ Ticket closed
forbidden:
description: You don't have permission to close this ticket.
title: ❌ Error
rejected: ✋ {user} rejected a request to close this ticket.
staff_request:
archived: |
The messages in this channel will be archived for future reference.
description: |
{requestedBy} wants to close this ticket.
Click "Accept" to close it now, or "Reject" if you still need help.
title: ❓ Can this ticket be closed?
user_request:
title: ❓ {requestedBy} wants to close this ticket
wait_for_staff: ✋ Please wait for staff to close this ticket.
wait_for_user: ✋ Please wait for the user to respond.
closing_soon:
description: |
This ticket will be closed due to inactivity <t:{timestamp}:R>.
Send a message to cancel this automation.
title: ⌛ This ticket will be closed soon
created:
description: "Your ticket channel has been created: {channel}."
title: ✅ Ticket created
edited:
description: Your changes have been saved.
title: ✅ Ticket updated
feedback: Thank you for your feedback.
inactive:
description: |
There hasn't been any activity in this channel since <t:{timestamp}:R>.
Please continue the conversation or {close} the ticket.
title: ⏰ This ticket is inactive
offline:
description:
There aren't any staff members available at the moment, so it may
take longer than usual to get a response.
title: 😴 We're not online
opening_message:
content: |
{staff}
{creator} has created a new ticket
fields:
topic: Topic
references_message:
description: References [a message]({url}) sent {timestamp} by {author}.
title: Reference
references_ticket:
description: "This ticket is related to a previous ticket:"
fields:
date: Created at
number: Number
topic: Topic
title: Reference
released: ♻️ {user} has released this ticket.
working_hours:
next:
description:
We'll be back at <t:{timestamp}:F> (<t:{timestamp}:R>), although
you may receive a response before then.
title: 🕗 We're not working at the moment
today:
description:
You may receive a response before, but we don't start working until
<t:{timestamp}:t> today (<t:{timestamp}:R>).
title: 🕗 We're not working at the moment

164
src/i18n/fi.yml Normal file
View File

@ -0,0 +1,164 @@
commands:
message:
pin:
not_ticket:
title: ❌ Tämä ei ole tiketti kanava
description: Voit kiinnittää viestejä vain tiketeissä.
name: Kiinnitä viesti
not_pinnable:
title: ❌ Virhe
pinned:
description: Viesti on kiinnitetty.
title: ✅ Kiinnitetty viesti
create:
name: Luo tiketti viestistä
slash:
add:
description: Lisää jäsen tikettiin
name: lisää
options:
ticket:
name: tiketti
description: Tiketti johon jäsen lisätään
member:
name: jäsen
description: Jäsen joka lisätään tikettiin
not_staff:
description: Vain henkilökunta voi lisätä toisia tiketteihin.
title: ❌ Virhe
success:
title: ✅ Lisätty
description: '{member} lisättiin {ticket}'
force-close:
not_staff:
title: ❌ Virhe
no_tickets:
title: ❌ Ei tikettejä
options:
category:
name: kategoria
reason:
name: syy
ticket:
name: tiketti
time:
name: aika
confirm_multiple:
title: ❓ Oletko varma?
help:
description: Näyttää apua valikon
response:
links:
feedback: Palaute
links: Hyödyllisiä linkkejä
support: Tuki
commands: Koko komento lista
settings: Botin asetukset
commands: Komennot
title: Apua
name: apua
move:
name: siirrä
options:
category:
name: kategoria
description: Siirrä tiketti toiseen kategoriaan
remove:
options:
member:
description: Jäsen joka poistetaan tiketistä
name: jäsen
ticket:
name: tiketti
name: poista
not_staff:
title: ❌ Virhe
success:
title: ✅ Poistettu
description: Poista jäsen tiketistä
close:
invalid_time:
title: ❌ Virheellinen
name: sulje
options:
reason:
name: syy
description: Syy tiketin(t) sulkemiseen
description: Pyydä tiketin sulkemista
claim:
description: Varaa tiketti
name: varaa
new:
description: Luo uusi tiketti
name: uusi
priority:
options:
priority:
choices:
LOW: 🟢 Hidas
HIGH: 🔴 Kiireellinen
MEDIUM: 🟠 Normaali
topic:
name: aihe
tickets:
not_staff:
title: ❌ Virhe
response:
title:
other: '{displayName} tiketit'
own: Sinun tiketit
fields:
closed:
name: Suljetut tiketit
name: tiketit
options:
member:
name: jäsen
transfer:
options:
member:
name: jäsen
name: siirrä
transcript:
options:
ticket:
name: tiketti
release:
name: julkaise
buttons:
close:
text: Sulje
emoji: ✖️
edit:
text: Muokkaa
emoji: ✏️
reject_close_request:
text: Hylkää
emoji: ✖️
unclaim:
text: Julkaise
emoji: ♻️
accept_close_request:
text: Hyväksy
emoji:
cancel:
text: Peruuta
emoji: ✖️
confirm_open:
text: Luo tiketti
emoji:
create:
emoji: 🎫
text: Luo tiketti
claim:
text: Varaa
emoji: 🙌
ticket:
opening_message:
fields:
topic: Aihe
references_ticket:
fields:
topic: Aihe
number: Numero
feedback: Kiitos palautteestasi.

410
src/i18n/fr.yml Normal file
View File

@ -0,0 +1,410 @@
buttons:
accept_close_request:
text: Accepter
emoji:
cancel:
emoji: ✖️
text: Annuler
claim:
emoji: 🙌
text: S'approprier
close:
text: Fermer
emoji: ✖️
reject_close_request:
emoji: ✖️
text: Rejeter
confirm_open:
emoji:
text: Créer un ticket
create:
text: Créer un ticket
emoji: 🎫
edit:
emoji: ✏️
text: Modifier
unclaim:
emoji: ♻️
text: Relâcher
commands:
message:
pin:
name: Épingler le message
not_pinnable:
title: ❌ Erreur
description: "Ce message ne peut être épinglé.\nVeuillez demander à un administrateur\
\ de vérifier les permissions du bot\n"
pinned:
title: ✅ Message épinglé
description: Les message à été épinglé.
not_ticket:
title: ❌ Ceci n'est pas un salon de ticket
description: Vous pouvez seulement épingler des messages dans les tickets.
create:
name: Transformer ce message en ticket
slash:
add:
not_staff:
title: ❌ Erreur
description: Seul les membres de l'équipe peuvent ajouter des membres aux
tickets des autres.
success:
title: ✅ Ajouté
description: '{member} à été ajouté au ticket {ticket}.'
added: '{added} à été ajouté par {by}.'
description: Ajouter un membre à un ticket
name: ajouter
options:
member:
description: Le membre à ajouter au ticket
name: membre
ticket:
description: Le ticket auquel ajouter le membre
name: ticket
close:
invalid_time:
title: ❌ Invalide
description: "`{input}` n'est pas un format de date valide."
options:
reason:
description: La raison de la clôture du ticket
name: raison
description: Demander la fermeture d'un ticket
name: fermer
force-close:
options:
ticket:
name: ticket
description: le ticket à clôturer
category:
description: Fermer tout les tickets dans la catégorie spécifiée (peut être
utilisée avec `time`)
name: catégorie
time:
description: Clôturer tout les tickets inactifs depuis le temps spécifié
(peut être utilisé avec `category`)
name: temps
reason:
name: raison
description: La raison de la fermeture du/des ticket(s)
description: Forcer la clôture d'un ticket
name: fermeture-forcée
no_tickets:
description: Il n'y as pas de tickets ouverts inactifs depuis plus de `{time}`.
title: ❌ Aucun ticket
not_staff:
description: Seuls les membres de l'équipe peuvent forcer la fermeture de
tickets.
title: ❌ Erreur
confirm_multiple:
description: "Vous êtes sur le point de clôturer **{count}** tickets inactifs\
\ depuis plus de `{time}` :\n{tickets}\n"
title: ❓Êtes vous sûr ?
claim:
description: S'approprier un ticket
name: claim
not_staff:
description: Seul les membres de l'équipe peuvent s'approprier un ticket.
title: ❌ Erreur
help:
name: aide
response:
links:
commands: Liste complète des commandes
docs: Documentation
support: Support
links: Liens utiles
feedback: Retour
commands: Commandes
settings: Paramètres du bot
description: '**Utilise {command} pour créer un ticket et obtenir un support.**'
title: Aide
description: Afficher le menu d'aide
move:
description: Déplacer un ticket dans une autre catégorie
options:
category:
name: catégorie
description: La catégorie dans laquelle déplacer le ticket
moved: 🗃️ {by} as déplacé ce ticket de **{from}** vers **{to}**.
name: déplacer
not_staff:
description: Seuls les membres de l'équipe peuvent déplacer des tickets.
title: ❌ Erreur
new:
name: nouveau
description: Créer un nouveau ticket
options:
references:
description: Le numéro d'un ticket associé
name: références
priority:
name: priorité
options:
priority:
choices:
MEDIUM: 🟠 Moyenne
HIGH: 🔴 Haute
LOW: 🟢 Basse
description: La priorité du ticket
name: priorité
description: Définir la priorité d'un ticket
success:
title: ✅ Priorité définie
description: La priorité du ticket à été définie sur `{priority}`.
not_staff:
title: ❌ Erreur
description: Seuls les membres de l'équipe peuvent changer la priorité d'un
ticket.
release:
name: relâcher
description: Relâcher (ne plus s'approprier) un ticket
remove:
description: Retirer un membre d'un ticket
name: retirer
success:
title: ✅ Retiré
description: '{member} as été retiré de {ticket}.'
options:
member:
description: Le membre à retirer du ticket
name: membre
ticket:
description: Le ticket dont il faut retirer le membre
name: ticket
removed: ⬅️ {removed} as été retiré par {by}.
not_staff:
description: Seuls les membres de l'équipe peuvent retirer des membres des
tickets des autres.
title: ❌ Erreur
tag:
name: tag
description: Utiliser un tag
options:
for:
description: L'utilisateur à qui attribuer le tag
name: pour
tag:
description: Le nom à utiliser pour le tag
name: tag
tickets:
options:
member:
name: membre
description: Le membre dont il faut lister les tickets
description: Lister votre tickets ou ceux de quelqu'un d'autre
not_staff:
description: Seuls les membres de l'équipe peuvent voir les tickets des autres
membres.
title: ❌ Erreur
response:
fields:
closed:
none:
other: "{user} n'as ouvert aucun ticket."
own: "Vous n'avez ouvert aucun ticket\nUtilisez {new} pour ouvrir un\
\ ticket.\n"
name: Tickets clôturés
open:
name: Tickets ouverts
description: Utilisez {transcript} pour télécharger la transcription du ticket.
title:
other: Tickets de {displayName}
own: Vos tickets
name: tickets
transcript:
name: transcription
description: Obtenir la transcription d'un ticket
options:
ticket:
name: ticket
description: Le nombre de ticket dont vous souhaitez obtenir la transcription
transfer:
description: Transférer la propriété d'un ticket à un autre membre
name: transférer
transferred_from: '{user} as transféré ce ticket de {from} à {to}.'
options:
member:
description: Le membre à qui transférer la propriété
name: membre
transferred: '{user} as transféré ce ticket à {to}.'
topic:
name: sujet
description: Changer le sujet d'un ticket
user:
create:
name: Créer un ticket pour un membre
misc:
update:
description: "> [Voir`{version}` sur GitHub]({github})\n> [Notice de changement]({changelog})\n\
> [Guide de mise à jour]({guide})\n"
title: Une mise à jour est disponible
category_full:
description: "Cette catégorie as atteint sa capacité maximum.\nVeuillez réessayer\
\ ultérieurement.\n"
title: ❌ Catégorie pleine
expires_in: Expire dans {time}
not_ticket:
title: ❌ Ce salon n'est pas un ticket
description: Vous ne pouvez utiliser cette commande que dans un ticket.
ratelimited:
description: Réessayez dans quelques secondes.
title: 🐢 Veuillez ralentir
blocked:
description: Vous n'êtes pas autorisé à créer un ticket.
title: ❌ Bloqué
cooldown:
title: ❌ Veuillez patienter
description: Veuillez patienter {time} avant de créer un autre ticket dans cette
catégorie.
error:
fields:
code: Code d'erreur
identifier: Identifiant
description: "Désolé, une erreur inattendue s'est produite.\nVeuillez transmettre\
\ cette information à un administrateur.\n"
title: ⚠️ Quelque chose s'est mal passé
missing_roles:
description: Vous n'avez pas les rôles requis pour créer un ticket dans cette
catégorie.
title: ❌ Rôles manquants
no_categories:
description: Aucune catégorie de ticket n'est configurée.
title: ❌ Il n'existe pas de catégorie de ticket
expired:
description: Vous n'avez pas répondu à temps. Veuillez réessayer.
title: ⏰ Expiré
invalid_ticket:
description: Veuillez spécifier un ticket valide.
title: ❌ Ticket invalide
member_limit:
title:
- ❌ Vous avez déjà un ticket
- Vous avez déjà %d tickets ouverts
description:
- Veuillez utiliser votre ticket ouvert ou le clôturer avant d'en créer un nouveau.
- "Veuillez clôturer votre ticket avant d'en créer un autre.\nUtilisez `/tickets`\
\ afin de voir vos tickets existants.\n"
unknown_category:
description: Veuillez tenter une autre catégorie.
title: ❌ Cette catégorie de ticket n'existe pas
modals:
feedback:
rating:
label: Notation
placeholder: 1-5
title: Comment avons-nous fait ?
comment:
label: Commentaire
placeholder: Avez vous un retour supplémentaire ?
topic:
label: Sujet
placeholder: Quelle est la raison de ce ticket ?
ticket:
answers:
no_value: '*Pas de réponse*'
claimed: 🙌 {user} s'est approprié ce ticket.
close:
closed:
description: Ce salon sera supprimé dans quelques secondes…
title: ✅ Ticket clôturé
staff_request:
title: ❓ Ce ticket peut il être clôturé ?
description: "{requestedBy} souhaite clôturer ce ticket.\nCliquez sur \"Accepter\"\
\ pour le fermer, or \"Refuser\" si vous avez toujours besoin d'aide.\n"
archived: "\nLes messages de ce salon seront archivés pour une référence future.\n"
forbidden:
title: ❌ Erreur
description: Vous n'avez pas la permission de clôturer ce ticket.
rejected: ✋ {user} as rejeté une demande de clôture de ce ticket.
user_request:
title: ❓ {requestedBy} souhaite clôturer le ticket
wait_for_staff: ✋ Veuillez attendre que l'équipe ferme ce ticket.
wait_for_user: ✋ Veuillez attendre une réponse de l'utilisateur.
released: ♻️ {user} as relâché ce ticket.
references_message:
title: Référence
description: Fait référence à [a message]({url}) envoyé {timestamp} par {author}.
references_ticket:
title: Référence
fields:
number: Nombre
topic: Sujet
date: Crée à
description: 'Ce ticket est lié à un ticket précédent :'
opening_message:
content: "{staff}\n{creator} as ouvert un nouveau ticket\n"
fields:
topic: Sujet
edited:
title: ✅ Ticket mis à jour
description: Vos modifications ont été enregistrées.
created:
description: 'Votre ticket as été crée : {channel}.'
title: ✅ Ticket crée
feedback: Merci pour votre retour.
dm:
closed:
title: Votre ticket as été clôturé
fields:
created: Crée à
closed:
name: Clôturé à
value: '{timestamp} (après {duration})'
closed_by: Clôturé par
feedback: Vos retours
ticket: Ticket
topic: Sujet
reason: Raison de la clôture
response: Temps de réponse
archived: Utilisez la commande `/transcript` sur **{guild}** pour voir les messages
archivés.
confirm_open:
title: Souhaitez vous ouvrir un ticket avec le sujet suivant ?
log:
admin:
changes: Modifications
description:
joined: '{user} {verb} {targetType}'
target:
tag: un tag
question: une question
settings: les paramètres
panel: un panneau
category: une catégorie
title:
target:
tag: Tag
category: Catégorie
settings: Paramètres
panel: Panneau
question: Question
joined: '{targetType} {verb}'
verb:
update: mis à jour
delete: supprimé
create: crée
ticket:
description: '{user} {verb} un ticket'
verb:
unclaim: Relâché
update: mis à jour
close: Fermé
create: Crée
claim: Approprié
added: Membres ajoutés
removed: Membres supprimés
title: Ticket {verb}
ticket: Ticket
message:
title: Message {verb}
description: '{user} {verb} un message'
verb:
update: mis à jour
delete: supprimé
message: Message
menus:
category:
placeholder: Sélectionnez une catégorie de ticket
guild:
placeholder: Sélectionnez un serveur

414
src/i18n/hu.yml Normal file
View File

@ -0,0 +1,414 @@
log:
admin:
description:
target:
panel: panel
question: kérdés
category: kategória
settings: beállítás
tag: címke
joined: '{user} {verb} {targetType}'
title:
target:
category: Kategória
settings: Beállítások
panel: Panel
question: Kérdés
tag: Címke
joined: '{targetType} {verb}'
verb:
create: létrehozva
delete: törölve
update: frissítve
changes: Változások
ticket:
added: Hozzáadott felhasználók
description: '{user} {verb} hibajegy'
removed: Eltávolított felhasználók
title: Hibjaegy {verb}
verb:
create: létrehozva
claim: begyűjtve
close: bezárva
unclaim: felszabadítva
update: frissítve
ticket: Hibajegy
message:
message: Üzenet
verb:
update: frissítve
delete: törölve
description: '{user} {verb} a(z) üzenet'
title: Üzenet {verb}
buttons:
accept_close_request:
emoji:
text: Elfogadás
cancel:
emoji: ✖️
text: Mégse
claim:
emoji: 🙌
text: Begyűjtés
close:
emoji: ✖️
text: Bezárás
confirm_open:
text: Hibajegy létrehozása
emoji:
edit:
text: Szerkesztés
emoji: ✏️
reject_close_request:
text: Elutasítás
emoji: ✖️
create:
emoji: 🎫
text: Hibajegy létrehozása
unclaim:
emoji: ♻️
text: Felszabadítás
commands:
message:
pin:
not_ticket:
title: ❌ Nem hibajegy csatorna
description: Csak a hibajegyekben tudod kitűzni az üzeneteket.
name: Üzenet kitűzése
not_pinnable:
title: ❌ Hiba
description: "Ezt az üzenetet nem tudod kitűzni.\nKérlek vedd fel a kapcsolatot\
\ egy adminsztrátorral, aki le tudja ellenőrizni a bot jogosultságait.\n"
pinned:
description: Az üzenet kitűzésre került.
title: ✅ Üzenet kitűzve
create:
name: Hibajegy létrehozása üzenetből
slash:
force-close:
options:
time:
name: idő
description: Összes hibajegy bezására, amely inaktív volt a megadott ideig
(használható a(z) `kategóriával` is)
category:
description: Összes hibajegy bezására a megadott kategóriában (használható
a(z) `idővel` is)
name: kategória
reason:
description: Indok a hibajegy(ek) bezárására
name: indok
ticket:
description: A hibajegy, amit be szeretnél zárni
name: hibajegy
no_tickets:
description: Nincs olyan nyitott hibajegy, mely inaktív lenne `{time}` ideje.
title: ❌ Nincs hibajegy
confirm_multiple:
description: "Bezására kerül **{count}** darab hibajegy, mely inaktív `{time}`\
\ ideje:\n{tickets}\n"
title: ❓ Biztos vagy benne?
description: Hibajegy erőltetett bezárása
name: erőltetett-bezárás
not_staff:
description: Csak a személyzeti tagok erőltethetik a hibajegy bezárását.
title: ❌ Hiba
close:
invalid_time:
description: '`{input}` nem megfelelő formátumú.'
title: ❌ Érvénytelen
description: Kérelmezd a hibajegy bezárását
options:
reason:
description: Indok a hibajegy(ek) bezárásához
name: indok
name: bezárás
help:
name: segítség
response:
commands: Parancsok
links:
support: Segítség
commands: Teljes parancs lista
docs: Dokumentáció
feedback: Visszajelzés
links: Hasznos linkek
settings: Bot konfiguráció
description: '**Használd a(z) {command} parancsot a hibajegy létrehozásához..**'
description: Segítség menü megjelenítése
title: Segítség
tickets:
response:
title:
own: Hibajegyeid
other: '{displayName} hibajegyei'
description: Használd a(z) {transcript}, hogy letölthesd a hibajegy átiratát.
fields:
closed:
name: Bezárt hibajegyek
none:
other: '{user} még nem hozott létre hibajegyet.'
own: "Nem hoztál még létre hibajegyet.\nHasználd a(z) {new} a létrehozáshoz.\n"
open:
name: Nyitott hibajegyek
name: hibajegyek
not_staff:
description: Csak a személyzet tagjai tekinthetik meg mások hibajegyeit.
title: ❌ Hiba
description: Sorolja fel saját vagy valaki más hibajegyeit
options:
member:
description: A felhasználó, akinek a hibajegyeit szeretnéd listázni
name: felhasználó
new:
options:
references:
description: Kapcsolódó hibajegyek száma
name: hivatkozások
name: új
description: Hibajegy létrehozása
priority:
options:
priority:
description: A hibajegy prioritása
choices:
HIGH: 🔴 Magas
LOW: 🟢 Alacsony
MEDIUM: 🟠 Közepes
name: prioritás
name: prioritás
success:
title: ✅ Prioritás beállítva
description: 'A hibajegy prioritása beállításra került: `{priority}`.'
description: Hibajegy prioritásának beállítása
not_staff:
description: Csak a személyzet tagjai tudják megváltoztatni a hibajegy prioritását.
title: ❌ Hiba
move:
description: Hibajegy áthelyezése másik kategóriába
moved: '🗃️ {by} áthelyezte ezt a jegyet a következőről: **{from}** a következőre:
**{to}**.'
options:
category:
description: Kategória, amibe át szeretnéd helyezni a hibajegyet
name: kategória
name: mozgat
not_staff:
title: ❌ Hiba
description: Csak a személyzet tagjai mozgathatják a jegyeket.
remove:
options:
ticket:
name: hibajegy
description: A hibajegy, amiből el szeretnéd távolítani a felhasználót
member:
name: felhasználó
description: A felhasználó, akit el szeretnél távolítani a hibajegyből
name: eltávolítás
not_staff:
description: Csak személyzeti tagok tudnak eltávolítani felhasználókat a felhasználók
hibajegyéből.
title: ❌ Hiba
removed: ⬅️ {removed} eltávolításra került {by} által.
success:
description: '{member} eltávolításra került a(z) {ticket} hibajegyből.'
title: ✅ Eltávolítva
description: Felhasználó eltávolítása a hibajegyből
release:
name: felszabadítás
description: Hibajegy felszabadítása
tag:
options:
tag:
name: címke
description: A használandó címke neve
for:
name: részére
description: A felhasználó, akihez a címkét szeretnéd kötni
description: Címke használata
name: címke
transfer:
name: átruházás
description: A hibajegy tulajdonjogának átruházása egy másik felhasználóra
options:
member:
description: A felhasználó, akire át szeretnéd ruházni a tulajdonjogot
name: felhasználó
transferred: 📨 {user} átruházta a hibajegy tulajdonjogát {to} számára.
transferred_from: '📨 {user} áthelyezte ezt a hibahegyet innen: {from} ide: {to}.'
add:
description: Felhasználó hozzáadása a hibajegyhez
success:
title: ✅ Hozzáadva
description: '{member} hozzáadásra került a(z) {ticket} hibajegyhez.'
name: hozzáadás
added: ➡️ {added} hozzáadásra került {by} által.
options:
member:
description: Felhasználó, akit hozzá szeretnél adni a hibajegyhez
name: felhasználó
ticket:
description: A hibajegy, amihez hozzá szeretnéd adni a felhasználót
name: hibajegy
not_staff:
description: Csak a személyzet tagiai tudnak hozzáadni felhasználót más felhasználó
hibajegyéhez.
title: ❌ Hiba
claim:
description: Hibajegy begyűjtése
name: begyűjtés
not_staff:
description: Csak a személyzet tagjai tudják begyűjteni a hibajegyet.
title: ❌ Hiba
topic:
description: Hibajegy témájának megváltoztatása
name: téma
transcript:
options:
ticket:
description: A hibajegy száma, melynek az átiratát szeretnéd lekérni
name: hibajegy
description: Hibajegy átiratának lekérése
name: hibajegy
user:
create:
name: Create ticket for user
dm:
closed:
title: A hibajegyed bezárásra került
archived: Használd a(z) `/transcript` a(z)**{guild}** szerveren, hogy megtekintsd
az archivált üzeneteket.
fields:
closed:
name: Bezárva ekkor
value: '{timestamp} ({duration} alatt)'
closed_by: Bezárva általa
created: Létrehozva ekkor
feedback: Visszajelzésed
reason: Bezárva ezért
response: Válaszidő
ticket: Hibajegy
topic: Téma
confirm_open:
title: Létre szeretnél hozni egy hibajegyet a megadott témával?
misc:
blocked:
title: ❌ Letiltva
description: Nem tudsz hibajegyet létrehozni.
category_full:
description: "A kategória elérte a maximális kapacitást.\nPróbáld újra később.\n"
title: ❌ Kategória megtelt
cooldown:
description: Kérlek várj {time}, mielőtt új hibajegyet hoznál létre a kategóriában.
title: ❌ Kérlek várj
ratelimited:
description: Próbáld újra néhány másodperc múlva.
title: 🐢 Kérlek lassíts
expires_in: Lejár {time}
error:
description: "Sajnálom váratlan hiba lépett fel.\nKérlek vedd fel a kapcsolatot\
\ az adminisztrátorral.\n"
fields:
code: Hibakód
identifier: Azonosító
title: ⚠️ Hiba lépett fel
invalid_ticket:
description: Kérlek add meg egy létező hibajegyet.
title: ❌ Érvénytelen hibajegy
expired:
description: Nem válaszoltál időben. Kérlek próbáld újra.
title: ⏰ Lejárt
member_limit:
description:
- Kérlek használd a meglévő hibajegyed, vagy zárd be mielőtt újat hoznál létre.
- "Kérlek zárd be a hibajegyet, mielőtt másikat hoznál létre.\nHasználd a(z) `/tickets`\
\ parancsot a meglévő hibajegyeid megtekintéséhez.\n"
title:
- ❌ Már van hibajegyed
- ❌ Már van %d megnyitott hibajegyed
missing_roles:
title: ❌ Nem megfelelő rangok
description: Nincs megfelelő rangod ahhoz, hogy létrehozhass hibajegyet ebbe a
kategóriába.
no_categories:
description: Nem került konfigurálásra egyetlen hibajegy kategória sem.
title: ❌ Nincsenek hibajegy kategóriák
not_ticket:
description: Csak hibajegyekben használhatod ezt a parancsot.
title: ❌ Nem hibajegy csatorna
unknown_category:
description: Kérlek próbálj egy másik kategóriát.
title: ❌ Nem létezik ilyen hibajegy kategória
update:
description: "> [`{version}` megtekintése a GitHubon]({github})\n> [Változásnapló]({changelog})\n\
> [Frissítési útmutató]({guide})\n"
title: Frissítés elérhető
menus:
category:
placeholder: Kategória választása
guild:
placeholder: Szerver választása
ticket:
edited:
description: A változtatások mentésre kerültek.
title: ✅ Hibajegy frissítve
references_ticket:
title: Referencia
fields:
topic: Téma
number: Szám
date: Létrehozva
description: 'Ez a hibajegy egy korábbi hibajegyhez kapcsolódik:'
close:
wait_for_staff: ✋ Kérlek várj a személyzetre, ahhoz hogy bezárhasd ezt a hibajegyet.
closed:
description: A csatorna törlésre kerül néhány másodperc múlva…
title: ✅ Hibajegy bezárva
forbidden:
description: Nincs jogosultságod a hibajegy bezárásához.
title: ❌ Hiba
rejected: ✋ {user} elutasította a kérést a hibajegy bezárásáról.
staff_request:
archived: "\nAz üzenetek ebben a csatornában archiválásra kerülnek a későbbiekre.\n"
description: "{requestedBy} be szeretné zárni ezt a hibajegyet.\nKattints \"\
Elfogadás\" a bezáráshoz, vagy \"Elutasíts\" ha további segítsére van szükséged.\n"
title: ❓ Bezárásra kerülhet a hibajegy?
user_request:
title: ❓ {requestedBy} be szeretné zárni ezt a hibajegyet
wait_for_user: ✋ Kérlek várj a felhasználó válaszára.
released: ♻️ {user} felszabadította a hibajegyet.
created:
description: 'A hibajegyed létrehozásra került: {channel}.'
title: ✅ Hibajegy létrehozva
claimed: 🙌 {user} begyűjtötte a hibajegyet.
answers:
no_value: '*Nem válaszolt*'
feedback: Köszönjük a visszajelzést.
opening_message:
content: "{staff}\n{creator} létrehozott egy új hibajegyet\n"
fields:
topic: Téma
references_message:
title: Referencia
description: Referencia [üzenet]({url}) elküldve {timestamp} {author} által.
working_hours:
next:
description: Visszatérünk a <t:{timestamp}:F> (<t:{timestamp}:R>) időpontra,
bár előfordulhat, hogy ez előtt választ kap.
title: 🕗 Jelenleg nem dolgozunk
today:
description: 'Előfordulhat, hogy korábban is kap választ, azonban nem dolgozunk
<t:{timestamp}:t> -ig (ma: <t:{timestamp}:R> )'
title: 🕗 Jelenleg nem dolgozunk
modals:
feedback:
rating:
placeholder: 1-5
label: Értékelés
comment:
label: Megjegyzés
placeholder: Van további visszajelzése?
title: Mit gondolsz, hogy teljesítettünk?
topic:
label: Téma
placeholder: Miről szól a hibajegy?

Some files were not shown because too many files have changed in this diff Show More