3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-07-06 10:37:19 +00:00

Merge branch 'master' into mod_actions-reason-aliases

This commit is contained in:
Tiago R 2023-12-28 10:52:12 +00:00 committed by GitHub
commit f6b3df4dd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
681 changed files with 24542 additions and 35605 deletions

29
.clabot Normal file
View file

@ -0,0 +1,29 @@
{
"contributors": [
"BanTheNons",
"CleverSource",
"DarkView",
"DenverCoder1",
"Jernik",
"Rstar284",
"almeidx",
"axisiscool",
"dexbiobot",
"greenbigfrog",
"k200-1",
"metal0",
"paolojpa",
"roflmaoqwerty",
"thewilloftheshadow",
"usoka",
"vcokltfre",
"Dragory",
"rubyowo",
"Dalkskkskk",
"iamshoXy",
"Scraayp",
"app/dependabot",
"zayKenyon"
],
"message": "Thank you for contributing to Zeppelin! We require contributors to sign our Contributor License Agreement (CLA). To let us review and merge your code, please visit https://github.com/ZeppelinBot/CLA to sign the CLA!"
}

View file

@ -0,0 +1,9 @@
{
"name": "Zeppelin Development",
"dockerComposeFile": "../docker-compose.development.yml",
"service": "devenv",
"remoteUser": "ubuntu",
"workspaceFolder": "/home/ubuntu/zeppelin"
}

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
.git
.github
.idea
.devcontainer
/docker/development/data
/docker/production/data
node_modules
/backend/dist
/dashboard/dist

View file

@ -1 +1,74 @@
KEY=32_character_encryption_key
# 32 character encryption key
KEY=
# Values from the Discord developer portal
CLIENT_ID=
CLIENT_SECRET=
BOT_TOKEN=
# The defaults here automatically work for the development environment.
# For production, change localhost:3300 to your domain.
DASHBOARD_URL=https://localhost:3300
API_URL=https://localhost:3300/api
# Comma-separated list of user IDs who should have access to the bot's global commands
STAFF=
# A comma-separated list of server IDs that should be allowed by default
DEFAULT_ALLOWED_SERVERS=
# When using the Docker-based development environment, this is only used internally. The API will be available at localhost:DOCKER_DEV_WEB_PORT/api.
API_PORT=3000
# Only required if relevant feature is used
#PHISHERMAN_API_KEY=
# The user ID and group ID that should be used within the Docker containers
# This should match your own user ID and group ID. Run `id -u` and `id -g` to find them.
DOCKER_USER_UID=
DOCKER_USER_GID=
#
# DOCKER (DEVELOPMENT)
# NOTE: You only need to fill in these values for running the development environment. See production config further below.
#
DOCKER_DEV_WEB_PORT=3300
# The MySQL database running in the container is exposed to the host on this port,
# allowing access with database tools such as DBeaver
DOCKER_DEV_MYSQL_PORT=3001
# Password for the Zeppelin database user
DOCKER_DEV_MYSQL_PASSWORD=
# Password for the MySQL root user
DOCKER_DEV_MYSQL_ROOT_PASSWORD=
# The development environment container has an SSH server that you can connect to.
# This is the port that server is exposed to the host on.
DOCKER_DEV_SSH_PORT=3002
DOCKER_DEV_SSH_PASSWORD=password
# If your user has a different UID than 1000, you might have to fill that in here to avoid permission issues
#DOCKER_DEV_UID=1000
#
# DOCKER (PRODUCTION)
# NOTE: You only need to fill in these values for running the production environment. See development config above.
#
DOCKER_PROD_DOMAIN=
DOCKER_PROD_WEB_PORT=443
# The MySQL database running in the container is exposed to the host on this port,
# allowing access with database tools such as DBeaver
DOCKER_PROD_MYSQL_PORT=3001
# Password for the Zeppelin database user
DOCKER_PROD_MYSQL_PASSWORD=
# Password for the MySQL root user
DOCKER_PROD_MYSQL_ROOT_PASSWORD=
# You only need to set these if you're running an external database.
# In a standard setup, the database is run in a docker container.
#DB_HOST=
#DB_USER=
#DB_PASSWORD=
#DB_DATABASE=

28
.eslintrc.js Normal file
View file

@ -0,0 +1,28 @@
module.exports = {
root: true,
env: {
node: true,
browser: true,
es6: true,
},
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
rules: {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"no-async-promise-executor": 0,
"@typescript-eslint/no-empty-interface": 0,
"no-constant-condition": ["error", {
checkLoops: false,
}],
"prefer-const": ["error", {
destructuring: "all",
ignoreReadBeforeAssign: true,
}],
"@typescript-eslint/no-namespace": ["error", {
allowDeclarations: true,
}],
},
};

View file

@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
node-version: [16.6]
node-version: [18.16]
steps:
- uses: actions/checkout@v1

3
.gitignore vendored
View file

@ -6,6 +6,9 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.clinic
.clinic-bot
.clinic-api
# Runtime data
pids

2
.nvmrc
View file

@ -1 +1 @@
16.6
18

71
DEVELOPMENT.md Normal file
View file

@ -0,0 +1,71 @@
# Zeppelin development environment
Zeppelin's development environment runs entirely within a Docker container.
Below you can find instructions for setting up the environment and getting started with development!
**Note:** If you'd just like to run the bot for your own server, see 👉 **[PRODUCTION.md](./PRODUCTION.md)** 👈
## Starting the development environment
### Using VSCode devcontainers
1. Install Docker
2. Make a copy of `.env.example` called `.env`
3. Fill in the missing values in `.env`
4. In VSCode: Install the `Remote - Containers` plugin
5. In VSCode: Run `Remote-Containers: Open Folder in Container...` and select the Zeppelin folder
### Using VSCode remote SSH plugin
1. Install Docker
2. Make a copy of `.env.example` called `.env`
3. Fill in the missing values in `.env`
4. Run `docker compose -f docker-compose.development.yml up` to start the development environment
5. In VSCode: Install the `Remote - SSH` plugin
6. In VSCode: Run `Remote-SSH: Connect to Host...`
* As the address, use `ubuntu@127.0.0.1:3002` (where `3002` matches `DOCKER_DEV_SSH_PORT` in `.env`)
* Use the password specified in `.env` as `DOCKER_DEV_SSH_PASSWORD`
7. In VSCode: Once connected, click `Open folder...` and select `/home/ubuntu/zeppelin`
### Using JetBrains Gateway
1. Install Docker
2. Make a copy of `.env.example` called `.env`
3. Fill in the missing values in `.env`
4. Run `docker compose -f docker-compose.development.yml up` to start the development environment
5. Choose `Connect via SSH` and create a new connection:
* Username: `ubuntu`
* Host: `127.0.0.1`
* Port: `3002` (matching the `DOCKER_DEV_SSH_PORT` value in `.env`)
6. Click `Check Connection and Continue` and enter the password specified in `.env` as `DOCKER_DEV_SSH_PASSWORD` when asked
7. In the next pane:
* IDE version: WebStorm, PHPStorm, or IntelliJ IDEA
* Project directory: `/home/ubuntu/zeppelin`
8. Click `Download and Start IDE`
### Using any other IDE with SSH development support
1. Install Docker
2. Make a copy of `.env.example` called `.env`
3. Fill in the missing values in `.env`
4. Run `docker compose -f docker-compose.development.yml up` to start the development environment
5. Use the following credentials for connecting with your IDE:
* Host: `127.0.0.1`
* Port: `3002` (matching the `DOCKER_DEV_SSH_PORT` value in `.env`)
* Username: `ubuntu`
* Password: As specified in `.env` as `DOCKER_DEV_SSH_PASSWORD`
## Starting the project
### Starting the backend (bot + api)
These commands are run inside the dev container. You should be able to open a terminal in your IDE after connecting.
1. `cd ~/zeppelin/backend`
2. `npm ci`
3. `npm run migrate-dev`
4. `npm run watch`
### Starting the dashboard
These commands are run inside the dev container. You should be able to open a terminal in your IDE after connecting.
1. `cd ~/zeppelin/dashboard`
2. `npm ci`
3. `npm run watch-build`
### Opening the dashboard
Browse to https://localhost:3300 to view the dashboard

55
LICENSE.md Normal file
View file

@ -0,0 +1,55 @@
# Elastic License 2.0 (ELv2)
## Elastic License
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations and conditions below.
### Limitations
You may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software.
You may not move, change, disable, or circumvent the license key functionality in the software, and you may not remove or obscure any functionality in the software that is protected by the license key.
You may not alter, remove, or obscure any licensing, copyright, or other notices of the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case subject to the limitations and conditions in this license. This license does not cover any patent claims that you cause to be infringed by modifications or additions to the software. If you or your company make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms.
If you modify the software, you must include in any modified copies of the software prominent notices stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your licenses will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your licenses will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your licenses to terminate automatically and permanently.
### No Liability
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
### Definitions
The **licensor** is the entity offering these terms, and the **software** is the software the licensor makes available under these terms, including any portion of it.
**you** refers to the individual or entity agreeing to these terms.
**your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
**your licenses** are all the licenses granted to you for the software under these terms.
**use** means anything you do with the software requiring one of your licenses.
**trademark** means trademarks, service marks, and similar rights.

34
MANAGEMENT.md Normal file
View file

@ -0,0 +1,34 @@
# Management
After starting Zeppelin -- either in the [development](./DEVELOPMENT.md) or [production](./PRODUCTION.md) environment -- you have several tools available to manage it.
## Note
Make sure to add yourself to the list of staff members (`STAFF`) in `.env` and allow at least one server by default (`DEFAULT_ALLOWED_SERVERS`). Then, invite the bot to the server.
In all examples below, `@Bot` refers to a user mention of the bot user. Make sure to run the commands on a server with the bot, in a channel that the bot can see.
In the command parameters, `<this>` refers to a required parameter (don't include the `< >` symbols) and `[this]` refers to an optional parameter (don't include the `[ ]` symbols). `<this...>` refers to being able to list multiple values, e.g. `value1 value2 value3`.
## Allow a server to invite the bot
Run the following command:
```
@Bot allow_server <serverId> [userId]
```
When specifying a user ID, that user will be given "Bot manager" level access to the server's dashboard, allowing them to manage access for other users.
## Disallow a server
Run the following command:
```
@Bot disallow_server <serverId>
```
## Grant access to a server's dashboard
Run the following command:
```
@Bot add_dashboard_user <serverId> <userId...>
```
## Remove access to a server's dashboard
Run the following command:
```
@Bot remove_dashboard_user <serverId> <userId...>
```

34
PRODUCTION.md Normal file
View file

@ -0,0 +1,34 @@
# Zeppelin production environment
Zeppelin's production environment - that is, the **bot, API, and dashboard** - uses Docker.
## Starting the production environment
1. Install Docker on the machine running the bot
2. Make a copy of `.env.example` called `.env`
3. Fill in the missing values in `.env`
4. Run `docker compose -f docker-compose.production.yml build`
5. Run `docker compose -f docker-compose.production.yml up -d`
**Note:** The dashboard and API are exposed with a self-signed certificate. It is recommended to set up a proxy with a proper certificate in front of them. Cloudflare is a popular choice here.
## Updating the bot
### One-click script
If you've downloaded the bot's files by cloning the git repository, you can use `update.sh` to update the bot.
### Manual instructions
1. Shut the bot down: `docker compose -f docker-compose.production.yml down`
2. Update the files (e.g. `git pull`)
3. Build new images: `docker compose -f docker-compose.production.yml build`
3. Start the bot again: `docker compose -f docker-compose.production.yml up -d`
### Ephemeral hotfixes
If you need to make a hotfix to the bot's source files directly on the server:
1. Shut the bot down: `docker compose -f docker-compose.production.yml down`
2. Make your edits
3. Build new images: `docker compose -f docker-compose.production.yml build`
4. Start the bot again: `docker compose -f docker-compose.production.yml up -d`
Make sure to revert any hotfixes before updating the bot normally.
## View logs
To view real-time logs, run `docker compose -f docker-compose.production.yml logs -t -f`

View file

@ -19,89 +19,15 @@ Zeppelin is a moderation bot for Discord, designed with large servers and reliab
See https://zeppelin.gg/ for more details.
## Usage documentation
For information on how to use the bot, see https://zeppelin.gg/docs
## Development
These instructions are intended for bot development only.
See [DEVELOPMENT.md](./DEVELOPMENT.md) for instructions on running the development environment.
👉 **No support is offered for self-hosting the bot!** 👈
Once you have the environment up and running, see [MANAGEMENT.md](./MANAGEMENT.md) for how to manage your bot.
### Running the bot
1. `cd backend`
2. `npm ci`
3. Make a copy of `bot.env.example` called `bot.env`, fill in the values
4. Run the desired start script:
* `npm run build` followed by `npm run start-bot-dev` to run the bot in a **development** environment
* `npm run build` followed by `npm run start-bot-prod` to run the bot in a **production** environment
* `npm run watch` to watch files and run the **bot and api both** in a **development** environment
with automatic restart on file changes
5. When testing, make sure you have your test server in the `allowed_guilds` table or the guild's config won't be loaded at all
## Production
See [PRODUCTION.md](./PRODUCTION.md) for instructions on how to run the bot in production.
### Running the API server
1. `cd backend`
2. `npm ci`
3. Make a copy of `api.env.example` called `api.env`, fill in the values
4. Run the desired start script:
* `npm run build` followed by `npm run start-api-dev` to run the api in a **development** environment
* `npm run build` followed by `npm run start-api-prod` to run the api in a **production** environment
* `npm run watch` to watch files and run the **bot and api both** in a **development** environment
with automatic restart on file changes
### Running the dashboard
1. `cd dashboard`
2. `npm ci`
3. Make a copy of `.env.example` called `.env`, fill in the values
4. Run the desired start script:
* `npm run build` compiles the dashboard's static files to `dist/` which can then be served with any web server
* `npm run watch` runs webpack's dev server that automatically reloads on changes
### Notes
* Since we now use shared paths in `tsconfig.json`, the compiled files in `backend/dist/` have longer paths, e.g.
`backend/dist/backend/src/index.js` instead of `backend/dist/index.js`. This is because the compiled shared files
are placed in `backend/dist/shared`.
* The `backend/register-tsconfig-paths.js` module takes care of registering shared paths from `tsconfig.json` for
`ava` and compiled `.js` files
* To run the tests for the files in the `shared/` directory, you also need to run `npm ci` there
### Config format example
Configuration is stored in the database in the `configs` table
```yml
prefix: '!'
# role id: level
levels:
"12345678": 100 # Example admin
"98765432": 50 # Example mod
plugins:
mod_plugin:
config:
kick_message: 'You have been kicked'
can_kick: false
overrides:
- level: '>=50'
config:
can_kick: true
- level: '>=100'
config:
kick_message: 'You have been kicked by an admin'
other_plugin:
config:
categories:
mycategory:
opt: "something"
othercategory:
enabled: false
opt: "hello"
overrides:
- level: '>=50'
config:
categories:
mycategory:
enabled: false
- channel: '1234'
config:
categories:
othercategory:
enabled: true
```
Once you have the environment up and running, see [MANAGEMENT.md](./MANAGEMENT.md) for how to manage your bot.

View file

@ -1,10 +0,0 @@
PORT=
CLIENT_ID=
CLIENT_SECRET=
OAUTH_CALLBACK_URL=
DASHBOARD_URL=
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_DATABASE=
STAFF=

View file

@ -1,7 +0,0 @@
TOKEN=
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_DATABASE=
PROFILING=false
PHISHERMAN_API_KEY=

View file

@ -1,65 +0,0 @@
const fs = require("fs");
const path = require("path");
const pkgUp = require("pkg-up");
const closestPackageJson = pkgUp.sync();
if (!closestPackageJson) {
throw new Error("Could not find project root from ormconfig.js");
}
const backendRoot = path.dirname(closestPackageJson);
try {
fs.accessSync(path.resolve(backendRoot, "bot.env"));
require("dotenv").config({ path: path.resolve(backendRoot, "bot.env") });
} catch {
try {
fs.accessSync(path.resolve(backendRoot, "api.env"));
require("dotenv").config({ path: path.resolve(backendRoot, "api.env") });
} catch {
throw new Error("bot.env or api.env required");
}
}
const moment = require("moment-timezone");
moment.tz.setDefault("UTC");
const entities = path.relative(process.cwd(), path.resolve(backendRoot, "dist/backend/src/data/entities/*.js"));
const migrations = path.relative(process.cwd(), path.resolve(backendRoot, "dist/backend/src/migrations/*.js"));
const migrationsDir = path.relative(process.cwd(), path.resolve(backendRoot, "src/migrations"));
module.exports = {
type: "mysql",
host: process.env.DB_HOST,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
charset: "utf8mb4",
supportBigNumbers: true,
bigNumberStrings: true,
dateStrings: true,
synchronize: false,
connectTimeout: 2000,
logging: ["error", "warn"],
// Entities
entities: [entities],
// Pool options
extra: {
typeCast(field, next) {
if (field.type === "DATETIME") {
const val = field.string();
return val != null ? moment.utc(val).format("YYYY-MM-DD HH:mm:ss") : null;
}
return next();
},
},
// Migrations
migrations: [migrations],
cli: {
migrationsDir,
},
};

14803
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,16 +8,22 @@
"watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"",
"build": "rimraf dist && tsc",
"start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9229 dist/backend/src/index.js",
"start-bot-dev-debug": "NODE_ENV=development DEBUG=true clinic heapprofiler --collect-only --dest .clinic-bot -- node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9229 dist/backend/src/index.js",
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/index.js",
"start-bot-prod-debug": "NODE_ENV=production DEBUG=true clinic heapprofiler --collect-only --dest .clinic-bot -- node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/index.js",
"watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"",
"start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9239 dist/backend/src/api/index.js",
"start-api-dev-debug": "NODE_ENV=development DEBUG=true clinic heapprofiler --collect-only --dest .clinic-api -- node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9239 dist/backend/src/api/index.js",
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/api/index.js",
"start-api-prod-debug": "NODE_ENV=production DEBUG=true clinic heapprofiler --collect-only --dest .clinic-api -- node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/api/index.js",
"watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"",
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
"migrate-prod": "npm run typeorm -- migration:run",
"migrate-dev": "npm run build && npm run typeorm -- migration:run",
"migrate-rollback-prod": "npm run typeorm -- migration:revert",
"migrate-rollback-dev": "npm run build && npm run typeorm -- migration:revert",
"migrate": "npm run typeorm -- migration:run -d dist/backend/src/data/dataSource.js",
"migrate-prod": "cross-env NODE_ENV=production npm run migrate",
"migrate-dev": "cross-env NODE_ENV=development npm run build && npm run migrate",
"migrate-rollback": "npm run typeorm -- migration:revert -d dist/backend/src/data/dataSource.js",
"migrate-rollback-prod": "cross-env NODE_ENV=production npm run migrate",
"migrate-rollback-dev": "cross-env NODE_ENV=development npm run build && npm run migrate",
"test": "npm run build && npm run run-tests",
"run-tests": "ava",
"test-watch": "tsc-watch --onSuccess \"npx ava\""
@ -25,21 +31,21 @@
"dependencies": {
"@silvia-odwyer/photon-node": "^0.3.1",
"bufferutil": "^4.0.3",
"clinic": "^13.0.0",
"cors": "^2.8.5",
"cross-env": "^5.2.0",
"cross-env": "^7.0.3",
"deep-diff": "^1.0.2",
"discord-api-types": "^0.22.0",
"discord.js": "^13.3.1",
"discord.js": "^14.11.0",
"dotenv": "^4.0.0",
"emoji-regex": "^8.0.0",
"erlpack": "github:almeidx/erlpack#f0c535f73817fd914806d6ca26a7730c14e0fb7c",
"erlpack": "github:discord/erlpack",
"escape-string-regexp": "^1.0.5",
"express": "^4.17.0",
"fp-ts": "^2.0.1",
"humanize-duration": "^3.15.0",
"io-ts": "^2.0.0",
"js-yaml": "^3.13.1",
"knub": "^30.0.0-beta.46",
"knub": "^32.0.0-next.16",
"knub-command-manager": "^9.1.0",
"last-commit-log": "^2.1.0",
"lodash.chunk": "^4.2.0",
@ -49,13 +55,12 @@
"lodash.isequal": "^4.5.0",
"lodash.pick": "^4.4.0",
"moment-timezone": "^0.5.21",
"multer": "^1.4.3",
"multer": "^1.4.5-lts.1",
"mysql": "^2.16.0",
"node-fetch": "^2.6.5",
"parse-color": "^1.0.0",
"passport": "^0.4.0",
"passport": "^0.6.0",
"passport-custom": "^1.0.5",
"passport-oauth2": "^1.5.0",
"passport-oauth2": "^1.6.1",
"pkg-up": "^3.1.0",
"reflect-metadata": "^0.1.12",
"regexp-worker": "^1.1.0",
@ -67,9 +72,9 @@
"tmp": "0.0.33",
"tsconfig-paths": "^3.9.0",
"twemoji": "^12.1.4",
"typeorm": "^0.2.31",
"typeorm": "^0.3.17",
"utf-8-validate": "^5.0.5",
"uuid": "^3.3.2",
"uuid": "^9.0.0",
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
"zlib-sync": "^0.1.7",
"zod": "^3.7.2"
@ -82,18 +87,17 @@
"@types/lodash.at": "^4.6.3",
"@types/moment-timezone": "^0.5.6",
"@types/multer": "^1.4.7",
"@types/node": "^14.0.14",
"@types/node-fetch": "^2.5.12",
"@types/node": "^18.16.3",
"@types/passport": "^1.0.0",
"@types/passport-oauth2": "^1.4.8",
"@types/passport-strategy": "^0.2.35",
"@types/safe-regex": "^1.1.2",
"@types/tmp": "0.0.33",
"@types/twemoji": "^12.1.0",
"ava": "^3.10.0",
"@types/uuid": "^9.0.2",
"ava": "^5.3.1",
"rimraf": "^2.6.2",
"source-map-support": "^0.5.16",
"tsc-watch": "^4.0.0"
"source-map-support": "^0.5.16"
},
"ava": {
"files": [

40
backend/src/Blocker.ts Normal file
View file

@ -0,0 +1,40 @@
export type Block = {
count: number;
unblock: () => void;
getPromise: () => Promise<void>;
};
export class Blocker {
#blocks: Map<string, Block> = new Map();
block(key: string): void {
if (!this.#blocks.has(key)) {
const promise = new Promise<void>((resolve) => {
this.#blocks.set(key, {
count: 0, // Incremented to 1 further below
unblock() {
this.count--;
if (this.count === 0) {
resolve();
}
},
getPromise: () => promise, // :d
});
});
}
this.#blocks.get(key)!.count++;
}
unblock(key: string): void {
if (this.#blocks.has(key)) {
this.#blocks.get(key)!.unblock();
}
}
async waitToBeUnblocked(key: string): Promise<void> {
if (!this.#blocks.has(key)) {
return;
}
await this.#blocks.get(key)!.getPromise();
}
}

View file

@ -7,7 +7,7 @@ export class SimpleError extends Error {
super(message);
}
[util.inspect.custom](depth, options) {
[util.inspect.custom]() {
return `Error: ${this.message}`;
}
}

View file

@ -8,6 +8,7 @@ import { ApiLogins } from "../data/ApiLogins";
import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments";
import { ApiUserInfo } from "../data/ApiUserInfo";
import { ApiUserInfoData } from "../data/entities/ApiUserInfo";
import { env } from "../env";
import { ok } from "./responses";
interface IPassportApiUser {
@ -17,7 +18,6 @@ interface IPassportApiUser {
declare global {
namespace Express {
// tslint:disable-next-line:no-empty-interface
interface User extends IPassportApiUser {}
}
}
@ -54,24 +54,8 @@ function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
export function initAuth(app: express.Express) {
app.use(passport.initialize());
if (!process.env.CLIENT_ID) {
throw new Error("Auth: CLIENT ID missing");
}
if (!process.env.CLIENT_SECRET) {
throw new Error("Auth: CLIENT SECRET missing");
}
if (!process.env.OAUTH_CALLBACK_URL) {
throw new Error("Auth: OAUTH CALLBACK URL missing");
}
if (!process.env.DASHBOARD_URL) {
throw new Error("DASHBOARD_URL missing!");
}
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user as IPassportApiUser));
const apiLogins = new ApiLogins();
const apiUserInfo = new ApiUserInfo();
@ -101,9 +85,9 @@ export function initAuth(app: express.Express) {
{
authorizationURL: "https://discord.com/api/oauth2/authorize",
tokenURL: "https://discord.com/api/oauth2/token",
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: process.env.OAUTH_CALLBACK_URL,
clientID: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
callbackURL: `${env.API_URL}/auth/oauth-callback`,
scope: ["identify"],
},
async (accessToken, refreshToken, profile, cb) => {
@ -132,9 +116,9 @@ export function initAuth(app: express.Express) {
passport.authenticate("oauth2", { failureRedirect: "/", session: false }),
(req: Request, res: Response) => {
if (req.user && req.user.apiKey) {
res.redirect(`${process.env.DASHBOARD_URL}/login-callback/?apiKey=${req.user.apiKey}`);
res.redirect(`${env.DASHBOARD_URL}/login-callback/?apiKey=${req.user.apiKey}`);
} else {
res.redirect(`${process.env.DASHBOARD_URL}/login-callback/?error=noAccess`);
res.redirect(`${env.DASHBOARD_URL}/login-callback/?error=noAccess`);
}
},
);
@ -165,7 +149,8 @@ export function initAuth(app: express.Express) {
export function apiTokenAuthHandlers() {
return [
passport.authenticate("api-token", { failWithError: true }),
passport.authenticate("api-token", { failWithError: true, session: false }),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(err, req: Request, res: Response, next) => {
return res.status(401).json({ error: err.message });
},

View file

@ -54,9 +54,10 @@ export function initDocs(app: express.Express) {
}
const name = plugin.name;
const info = plugin.info || {};
const info = { ...(plugin.info || {}) };
delete info.configSchema;
const commands = (plugin.commands || []).map((cmd) => ({
const messageCommands = (plugin.messageCommands || []).map((cmd) => ({
trigger: cmd.trigger,
permission: cmd.permission,
signature: cmd.signature,
@ -66,14 +67,14 @@ export function initDocs(app: express.Express) {
}));
const defaultOptions = plugin.defaultOptions || {};
const configSchema = plugin.configSchema && formatConfigSchema(plugin.configSchema);
const configSchema = plugin.info?.configSchema && formatConfigSchema(plugin.info.configSchema);
res.json({
name,
info,
configSchema,
defaultOptions,
commands,
messageCommands,
});
});
}

View file

@ -1,20 +1,20 @@
import { ApiPermissions } from "@shared/apiPermissions";
import express, { Request, Response } from "express";
import { YAMLException } from "js-yaml";
import moment from "moment-timezone";
import { Queue } from "../Queue";
import { validateGuildConfig } from "../configValidator";
import { AllowedGuilds } from "../data/AllowedGuilds";
import { ApiAuditLog } from "../data/ApiAuditLog";
import { ApiPermissionAssignments, ApiPermissionTypes } from "../data/ApiPermissionAssignments";
import { Configs } from "../data/Configs";
import { AuditLogEventTypes } from "../data/apiAuditLogTypes";
import { isSnowflake } from "../utils";
import { loadYamlSafely } from "../utils/loadYamlSafely";
import { ObjectAliasError } from "../utils/validateNoObjectAliases";
import { apiTokenAuthHandlers } from "./auth";
import { hasGuildPermission, requireGuildPermission } from "./permissions";
import { clientError, ok, serverError, unauthorized } from "./responses";
import { loadYamlSafely } from "../utils/loadYamlSafely";
import { ObjectAliasError } from "../utils/validateNoObjectAliases";
import { isSnowflake } from "../utils";
import moment from "moment-timezone";
import { ApiAuditLog } from "../data/ApiAuditLog";
import { AuditLogEventTypes } from "../data/apiAuditLogTypes";
import { Queue } from "../Queue";
const apiPermissionAssignments = new ApiPermissionAssignments();
const auditLog = new ApiAuditLog();
@ -126,7 +126,7 @@ export function initGuildsAPI(app: express.Express) {
if (type !== ApiPermissionTypes.User) {
return clientError(res, "Invalid type");
}
if (!isSnowflake(targetId)) {
if (!isSnowflake(targetId) || targetId === req.user!.userId) {
return clientError(res, "Invalid targetId");
}
const validPermissions = new Set(Object.values(ApiPermissions));

View file

@ -1,13 +1,13 @@
import { ApiPermissions } from "@shared/apiPermissions";
import express, { Request, Response } from "express";
import { requireGuildPermission } from "../permissions";
import { clientError, ok } from "../responses";
import { GuildCases } from "../../data/GuildCases";
import { z } from "zod";
import { Case } from "../../data/entities/Case";
import { rateLimit } from "../rateLimits";
import { MINUTES } from "../../utils";
import moment from "moment-timezone";
import { z } from "zod";
import { GuildCases } from "../../data/GuildCases";
import { Case } from "../../data/entities/Case";
import { MINUTES } from "../../utils";
import { requireGuildPermission } from "../permissions";
import { rateLimit } from "../rateLimits";
import { clientError, ok } from "../responses";
const caseHandlingModeSchema = z.union([
z.literal("replace"),
@ -50,7 +50,7 @@ export function initGuildsImportExportAPI(guildRouter: express.Router) {
importExportRouter.get(
"/:guildId/pre-import",
requireGuildPermission(ApiPermissions.ManageAccess),
async (req: Request, res: Response) => {
async (req: Request) => {
const guildCases = GuildCases.getGuildInstance(req.params.guildId);
const minNum = await guildCases.getMinCaseNumber();
const maxNum = await guildCases.getMaxCaseNumber();
@ -75,7 +75,10 @@ export function initGuildsImportExportAPI(guildRouter: express.Router) {
try {
data = importExportData.parse(req.body.data);
} catch (err) {
return clientError(res, "Invalid import data format");
const prettyMessage = `${err.issues[0].code}: expected ${err.issues[0].expected}, received ${
err.issues[0].received
} at /${err.issues[0].path.join("/")}`;
return clientError(res, `Invalid import data format: ${prettyMessage}`);
return;
}
@ -87,6 +90,14 @@ export function initGuildsImportExportAPI(guildRouter: express.Router) {
return;
}
const seenCaseNumbers = new Set();
for (const theCase of data.cases) {
if (seenCaseNumbers.has(theCase.case_number)) {
return clientError(res, `Duplicate case number: ${theCase.case_number}`);
}
seenCaseNumbers.add(theCase.case_number);
}
const guildCases = GuildCases.getGuildInstance(req.params.guildId);
// Prepare cases

View file

@ -1,7 +1,7 @@
import express from "express";
import { apiTokenAuthHandlers } from "../auth";
import { initGuildsMiscAPI } from "./misc";
import { initGuildsImportExportAPI } from "./importExport";
import { initGuildsMiscAPI } from "./misc";
export function initGuildsAPI(app: express.Express) {
const guildRouter = express.Router();

View file

@ -1,22 +1,19 @@
import { ApiPermissions } from "@shared/apiPermissions";
import express, { Request, Response } from "express";
import { YAMLException } from "js-yaml";
import moment from "moment-timezone";
import { Queue } from "../../Queue";
import { validateGuildConfig } from "../../configValidator";
import { AllowedGuilds } from "../../data/AllowedGuilds";
import { ApiAuditLog } from "../../data/ApiAuditLog";
import { ApiPermissionAssignments, ApiPermissionTypes } from "../../data/ApiPermissionAssignments";
import { Configs } from "../../data/Configs";
import { apiTokenAuthHandlers } from "../auth";
import { hasGuildPermission, requireGuildPermission } from "../permissions";
import { clientError, ok, serverError, unauthorized } from "../responses";
import { AuditLogEventTypes } from "../../data/apiAuditLogTypes";
import { isSnowflake } from "../../utils";
import { loadYamlSafely } from "../../utils/loadYamlSafely";
import { ObjectAliasError } from "../../utils/validateNoObjectAliases";
import { isSnowflake } from "../../utils";
import moment from "moment-timezone";
import { ApiAuditLog } from "../../data/ApiAuditLog";
import { AuditLogEventTypes } from "../../data/apiAuditLogTypes";
import { Queue } from "../../Queue";
import { GuildCases } from "../../data/GuildCases";
import { z } from "zod";
import { hasGuildPermission, requireGuildPermission } from "../permissions";
import { clientError, ok, serverError, unauthorized } from "../responses";
const apiPermissionAssignments = new ApiPermissionAssignments();
const auditLog = new ApiAuditLog();

View file

@ -1,8 +1,12 @@
import { connect } from "../data/db";
import { setIsAPI } from "../globals";
import "./loadEnv";
// KEEP THIS AS FIRST IMPORT
// See comment in module for details
import "../threadsSignalFix";
if (!process.env.KEY) {
import { connect } from "../data/db";
import { env } from "../env";
import { setIsAPI } from "../globals";
if (!env.KEY) {
// tslint:disable-next-line:no-console
console.error("Project root .env with KEY is required!");
process.exit(1);
@ -20,5 +24,5 @@ setIsAPI(true);
// Connect to the database before loading the rest of the code (that depend on the database connection)
console.log("Connecting to database..."); // tslint:disable-line
connect().then(() => {
import("./start");
import("./start.js");
});

View file

@ -1,4 +0,0 @@
import path from "path";
require("dotenv").config({ path: path.resolve(process.cwd(), "../.env") });
require("dotenv").config({ path: path.resolve(process.cwd(), "api.env") });

View file

@ -4,7 +4,7 @@ export function unauthorized(res: Response) {
res.status(403).json({ error: "Unauthorized" });
}
export function error(res: Response, message: string, statusCode: number = 500) {
export function error(res: Response, message: string, statusCode = 500) {
res.status(statusCode).json({ error: message });
}

View file

@ -1,19 +1,20 @@
import cors from "cors";
import express from "express";
import multer from "multer";
import { TokenError } from "passport-oauth2";
import { env } from "../env";
import { initArchives } from "./archives";
import { initAuth } from "./auth";
import { initDocs } from "./docs";
import { initGuildsAPI } from "./guilds/index";
import { clientError, error, notFound } from "./responses";
import { startBackgroundTasks } from "./tasks";
import multer from "multer";
const app = express();
app.use(
cors({
origin: process.env.DASHBOARD_URL,
origin: env.DASHBOARD_URL,
}),
);
app.use(
@ -34,6 +35,7 @@ app.get("/", (req, res) => {
});
// Error response
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((err, req, res, next) => {
if (err instanceof TokenError) {
clientError(res, "Invalid code");
@ -44,11 +46,12 @@ app.use((err, req, res, next) => {
});
// 404 response
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((req, res, next) => {
return notFound(res);
});
const port = (process.env.PORT && parseInt(process.env.PORT, 10)) || 3000;
const port = env.API_PORT;
app.listen(port, "0.0.0.0", () => console.log(`API server listening on port ${port}`)); // tslint:disable-line
startBackgroundTasks();

View file

@ -1,5 +1,18 @@
import { GuildChannel, GuildMember, Snowflake, Util, User } from "discord.js";
import { baseCommandParameterTypeHelpers, baseTypeConverters, CommandContext, TypeConversionError } from "knub";
import {
escapeCodeBlock,
escapeInlineCode,
GuildChannel,
GuildMember,
GuildTextBasedChannel,
Snowflake,
User,
} from "discord.js";
import {
baseCommandParameterTypeHelpers,
CommandContext,
messageCommandBaseTypeConverters,
TypeConversionError,
} from "knub";
import { createTypeHelper } from "knub-command-manager";
import {
channelMentionRegex,
@ -16,7 +29,7 @@ import { MessageTarget, resolveMessageTarget } from "./utils/resolveMessageTarge
import { inputPatternToRegExp } from "./validatorUtils";
export const commandTypes = {
...baseTypeConverters,
...messageCommandBaseTypeConverters,
delay(value) {
const result = convertDelayStringToMS(value);
@ -30,7 +43,7 @@ export const commandTypes = {
async resolvedUser(value, context: CommandContext<any>) {
const result = await resolveUser(context.pluginData.client, value);
if (result == null || result instanceof UnknownUser) {
throw new TypeConversionError(`User \`${Util.escapeCodeBlock(value)}\` was not found`);
throw new TypeConversionError(`User \`${escapeCodeBlock(value)}\` was not found`);
}
return result;
},
@ -38,7 +51,7 @@ export const commandTypes = {
async resolvedUserLoose(value, context: CommandContext<any>) {
const result = await resolveUser(context.pluginData.client, value);
if (result == null) {
throw new TypeConversionError(`Invalid user: \`${Util.escapeCodeBlock(value)}\``);
throw new TypeConversionError(`Invalid user: \`${escapeCodeBlock(value)}\``);
}
return result;
},
@ -50,9 +63,7 @@ export const commandTypes = {
const result = await resolveMember(context.pluginData.client, context.message.channel.guild, value);
if (result == null) {
throw new TypeConversionError(
`Member \`${Util.escapeCodeBlock(value)}\` was not found or they have left the server`,
);
throw new TypeConversionError(`Member \`${escapeCodeBlock(value)}\` was not found or they have left the server`);
}
return result;
},
@ -62,7 +73,7 @@ export const commandTypes = {
const result = await resolveMessageTarget(context.pluginData, value);
if (!result) {
throw new TypeConversionError(`Unknown message \`${Util.escapeInlineCode(value)}\``);
throw new TypeConversionError(`Unknown message \`${escapeInlineCode(value)}\``);
}
return result;
@ -82,24 +93,28 @@ export const commandTypes = {
return value as Snowflake;
}
throw new TypeConversionError(`Could not parse ID: \`${Util.escapeInlineCode(value)}\``);
throw new TypeConversionError(`Could not parse ID: \`${escapeInlineCode(value)}\``);
},
regex(value: string, context: CommandContext<any>): RegExp {
regex(value: string): RegExp {
try {
return inputPatternToRegExp(value);
} catch (e) {
throw new TypeConversionError(`Could not parse RegExp: \`${Util.escapeInlineCode(e.message)}\``);
throw new TypeConversionError(`Could not parse RegExp: \`${escapeInlineCode(e.message)}\``);
}
},
timezone(value: string) {
if (!isValidTimezone(value)) {
throw new TypeConversionError(`Invalid timezone: ${Util.escapeInlineCode(value)}`);
throw new TypeConversionError(`Invalid timezone: ${escapeInlineCode(value)}`);
}
return value;
},
guildTextBasedChannel(value: string, context: CommandContext<any>) {
return messageCommandBaseTypeConverters.textChannel(value, context);
},
};
export const commandTypeHelpers = {
@ -113,4 +128,5 @@ export const commandTypeHelpers = {
anyId: createTypeHelper<Promise<Snowflake>>(commandTypes.anyId),
regex: createTypeHelper<RegExp>(commandTypes.regex),
timezone: createTypeHelper<string>(commandTypes.timezone),
guildTextBasedChannel: createTypeHelper<GuildTextBasedChannel>(commandTypes.guildTextBasedChannel),
};

View file

@ -1,9 +1,9 @@
import { configUtils, ConfigValidationError, PluginOptions } from "knub";
import { ConfigValidationError, PluginConfigManager } from "knub";
import moment from "moment-timezone";
import { guildPlugins } from "./plugins/availablePlugins";
import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
import { guildPlugins } from "./plugins/availablePlugins";
import { PartialZeppelinGuildConfigSchema, ZeppelinGuildConfig } from "./types";
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
import { StrictValidationError, decodeAndValidateStrict } from "./validatorUtils";
const pluginNameToPlugin = new Map<string, ZeppelinPlugin>();
for (const plugin of guildPlugins) {
@ -34,9 +34,12 @@ export async function validateGuildConfig(config: any): Promise<string | null> {
}
const plugin = pluginNameToPlugin.get(pluginName)!;
const configManager = new PluginConfigManager(plugin.defaultOptions || { config: {} }, pluginOptions, {
levels: {},
parser: plugin.configParser,
});
try {
const mergedOptions = configUtils.mergeConfig(plugin.defaultOptions || {}, pluginOptions);
await plugin.configPreprocessor?.(mergedOptions as unknown as PluginOptions<any>, true);
await configManager.init();
} catch (err) {
if (err instanceof ConfigValidationError || err instanceof StrictValidationError) {
return `${pluginName}: ${err.message}`;

View file

@ -1,19 +1,20 @@
import { getRepository, Repository } from "typeorm";
import moment from "moment-timezone";
import { Repository } from "typeorm";
import { DBDateFormat } from "../utils";
import { ApiPermissionTypes } from "./ApiPermissionAssignments";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { AllowedGuild } from "./entities/AllowedGuild";
import moment from "moment-timezone";
import { DBDateFormat } from "../utils";
export class AllowedGuilds extends BaseRepository {
private allowedGuilds: Repository<AllowedGuild>;
constructor() {
super();
this.allowedGuilds = getRepository(AllowedGuild);
this.allowedGuilds = dataSource.getRepository(AllowedGuild);
}
async isAllowed(guildId) {
async isAllowed(guildId: string) {
const count = await this.allowedGuilds.count({
where: {
id: guildId,
@ -22,11 +23,15 @@ export class AllowedGuilds extends BaseRepository {
return count !== 0;
}
find(guildId) {
return this.allowedGuilds.findOne(guildId);
find(guildId: string) {
return this.allowedGuilds.findOne({
where: {
id: guildId,
},
});
}
getForApiUser(userId) {
getForApiUser(userId: string) {
return this.allowedGuilds
.createQueryBuilder("allowed_guilds")
.innerJoin(

View file

@ -1,15 +1,15 @@
import { Repository } from "typeorm/index";
import { BaseRepository } from "./BaseRepository";
import { getRepository, Repository } from "typeorm/index";
import { ApiAuditLogEntry } from "./entities/ApiAuditLogEntry";
import { ApiLogin } from "./entities/ApiLogin";
import { AuditLogEventData, AuditLogEventType } from "./apiAuditLogTypes";
import { dataSource } from "./dataSource";
import { ApiAuditLogEntry } from "./entities/ApiAuditLogEntry";
export class ApiAuditLog extends BaseRepository {
private auditLog: Repository<ApiAuditLogEntry<any>>;
constructor() {
super();
this.auditLog = getRepository(ApiAuditLogEntry);
this.auditLog = dataSource.getRepository(ApiAuditLogEntry);
}
addEntry<TEventType extends AuditLogEventType>(

View file

@ -1,10 +1,11 @@
import crypto from "crypto";
import moment from "moment-timezone";
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
// tslint:disable-next-line:no-submodule-imports
import uuidv4 from "uuid/v4";
import { v4 as uuidv4 } from "uuid";
import { DAYS, DBDateFormat } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { ApiLogin } from "./entities/ApiLogin";
const LOGIN_EXPIRY_TIME = 1 * DAYS;
@ -14,7 +15,7 @@ export class ApiLogins extends BaseRepository {
constructor() {
super();
this.apiLogins = getRepository(ApiLogin);
this.apiLogins = dataSource.getRepository(ApiLogin);
}
async getUserIdByApiKey(apiKey: string): Promise<string | null> {
@ -90,10 +91,15 @@ export class ApiLogins extends BaseRepository {
const [loginId, token] = apiKey.split(".");
if (!loginId || !token) return;
const updatedTime = moment().utc().add(LOGIN_EXPIRY_TIME, "ms");
const login = await this.apiLogins.createQueryBuilder().where("id = :id", { id: loginId }).getOne();
if (!login || moment.utc(login.expires_at).isSameOrAfter(updatedTime)) return;
await this.apiLogins.update(
{ id: loginId },
{
expires_at: moment().utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
expires_at: updatedTime.format(DBDateFormat),
},
);
}

View file

@ -1,10 +1,10 @@
import { ApiPermissions } from "@shared/apiPermissions";
import { getRepository, Repository } from "typeorm";
import { BaseRepository } from "./BaseRepository";
import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment";
import { Permissions } from "discord.js";
import { Repository } from "typeorm";
import { ApiAuditLog } from "./ApiAuditLog";
import { BaseRepository } from "./BaseRepository";
import { AuditLogEventTypes } from "./apiAuditLogTypes";
import { dataSource } from "./dataSource";
import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment";
export enum ApiPermissionTypes {
User = "USER",
@ -17,7 +17,7 @@ export class ApiPermissionAssignments extends BaseRepository {
constructor() {
super();
this.apiPermissions = getRepository(ApiPermissionAssignment);
this.apiPermissions = dataSource.getRepository(ApiPermissionAssignment);
this.auditLogs = new ApiAuditLog();
}
@ -80,7 +80,8 @@ export class ApiPermissionAssignments extends BaseRepository {
.createQueryBuilder()
.where("expires_at IS NOT NULL")
.andWhere("expires_at <= NOW()")
.delete();
.delete()
.execute();
}
async applyOwnerChange(guildId: string, newOwnerId: string) {

View file

@ -1,16 +1,16 @@
import moment from "moment-timezone";
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { DBDateFormat } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { connection } from "./db";
import { ApiUserInfo as ApiUserInfoEntity, ApiUserInfoData } from "./entities/ApiUserInfo";
import { dataSource } from "./dataSource";
import { ApiUserInfoData, ApiUserInfo as ApiUserInfoEntity } from "./entities/ApiUserInfo";
export class ApiUserInfo extends BaseRepository {
private apiUserInfo: Repository<ApiUserInfoEntity>;
constructor() {
super();
this.apiUserInfo = getRepository(ApiUserInfoEntity);
this.apiUserInfo = dataSource.getRepository(ApiUserInfoEntity);
}
get(id) {
@ -22,7 +22,7 @@ export class ApiUserInfo extends BaseRepository {
}
update(id, data: ApiUserInfoData) {
return connection.transaction(async (entityManager) => {
return dataSource.transaction(async (entityManager) => {
const repo = entityManager.getRepository(ApiUserInfoEntity);
const existingInfo = await repo.findOne({ where: { id } });

View file

@ -1,13 +1,14 @@
import { getRepository, Repository } from "typeorm";
import { ArchiveEntry } from "./entities/ArchiveEntry";
import { Repository } from "typeorm";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { ArchiveEntry } from "./entities/ArchiveEntry";
export class Archives extends BaseRepository {
protected archives: Repository<ArchiveEntry>;
constructor() {
super();
this.archives = getRepository(ArchiveEntry);
this.archives = dataSource.getRepository(ArchiveEntry);
}
public deleteExpiredArchives() {

View file

@ -1,6 +1,6 @@
import { BaseRepository } from "./BaseRepository";
export class BaseGuildRepository<TEntity extends unknown = unknown> extends BaseRepository<TEntity> {
export class BaseGuildRepository<TEntity = unknown> extends BaseRepository<TEntity> {
private static guildInstances: Map<string, any>;
protected guildId: string;

View file

@ -1,6 +1,6 @@
import { asyncMap } from "../utils/async";
export class BaseRepository<TEntity extends unknown = unknown> {
export class BaseRepository<TEntity = unknown> {
private nextRelations: string[];
constructor() {
@ -40,7 +40,7 @@ export class BaseRepository<TEntity extends unknown = unknown> {
return entity;
}
protected async processEntityFromDB<T extends TEntity | undefined>(entity: T): Promise<T> {
protected async processEntityFromDB<T extends TEntity | null>(entity: T): Promise<T> {
return this._processEntityFromDB(entity);
}

View file

@ -1,20 +1,21 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { isAPI } from "../globals";
import { HOURS, SECONDS } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { cleanupConfigs } from "./cleanup/configs";
import { connection } from "./db";
import { dataSource } from "./dataSource";
import { Config } from "./entities/Config";
const CLEANUP_INTERVAL = 1 * HOURS;
async function cleanup() {
await cleanupConfigs();
setTimeout(cleanup, CLEANUP_INTERVAL);
}
if (isAPI()) {
const CLEANUP_INTERVAL = 1 * HOURS;
async function cleanup() {
await cleanupConfigs();
setTimeout(cleanup, CLEANUP_INTERVAL);
}
// Start first cleanup 30 seconds after startup
// TODO: Move to bot startup code
setTimeout(cleanup, 30 * SECONDS);
}
@ -23,7 +24,7 @@ export class Configs extends BaseRepository {
constructor() {
super();
this.configs = getRepository(Config);
this.configs = dataSource.getRepository(Config);
}
getActiveByKey(key) {
@ -36,7 +37,7 @@ export class Configs extends BaseRepository {
}
async getHighestId(): Promise<number> {
const rows = await connection.query("SELECT MAX(id) AS highest_id FROM configs");
const rows = await dataSource.query("SELECT MAX(id) AS highest_id FROM configs");
return (rows.length && rows[0].highest_id) || 0;
}
@ -61,7 +62,7 @@ export class Configs extends BaseRepository {
}
async saveNewRevision(key, config, editedBy) {
return connection.transaction(async (entityManager) => {
return dataSource.transaction(async (entityManager) => {
const repo = entityManager.getRepository(Config);
// Mark all old revisions inactive
await repo.update({ key }, { is_active: false });

View file

@ -11,10 +11,10 @@
"MEMBER_UNBAN": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}",
"MEMBER_FORCEBAN": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}",
"MEMBER_SOFTBAN": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}",
"MEMBER_JOIN": "{timestamp} 📥 {new} {userMention(member)} joined (created {account_age} ago)",
"MEMBER_JOIN": "{timestamp} 📥 {new} {userMention(member)} joined (created <t:{account_age_ts}:R>)",
"MEMBER_LEAVE": "{timestamp} 📤 {userMention(member)} left the server",
"MEMBER_ROLE_ADD": "{timestamp} 🔑 {userMention(member)} received roles: **{roles}**",
"MEMBER_ROLE_REMOVE": "{timestamp} 🔑 {userMention(member)} lost roles: **{roles}**",
"MEMBER_ROLE_ADD": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**",
"MEMBER_ROLE_REMOVE": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**",
"MEMBER_ROLE_CHANGES": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**",
"MEMBER_NICK_CHANGE": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**",
"MEMBER_USERNAME_CHANGE": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**",

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { AntiraidLevel } from "./entities/AntiraidLevel";
export class GuildAntiraidLevels extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildAntiraidLevels extends BaseGuildRepository {
constructor(guildId: string) {
super(guildId);
this.antiraidLevels = getRepository(AntiraidLevel);
this.antiraidLevels = dataSource.getRepository(AntiraidLevel);
}
async get() {

View file

@ -1,18 +1,15 @@
import { Guild, Snowflake, User } from "discord.js";
import { Guild, Snowflake } from "discord.js";
import moment from "moment-timezone";
import { isDefaultSticker } from "src/utils/isDefaultSticker";
import { getRepository, Repository } from "typeorm";
import { renderTemplate, TemplateSafeValueContainer } from "../templateFormatter";
import { trimLines } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { ArchiveEntry } from "./entities/ArchiveEntry";
import {
channelToTemplateSafeChannel,
guildToTemplateSafeGuild,
userToTemplateSafeUser,
} from "../utils/templateSafeObjects";
import { SavedMessage } from "./entities/SavedMessage";
import { Repository } from "typeorm";
import { TemplateSafeValueContainer, renderTemplate } from "../templateFormatter";
import { renderUsername, trimLines } from "../utils";
import { decrypt, encrypt } from "../utils/crypt";
import { channelToTemplateSafeChannel, guildToTemplateSafeGuild } from "../utils/templateSafeObjects";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { ArchiveEntry } from "./entities/ArchiveEntry";
import { SavedMessage } from "./entities/SavedMessage";
const DEFAULT_EXPIRY_DAYS = 30;
@ -20,14 +17,14 @@ const MESSAGE_ARCHIVE_HEADER_FORMAT = trimLines(`
Server: {guild.name} ({guild.id})
`);
const MESSAGE_ARCHIVE_MESSAGE_FORMAT =
"[#{channel.name}] [{user.id}] [{timestamp}] {user.username}#{user.discriminator}: {content}{attachments}{stickers}";
"[#{channel.name}] [{user.id}] [{timestamp}] {username}: {content}{attachments}{stickers}";
export class GuildArchives extends BaseGuildRepository<ArchiveEntry> {
protected archives: Repository<ArchiveEntry>;
constructor(guildId) {
super(guildId);
this.archives = getRepository(ArchiveEntry);
this.archives = dataSource.getRepository(ArchiveEntry);
}
protected async _processEntityFromDB(entity: ArchiveEntry | undefined) {
@ -46,7 +43,7 @@ export class GuildArchives extends BaseGuildRepository<ArchiveEntry> {
return entity;
}
async find(id: string): Promise<ArchiveEntry | undefined> {
async find(id: string): Promise<ArchiveEntry | null> {
const result = await this.archives.findOne({
where: { id },
relations: this.getRelations(),
@ -101,6 +98,7 @@ export class GuildArchives extends BaseGuildRepository<ArchiveEntry> {
}),
user: partialUser,
channel: channel ? channelToTemplateSafeChannel(channel) : null,
username: renderUsername(msg.data.author.username, msg.data.author.discriminator),
}),
);

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { AutoReaction } from "./entities/AutoReaction";
export class GuildAutoReactions extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildAutoReactions extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.autoReactions = getRepository(AutoReaction);
this.autoReactions = dataSource.getRepository(AutoReaction);
}
async all(): Promise<AutoReaction[]> {
@ -18,7 +19,7 @@ export class GuildAutoReactions extends BaseGuildRepository {
});
}
async getForChannel(channelId: string): Promise<AutoReaction | undefined> {
async getForChannel(channelId: string): Promise<AutoReaction | null> {
return this.autoReactions.findOne({
where: {
guild_id: this.guildId,

View file

@ -12,15 +12,19 @@ export class GuildButtonRoles extends BaseGuildRepository {
async getForButtonId(buttonId: string) {
return this.buttonRoles.findOne({
guild_id: this.guildId,
button_id: buttonId,
where: {
guild_id: this.guildId,
button_id: buttonId,
},
});
}
async getAllForMessageId(messageId: string) {
return this.buttonRoles.find({
guild_id: this.guildId,
message_id: messageId,
where: {
guild_id: this.guildId,
message_id: messageId,
},
});
}
@ -40,8 +44,10 @@ export class GuildButtonRoles extends BaseGuildRepository {
async getForButtonGroup(buttonGroup: string) {
return this.buttonRoles.find({
guild_id: this.guildId,
button_group: buttonGroup,
where: {
guild_id: this.guildId,
button_group: buttonGroup,
},
});
}

View file

@ -1,14 +1,11 @@
import { getRepository, In, InsertResult, Repository } from "typeorm";
import { In, InsertResult, Repository } from "typeorm";
import { Queue } from "../Queue";
import { chunkArray } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { CaseTypes } from "./CaseTypes";
import { connection } from "./db";
import { dataSource } from "./dataSource";
import { Case } from "./entities/Case";
import { CaseNote } from "./entities/CaseNote";
import moment from "moment-timezone";
import { chunkArray } from "../utils";
import { Queue } from "../Queue";
const CASE_SUMMARY_REASON_MAX_LENGTH = 300;
export class GuildCases extends BaseGuildRepository {
private cases: Repository<Case>;
@ -18,8 +15,8 @@ export class GuildCases extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.cases = getRepository(Case);
this.caseNotes = getRepository(CaseNote);
this.cases = dataSource.getRepository(Case);
this.caseNotes = dataSource.getRepository(CaseNote);
this.createQueue = new Queue();
}
@ -33,7 +30,7 @@ export class GuildCases extends BaseGuildRepository {
});
}
async find(id: number): Promise<Case | undefined> {
async find(id: number): Promise<Case | null> {
return this.cases.findOne({
relations: this.getRelations(),
where: {
@ -43,7 +40,7 @@ export class GuildCases extends BaseGuildRepository {
});
}
async findByCaseNumber(caseNumber: number): Promise<Case | undefined> {
async findByCaseNumber(caseNumber: number): Promise<Case | null> {
return this.cases.findOne({
relations: this.getRelations(),
where: {
@ -53,7 +50,7 @@ export class GuildCases extends BaseGuildRepository {
});
}
async findLatestByModId(modId: string): Promise<Case | undefined> {
async findLatestByModId(modId: string): Promise<Case | null> {
return this.cases.findOne({
relations: this.getRelations(),
where: {
@ -66,7 +63,7 @@ export class GuildCases extends BaseGuildRepository {
});
}
async findByAuditLogId(auditLogId: string): Promise<Case | undefined> {
async findByAuditLogId(auditLogId: string): Promise<Case | null> {
return this.cases.findOne({
relations: this.getRelations(),
where: {
@ -91,7 +88,7 @@ export class GuildCases extends BaseGuildRepository {
where: {
guild_id: this.guildId,
mod_id: modId,
is_hidden: 0,
is_hidden: false,
},
});
}
@ -102,7 +99,7 @@ export class GuildCases extends BaseGuildRepository {
where: {
guild_id: this.guildId,
mod_id: modId,
is_hidden: 0,
is_hidden: false,
},
skip,
take: count,
@ -184,7 +181,7 @@ export class GuildCases extends BaseGuildRepository {
}
async softDelete(id: number, deletedById: string, deletedByName: string, deletedByText: string) {
return connection.transaction(async (entityManager) => {
return dataSource.transaction(async (entityManager) => {
const cases = entityManager.getRepository(Case);
const caseNotes = entityManager.getRepository(CaseNote);

View file

@ -1,5 +1,6 @@
import { DeleteResult, getRepository, InsertResult, Repository } from "typeorm";
import { DeleteResult, InsertResult, Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { ContextMenuLink } from "./entities/ContextMenuLink";
export class GuildContextMenuLinks extends BaseGuildRepository {
@ -7,10 +8,10 @@ export class GuildContextMenuLinks extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.contextLinks = getRepository(ContextMenuLink);
this.contextLinks = dataSource.getRepository(ContextMenuLink);
}
async get(id: string): Promise<ContextMenuLink | undefined> {
async get(id: string): Promise<ContextMenuLink | null> {
return this.contextLinks.findOne({
where: {
guild_id: this.guildId,

View file

@ -1,11 +1,11 @@
import moment from "moment-timezone";
import { FindConditions, getRepository, In, IsNull, Not, Repository } from "typeorm";
import { FindOptionsWhere, In, IsNull, Not, Repository } from "typeorm";
import { Queue } from "../Queue";
import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { connection } from "./db";
import { dataSource } from "./dataSource";
import { Counter } from "./entities/Counter";
import { CounterTrigger, isValidCounterComparisonOp, TriggerComparisonOp } from "./entities/CounterTrigger";
import { CounterTrigger, TriggerComparisonOp, isValidCounterComparisonOp } from "./entities/CounterTrigger";
import { CounterTriggerState } from "./entities/CounterTriggerState";
import { CounterValue } from "./entities/CounterValue";
@ -17,11 +17,11 @@ const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT
const decayQueue = new Queue();
async function deleteCountersMarkedToBeDeleted(): Promise<void> {
await getRepository(Counter).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
await dataSource.getRepository(Counter).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
}
async function deleteTriggersMarkedToBeDeleted(): Promise<void> {
await getRepository(CounterTrigger).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
await dataSource.getRepository(CounterTrigger).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
}
setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS);
@ -38,10 +38,10 @@ export class GuildCounters extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.counters = getRepository(Counter);
this.counterValues = getRepository(CounterValue);
this.counterTriggers = getRepository(CounterTrigger);
this.counterTriggerStates = getRepository(CounterTriggerState);
this.counters = dataSource.getRepository(Counter);
this.counterValues = dataSource.getRepository(CounterValue);
this.counterTriggers = dataSource.getRepository(CounterTrigger);
this.counterTriggerStates = dataSource.getRepository(CounterTriggerState);
}
async findOrCreateCounter(name: string, perChannel: boolean, perUser: boolean): Promise<Counter> {
@ -80,7 +80,7 @@ export class GuildCounters extends BaseGuildRepository {
}
async markUnusedCountersToBeDeleted(idsToKeep: number[]): Promise<void> {
const criteria: FindConditions<Counter> = {
const criteria: FindOptionsWhere<Counter> = {
guild_id: this.guildId,
delete_at: IsNull(),
};
@ -161,7 +161,7 @@ export class GuildCounters extends BaseGuildRepository {
}
const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount);
if (decayAmountToApply === 0) {
if (decayAmountToApply === 0 || Number.isNaN(decayAmountToApply)) {
return;
}
@ -256,10 +256,12 @@ export class GuildCounters extends BaseGuildRepository {
throw new Error(`Invalid comparison value: ${reverseComparisonValue}`);
}
return connection.transaction(async (entityManager) => {
return dataSource.transaction(async (entityManager) => {
const existing = await entityManager.findOne(CounterTrigger, {
counter_id: counterId,
name: triggerName,
where: {
counter_id: counterId,
name: triggerName,
},
});
if (existing) {
@ -283,7 +285,11 @@ export class GuildCounters extends BaseGuildRepository {
reverse_comparison_value: reverseComparisonValue,
});
return (await entityManager.findOne(CounterTrigger, insertResult.identifiers[0].id))!;
return (await entityManager.findOne(CounterTrigger, {
where: {
id: insertResult.identifiers[0].id,
},
}))!;
});
}
@ -308,11 +314,13 @@ export class GuildCounters extends BaseGuildRepository {
channelId = channelId || "0";
userId = userId || "0";
return connection.transaction(async (entityManager) => {
return dataSource.transaction(async (entityManager) => {
const previouslyTriggered = await entityManager.findOne(CounterTriggerState, {
trigger_id: counterTrigger.id,
user_id: userId!,
channel_id: channelId!,
where: {
trigger_id: counterTrigger.id,
user_id: userId!,
channel_id: channelId!,
},
});
if (previouslyTriggered) {
@ -356,7 +364,7 @@ export class GuildCounters extends BaseGuildRepository {
async checkAllValuesForTrigger(
counterTrigger: CounterTrigger,
): Promise<Array<{ channelId: string; userId: string }>> {
return connection.transaction(async (entityManager) => {
return dataSource.transaction(async (entityManager) => {
const matchingValues = await entityManager
.createQueryBuilder(CounterValue, "cv")
.leftJoin(
@ -407,7 +415,7 @@ export class GuildCounters extends BaseGuildRepository {
channelId = channelId || "0";
userId = userId || "0";
return connection.transaction(async (entityManager) => {
return dataSource.transaction(async (entityManager) => {
const matchingValue = await entityManager
.createQueryBuilder(CounterValue, "cv")
.innerJoin(
@ -446,7 +454,7 @@ export class GuildCounters extends BaseGuildRepository {
async checkAllValuesForReverseTrigger(
counterTrigger: CounterTrigger,
): Promise<Array<{ channelId: string; userId: string }>> {
return connection.transaction(async (entityManager) => {
return dataSource.transaction(async (entityManager) => {
const matchingValues: Array<{
id: string;
triggerStateId: string;

View file

@ -1,11 +1,12 @@
import { Mute } from "./entities/Mute";
import { ScheduledPost } from "./entities/ScheduledPost";
import { Reminder } from "./entities/Reminder";
import { ScheduledPost } from "./entities/ScheduledPost";
import { Tempban } from "./entities/Tempban";
import { VCAlert } from "./entities/VCAlert";
interface GuildEventArgs extends Record<string, unknown[]> {
expiredMute: [Mute];
timeoutMuteToRenew: [Mute];
scheduledPost: [ScheduledPost];
reminder: [Reminder];
expiredTempban: [Tempban];

View file

@ -0,0 +1,102 @@
import moment from "moment-timezone";
import { Repository } from "typeorm";
import { Blocker } from "../Blocker";
import { DBDateFormat, MINUTES } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { MemberCacheItem } from "./entities/MemberCacheItem";
const SAVE_PENDING_BLOCKER_KEY = "save-pending" as const;
const DELETION_DELAY = 5 * MINUTES;
type UpdateData = Pick<MemberCacheItem, "username" | "nickname" | "roles">;
export class GuildMemberCache extends BaseGuildRepository {
#memberCache: Repository<MemberCacheItem>;
#pendingUpdates: Map<string, Partial<MemberCacheItem>>;
#blocker: Blocker;
constructor(guildId: string) {
super(guildId);
this.#memberCache = dataSource.getRepository(MemberCacheItem);
this.#pendingUpdates = new Map();
this.#blocker = new Blocker();
}
async savePendingUpdates(): Promise<void> {
await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY);
if (this.#pendingUpdates.size === 0) {
return;
}
this.#blocker.block(SAVE_PENDING_BLOCKER_KEY);
const entitiesToSave = Array.from(this.#pendingUpdates.values());
this.#pendingUpdates.clear();
await this.#memberCache.upsert(entitiesToSave, ["guild_id", "user_id"]).finally(() => {
this.#blocker.unblock(SAVE_PENDING_BLOCKER_KEY);
});
}
async getCachedMemberData(userId: string): Promise<MemberCacheItem | null> {
await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY);
const dbItem = await this.#memberCache.findOne({
where: {
guild_id: this.guildId,
user_id: userId,
},
});
const pendingItem = this.#pendingUpdates.get(userId);
if (!dbItem && !pendingItem) {
return null;
}
const item = new MemberCacheItem();
Object.assign(item, dbItem ?? {});
Object.assign(item, pendingItem ?? {});
return item;
}
async setCachedMemberData(userId: string, data: UpdateData): Promise<void> {
await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY);
if (!this.#pendingUpdates.has(userId)) {
const newItem = new MemberCacheItem();
newItem.guild_id = this.guildId;
newItem.user_id = userId;
this.#pendingUpdates.set(userId, newItem);
}
Object.assign(this.#pendingUpdates.get(userId)!, data);
this.#pendingUpdates.get(userId)!.last_seen = moment().format("YYYY-MM-DD");
}
async markMemberForDeletion(userId: string): Promise<void> {
await this.#memberCache.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
delete_at: moment().add(DELETION_DELAY, "ms").format(DBDateFormat),
},
);
}
async unmarkMemberForDeletion(userId: string): Promise<void> {
await this.#memberCache.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
delete_at: null,
},
);
}
}

View file

@ -1,6 +1,6 @@
import { getRepository, Repository } from "typeorm/index";
import { Repository } from "typeorm/index";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { connection } from "./db";
import { dataSource } from "./dataSource";
import { MemberTimezone } from "./entities/MemberTimezone";
export class GuildMemberTimezones extends BaseGuildRepository {
@ -8,22 +8,26 @@ export class GuildMemberTimezones extends BaseGuildRepository {
constructor(guildId: string) {
super(guildId);
this.memberTimezones = getRepository(MemberTimezone);
this.memberTimezones = dataSource.getRepository(MemberTimezone);
}
get(memberId: string) {
return this.memberTimezones.findOne({
guild_id: this.guildId,
member_id: memberId,
where: {
guild_id: this.guildId,
member_id: memberId,
},
});
}
async set(memberId, timezone: string) {
await connection.transaction(async (entityManager) => {
await dataSource.transaction(async (entityManager) => {
const repo = entityManager.getRepository(MemberTimezone);
const existingRow = await repo.findOne({
guild_id: this.guildId,
member_id: memberId,
where: {
guild_id: this.guildId,
member_id: memberId,
},
});
if (existingRow) {

View file

@ -1,14 +1,26 @@
import moment from "moment-timezone";
import { Brackets, getRepository, Repository } from "typeorm";
import { Brackets, Repository } from "typeorm";
import { DBDateFormat } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { MuteTypes } from "./MuteTypes";
import { dataSource } from "./dataSource";
import { Mute } from "./entities/Mute";
export type AddMuteParams = {
userId: Mute["user_id"];
type: MuteTypes;
expiresAt: number | null;
rolesToRestore?: Mute["roles_to_restore"];
muteRole?: string | null;
timeoutExpiresAt?: number;
};
export class GuildMutes extends BaseGuildRepository {
private mutes: Repository<Mute>;
constructor(guildId) {
super(guildId);
this.mutes = getRepository(Mute);
this.mutes = dataSource.getRepository(Mute);
}
async getExpiredMutes(): Promise<Mute[]> {
@ -20,7 +32,7 @@ export class GuildMutes extends BaseGuildRepository {
.getMany();
}
async findExistingMuteForUserId(userId: string): Promise<Mute | undefined> {
async findExistingMuteForUserId(userId: string): Promise<Mute | null> {
return this.mutes.findOne({
where: {
guild_id: this.guildId,
@ -34,14 +46,18 @@ export class GuildMutes extends BaseGuildRepository {
return mute != null;
}
async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> {
const expiresAt = expiryTime ? moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
async addMute(params: AddMuteParams): Promise<Mute> {
const expiresAt = params.expiresAt ? moment.utc(params.expiresAt).format(DBDateFormat) : null;
const timeoutExpiresAt = params.timeoutExpiresAt ? moment.utc(params.timeoutExpiresAt).format(DBDateFormat) : null;
const result = await this.mutes.insert({
guild_id: this.guildId,
user_id: userId,
user_id: params.userId,
type: params.type,
expires_at: expiresAt,
roles_to_restore: rolesToRestore ?? [],
roles_to_restore: params.rolesToRestore ?? [],
mute_role: params.muteRole,
timeout_expires_at: timeoutExpiresAt,
});
return (await this.mutes.findOne({ where: result.identifiers[0] }))!;
@ -74,6 +90,32 @@ export class GuildMutes extends BaseGuildRepository {
}
}
async updateExpiresAt(userId: string, timestamp: number | null): Promise<void> {
const expiresAt = timestamp ? moment.utc(timestamp).format("YYYY-MM-DD HH:mm:ss") : null;
await this.mutes.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
expires_at: expiresAt,
},
);
}
async updateTimeoutExpiresAt(userId: string, timestamp: number): Promise<void> {
const timeoutExpiresAt = moment.utc(timestamp).format(DBDateFormat);
await this.mutes.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
timeout_expires_at: timeoutExpiresAt,
},
);
}
async getActiveMutes(): Promise<Mute[]> {
return this.mutes
.createQueryBuilder("mutes")
@ -104,4 +146,16 @@ export class GuildMutes extends BaseGuildRepository {
user_id: userId,
});
}
async fillMissingMuteRole(muteRole: string): Promise<void> {
await this.mutes
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("type = :type", { type: MuteTypes.Role })
.andWhere("mute_role IS NULL")
.update({
mute_role: muteRole,
})
.execute();
}
}

View file

@ -1,19 +1,21 @@
import { getRepository, In, Repository } from "typeorm";
import { In, Repository } from "typeorm";
import { isAPI } from "../globals";
import { MINUTES, SECONDS } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { cleanupNicknames } from "./cleanup/nicknames";
import { dataSource } from "./dataSource";
import { NicknameHistoryEntry } from "./entities/NicknameHistoryEntry";
const CLEANUP_INTERVAL = 5 * MINUTES;
async function cleanup() {
await cleanupNicknames();
setTimeout(cleanup, CLEANUP_INTERVAL);
}
if (!isAPI()) {
const CLEANUP_INTERVAL = 5 * MINUTES;
async function cleanup() {
await cleanupNicknames();
setTimeout(cleanup, CLEANUP_INTERVAL);
}
// Start first cleanup 30 seconds after startup
// TODO: Move to bot startup code
setTimeout(cleanup, 30 * SECONDS);
}
@ -24,7 +26,7 @@ export class GuildNicknameHistory extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.nicknameHistory = getRepository(NicknameHistoryEntry);
this.nicknameHistory = dataSource.getRepository(NicknameHistoryEntry);
}
async getByUserId(userId): Promise<NicknameHistoryEntry[]> {
@ -39,7 +41,7 @@ export class GuildNicknameHistory extends BaseGuildRepository {
});
}
getLastEntry(userId): Promise<NicknameHistoryEntry | undefined> {
getLastEntry(userId): Promise<NicknameHistoryEntry | null> {
return this.nicknameHistory.findOne({
where: {
guild_id: this.guildId,

View file

@ -1,18 +1,14 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { PersistedData } from "./entities/PersistedData";
export interface IPartialPersistData {
roles?: string[];
nickname?: string;
}
export class GuildPersistedData extends BaseGuildRepository {
private persistedData: Repository<PersistedData>;
constructor(guildId) {
super(guildId);
this.persistedData = getRepository(PersistedData);
this.persistedData = dataSource.getRepository(PersistedData);
}
async find(userId: string) {
@ -24,11 +20,7 @@ export class GuildPersistedData extends BaseGuildRepository {
});
}
async set(userId: string, data: IPartialPersistData = {}) {
const finalData: any = {};
if (data.roles) finalData.roles = data.roles.join(",");
if (data.nickname) finalData.nickname = data.nickname;
async set(userId: string, data: Partial<PersistedData> = {}) {
const existing = await this.find(userId);
if (existing) {
await this.persistedData.update(
@ -36,11 +28,11 @@ export class GuildPersistedData extends BaseGuildRepository {
guild_id: this.guildId,
user_id: userId,
},
finalData,
data,
);
} else {
await this.persistedData.insert({
...finalData,
...data,
guild_id: this.guildId,
user_id: userId,
});

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { PingableRole } from "./entities/PingableRole";
export class GuildPingableRoles extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildPingableRoles extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.pingableRoles = getRepository(PingableRole);
this.pingableRoles = dataSource.getRepository(PingableRole);
}
async all(): Promise<PingableRole[]> {
@ -27,7 +28,7 @@ export class GuildPingableRoles extends BaseGuildRepository {
});
}
async getByChannelAndRoleId(channelId: string, roleId: string): Promise<PingableRole | undefined> {
async getByChannelAndRoleId(channelId: string, roleId: string): Promise<PingableRole | null> {
return this.pingableRoles.findOne({
where: {
guild_id: this.guildId,

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { ReactionRole } from "./entities/ReactionRole";
export class GuildReactionRoles extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildReactionRoles extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.reactionRoles = getRepository(ReactionRole);
this.reactionRoles = dataSource.getRepository(ReactionRole);
}
async all(): Promise<ReactionRole[]> {
@ -30,7 +31,7 @@ export class GuildReactionRoles extends BaseGuildRepository {
});
}
async getByMessageAndEmoji(messageId: string, emoji: string): Promise<ReactionRole | undefined> {
async getByMessageAndEmoji(messageId: string, emoji: string): Promise<ReactionRole | null> {
return this.reactionRoles.findOne({
where: {
guild_id: this.guildId,

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { Reminder } from "./entities/Reminder";
export class GuildReminders extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildReminders extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.reminders = getRepository(Reminder);
this.reminders = dataSource.getRepository(Reminder);
}
async getDueReminders(): Promise<Reminder[]> {
@ -28,7 +29,9 @@ export class GuildReminders extends BaseGuildRepository {
}
find(id: number) {
return this.reminders.findOne({ id });
return this.reminders.findOne({
where: { id },
});
}
async delete(id) {

View file

@ -0,0 +1,38 @@
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { RoleButtonsItem } from "./entities/RoleButtonsItem";
export class GuildRoleButtons extends BaseGuildRepository {
private roleButtons: Repository<RoleButtonsItem>;
constructor(guildId) {
super(guildId);
this.roleButtons = dataSource.getRepository(RoleButtonsItem);
}
getSavedRoleButtons(): Promise<RoleButtonsItem[]> {
return this.roleButtons.find({
where: {
guild_id: this.guildId,
},
});
}
async deleteRoleButtonItem(name: string): Promise<void> {
await this.roleButtons.delete({
guild_id: this.guildId,
name,
});
}
async saveRoleButtonItem(name: string, channelId: string, messageId: string, hash: string): Promise<void> {
await this.roleButtons.insert({
guild_id: this.guildId,
name,
channel_id: channelId,
message_id: messageId,
hash,
});
}
}

View file

@ -0,0 +1,44 @@
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { RoleQueueItem } from "./entities/RoleQueueItem";
export class GuildRoleQueue extends BaseGuildRepository {
private roleQueue: Repository<RoleQueueItem>;
constructor(guildId) {
super(guildId);
this.roleQueue = dataSource.getRepository(RoleQueueItem);
}
consumeNextRoleAssignments(count: number): Promise<RoleQueueItem[]> {
return dataSource.transaction(async (entityManager) => {
const repository = entityManager.getRepository(RoleQueueItem);
const nextAssignments = await repository
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.addOrderBy("priority", "DESC")
.addOrderBy("id", "ASC")
.take(count)
.getMany();
if (nextAssignments.length > 0) {
const ids = nextAssignments.map((assignment) => assignment.id);
await repository.createQueryBuilder().where("id IN (:ids)", { ids }).delete().execute();
}
return nextAssignments;
});
}
async addQueueItem(userId: string, roleId: string, shouldAdd: boolean, priority = 0) {
await this.roleQueue.insert({
guild_id: this.guildId,
user_id: userId,
role_id: roleId,
should_add: shouldAdd,
priority,
});
}
}

View file

@ -1,15 +1,14 @@
import { GuildChannel, Message } from "discord.js";
import moment from "moment-timezone";
import { getRepository, Repository } from "typeorm";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { Repository } from "typeorm";
import { QueuedEventEmitter } from "../QueuedEventEmitter";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { ISavedMessageData, SavedMessage } from "./entities/SavedMessage";
import { buildEntity } from "./buildEntity";
import { noop } from "../utils";
import { decrypt } from "../utils/crypt";
import { decryptJson, encryptJson } from "../utils/cryptHelpers";
import { asyncMap } from "../utils/async";
import { decryptJson, encryptJson } from "../utils/cryptHelpers";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { buildEntity } from "./buildEntity";
import { dataSource } from "./dataSource";
import { ISavedMessageData, SavedMessage } from "./entities/SavedMessage";
export class GuildSavedMessages extends BaseGuildRepository<SavedMessage> {
private messages: Repository<SavedMessage>;
@ -19,7 +18,7 @@ export class GuildSavedMessages extends BaseGuildRepository<SavedMessage> {
constructor(guildId) {
super(guildId);
this.messages = getRepository(SavedMessage);
this.messages = dataSource.getRepository(SavedMessage);
this.events = new QueuedEventEmitter();
this.toBePermanent = new Set();
@ -53,13 +52,13 @@ export class GuildSavedMessages extends BaseGuildRepository<SavedMessage> {
title: embed.title,
description: embed.description,
url: embed.url,
timestamp: embed.timestamp,
timestamp: embed.timestamp ? Date.parse(embed.timestamp) : null,
color: embed.color,
fields: embed.fields.map((field) => ({
name: field.name,
value: field.value,
inline: field.inline,
inline: field.inline ?? false,
})),
author: embed.author
@ -128,7 +127,7 @@ export class GuildSavedMessages extends BaseGuildRepository<SavedMessage> {
return entity;
}
entity.data = await decryptJson(entity.data as unknown as string);
entity.data = (await decryptJson(entity.data as unknown as string)) as ISavedMessageData;
return entity;
}
@ -139,7 +138,7 @@ export class GuildSavedMessages extends BaseGuildRepository<SavedMessage> {
return entity;
}
async find(id: string, includeDeleted = false): Promise<SavedMessage | undefined> {
async find(id: string, includeDeleted = false): Promise<SavedMessage | null> {
let query = this.messages
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { ScheduledPost } from "./entities/ScheduledPost";
export class GuildScheduledPosts extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildScheduledPosts extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.scheduledPosts = getRepository(ScheduledPost);
this.scheduledPosts = dataSource.getRepository(ScheduledPost);
}
all(): Promise<ScheduledPost[]> {
@ -23,7 +24,11 @@ export class GuildScheduledPosts extends BaseGuildRepository {
}
find(id: number) {
return this.scheduledPosts.findOne({ id });
return this.scheduledPosts.findOne({
where: {
id,
},
});
}
async delete(id) {

View file

@ -1,6 +1,7 @@
import moment from "moment-timezone";
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { SlowmodeChannel } from "./entities/SlowmodeChannel";
import { SlowmodeUser } from "./entities/SlowmodeUser";
@ -10,11 +11,11 @@ export class GuildSlowmodes extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.slowmodeChannels = getRepository(SlowmodeChannel);
this.slowmodeUsers = getRepository(SlowmodeUser);
this.slowmodeChannels = dataSource.getRepository(SlowmodeChannel);
this.slowmodeUsers = dataSource.getRepository(SlowmodeUser);
}
async getChannelSlowmode(channelId): Promise<SlowmodeChannel | undefined> {
async getChannelSlowmode(channelId): Promise<SlowmodeChannel | null> {
return this.slowmodeChannels.findOne({
where: {
guild_id: this.guildId,
@ -51,11 +52,13 @@ export class GuildSlowmodes extends BaseGuildRepository {
});
}
async getChannelSlowmodeUser(channelId, userId): Promise<SlowmodeUser | undefined> {
async getChannelSlowmodeUser(channelId, userId): Promise<SlowmodeUser | null> {
return this.slowmodeUsers.findOne({
guild_id: this.guildId,
channel_id: channelId,
user_id: userId,
where: {
guild_id: this.guildId,
channel_id: channelId,
user_id: userId,
},
});
}

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { StarboardMessage } from "./entities/StarboardMessage";
export class GuildStarboardMessages extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildStarboardMessages extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.allStarboardMessages = getRepository(StarboardMessage);
this.allStarboardMessages = dataSource.getRepository(StarboardMessage);
}
async getStarboardMessagesForMessageId(messageId: string) {

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { StarboardReaction } from "./entities/StarboardReaction";
export class GuildStarboardReactions extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildStarboardReactions extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.allStarboardReactions = getRepository(StarboardReaction);
this.allStarboardReactions = dataSource.getRepository(StarboardReaction);
}
async getAllReactionsForMessageId(messageId: string) {

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { StatValue } from "./entities/StatValue";
export class GuildStats extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildStats extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.stats = getRepository(StatValue);
this.stats = dataSource.getRepository(StatValue);
}
async saveValue(source: string, key: string, value: number): Promise<void> {

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { Tag } from "./entities/Tag";
import { TagResponse } from "./entities/TagResponse";
@ -9,8 +10,8 @@ export class GuildTags extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.tags = getRepository(Tag);
this.tagResponses = getRepository(TagResponse);
this.tags = dataSource.getRepository(Tag);
this.tagResponses = dataSource.getRepository(TagResponse);
}
async all(): Promise<Tag[]> {
@ -21,7 +22,7 @@ export class GuildTags extends BaseGuildRepository {
});
}
async find(tag): Promise<Tag | undefined> {
async find(tag): Promise<Tag | null> {
return this.tags.findOne({
where: {
guild_id: this.guildId,
@ -61,7 +62,7 @@ export class GuildTags extends BaseGuildRepository {
});
}
async findResponseByCommandMessageId(messageId: string): Promise<TagResponse | undefined> {
async findResponseByCommandMessageId(messageId: string): Promise<TagResponse | null> {
return this.tagResponses.findOne({
where: {
guild_id: this.guildId,
@ -70,7 +71,7 @@ export class GuildTags extends BaseGuildRepository {
});
}
async findResponseByResponseMessageId(messageId: string): Promise<TagResponse | undefined> {
async findResponseByResponseMessageId(messageId: string): Promise<TagResponse | null> {
return this.tagResponses.findOne({
where: {
guild_id: this.guildId,

View file

@ -1,6 +1,7 @@
import moment from "moment-timezone";
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { Tempban } from "./entities/Tempban";
export class GuildTempbans extends BaseGuildRepository {
@ -8,7 +9,7 @@ export class GuildTempbans extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.tempbans = getRepository(Tempban);
this.tempbans = dataSource.getRepository(Tempban);
}
async getExpiredTempbans(): Promise<Tempban[]> {
@ -20,7 +21,7 @@ export class GuildTempbans extends BaseGuildRepository {
.getMany();
}
async findExistingTempbanForUserId(userId: string): Promise<Tempban | undefined> {
async findExistingTempbanForUserId(userId: string): Promise<Tempban | null> {
return this.tempbans.findOne({
where: {
guild_id: this.guildId,

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { dataSource } from "./dataSource";
import { VCAlert } from "./entities/VCAlert";
export class GuildVCAlerts extends BaseGuildRepository {
@ -7,7 +8,7 @@ export class GuildVCAlerts extends BaseGuildRepository {
constructor(guildId) {
super(guildId);
this.allAlerts = getRepository(VCAlert);
this.allAlerts = dataSource.getRepository(VCAlert);
}
async getOutdatedAlerts(): Promise<VCAlert[]> {
@ -41,7 +42,9 @@ export class GuildVCAlerts extends BaseGuildRepository {
}
find(id: number) {
return this.allAlerts.findOne({ id });
return this.allAlerts.findOne({
where: { id },
});
}
async delete(id) {

View file

@ -0,0 +1,30 @@
import moment from "moment-timezone";
import { Repository } from "typeorm";
import { DAYS } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { MemberCacheItem } from "./entities/MemberCacheItem";
const STALE_PERIOD = 90 * DAYS;
export class MemberCache extends BaseRepository {
#memberCache: Repository<MemberCacheItem>;
constructor() {
super();
this.#memberCache = dataSource.getRepository(MemberCacheItem);
}
async deleteStaleData(): Promise<void> {
const cutoff = moment().subtract(STALE_PERIOD, "ms").format("YYYY-MM-DD");
await this.#memberCache.createQueryBuilder().where("last_seen < :cutoff", { cutoff }).delete().execute();
}
async deleteMarkedToBeDeletedEntries(): Promise<void> {
await this.#memberCache
.createQueryBuilder()
.where("delete_at IS NOT NULL AND delete_at <= NOW()")
.delete()
.execute();
}
}

View file

@ -0,0 +1,4 @@
export enum MuteTypes {
Role = 1,
Timeout = 2,
}

View file

@ -1,20 +1,35 @@
import moment from "moment-timezone";
import { Brackets, getRepository, Repository } from "typeorm";
import { Mute } from "./entities/Mute";
import { Repository } from "typeorm";
import { DAYS, DBDateFormat } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { MuteTypes } from "./MuteTypes";
import { dataSource } from "./dataSource";
import { Mute } from "./entities/Mute";
const OLD_EXPIRED_MUTE_THRESHOLD = 7 * DAYS;
export const MAX_TIMEOUT_DURATION = 27 * DAYS;
// When a timeout is under this duration but the mute expires later, the timeout will be reset to max duration
export const TIMEOUT_RENEWAL_THRESHOLD = 21 * DAYS;
export class Mutes extends BaseRepository {
private mutes: Repository<Mute>;
constructor() {
super();
this.mutes = getRepository(Mute);
this.mutes = dataSource.getRepository(Mute);
}
async getSoonExpiringMutes(threshold: number): Promise<Mute[]> {
findMute(guildId: string, userId: string): Promise<Mute | null> {
return this.mutes.findOne({
where: {
guild_id: guildId,
user_id: userId,
},
});
}
getSoonExpiringMutes(threshold: number): Promise<Mute[]> {
const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
return this.mutes
.createQueryBuilder("mutes")
@ -23,6 +38,16 @@ export class Mutes extends BaseRepository {
.getMany();
}
getTimeoutMutesToRenew(threshold: number): Promise<Mute[]> {
const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
return this.mutes
.createQueryBuilder("mutes")
.andWhere("type = :type", { type: MuteTypes.Timeout })
.andWhere("(expires_at IS NULL OR timeout_expires_at < expires_at)")
.andWhere("timeout_expires_at <= :date", { date: thresholdDateStr })
.getMany();
}
async clearOldExpiredMutes(): Promise<void> {
const thresholdDateStr = moment.utc().subtract(OLD_EXPIRED_MUTE_THRESHOLD, "ms").format(DBDateFormat);
await this.mutes

View file

@ -1,14 +1,15 @@
import { getRepository, Repository } from "typeorm";
import { PhishermanCacheEntry } from "./entities/PhishermanCacheEntry";
import { PhishermanDomainInfo, PhishermanUnknownDomain } from "./types/phisherman";
import fetch, { Headers } from "node-fetch";
import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils";
import moment from "moment-timezone";
import { PhishermanKeyCacheEntry } from "./entities/PhishermanKeyCacheEntry";
import crypto from "crypto";
import moment from "moment-timezone";
import { Repository } from "typeorm";
import { env } from "../env";
import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils";
import { dataSource } from "./dataSource";
import { PhishermanCacheEntry } from "./entities/PhishermanCacheEntry";
import { PhishermanKeyCacheEntry } from "./entities/PhishermanKeyCacheEntry";
import { PhishermanDomainInfo, PhishermanUnknownDomain } from "./types/phisherman";
const API_URL = "https://api.phisherman.gg";
const MASTER_API_KEY = process.env.PHISHERMAN_API_KEY;
const MASTER_API_KEY = env.PHISHERMAN_API_KEY;
let caughtDomainTrackingMap: Map<string, Map<string, number[]>> = new Map();
@ -39,7 +40,7 @@ const KEY_VALIDITY_LIFETIME = 24 * HOURS;
let cacheRepository: Repository<PhishermanCacheEntry> | null = null;
function getCacheRepository(): Repository<PhishermanCacheEntry> {
if (cacheRepository == null) {
cacheRepository = getRepository(PhishermanCacheEntry);
cacheRepository = dataSource.getRepository(PhishermanCacheEntry);
}
return cacheRepository;
}
@ -47,7 +48,7 @@ function getCacheRepository(): Repository<PhishermanCacheEntry> {
let keyCacheRepository: Repository<PhishermanKeyCacheEntry> | null = null;
function getKeyCacheRepository(): Repository<PhishermanKeyCacheEntry> {
if (keyCacheRepository == null) {
keyCacheRepository = getRepository(PhishermanKeyCacheEntry);
keyCacheRepository = dataSource.getRepository(PhishermanKeyCacheEntry);
}
return keyCacheRepository;
}
@ -153,7 +154,9 @@ export async function getPhishermanDomainInfo(domain: string): Promise<Phisherma
}
const dbCache = getCacheRepository();
const existingCachedEntry = await dbCache.findOne({ domain });
const existingCachedEntry = await dbCache.findOne({
where: { domain },
});
if (existingCachedEntry) {
return existingCachedEntry.data;
}
@ -196,7 +199,9 @@ export async function phishermanApiKeyIsValid(apiKey: string): Promise<boolean>
const keyCache = getKeyCacheRepository();
const hash = crypto.createHash("sha256").update(apiKey).digest("hex");
const entry = await keyCache.findOne({ hash });
const entry = await keyCache.findOne({
where: { hash },
});
if (entry) {
return entry.is_valid;
}

View file

@ -1,15 +1,16 @@
import { getRepository, Repository } from "typeorm";
import { Reminder } from "./entities/Reminder";
import { BaseRepository } from "./BaseRepository";
import moment from "moment-timezone";
import { Repository } from "typeorm";
import { DBDateFormat } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { Reminder } from "./entities/Reminder";
export class Reminders extends BaseRepository {
private reminders: Repository<Reminder>;
constructor() {
super();
this.reminders = getRepository(Reminder);
this.reminders = dataSource.getRepository(Reminder);
}
async getRemindersDueSoon(threshold: number): Promise<Reminder[]> {

View file

@ -1,15 +1,16 @@
import { getRepository, Repository } from "typeorm";
import { ScheduledPost } from "./entities/ScheduledPost";
import { BaseRepository } from "./BaseRepository";
import moment from "moment-timezone";
import { Repository } from "typeorm";
import { DBDateFormat } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { ScheduledPost } from "./entities/ScheduledPost";
export class ScheduledPosts extends BaseRepository {
private scheduledPosts: Repository<ScheduledPost>;
constructor() {
super();
this.scheduledPosts = getRepository(ScheduledPost);
this.scheduledPosts = dataSource.getRepository(ScheduledPost);
}
getScheduledPostsDueSoon(threshold: number): Promise<ScheduledPost[]> {

View file

@ -1,5 +1,6 @@
import { getRepository, Repository } from "typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { Supporter } from "./entities/Supporter";
export class Supporters extends BaseRepository {
@ -7,7 +8,7 @@ export class Supporters extends BaseRepository {
constructor() {
super();
this.supporters = getRepository(Supporter);
this.supporters = dataSource.getRepository(Supporter);
}
getAll() {

View file

@ -1,15 +1,16 @@
import moment from "moment-timezone";
import { getRepository, Repository } from "typeorm";
import { Tempban } from "./entities/Tempban";
import { BaseRepository } from "./BaseRepository";
import { Repository } from "typeorm";
import { DBDateFormat } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { Tempban } from "./entities/Tempban";
export class Tempbans extends BaseRepository {
private tempbans: Repository<Tempban>;
constructor() {
super();
this.tempbans = getRepository(Tempban);
this.tempbans = dataSource.getRepository(Tempban);
}
getSoonExpiringTempbans(threshold: number): Promise<Tempban[]> {

View file

@ -1,19 +1,21 @@
import { getRepository, In, Repository } from "typeorm";
import { In, Repository } from "typeorm";
import { isAPI } from "../globals";
import { MINUTES, SECONDS } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { cleanupUsernames } from "./cleanup/usernames";
import { dataSource } from "./dataSource";
import { UsernameHistoryEntry } from "./entities/UsernameHistoryEntry";
const CLEANUP_INTERVAL = 5 * MINUTES;
async function cleanup() {
await cleanupUsernames();
setTimeout(cleanup, CLEANUP_INTERVAL);
}
if (!isAPI()) {
const CLEANUP_INTERVAL = 5 * MINUTES;
async function cleanup() {
await cleanupUsernames();
setTimeout(cleanup, CLEANUP_INTERVAL);
}
// Start first cleanup 30 seconds after startup
// TODO: Move to bot startup code
setTimeout(cleanup, 30 * SECONDS);
}
@ -24,7 +26,7 @@ export class UsernameHistory extends BaseRepository {
constructor() {
super();
this.usernameHistory = getRepository(UsernameHistoryEntry);
this.usernameHistory = dataSource.getRepository(UsernameHistoryEntry);
}
async getByUserId(userId): Promise<UsernameHistoryEntry[]> {
@ -39,7 +41,7 @@ export class UsernameHistory extends BaseRepository {
});
}
getLastEntry(userId): Promise<UsernameHistoryEntry | undefined> {
getLastEntry(userId): Promise<UsernameHistoryEntry | null> {
return this.usernameHistory.findOne({
where: {
user_id: userId,

View file

@ -1,15 +1,16 @@
import { getRepository, Repository } from "typeorm";
import { VCAlert } from "./entities/VCAlert";
import { BaseRepository } from "./BaseRepository";
import moment from "moment-timezone";
import { Repository } from "typeorm";
import { DBDateFormat } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { VCAlert } from "./entities/VCAlert";
export class VCAlerts extends BaseRepository {
private allAlerts: Repository<VCAlert>;
constructor() {
super();
this.allAlerts = getRepository(VCAlert);
this.allAlerts = dataSource.getRepository(VCAlert);
}
async getSoonExpiringAlerts(threshold: number): Promise<VCAlert[]> {

View file

@ -1,10 +1,11 @@
import { getRepository, Repository } from "typeorm";
import { Webhook } from "./entities/Webhook";
import { BaseRepository } from "./BaseRepository";
import { Repository } from "typeorm";
import { decrypt, encrypt } from "../utils/crypt";
import { BaseRepository } from "./BaseRepository";
import { dataSource } from "./dataSource";
import { Webhook } from "./entities/Webhook";
export class Webhooks extends BaseRepository {
repository: Repository<Webhook> = getRepository(Webhook);
repository: Repository<Webhook> = dataSource.getRepository(Webhook);
protected async _processEntityFromDB(entity) {
entity.token = await decrypt(entity.token);

View file

@ -19,8 +19,6 @@ export type RemoveApiPermissionEventData = {
target_id: string;
};
export type EditConfigEventData = {};
export interface AuditLogEventData extends Record<AuditLogEventType, unknown> {
ADD_API_PERMISSION: {
type: ApiPermissionTypes;
@ -41,7 +39,7 @@ export interface AuditLogEventData extends Record<AuditLogEventType, unknown> {
target_id: string;
};
EDIT_CONFIG: {};
EDIT_CONFIG: Record<string, never>;
}
export type AnyAuditLogEventData = AuditLogEventData[AuditLogEventType];

View file

@ -1,4 +1,4 @@
export function buildEntity<T extends any>(Entity: new () => T, data: Partial<T>): T {
export function buildEntity<T extends object>(Entity: new () => T, data: Partial<T>): T {
const instance = new Entity();
for (const [key, value] of Object.entries(data)) {
instance[key] = value;

View file

@ -1,13 +1,16 @@
import moment from "moment-timezone";
import { getRepository, In } from "typeorm";
import { In } from "typeorm";
import { DBDateFormat } from "../../utils";
import { connection } from "../db";
import { dataSource } from "../dataSource";
import { Config } from "../entities/Config";
const CLEAN_PER_LOOP = 50;
export async function cleanupConfigs() {
const configRepository = getRepository(Config);
const configRepository = dataSource.getRepository(Config);
// FIXME: The query below doesn't work on MySQL 8.0. Pending an update.
return;
let cleaned = 0;
let rows;
@ -15,7 +18,7 @@ export async function cleanupConfigs() {
// >1 month old: 1 config retained per month
const oneMonthCutoff = moment.utc().subtract(30, "days").format(DBDateFormat);
do {
rows = await connection.query(
rows = await dataSource.query(
`
WITH _configs
AS (
@ -53,7 +56,7 @@ export async function cleanupConfigs() {
// >2 weeks old: 1 config retained per day
const twoWeekCutoff = moment.utc().subtract(2, "weeks").format(DBDateFormat);
do {
rows = await connection.query(
rows = await dataSource.query(
`
WITH _configs
AS (

View file

@ -1,7 +1,7 @@
import moment from "moment-timezone";
import { getRepository, In } from "typeorm";
import { In } from "typeorm";
import { DAYS, DBDateFormat, MINUTES, SECONDS, sleep } from "../../utils";
import { connection } from "../db";
import { dataSource } from "../dataSource";
import { SavedMessage } from "../entities/SavedMessage";
/**
@ -16,7 +16,7 @@ const CLEAN_PER_LOOP = 100;
export async function cleanupMessages(): Promise<number> {
let cleaned = 0;
const messagesRepository = getRepository(SavedMessage);
const messagesRepository = dataSource.getRepository(SavedMessage);
const deletedAtThreshold = moment.utc().subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat);
const postedAtThreshold = moment.utc().subtract(RETENTION_PERIOD, "ms").format(DBDateFormat);
@ -27,7 +27,7 @@ export async function cleanupMessages(): Promise<number> {
// when a message was being inserted at the same time
let ids: string[];
do {
const deletedMessageRows = await connection.query(
const deletedMessageRows = await dataSource.query(
`
SELECT id
FROM messages
@ -40,7 +40,7 @@ export async function cleanupMessages(): Promise<number> {
[deletedAtThreshold],
);
const oldPostedRows = await connection.query(
const oldPostedRows = await dataSource.query(
`
SELECT id
FROM messages
@ -53,7 +53,7 @@ export async function cleanupMessages(): Promise<number> {
[postedAtThreshold],
);
const oldBotPostedRows = await connection.query(
const oldBotPostedRows = await dataSource.query(
`
SELECT id
FROM messages

View file

@ -1,7 +1,7 @@
import moment from "moment-timezone";
import { getRepository, In } from "typeorm";
import { In } from "typeorm";
import { DAYS, DBDateFormat } from "../../utils";
import { connection } from "../db";
import { dataSource } from "../dataSource";
import { NicknameHistoryEntry } from "../entities/NicknameHistoryEntry";
export const NICKNAME_RETENTION_PERIOD = 30 * DAYS;
@ -10,13 +10,13 @@ const CLEAN_PER_LOOP = 500;
export async function cleanupNicknames(): Promise<number> {
let cleaned = 0;
const nicknameHistoryRepository = getRepository(NicknameHistoryEntry);
const nicknameHistoryRepository = dataSource.getRepository(NicknameHistoryEntry);
const dateThreshold = moment.utc().subtract(NICKNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
// Clean old nicknames (NICKNAME_RETENTION_PERIOD)
let rows;
do {
rows = await connection.query(
rows = await dataSource.query(
`
SELECT id
FROM nickname_history

View file

@ -1,7 +1,7 @@
import moment from "moment-timezone";
import { getRepository, In } from "typeorm";
import { In } from "typeorm";
import { DAYS, DBDateFormat } from "../../utils";
import { connection } from "../db";
import { dataSource } from "../dataSource";
import { UsernameHistoryEntry } from "../entities/UsernameHistoryEntry";
export const USERNAME_RETENTION_PERIOD = 30 * DAYS;
@ -10,13 +10,13 @@ const CLEAN_PER_LOOP = 500;
export async function cleanupUsernames(): Promise<number> {
let cleaned = 0;
const usernameHistoryRepository = getRepository(UsernameHistoryEntry);
const usernameHistoryRepository = dataSource.getRepository(UsernameHistoryEntry);
const dateThreshold = moment.utc().subtract(USERNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
// Clean old usernames (USERNAME_RETENTION_PERIOD)
let rows;
do {
rows = await connection.query(
rows = await dataSource.query(
`
SELECT id
FROM username_history

View file

@ -0,0 +1,45 @@
import moment from "moment-timezone";
import path from "path";
import { DataSource } from "typeorm";
import { env } from "../env";
import { backendDir } from "../paths";
moment.tz.setDefault("UTC");
const entities = path.relative(process.cwd(), path.resolve(backendDir, "dist/backend/src/data/entities/*.js"));
const migrations = path.relative(process.cwd(), path.resolve(backendDir, "dist/backend/src/migrations/*.js"));
export const dataSource = new DataSource({
type: "mysql",
host: env.DB_HOST,
port: env.DB_PORT,
username: env.DB_USER,
password: env.DB_PASSWORD,
database: env.DB_DATABASE,
charset: "utf8mb4",
supportBigNumbers: true,
bigNumberStrings: true,
dateStrings: true,
synchronize: false,
connectTimeout: 2000,
logging: ["error", "warn"],
// Entities
entities: [entities],
// Pool options
extra: {
typeCast(field, next) {
if (field.type === "DATETIME") {
const val = field.string();
return val != null ? moment.utc(val).format("YYYY-MM-DD HH:mm:ss") : null;
}
return next();
},
},
// Migrations
migrations: [migrations],
});

View file

@ -1,28 +1,15 @@
import { Connection, createConnection } from "typeorm";
import { SimpleError } from "../SimpleError";
import connectionOptions from "../../ormconfig";
import { QueryLogger } from "./queryLogger";
import { dataSource } from "./dataSource";
let connectionPromise: Promise<Connection>;
export let connection: Connection;
let connectionPromise: Promise<void>;
export function connect() {
if (!connectionPromise) {
connectionPromise = createConnection({
...(connectionOptions as any),
logging: ["query", "error"],
logger: new QueryLogger(),
}).then((newConnection) => {
// Verify the DB timezone is set to UTC
return newConnection.query("SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP) AS tz").then((r) => {
if (r[0].tz !== "00:00:00") {
throw new SimpleError(`Database timezone must be UTC (detected ${r[0].tz})`);
}
connection = newConnection;
return newConnection;
});
connectionPromise = dataSource.initialize().then(async (initializedDataSource) => {
const tzResult = await initializedDataSource.query("SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP) AS tz");
if (tzResult[0].tz !== "00:00:00") {
throw new SimpleError(`Database timezone must be UTC (detected ${tzResult[0].tz})`);
}
});
}

View file

@ -1,5 +1,4 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm";
import { ApiUserInfo } from "./ApiUserInfo";
import { Column, Entity, PrimaryColumn } from "typeorm";
import { AuditLogEventData, AuditLogEventType } from "../apiAuditLogTypes";
@Entity("api_audit_log")

View file

@ -19,7 +19,7 @@ export class ApiLogin {
@Column()
expires_at: string;
@ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.logins)
@ManyToOne(() => ApiUserInfo, (userInfo) => userInfo.logins)
@JoinColumn({ name: "user_id" })
userInfo: ApiUserInfo;
}

View file

@ -1,6 +1,6 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm";
import { ApiUserInfo } from "./ApiUserInfo";
import { ApiPermissionTypes } from "../ApiPermissionAssignments";
import { ApiUserInfo } from "./ApiUserInfo";
@Entity("api_permissions")
export class ApiPermissionAssignment {
@ -22,7 +22,7 @@ export class ApiPermissionAssignment {
@Column({ type: String, nullable: true })
expires_at: string | null;
@ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.permissionAssignments)
@ManyToOne(() => ApiUserInfo, (userInfo) => userInfo.permissionAssignments)
@JoinColumn({ name: "target_id" })
userInfo: ApiUserInfo;
}

View file

@ -20,9 +20,9 @@ export class ApiUserInfo {
@Column()
updated_at: string;
@OneToMany((type) => ApiLogin, (login) => login.userInfo)
@OneToMany(() => ApiLogin, (login) => login.userInfo)
logins: ApiLogin[];
@OneToMany((type) => ApiPermissionAssignment, (p) => p.userInfo)
@OneToMany(() => ApiPermissionAssignment, (p) => p.userInfo)
permissionAssignments: ApiPermissionAssignment[];
}

View file

@ -35,6 +35,6 @@ export class Case {
*/
@Column({ type: String, nullable: true }) log_message_id: string | null;
@OneToMany((type) => CaseNote, (note) => note.case)
@OneToMany(() => CaseNote, (note) => note.case)
notes: CaseNote[];
}

View file

@ -15,7 +15,7 @@ export class CaseNote {
@Column() created_at: string;
@ManyToOne((type) => Case, (theCase) => theCase.notes)
@ManyToOne(() => Case, (theCase) => theCase.notes)
@JoinColumn({ name: "case_id" })
case: Case;
}

View file

@ -22,7 +22,7 @@ export class Config {
@Column()
edited_at: string;
@ManyToOne((type) => ApiUserInfo)
@ManyToOne(() => ApiUserInfo)
@JoinColumn({ name: "edited_by" })
userInfo: ApiUserInfo;
}

View file

@ -2,7 +2,7 @@ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
export const TRIGGER_COMPARISON_OPS = ["=", "!=", ">", "<", ">=", "<="] as const;
export type TriggerComparisonOp = typeof TRIGGER_COMPARISON_OPS[number];
export type TriggerComparisonOp = (typeof TRIGGER_COMPARISON_OPS)[number];
const REVERSE_OPS: Record<TriggerComparisonOp, TriggerComparisonOp> = {
"=": "!=",

View file

@ -0,0 +1,20 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("member_cache")
export class MemberCacheItem {
@PrimaryGeneratedColumn() id: number;
@Column() guild_id: string;
@Column() user_id: string;
@Column() username: string;
@Column({ type: String, nullable: true }) nickname: string | null;
@Column("simple-json") roles: string[];
@Column() last_seen: string;
@Column({ type: String, nullable: true }) delete_at: string | null;
}

View file

@ -10,6 +10,8 @@ export class Mute {
@PrimaryColumn()
user_id: string;
@Column() type: number;
@Column() created_at: string;
@Column({ type: String, nullable: true }) expires_at: string | null;
@ -17,4 +19,8 @@ export class Mute {
@Column() case_id: number;
@Column("simple-array") roles_to_restore: string[];
@Column({ type: String, nullable: true }) mute_role: string | null;
@Column({ type: String, nullable: true }) timeout_expires_at: string | null;
}

View file

@ -14,5 +14,5 @@ export class PersistedData {
@Column() nickname: string;
@Column() is_voice_muted: number;
@Column({ type: "boolean" }) is_voice_muted: boolean;
}

View file

@ -1,4 +1,4 @@
import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm";
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("reminders")
export class Reminder {

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