mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-05-10 04:25:01 +00:00
Reorganize project. Add folder for shared code between backend/dashboard. Switch from jest to ava for tests.
This commit is contained in:
parent
80a82fe348
commit
16111bbe84
162 changed files with 11056 additions and 9900 deletions
3
backend/.gitignore
vendored
Normal file
3
backend/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/.cache
|
||||
/dist
|
||||
/node_modules
|
9
backend/api.env.example
Normal file
9
backend/api.env.example
Normal file
|
@ -0,0 +1,9 @@
|
|||
PORT=
|
||||
CLIENT_ID=
|
||||
CLIENT_SECRET=
|
||||
OAUTH_CALLBACK_URL=
|
||||
DASHBOARD_URL=
|
||||
DB_HOST=
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=
|
5
backend/bot.env.example
Normal file
5
backend/bot.env.example
Normal file
|
@ -0,0 +1,5 @@
|
|||
TOKEN=
|
||||
DB_HOST=
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=
|
5
backend/nodemon-api.json
Normal file
5
backend/nodemon-api.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"watch": "src",
|
||||
"ext": "ts",
|
||||
"exec": "node -r ts-node/register ./src/api/index.ts"
|
||||
}
|
5
backend/nodemon-bot.json
Normal file
5
backend/nodemon-bot.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"watch": "src",
|
||||
"ext": "ts",
|
||||
"exec": "node -r ts-node/register ./src/index.ts"
|
||||
}
|
60
backend/ormconfig.js
Normal file
60
backend/ormconfig.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
try {
|
||||
fs.accessSync(path.resolve(__dirname, 'bot.env'));
|
||||
require('dotenv').config({ path: path.resolve(__dirname, 'bot.env') });
|
||||
} catch (e) {
|
||||
try {
|
||||
fs.accessSync(path.resolve(__dirname, 'api.env'));
|
||||
require('dotenv').config({ path: path.resolve(__dirname, 'api.env') });
|
||||
} catch (e) {
|
||||
throw new Error("bot.env or api.env required");
|
||||
}
|
||||
}
|
||||
|
||||
const moment = require('moment-timezone');
|
||||
moment.tz.setDefault('UTC');
|
||||
|
||||
const entities = process.env.NODE_ENV === 'production'
|
||||
? path.relative(process.cwd(), path.resolve(__dirname, 'dist/data/entities/*.js'))
|
||||
: path.relative(process.cwd(), path.resolve(__dirname, 'src/data/entities/*.ts'));
|
||||
|
||||
const migrations = process.env.NODE_ENV === 'production'
|
||||
? path.relative(process.cwd(), path.resolve(__dirname, 'dist/migrations/*.js'))
|
||||
: path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations/*.ts'));
|
||||
|
||||
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,
|
||||
|
||||
// Entities
|
||||
entities: [entities],
|
||||
|
||||
// Pool options
|
||||
extra: {
|
||||
typeCast(field, next) {
|
||||
if (field.type === 'DATETIME') {
|
||||
const val = field.string();
|
||||
return val != null ? moment(val).format('YYYY-MM-DD HH:mm:ss') : null;
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
},
|
||||
|
||||
// Migrations
|
||||
migrations: [migrations],
|
||||
cli: {
|
||||
migrationsDir: path.dirname(migrations)
|
||||
},
|
||||
};
|
6866
backend/package-lock.json
generated
Normal file
6866
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
83
backend/package.json
Normal file
83
backend/package.json
Normal file
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"name": "@zeppelin/backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-bot-dev": "node -r ts-node/register src/index.ts",
|
||||
"start-bot-prod": "cross-env NODE_ENV=production node dist/index.js",
|
||||
"watch-bot": "nodemon --config nodemon-bot.json",
|
||||
"build": "rimraf dist && tsc",
|
||||
"start-api-dev": "node -r ts-node/register src/api/index.ts",
|
||||
"start-api-prod": "cross-env NODE_ENV=production node dist/api/index.js",
|
||||
"watch-api": "nodemon --config nodemon-api.json",
|
||||
"format": "prettier --write \"./src/**/*.ts\"",
|
||||
"typeorm": "node -r ts-node/register ./node_modules/typeorm/cli.js",
|
||||
"migrate": "npm run typeorm -- migration:run",
|
||||
"migrate-rollback": "npm run typeorm -- migration:revert",
|
||||
"test": "ava"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^5.2.0",
|
||||
"deep-diff": "^1.0.2",
|
||||
"dotenv": "^4.0.0",
|
||||
"emoji-regex": "^8.0.0",
|
||||
"eris": "^0.11.0",
|
||||
"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": "^26.0.2",
|
||||
"knub-command-manager": "^6.1.0",
|
||||
"last-commit-log": "^2.1.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"lodash.intersection": "^4.4.0",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.pick": "^4.4.0",
|
||||
"moment-timezone": "^0.5.21",
|
||||
"mysql": "^2.16.0",
|
||||
"passport": "^0.4.0",
|
||||
"passport-custom": "^1.0.5",
|
||||
"passport-oauth2": "^1.5.0",
|
||||
"reflect-metadata": "^0.1.12",
|
||||
"safe-regex": "^2.0.2",
|
||||
"seedrandom": "^3.0.1",
|
||||
"tlds": "^1.203.1",
|
||||
"tmp": "0.0.33",
|
||||
"typeorm": "^0.2.14",
|
||||
"uuid": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.5",
|
||||
"@types/express": "^4.16.1",
|
||||
"@types/jest": "^24.0.15",
|
||||
"@types/js-yaml": "^3.12.1",
|
||||
"@types/lodash.at": "^4.6.3",
|
||||
"@types/moment-timezone": "^0.5.6",
|
||||
"@types/node": "^12.7.5",
|
||||
"@types/passport": "^1.0.0",
|
||||
"@types/passport-oauth2": "^1.4.8",
|
||||
"@types/passport-strategy": "^0.2.35",
|
||||
"@types/tmp": "0.0.33",
|
||||
"ava": "^2.4.0",
|
||||
"nodemon": "^1.19.4",
|
||||
"rimraf": "^2.6.2",
|
||||
"ts-node": "^8.4.1",
|
||||
"typescript": "^3.6.4"
|
||||
},
|
||||
"ava": {
|
||||
"compileEnhancements": false,
|
||||
"files": [
|
||||
"src/**/*.test.ts"
|
||||
],
|
||||
"extensions": ["ts"],
|
||||
"require": [
|
||||
"ts-node/register"
|
||||
]
|
||||
}
|
||||
}
|
21
backend/src/PluginRuntimeError.ts
Normal file
21
backend/src/PluginRuntimeError.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import util from "util";
|
||||
|
||||
export class PluginRuntimeError {
|
||||
public message: string;
|
||||
public pluginName: string;
|
||||
public guildId: string;
|
||||
|
||||
constructor(message: string, pluginName: string, guildId: string) {
|
||||
this.message = message;
|
||||
this.pluginName = pluginName;
|
||||
this.guildId = guildId;
|
||||
}
|
||||
|
||||
[util.inspect.custom](depth?, options?) {
|
||||
return `PRE [${this.pluginName}] [${this.guildId}] ${this.message}`;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this[util.inspect.custom]();
|
||||
}
|
||||
}
|
44
backend/src/Queue.ts
Normal file
44
backend/src/Queue.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { SECONDS } from "./utils";
|
||||
|
||||
type QueueFn = (...args: any[]) => Promise<any>;
|
||||
|
||||
const DEFAULT_TIMEOUT = 10 * SECONDS;
|
||||
|
||||
export class Queue {
|
||||
protected running: boolean = false;
|
||||
protected queue: QueueFn[] = [];
|
||||
protected timeout: number;
|
||||
|
||||
constructor(timeout = DEFAULT_TIMEOUT) {
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public add(fn) {
|
||||
const promise = new Promise(resolve => {
|
||||
this.queue.push(async () => {
|
||||
await fn();
|
||||
resolve();
|
||||
});
|
||||
|
||||
if (!this.running) this.next();
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
public next() {
|
||||
this.running = true;
|
||||
|
||||
if (this.queue.length === 0) {
|
||||
this.running = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const fn = this.queue.shift();
|
||||
new Promise(resolve => {
|
||||
// Either fn() completes or the timeout is reached
|
||||
fn().then(resolve);
|
||||
setTimeout(resolve, this.timeout);
|
||||
}).then(() => this.next());
|
||||
}
|
||||
}
|
51
backend/src/QueuedEventEmitter.ts
Normal file
51
backend/src/QueuedEventEmitter.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { Queue } from "./Queue";
|
||||
|
||||
type Listener = (...args: any[]) => void;
|
||||
|
||||
export class QueuedEventEmitter {
|
||||
protected listeners: Map<string, Listener[]>;
|
||||
protected queue: Queue;
|
||||
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
this.queue = new Queue();
|
||||
}
|
||||
|
||||
on(eventName: string, listener: Listener): Listener {
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, []);
|
||||
}
|
||||
|
||||
this.listeners.get(eventName).push(listener);
|
||||
return listener;
|
||||
}
|
||||
|
||||
off(eventName: string, listener: Listener) {
|
||||
if (!this.listeners.has(eventName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listeners = this.listeners.get(eventName);
|
||||
listeners.splice(listeners.indexOf(listener), 1);
|
||||
}
|
||||
|
||||
once(eventName: string, listener: Listener): Listener {
|
||||
const handler = this.on(eventName, (...args) => {
|
||||
const result = listener(...args);
|
||||
this.off(eventName, handler);
|
||||
return result;
|
||||
});
|
||||
return handler;
|
||||
}
|
||||
|
||||
emit(eventName: string, args: any[] = []): Promise<void> {
|
||||
const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])];
|
||||
|
||||
let promise: Promise<any> = Promise.resolve();
|
||||
listeners.forEach(listener => {
|
||||
promise = this.queue.add(listener.bind(null, ...args));
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
69
backend/src/SimpleCache.ts
Normal file
69
backend/src/SimpleCache.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import Timeout = NodeJS.Timeout;
|
||||
|
||||
const CLEAN_INTERVAL = 1000;
|
||||
|
||||
export class SimpleCache<T = any> {
|
||||
protected readonly retentionTime: number;
|
||||
protected readonly maxItems: number;
|
||||
|
||||
protected cleanTimeout: Timeout;
|
||||
protected unloaded: boolean;
|
||||
|
||||
protected store: Map<string, { remove_at: number; value: T }>;
|
||||
|
||||
constructor(retentionTime: number, maxItems?: number) {
|
||||
this.retentionTime = retentionTime;
|
||||
this.maxItems = maxItems;
|
||||
|
||||
this.store = new Map();
|
||||
}
|
||||
|
||||
unload() {
|
||||
this.unloaded = true;
|
||||
clearTimeout(this.cleanTimeout);
|
||||
}
|
||||
|
||||
cleanLoop() {
|
||||
const now = Date.now();
|
||||
for (const [key, info] of this.store.entries()) {
|
||||
if (now >= info.remove_at) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.unloaded) {
|
||||
this.cleanTimeout = setTimeout(() => this.cleanLoop(), CLEAN_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
set(key: string, value: T) {
|
||||
this.store.set(key, {
|
||||
remove_at: Date.now() + this.retentionTime,
|
||||
value,
|
||||
});
|
||||
|
||||
if (this.maxItems && this.store.size > this.maxItems) {
|
||||
const keyToDelete = this.store.keys().next().value;
|
||||
this.store.delete(keyToDelete);
|
||||
}
|
||||
}
|
||||
|
||||
get(key: string): T {
|
||||
const info = this.store.get(key);
|
||||
if (!info) return null;
|
||||
|
||||
return info.value;
|
||||
}
|
||||
|
||||
has(key: string) {
|
||||
return this.store.has(key);
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
13
backend/src/SimpleError.ts
Normal file
13
backend/src/SimpleError.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import util from "util";
|
||||
|
||||
export class SimpleError {
|
||||
public message: string;
|
||||
|
||||
constructor(message: string) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
[util.inspect.custom](depth, options) {
|
||||
return `Error: ${this.message}`;
|
||||
}
|
||||
}
|
34
backend/src/api/archives.ts
Normal file
34
backend/src/api/archives.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import express, { Request, Response } from "express";
|
||||
import { GuildArchives } from "../data/GuildArchives";
|
||||
import { notFound } from "./responses";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
export function initArchives(app: express.Express) {
|
||||
const archives = new GuildArchives(null);
|
||||
|
||||
// Legacy redirect
|
||||
app.get("/spam-logs/:id", (req: Request, res: Response) => {
|
||||
res.redirect("/archives/" + req.params.id);
|
||||
});
|
||||
|
||||
app.get("/archives/:id", async (req: Request, res: Response) => {
|
||||
const archive = await archives.find(req.params.id);
|
||||
if (!archive) return notFound(res);
|
||||
|
||||
let body = archive.body;
|
||||
|
||||
// Add some metadata at the end of the log file (but only if it doesn't already have it directly in the body)
|
||||
if (archive.body.indexOf("Log file generated on") === -1) {
|
||||
const createdAt = moment(archive.created_at).format("YYYY-MM-DD [at] HH:mm:ss [(+00:00)]");
|
||||
body += `\n\nLog file generated on ${createdAt}`;
|
||||
|
||||
if (archive.expires_at !== null) {
|
||||
const expiresAt = moment(archive.expires_at).format("YYYY-MM-DD [at] HH:mm:ss [(+00:00)]");
|
||||
body += `\nExpires at ${expiresAt}`;
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "text/plain; charset=UTF-8");
|
||||
res.end(body);
|
||||
});
|
||||
}
|
162
backend/src/api/auth.ts
Normal file
162
backend/src/api/auth.ts
Normal file
|
@ -0,0 +1,162 @@
|
|||
import express, { Request, Response } from "express";
|
||||
import passport, { Strategy } from "passport";
|
||||
import OAuth2Strategy from "passport-oauth2";
|
||||
import { Strategy as CustomStrategy } from "passport-custom";
|
||||
import { ApiLogins } from "../data/ApiLogins";
|
||||
import pick from "lodash.pick";
|
||||
import https from "https";
|
||||
import { ApiUserInfo } from "../data/ApiUserInfo";
|
||||
import { ApiUserInfoData } from "../data/entities/ApiUserInfo";
|
||||
import { ApiPermissions } from "../data/ApiPermissions";
|
||||
import { ok } from "./responses";
|
||||
|
||||
interface IPassportApiUser {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
// tslint:disable-next-line:no-empty-interface
|
||||
interface User extends IPassportApiUser {}
|
||||
}
|
||||
}
|
||||
|
||||
const DISCORD_API_URL = "https://discordapp.com/api";
|
||||
|
||||
function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(
|
||||
`${DISCORD_API_URL}/${path}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
},
|
||||
},
|
||||
res => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Discord API error ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
res.on("data", data => resolve(JSON.parse(data)));
|
||||
},
|
||||
);
|
||||
|
||||
request.on("error", err => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
const apiLogins = new ApiLogins();
|
||||
const apiUserInfo = new ApiUserInfo();
|
||||
const apiPermissions = new ApiPermissions();
|
||||
|
||||
// Initialize API tokens
|
||||
passport.use(
|
||||
"api-token",
|
||||
new CustomStrategy(async (req, cb) => {
|
||||
const apiKey = req.header("X-Api-Key");
|
||||
if (!apiKey) return cb("API key missing");
|
||||
|
||||
const userId = await apiLogins.getUserIdByApiKey(apiKey);
|
||||
if (userId) {
|
||||
return cb(null, { apiKey, userId });
|
||||
}
|
||||
|
||||
cb("API key not found");
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize OAuth2 for Discord login
|
||||
// When the user logs in through OAuth2, we create them a "login" (= api token) and update their user info in the DB
|
||||
passport.use(
|
||||
new OAuth2Strategy(
|
||||
{
|
||||
authorizationURL: "https://discordapp.com/api/oauth2/authorize",
|
||||
tokenURL: "https://discordapp.com/api/oauth2/token",
|
||||
clientID: process.env.CLIENT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
callbackURL: process.env.OAUTH_CALLBACK_URL,
|
||||
scope: ["identify"],
|
||||
},
|
||||
async (accessToken, refreshToken, profile, cb) => {
|
||||
const user = await simpleDiscordAPIRequest(accessToken, "users/@me");
|
||||
|
||||
// Make sure the user is able to access at least 1 guild
|
||||
const permissions = await apiPermissions.getByUserId(user.id);
|
||||
if (permissions.length === 0) {
|
||||
cb(null, {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate API key
|
||||
const apiKey = await apiLogins.addLogin(user.id);
|
||||
const userData = pick(user, ["username", "discriminator", "avatar"]) as ApiUserInfoData;
|
||||
await apiUserInfo.update(user.id, userData);
|
||||
// TODO: Revoke access token, we don't need it anymore
|
||||
cb(null, { apiKey });
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.get("/auth/login", passport.authenticate("oauth2"));
|
||||
app.get(
|
||||
"/auth/oauth-callback",
|
||||
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}`);
|
||||
} else {
|
||||
res.redirect(`${process.env.DASHBOARD_URL}/login-callback/?error=noAccess`);
|
||||
}
|
||||
},
|
||||
);
|
||||
app.post("/auth/validate-key", async (req: Request, res: Response) => {
|
||||
const key = req.body.key;
|
||||
if (!key) {
|
||||
return res.status(400).json({ error: "No key supplied" });
|
||||
}
|
||||
|
||||
const userId = await apiLogins.getUserIdByApiKey(key);
|
||||
if (!userId) {
|
||||
return res.json({ valid: false });
|
||||
}
|
||||
|
||||
res.json({ valid: true });
|
||||
});
|
||||
app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => {
|
||||
await apiLogins.expireApiKey(req.user.apiKey);
|
||||
return ok(res);
|
||||
});
|
||||
}
|
||||
|
||||
export function apiTokenAuthHandlers() {
|
||||
return [
|
||||
passport.authenticate("api-token", { failWithError: true }),
|
||||
(err, req: Request, res: Response, next) => {
|
||||
return res.json({ error: err.message });
|
||||
},
|
||||
];
|
||||
}
|
90
backend/src/api/docs.ts
Normal file
90
backend/src/api/docs.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import express from "express";
|
||||
import { availablePlugins } from "../plugins/availablePlugins";
|
||||
import { ZeppelinPlugin } from "../plugins/ZeppelinPlugin";
|
||||
import { notFound } from "./responses";
|
||||
import { dropPropertiesByName, indentLines } from "../utils";
|
||||
import { IPluginCommandConfig, Plugin, pluginUtils } from "knub";
|
||||
import { parseParameters } from "knub-command-manager";
|
||||
|
||||
function formatConfigSchema(schema) {
|
||||
if (schema._tag === "InterfaceType" || schema._tag === "PartialType") {
|
||||
return (
|
||||
`{\n` +
|
||||
Object.entries(schema.props)
|
||||
.map(([k, value]) => indentLines(`${k}: ${formatConfigSchema(value)}`, 2))
|
||||
.join("\n") +
|
||||
"\n}"
|
||||
);
|
||||
} else if (schema._tag === "DictionaryType") {
|
||||
return "{\n" + indentLines(`[string]: ${formatConfigSchema(schema.codomain)}`, 2) + "\n}";
|
||||
} else if (schema._tag === "ArrayType") {
|
||||
return `Array<${formatConfigSchema(schema.type)}>`;
|
||||
} else if (schema._tag === "UnionType") {
|
||||
if (schema.name.startsWith("Nullable<")) {
|
||||
return `Nullable<${formatConfigSchema(schema.types[0])}>`;
|
||||
} else {
|
||||
return schema.types.map(t => formatConfigSchema(t)).join(" | ");
|
||||
}
|
||||
} else if (schema._tag === "IntersectionType") {
|
||||
return schema.types.map(t => formatConfigSchema(t)).join(" & ");
|
||||
} else {
|
||||
return schema.name;
|
||||
}
|
||||
}
|
||||
|
||||
export function initDocs(app: express.Express) {
|
||||
const docsPlugins = availablePlugins.filter(pluginClass => pluginClass.showInDocs);
|
||||
|
||||
app.get("/docs/plugins", (req: express.Request, res: express.Response) => {
|
||||
res.json(
|
||||
docsPlugins.map(pluginClass => {
|
||||
const thinInfo = pluginClass.pluginInfo ? { prettyName: pluginClass.pluginInfo.prettyName } : {};
|
||||
return {
|
||||
name: pluginClass.pluginName,
|
||||
info: thinInfo,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/docs/plugins/:pluginName", (req: express.Request, res: express.Response) => {
|
||||
const pluginClass = docsPlugins.find(obj => obj.pluginName === req.params.pluginName);
|
||||
if (!pluginClass) {
|
||||
return notFound(res);
|
||||
}
|
||||
|
||||
const decoratorCommands = pluginUtils.getPluginDecoratorCommands(pluginClass as typeof Plugin) || [];
|
||||
const commands = decoratorCommands.map(cmd => {
|
||||
const trigger = typeof cmd.trigger === "string" ? cmd.trigger : cmd.trigger.source;
|
||||
const parameters = cmd.parameters
|
||||
? typeof cmd.parameters === "string"
|
||||
? parseParameters(cmd.parameters)
|
||||
: cmd.parameters
|
||||
: [];
|
||||
const config: IPluginCommandConfig = cmd.config || {};
|
||||
if (config.overloads) {
|
||||
config.overloads = config.overloads.map(overload => {
|
||||
return typeof overload === "string" ? parseParameters(overload) : overload;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
trigger,
|
||||
parameters,
|
||||
config,
|
||||
};
|
||||
});
|
||||
|
||||
const defaultOptions = (pluginClass as typeof ZeppelinPlugin).getStaticDefaultOptions();
|
||||
|
||||
const configSchema = pluginClass.configSchema && formatConfigSchema(pluginClass.configSchema);
|
||||
|
||||
res.json({
|
||||
name: pluginClass.pluginName,
|
||||
info: pluginClass.pluginInfo || {},
|
||||
configSchema,
|
||||
defaultOptions,
|
||||
commands,
|
||||
});
|
||||
});
|
||||
}
|
69
backend/src/api/guilds.ts
Normal file
69
backend/src/api/guilds.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import express from "express";
|
||||
import passport from "passport";
|
||||
import { AllowedGuilds } from "../data/AllowedGuilds";
|
||||
import { ApiPermissions } from "../data/ApiPermissions";
|
||||
import { clientError, error, ok, serverError, unauthorized } from "./responses";
|
||||
import { Configs } from "../data/Configs";
|
||||
import { ApiRoles } from "../data/ApiRoles";
|
||||
import { validateGuildConfig } from "../configValidator";
|
||||
import yaml, { YAMLException } from "js-yaml";
|
||||
import { apiTokenAuthHandlers } from "./auth";
|
||||
|
||||
export function initGuildsAPI(app: express.Express) {
|
||||
const allowedGuilds = new AllowedGuilds();
|
||||
const apiPermissions = new ApiPermissions();
|
||||
const configs = new Configs();
|
||||
|
||||
app.get("/guilds/available", ...apiTokenAuthHandlers(), async (req, res) => {
|
||||
const guilds = await allowedGuilds.getForApiUser(req.user.userId);
|
||||
res.json(guilds);
|
||||
});
|
||||
|
||||
app.get("/guilds/:guildId/config", ...apiTokenAuthHandlers(), async (req, res) => {
|
||||
const permissions = await apiPermissions.getByGuildAndUserId(req.params.guildId, req.user.userId);
|
||||
if (!permissions) return unauthorized(res);
|
||||
|
||||
const config = await configs.getActiveByKey(`guild-${req.params.guildId}`);
|
||||
res.json({ config: config ? config.config : "" });
|
||||
});
|
||||
|
||||
app.post("/guilds/:guildId/config", ...apiTokenAuthHandlers(), async (req, res) => {
|
||||
const permissions = await apiPermissions.getByGuildAndUserId(req.params.guildId, req.user.userId);
|
||||
if (!permissions || ApiRoles[permissions.role] < ApiRoles.Editor) return unauthorized(res);
|
||||
|
||||
let config = req.body.config;
|
||||
if (config == null) return clientError(res, "No config supplied");
|
||||
|
||||
config = config.trim() + "\n"; // Normalize start/end whitespace in the config
|
||||
|
||||
const currentConfig = await configs.getActiveByKey(`guild-${req.params.guildId}`);
|
||||
if (config === currentConfig.config) {
|
||||
return ok(res);
|
||||
}
|
||||
|
||||
// Validate config
|
||||
let parsedConfig;
|
||||
try {
|
||||
parsedConfig = yaml.safeLoad(config);
|
||||
} catch (e) {
|
||||
if (e instanceof YAMLException) {
|
||||
return res.status(400).json({ errors: [e.message] });
|
||||
}
|
||||
|
||||
console.error("Error when loading YAML: " + e.message);
|
||||
return serverError(res, "Server error");
|
||||
}
|
||||
|
||||
if (parsedConfig == null) {
|
||||
parsedConfig = {};
|
||||
}
|
||||
|
||||
const errors = validateGuildConfig(parsedConfig);
|
||||
if (errors) {
|
||||
return res.status(422).json({ errors });
|
||||
}
|
||||
|
||||
await configs.saveNewRevision(`guild-${req.params.guildId}`, config, req.user.userId);
|
||||
ok(res);
|
||||
});
|
||||
}
|
61
backend/src/api/index.ts
Normal file
61
backend/src/api/index.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { clientError, error, notFound } from "./responses";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { initAuth } from "./auth";
|
||||
import { initGuildsAPI } from "./guilds";
|
||||
import { initArchives } from "./archives";
|
||||
import { initDocs } from "./docs";
|
||||
import { connect } from "../data/db";
|
||||
import path from "path";
|
||||
import { TokenError } from "passport-oauth2";
|
||||
import { PluginError } from "knub";
|
||||
|
||||
require("dotenv").config({ path: path.resolve(__dirname, "..", "..", "api.env") });
|
||||
|
||||
function errorHandler(err) {
|
||||
console.error(err.stack || err); // tslint:disable-line:no-console
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", errorHandler);
|
||||
|
||||
console.log("Connecting to database..."); // tslint:disable-line
|
||||
connect().then(() => {
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.DASHBOARD_URL,
|
||||
}),
|
||||
);
|
||||
app.use(express.json());
|
||||
|
||||
initAuth(app);
|
||||
initGuildsAPI(app);
|
||||
initArchives(app);
|
||||
initDocs(app);
|
||||
|
||||
// Default route
|
||||
app.get("/", (req, res) => {
|
||||
res.json({ status: "cookies", with: "milk" });
|
||||
});
|
||||
|
||||
// Error response
|
||||
app.use((err, req, res, next) => {
|
||||
if (err instanceof TokenError) {
|
||||
clientError(res, "Invalid code");
|
||||
} else {
|
||||
console.error(err); // tslint:disable-line
|
||||
error(res, "Server error", err.status || 500);
|
||||
}
|
||||
});
|
||||
|
||||
// 404 response
|
||||
app.use((req, res, next) => {
|
||||
return notFound(res);
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
// tslint:disable-next-line
|
||||
app.listen(port, () => console.log(`API server listening on port ${port}`));
|
||||
});
|
25
backend/src/api/responses.ts
Normal file
25
backend/src/api/responses.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Response } from "express";
|
||||
|
||||
export function unauthorized(res: Response) {
|
||||
res.status(403).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
export function error(res: Response, message: string, statusCode: number = 500) {
|
||||
res.status(statusCode).json({ error: message });
|
||||
}
|
||||
|
||||
export function serverError(res: Response, message = "Server error") {
|
||||
error(res, message, 500);
|
||||
}
|
||||
|
||||
export function clientError(res: Response, message: string) {
|
||||
error(res, message, 400);
|
||||
}
|
||||
|
||||
export function notFound(res: Response) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
export function ok(res: Response) {
|
||||
res.json({ result: "ok" });
|
||||
}
|
51
backend/src/configValidator.ts
Normal file
51
backend/src/configValidator.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import * as t from "io-ts";
|
||||
import { IPluginOptions } from "knub";
|
||||
import { pipe } from "fp-ts/lib/pipeable";
|
||||
import { fold } from "fp-ts/lib/Either";
|
||||
import { PathReporter } from "io-ts/lib/PathReporter";
|
||||
import { availablePlugins } from "./plugins/availablePlugins";
|
||||
import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
|
||||
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
|
||||
|
||||
const pluginNameToClass = new Map<string, typeof ZeppelinPlugin>();
|
||||
for (const pluginClass of availablePlugins) {
|
||||
// @ts-ignore
|
||||
pluginNameToClass.set(pluginClass.pluginName, pluginClass);
|
||||
}
|
||||
|
||||
const guildConfigRootSchema = t.type({
|
||||
prefix: t.string,
|
||||
levels: t.record(t.string, t.number),
|
||||
plugins: t.record(t.string, t.unknown),
|
||||
});
|
||||
const partialGuildConfigRootSchema = t.partial(guildConfigRootSchema.props);
|
||||
|
||||
const globalConfigRootSchema = t.type({
|
||||
url: t.string,
|
||||
owners: t.array(t.string),
|
||||
plugins: t.record(t.string, t.unknown),
|
||||
});
|
||||
|
||||
const partialMegaTest = t.partial({ name: t.string });
|
||||
|
||||
export function validateGuildConfig(config: any): string[] | null {
|
||||
const validationResult = decodeAndValidateStrict(partialGuildConfigRootSchema, config);
|
||||
if (validationResult instanceof StrictValidationError) return validationResult.getErrors();
|
||||
|
||||
if (config.plugins) {
|
||||
for (const [pluginName, pluginOptions] of Object.entries(config.plugins)) {
|
||||
if (!pluginNameToClass.has(pluginName)) {
|
||||
return [`Unknown plugin: ${pluginName}`];
|
||||
}
|
||||
|
||||
const pluginClass = pluginNameToClass.get(pluginName);
|
||||
let pluginErrors = pluginClass.validateOptions(pluginOptions);
|
||||
if (pluginErrors) {
|
||||
pluginErrors = pluginErrors.map(err => `${pluginName}: ${err}`);
|
||||
return pluginErrors;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
49
backend/src/customArgumentTypes.ts
Normal file
49
backend/src/customArgumentTypes.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
convertDelayStringToMS,
|
||||
deactivateMentions,
|
||||
disableCodeBlocks,
|
||||
resolveMember,
|
||||
resolveUser,
|
||||
UnknownUser,
|
||||
} from "./utils";
|
||||
import { Client, GuildChannel, Message } from "eris";
|
||||
import { ICommandContext, TypeConversionError } from "knub";
|
||||
|
||||
export const customArgumentTypes = {
|
||||
delay(value) {
|
||||
const result = convertDelayStringToMS(value);
|
||||
if (result == null) {
|
||||
throw new TypeConversionError(`Could not convert ${value} to a delay`);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async resolvedUser(value, context: ICommandContext) {
|
||||
const result = await resolveUser(context.bot, value);
|
||||
if (result == null || result instanceof UnknownUser) {
|
||||
throw new TypeConversionError(`User \`${disableCodeBlocks(value)}\` was not found`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async resolvedUserLoose(value, context: ICommandContext) {
|
||||
const result = await resolveUser(context.bot, value);
|
||||
if (result == null) {
|
||||
throw new TypeConversionError(`Invalid user: \`${disableCodeBlocks(value)}\``);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async resolvedMember(value, context: ICommandContext) {
|
||||
if (!(context.message.channel instanceof GuildChannel)) return null;
|
||||
|
||||
const result = await resolveMember(context.bot, context.message.channel.guild, value);
|
||||
if (result == null) {
|
||||
throw new TypeConversionError(
|
||||
`Member \`${disableCodeBlocks(value)}\` was not found or they have left the server`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
45
backend/src/data/AllowedGuilds.ts
Normal file
45
backend/src/data/AllowedGuilds.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { AllowedGuild } from "./entities/AllowedGuild";
|
||||
import {
|
||||
getConnection,
|
||||
getRepository,
|
||||
Repository,
|
||||
Transaction,
|
||||
TransactionManager,
|
||||
TransactionRepository,
|
||||
} from "typeorm";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
|
||||
export class AllowedGuilds extends BaseRepository {
|
||||
private allowedGuilds: Repository<AllowedGuild>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.allowedGuilds = getRepository(AllowedGuild);
|
||||
}
|
||||
|
||||
async isAllowed(guildId) {
|
||||
const count = await this.allowedGuilds.count({
|
||||
where: {
|
||||
id: guildId,
|
||||
},
|
||||
});
|
||||
return count !== 0;
|
||||
}
|
||||
|
||||
getForApiUser(userId) {
|
||||
return this.allowedGuilds
|
||||
.createQueryBuilder("allowed_guilds")
|
||||
.innerJoin(
|
||||
"api_permissions",
|
||||
"api_permissions",
|
||||
"api_permissions.guild_id = allowed_guilds.id AND api_permissions.user_id = :userId",
|
||||
{ userId },
|
||||
)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
updateInfo(id, name, icon, ownerId) {
|
||||
return this.allowedGuilds.update({ id }, { name, icon, owner_id: ownerId });
|
||||
}
|
||||
}
|
90
backend/src/data/ApiLogins.ts
Normal file
90
backend/src/data/ApiLogins.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { getRepository, Repository } from "typeorm";
|
||||
import { ApiLogin } from "./entities/ApiLogin";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
import crypto from "crypto";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
// tslint:disable-next-line:no-submodule-imports
|
||||
import uuidv4 from "uuid/v4";
|
||||
import { DBDateFormat } from "../utils";
|
||||
import { log } from "util";
|
||||
|
||||
export class ApiLogins extends BaseRepository {
|
||||
private apiLogins: Repository<ApiLogin>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.apiLogins = getRepository(ApiLogin);
|
||||
}
|
||||
|
||||
async getUserIdByApiKey(apiKey: string): Promise<string | null> {
|
||||
const [loginId, token] = apiKey.split(".");
|
||||
if (!loginId || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const login = await this.apiLogins
|
||||
.createQueryBuilder()
|
||||
.where("id = :id", { id: loginId })
|
||||
.andWhere("expires_at > NOW()")
|
||||
.getOne();
|
||||
|
||||
if (!login) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(loginId + token); // Remember to use loginId as the salt
|
||||
const hashedToken = hash.digest("hex");
|
||||
if (hashedToken !== login.token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return login.user_id;
|
||||
}
|
||||
|
||||
async addLogin(userId: string): Promise<string> {
|
||||
// Generate random login id
|
||||
let loginId;
|
||||
while (true) {
|
||||
loginId = uuidv4();
|
||||
const existing = await this.apiLogins.findOne({
|
||||
where: {
|
||||
id: loginId,
|
||||
},
|
||||
});
|
||||
if (!existing) break;
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = uuidv4();
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(loginId + token); // Use loginId as a salt
|
||||
const hashedToken = hash.digest("hex");
|
||||
|
||||
// Save this to the DB
|
||||
await this.apiLogins.insert({
|
||||
id: loginId,
|
||||
token: hashedToken,
|
||||
user_id: userId,
|
||||
logged_in_at: moment().format(DBDateFormat),
|
||||
expires_at: moment()
|
||||
.add(1, "day")
|
||||
.format(DBDateFormat),
|
||||
});
|
||||
|
||||
return `${loginId}.${token}`;
|
||||
}
|
||||
|
||||
expireApiKey(apiKey) {
|
||||
const [loginId, token] = apiKey.split(".");
|
||||
if (!loginId || !token) return;
|
||||
|
||||
return this.apiLogins.update(
|
||||
{ id: loginId },
|
||||
{
|
||||
expires_at: moment().format(DBDateFormat),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
29
backend/src/data/ApiPermissions.ts
Normal file
29
backend/src/data/ApiPermissions.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { getRepository, Repository } from "typeorm";
|
||||
import { ApiPermission } from "./entities/ApiPermission";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
|
||||
export class ApiPermissions extends BaseRepository {
|
||||
private apiPermissions: Repository<ApiPermission>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.apiPermissions = getRepository(ApiPermission);
|
||||
}
|
||||
|
||||
getByUserId(userId) {
|
||||
return this.apiPermissions.find({
|
||||
where: {
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getByGuildAndUserId(guildId, userId) {
|
||||
return this.apiPermissions.findOne({
|
||||
where: {
|
||||
guild_id: guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
6
backend/src/data/ApiRoles.ts
Normal file
6
backend/src/data/ApiRoles.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export enum ApiRoles {
|
||||
Viewer = 1,
|
||||
Editor,
|
||||
Manager,
|
||||
ServerOwner,
|
||||
}
|
38
backend/src/data/ApiUserInfo.ts
Normal file
38
backend/src/data/ApiUserInfo.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { getRepository, Repository } from "typeorm";
|
||||
import { ApiUserInfo as ApiUserInfoEntity, ApiUserInfoData } from "./entities/ApiUserInfo";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
import { connection } from "./db";
|
||||
import moment from "moment-timezone";
|
||||
import { DBDateFormat } from "../utils";
|
||||
|
||||
export class ApiUserInfo extends BaseRepository {
|
||||
private apiUserInfo: Repository<ApiUserInfoEntity>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.apiUserInfo = getRepository(ApiUserInfoEntity);
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this.apiUserInfo.findOne({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data: ApiUserInfoData) {
|
||||
return connection.transaction(async entityManager => {
|
||||
const repo = entityManager.getRepository(ApiUserInfoEntity);
|
||||
|
||||
const existingInfo = await repo.findOne({ where: { id } });
|
||||
const updatedAt = moment().format(DBDateFormat);
|
||||
|
||||
if (existingInfo) {
|
||||
await repo.update({ id }, { data, updated_at: updatedAt });
|
||||
} else {
|
||||
await repo.insert({ id, data, updated_at: updatedAt });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
28
backend/src/data/BaseGuildRepository.ts
Normal file
28
backend/src/data/BaseGuildRepository.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { BaseRepository } from "./BaseRepository";
|
||||
|
||||
export class BaseGuildRepository extends BaseRepository {
|
||||
private static guildInstances: Map<string, any>;
|
||||
|
||||
protected guildId: string;
|
||||
|
||||
constructor(guildId: string) {
|
||||
super();
|
||||
this.guildId = guildId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cached instance of the inheriting class for the specified guildId,
|
||||
* or creates a new instance if one doesn't exist yet
|
||||
*/
|
||||
public static getGuildInstance<T extends typeof BaseGuildRepository>(this: T, guildId: string): InstanceType<T> {
|
||||
if (!this.guildInstances) {
|
||||
this.guildInstances = new Map();
|
||||
}
|
||||
|
||||
if (!this.guildInstances.has(guildId)) {
|
||||
this.guildInstances.set(guildId, new this(guildId));
|
||||
}
|
||||
|
||||
return this.guildInstances.get(guildId) as InstanceType<T>;
|
||||
}
|
||||
}
|
30
backend/src/data/BaseRepository.ts
Normal file
30
backend/src/data/BaseRepository.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
export class BaseRepository {
|
||||
private nextRelations: string[];
|
||||
|
||||
constructor() {
|
||||
this.nextRelations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Primes the specified relation(s) to be used in the next database operation.
|
||||
* Can be chained.
|
||||
*/
|
||||
public with(relations: string | string[]): this {
|
||||
if (Array.isArray(relations)) {
|
||||
this.nextRelations.push(...relations);
|
||||
} else {
|
||||
this.nextRelations.push(relations);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets and resets the relations primed using with()
|
||||
*/
|
||||
protected getRelations(): string[] {
|
||||
const relations = this.nextRelations || [];
|
||||
this.nextRelations = [];
|
||||
return relations;
|
||||
}
|
||||
}
|
12
backend/src/data/CaseTypeColors.ts
Normal file
12
backend/src/data/CaseTypeColors.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { CaseTypes } from "./CaseTypes";
|
||||
|
||||
export const CaseTypeColors = {
|
||||
[CaseTypes.Note]: 0x3498db,
|
||||
[CaseTypes.Warn]: 0xdae622,
|
||||
[CaseTypes.Mute]: 0xe6b122,
|
||||
[CaseTypes.Unmute]: 0xa175b3,
|
||||
[CaseTypes.Kick]: 0xe67e22,
|
||||
[CaseTypes.Softban]: 0xe67e22,
|
||||
[CaseTypes.Ban]: 0xcb4314,
|
||||
[CaseTypes.Unban]: 0x9b59b6,
|
||||
};
|
11
backend/src/data/CaseTypes.ts
Normal file
11
backend/src/data/CaseTypes.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export enum CaseTypes {
|
||||
Ban = 1,
|
||||
Unban,
|
||||
Note,
|
||||
Warn,
|
||||
Kick,
|
||||
Mute,
|
||||
Unmute,
|
||||
Expunged,
|
||||
Softban,
|
||||
}
|
74
backend/src/data/Configs.ts
Normal file
74
backend/src/data/Configs.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { Config } from "./entities/Config";
|
||||
import {
|
||||
getConnection,
|
||||
getRepository,
|
||||
Repository,
|
||||
Transaction,
|
||||
TransactionManager,
|
||||
TransactionRepository,
|
||||
} from "typeorm";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { connection } from "./db";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
|
||||
export class Configs extends BaseRepository {
|
||||
private configs: Repository<Config>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.configs = getRepository(Config);
|
||||
}
|
||||
|
||||
getActiveByKey(key) {
|
||||
return this.configs.findOne({
|
||||
where: {
|
||||
key,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getHighestId(): Promise<number> {
|
||||
const rows = await connection.query("SELECT MAX(id) AS highest_id FROM configs");
|
||||
return (rows.length && rows[0].highest_id) || 0;
|
||||
}
|
||||
|
||||
getActiveLargerThanId(id) {
|
||||
return this.configs
|
||||
.createQueryBuilder()
|
||||
.where("id > :id", { id })
|
||||
.andWhere("is_active = 1")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async hasConfig(key) {
|
||||
return (await this.getActiveByKey(key)) != null;
|
||||
}
|
||||
|
||||
getRevisions(key, num = 10) {
|
||||
return this.configs.find({
|
||||
relations: this.getRelations(),
|
||||
where: { key },
|
||||
select: ["id", "key", "is_active", "edited_by", "edited_at"],
|
||||
order: {
|
||||
edited_at: "DESC",
|
||||
},
|
||||
take: num,
|
||||
});
|
||||
}
|
||||
|
||||
async saveNewRevision(key, config, editedBy) {
|
||||
return connection.transaction(async entityManager => {
|
||||
const repo = entityManager.getRepository(Config);
|
||||
// Mark all old revisions inactive
|
||||
await repo.update({ key }, { is_active: false });
|
||||
// Add new, active revision
|
||||
await repo.insert({
|
||||
key,
|
||||
config,
|
||||
is_active: true,
|
||||
edited_by: editedBy,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
62
backend/src/data/DefaultLogMessages.json
Normal file
62
backend/src/data/DefaultLogMessages.json
Normal file
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"MEMBER_WARN": "⚠️ {userMention(member)} was warned by {userMention(mod)}",
|
||||
"MEMBER_MUTE": "🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}",
|
||||
"MEMBER_TIMED_MUTE": "🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}",
|
||||
"MEMBER_UNMUTE": "🔊 {userMention(user)} was unmuted by {userMention(mod)}",
|
||||
"MEMBER_TIMED_UNMUTE": "🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}",
|
||||
"MEMBER_MUTE_EXPIRED": "🔊 {userMention(member)}'s mute expired",
|
||||
"MEMBER_KICK": "👢 {userMention(user)} was kicked by {userMention(mod)}",
|
||||
"MEMBER_BAN": "🔨 {userMention(user)} was banned by {userMention(mod)}",
|
||||
"MEMBER_UNBAN": "🔓 User (`{userId}`) was unbanned by {userMention(mod)}",
|
||||
"MEMBER_FORCEBAN": "🔨 User (`{userId}`) was forcebanned by {userMention(mod)}",
|
||||
"MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}",
|
||||
"MEMBER_JOIN": "📥 {userMention(member)} joined{new} (created {account_age} ago)",
|
||||
"MEMBER_LEAVE": "📤 {userMention(member)} left the server",
|
||||
"MEMBER_ROLE_ADD": "🔑 {userMention(member)}: role(s) **{roles}** added by {userMention(mod)}",
|
||||
"MEMBER_ROLE_REMOVE": "🔑 {userMention(member)}: role(s) **{roles}** removed by {userMention(mod)}",
|
||||
"MEMBER_ROLE_CHANGES": "🔑 {userMention(member)}: roles changed: added **{addedRoles}**, removed **{removedRoles}** by {userMention(mod)}",
|
||||
"MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**",
|
||||
"MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**",
|
||||
"MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin",
|
||||
|
||||
"CHANNEL_CREATE": "🖊 Channel {channelMention(channel)} was created",
|
||||
"CHANNEL_DELETE": "🗑 Channel {channelMention(channel)} was deleted",
|
||||
"CHANNEL_EDIT": "✏ Channel {channelMention(channel)} was edited",
|
||||
|
||||
"ROLE_CREATE": "🖊 Role **{role.name}** (`{role.id}`) was created",
|
||||
"ROLE_DELETE": "🖊 Role **{role.name}** (`{role.id}`) was deleted",
|
||||
"ROLE_EDIT": "🖊 Role **{role.name}** (`{role.id}`) was edited",
|
||||
|
||||
"MESSAGE_EDIT": "✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}",
|
||||
"MESSAGE_DELETE": "🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}",
|
||||
"MESSAGE_DELETE_BULK": "🗑 **{count}** messages deleted in {channelMention(channel)} ({archiveUrl})",
|
||||
"MESSAGE_DELETE_BARE": "🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)",
|
||||
|
||||
"VOICE_CHANNEL_JOIN": "🎙 🔵 {userMention(member)} joined **{channel.name}**",
|
||||
"VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from **{oldChannel.name}** to **{newChannel.name}**",
|
||||
"VOICE_CHANNEL_LEAVE": "🎙 🔴 {userMention(member)} left **{channel.name}**",
|
||||
"VOICE_CHANNEL_FORCE_MOVE": "\uD83C\uDF99 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}",
|
||||
|
||||
"COMMAND": "🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`",
|
||||
|
||||
"MESSAGE_SPAM_DETECTED": "🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}",
|
||||
"OTHER_SPAM_DETECTED": "🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)",
|
||||
"CENSOR": "🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```",
|
||||
"CLEAN": "🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}",
|
||||
|
||||
"CASE_CREATE": "✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})",
|
||||
|
||||
"MASSBAN": "⚒ {userMention(mod)} massbanned {count} users",
|
||||
|
||||
"MEMBER_JOIN_WITH_PRIOR_RECORDS": "⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}",
|
||||
|
||||
"CASE_UPDATE": "✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```",
|
||||
|
||||
"MEMBER_MUTE_REJOIN": "⚠ Reapplied active mute for {userMention(member)} on rejoin",
|
||||
|
||||
"SCHEDULED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {date} at {time} (UTC)",
|
||||
"POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}",
|
||||
|
||||
"BOT_ALERT": "⚠ {tmplEval(body)}",
|
||||
"AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}. Actions taken: **{actionsTaken}**\n{matchSummary}"
|
||||
}
|
100
backend/src/data/GuildArchives.ts
Normal file
100
backend/src/data/GuildArchives.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
import uuid from "uuid/v4"; // tslint:disable-line
|
||||
import moment from "moment-timezone";
|
||||
import { ArchiveEntry } from "./entities/ArchiveEntry";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { trimLines } from "../utils";
|
||||
import { SavedMessage } from "./entities/SavedMessage";
|
||||
import { Channel, Guild, User } from "eris";
|
||||
import { renderTemplate } from "../templateFormatter";
|
||||
|
||||
const DEFAULT_EXPIRY_DAYS = 30;
|
||||
|
||||
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}";
|
||||
|
||||
export class GuildArchives extends BaseGuildRepository {
|
||||
protected archives: Repository<ArchiveEntry>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.archives = getRepository(ArchiveEntry);
|
||||
|
||||
// Clean expired archives at start and then every hour
|
||||
this.deleteExpiredArchives();
|
||||
setInterval(() => this.deleteExpiredArchives(), 1000 * 60 * 60);
|
||||
}
|
||||
|
||||
private deleteExpiredArchives() {
|
||||
this.archives
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("expires_at IS NOT NULL")
|
||||
.andWhere("expires_at <= NOW()")
|
||||
.delete()
|
||||
.execute();
|
||||
}
|
||||
|
||||
async find(id: string): Promise<ArchiveEntry> {
|
||||
return this.archives.findOne({
|
||||
where: { id },
|
||||
relations: this.getRelations(),
|
||||
});
|
||||
}
|
||||
|
||||
async makePermanent(id: string): Promise<void> {
|
||||
await this.archives.update(
|
||||
{ id },
|
||||
{
|
||||
expires_at: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns ID of the created entry
|
||||
*/
|
||||
async create(body: string, expiresAt: moment.Moment = null): Promise<string> {
|
||||
if (!expiresAt) {
|
||||
expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days");
|
||||
}
|
||||
|
||||
const result = await this.archives.insert({
|
||||
guild_id: this.guildId,
|
||||
body,
|
||||
expires_at: expiresAt.format("YYYY-MM-DD HH:mm:ss"),
|
||||
});
|
||||
|
||||
return result.identifiers[0].id;
|
||||
}
|
||||
|
||||
async createFromSavedMessages(savedMessages: SavedMessage[], guild: Guild, expiresAt = null) {
|
||||
if (expiresAt == null) expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days");
|
||||
|
||||
const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild });
|
||||
const msgLines = [];
|
||||
for (const msg of savedMessages) {
|
||||
const channel = guild.channels.get(msg.channel_id);
|
||||
const user = { ...msg.data.author, id: msg.user_id };
|
||||
|
||||
const line = await renderTemplate(MESSAGE_ARCHIVE_MESSAGE_FORMAT, {
|
||||
id: msg.id,
|
||||
timestamp: moment(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"),
|
||||
content: msg.data.content,
|
||||
user,
|
||||
channel,
|
||||
});
|
||||
msgLines.push(line);
|
||||
}
|
||||
const messagesStr = msgLines.join("\n");
|
||||
|
||||
return this.create([headerStr, messagesStr].join("\n\n"), expiresAt);
|
||||
}
|
||||
|
||||
getUrl(baseUrl, archiveId) {
|
||||
return baseUrl ? `${baseUrl}/archives/${archiveId}` : `Archive ID: ${archiveId}`;
|
||||
}
|
||||
}
|
57
backend/src/data/GuildAutoReactions.ts
Normal file
57
backend/src/data/GuildAutoReactions.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { AutoReaction } from "./entities/AutoReaction";
|
||||
|
||||
export class GuildAutoReactions extends BaseGuildRepository {
|
||||
private autoReactions: Repository<AutoReaction>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.autoReactions = getRepository(AutoReaction);
|
||||
}
|
||||
|
||||
async all(): Promise<AutoReaction[]> {
|
||||
return this.autoReactions.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getForChannel(channelId: string): Promise<AutoReaction> {
|
||||
return this.autoReactions.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromChannel(channelId: string) {
|
||||
await this.autoReactions.delete({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
}
|
||||
|
||||
async set(channelId: string, reactions: string[]) {
|
||||
const existingRecord = await this.getForChannel(channelId);
|
||||
if (existingRecord) {
|
||||
this.autoReactions.update(
|
||||
{
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
{
|
||||
reactions,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await this.autoReactions.insert({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
reactions,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
155
backend/src/data/GuildCases.ts
Normal file
155
backend/src/data/GuildCases.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { Case } from "./entities/Case";
|
||||
import { CaseNote } from "./entities/CaseNote";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, In, Repository } from "typeorm";
|
||||
import { disableLinkPreviews } from "../utils";
|
||||
import { CaseTypes } from "./CaseTypes";
|
||||
import moment = require("moment-timezone");
|
||||
|
||||
const CASE_SUMMARY_REASON_MAX_LENGTH = 300;
|
||||
|
||||
export class GuildCases extends BaseGuildRepository {
|
||||
private cases: Repository<Case>;
|
||||
private caseNotes: Repository<CaseNote>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.cases = getRepository(Case);
|
||||
this.caseNotes = getRepository(CaseNote);
|
||||
}
|
||||
|
||||
async get(ids: number[]): Promise<Case[]> {
|
||||
return this.cases.find({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
id: In(ids),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async find(id: number): Promise<Case> {
|
||||
return this.cases.findOne({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByCaseNumber(caseNumber: number): Promise<Case> {
|
||||
return this.cases.findOne({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
case_number: caseNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findLatestByModId(modId: string): Promise<Case> {
|
||||
return this.cases.findOne({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
mod_id: modId,
|
||||
},
|
||||
order: {
|
||||
case_number: "DESC",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByAuditLogId(auditLogId: string): Promise<Case> {
|
||||
return this.cases.findOne({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
audit_log_id: auditLogId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUserId(userId: string): Promise<Case[]> {
|
||||
return this.cases.find({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRecentByModId(modId: string, count: number): Promise<Case[]> {
|
||||
return this.cases.find({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
mod_id: modId,
|
||||
is_hidden: 0,
|
||||
},
|
||||
take: count,
|
||||
order: {
|
||||
case_number: "DESC",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setHidden(id: number, hidden: boolean): Promise<void> {
|
||||
await this.cases.update(
|
||||
{ id },
|
||||
{
|
||||
is_hidden: hidden,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async create(data): Promise<Case> {
|
||||
const result = await this.cases.insert({
|
||||
...data,
|
||||
guild_id: this.guildId,
|
||||
case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`,
|
||||
});
|
||||
|
||||
return this.find(result.identifiers[0].id);
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return this.cases.update(id, data);
|
||||
}
|
||||
|
||||
async createNote(caseId: number, data: any): Promise<void> {
|
||||
await this.caseNotes.insert({
|
||||
...data,
|
||||
case_id: caseId,
|
||||
});
|
||||
}
|
||||
|
||||
getSummaryText(theCase: Case) {
|
||||
const firstNote = theCase.notes[0];
|
||||
let reason = firstNote ? firstNote.body : "";
|
||||
|
||||
if (reason.length > CASE_SUMMARY_REASON_MAX_LENGTH) {
|
||||
const match = reason.slice(CASE_SUMMARY_REASON_MAX_LENGTH, 100).match(/(?:[.,!?\s]|$)/);
|
||||
const nextWhitespaceIndex = match ? CASE_SUMMARY_REASON_MAX_LENGTH + match.index : CASE_SUMMARY_REASON_MAX_LENGTH;
|
||||
if (nextWhitespaceIndex < reason.length) {
|
||||
reason = reason.slice(0, nextWhitespaceIndex - 1) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
reason = disableLinkPreviews(reason);
|
||||
|
||||
const timestamp = moment(theCase.created_at).format("YYYY-MM-DD");
|
||||
let line = `\`[${timestamp}]\` \`Case #${theCase.case_number}\` __${CaseTypes[theCase.type]}__ ${reason}`;
|
||||
if (theCase.notes.length > 1) {
|
||||
line += ` *(+${theCase.notes.length - 1} ${theCase.notes.length === 2 ? "note" : "notes"})*`;
|
||||
}
|
||||
|
||||
if (theCase.is_hidden) {
|
||||
line += " *(hidden)*";
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
}
|
42
backend/src/data/GuildEvents.ts
Normal file
42
backend/src/data/GuildEvents.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { QueuedEventEmitter } from "../QueuedEventEmitter";
|
||||
|
||||
export class GuildEvents extends BaseGuildRepository {
|
||||
private queuedEventEmitter: QueuedEventEmitter;
|
||||
private pluginListeners: Map<string, Map<string, any[]>>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.queuedEventEmitter = new QueuedEventEmitter();
|
||||
}
|
||||
|
||||
public on(pluginName: string, eventName: string, fn) {
|
||||
this.queuedEventEmitter.on(eventName, fn);
|
||||
|
||||
if (!this.pluginListeners.has(pluginName)) {
|
||||
this.pluginListeners.set(pluginName, new Map());
|
||||
}
|
||||
|
||||
const pluginListeners = this.pluginListeners.get(pluginName);
|
||||
if (!pluginListeners.has(eventName)) {
|
||||
pluginListeners.set(eventName, []);
|
||||
}
|
||||
|
||||
const pluginEventListeners = pluginListeners.get(eventName);
|
||||
pluginEventListeners.push(fn);
|
||||
}
|
||||
|
||||
public offPlugin(pluginName: string) {
|
||||
const pluginListeners = this.pluginListeners.get(pluginName) || new Map();
|
||||
for (const [eventName, listeners] of Array.from(pluginListeners.entries())) {
|
||||
for (const listener of listeners) {
|
||||
this.queuedEventEmitter.off(eventName, listener);
|
||||
}
|
||||
}
|
||||
this.pluginListeners.delete(pluginName);
|
||||
}
|
||||
|
||||
public emit(eventName: string, args: any[] = []) {
|
||||
return this.queuedEventEmitter.emit(eventName, args);
|
||||
}
|
||||
}
|
55
backend/src/data/GuildLogs.ts
Normal file
55
backend/src/data/GuildLogs.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import EventEmitter from "events";
|
||||
import { LogType } from "./LogType";
|
||||
|
||||
// Use the same instance for the same guild, even if a new instance is created
|
||||
const guildInstances: Map<string, GuildLogs> = new Map();
|
||||
|
||||
interface IIgnoredLog {
|
||||
type: LogType;
|
||||
ignoreId: any;
|
||||
}
|
||||
|
||||
export class GuildLogs extends EventEmitter {
|
||||
protected guildId: string;
|
||||
protected ignoredLogs: IIgnoredLog[];
|
||||
|
||||
constructor(guildId) {
|
||||
if (guildInstances.has(guildId)) {
|
||||
// Return existing instance for this guild if one exists
|
||||
return guildInstances.get(guildId);
|
||||
}
|
||||
|
||||
super();
|
||||
this.guildId = guildId;
|
||||
this.ignoredLogs = [];
|
||||
|
||||
// Store the instance for this guild so it can be returned later if a new instance for this guild is requested
|
||||
guildInstances.set(guildId, this);
|
||||
}
|
||||
|
||||
log(type: LogType, data: any, ignoreId = null) {
|
||||
if (ignoreId && this.isLogIgnored(type, ignoreId)) {
|
||||
this.clearIgnoredLog(type, ignoreId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit("log", { type, data });
|
||||
}
|
||||
|
||||
ignoreLog(type: LogType, ignoreId: any, timeout: number = null) {
|
||||
this.ignoredLogs.push({ type, ignoreId });
|
||||
|
||||
// Clear after expiry (15sec by default)
|
||||
setTimeout(() => {
|
||||
this.clearIgnoredLog(type, ignoreId);
|
||||
}, timeout || 1000 * 15);
|
||||
}
|
||||
|
||||
isLogIgnored(type: LogType, ignoreId: any) {
|
||||
return this.ignoredLogs.some(info => type === info.type && ignoreId === info.ignoreId);
|
||||
}
|
||||
|
||||
clearIgnoredLog(type: LogType, ignoreId: any) {
|
||||
this.ignoredLogs.splice(this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId), 1);
|
||||
}
|
||||
}
|
101
backend/src/data/GuildMutes.ts
Normal file
101
backend/src/data/GuildMutes.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import moment from "moment-timezone";
|
||||
import { Mute } from "./entities/Mute";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository, Brackets } from "typeorm";
|
||||
|
||||
export class GuildMutes extends BaseGuildRepository {
|
||||
private mutes: Repository<Mute>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.mutes = getRepository(Mute);
|
||||
}
|
||||
|
||||
async getExpiredMutes(): Promise<Mute[]> {
|
||||
return this.mutes
|
||||
.createQueryBuilder("mutes")
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("expires_at IS NOT NULL")
|
||||
.andWhere("expires_at <= NOW()")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findExistingMuteForUserId(userId: string): Promise<Mute> {
|
||||
return this.mutes.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async isMuted(userId: string): Promise<boolean> {
|
||||
const mute = await this.findExistingMuteForUserId(userId);
|
||||
return mute != null;
|
||||
}
|
||||
|
||||
async addMute(userId, expiryTime): Promise<Mute> {
|
||||
const expiresAt = expiryTime
|
||||
? moment()
|
||||
.add(expiryTime, "ms")
|
||||
.format("YYYY-MM-DD HH:mm:ss")
|
||||
: null;
|
||||
|
||||
const result = await this.mutes.insert({
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
return this.mutes.findOne({ where: result.identifiers[0] });
|
||||
}
|
||||
|
||||
async updateExpiryTime(userId, newExpiryTime) {
|
||||
const expiresAt = newExpiryTime
|
||||
? moment()
|
||||
.add(newExpiryTime, "ms")
|
||||
.format("YYYY-MM-DD HH:mm:ss")
|
||||
: null;
|
||||
|
||||
return this.mutes.update(
|
||||
{
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
{
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getActiveMutes(): Promise<Mute[]> {
|
||||
return this.mutes
|
||||
.createQueryBuilder("mutes")
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere(
|
||||
new Brackets(qb => {
|
||||
qb.where("expires_at > NOW()").orWhere("expires_at IS NULL");
|
||||
}),
|
||||
)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async setCaseId(userId: string, caseId: number) {
|
||||
await this.mutes.update(
|
||||
{
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
{
|
||||
case_id: caseId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async clear(userId) {
|
||||
await this.mutes.delete({
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
}
|
68
backend/src/data/GuildNicknameHistory.ts
Normal file
68
backend/src/data/GuildNicknameHistory.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { NicknameHistoryEntry } from "./entities/NicknameHistoryEntry";
|
||||
import { sorter } from "../utils";
|
||||
|
||||
export const MAX_NICKNAME_ENTRIES_PER_USER = 10;
|
||||
|
||||
export class GuildNicknameHistory extends BaseGuildRepository {
|
||||
private nicknameHistory: Repository<NicknameHistoryEntry>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.nicknameHistory = getRepository(NicknameHistoryEntry);
|
||||
}
|
||||
|
||||
async getByUserId(userId): Promise<NicknameHistoryEntry[]> {
|
||||
return this.nicknameHistory.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getLastEntry(userId): Promise<NicknameHistoryEntry> {
|
||||
return this.nicknameHistory.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addEntry(userId, nickname) {
|
||||
await this.nicknameHistory.insert({
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
nickname,
|
||||
});
|
||||
|
||||
// Cleanup (leave only the last MAX_NICKNAME_ENTRIES_PER_USER entries)
|
||||
const lastEntries = await this.getByUserId(userId);
|
||||
if (lastEntries.length > MAX_NICKNAME_ENTRIES_PER_USER) {
|
||||
const earliestEntry = lastEntries
|
||||
.sort(sorter("timestamp", "DESC"))
|
||||
.slice(0, 10)
|
||||
.reduce((earliest, entry) => {
|
||||
if (earliest == null) return entry;
|
||||
if (entry.id < earliest.id) return entry;
|
||||
return earliest;
|
||||
}, null);
|
||||
|
||||
this.nicknameHistory
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.andWhere("user_id = :userId", { userId })
|
||||
.andWhere("id < :id", { id: earliestEntry.id })
|
||||
.delete()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
}
|
58
backend/src/data/GuildPersistedData.ts
Normal file
58
backend/src/data/GuildPersistedData.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { PersistedData } from "./entities/PersistedData";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
|
||||
export interface IPartialPersistData {
|
||||
roles?: string[];
|
||||
nickname?: string;
|
||||
is_voice_muted?: boolean;
|
||||
}
|
||||
|
||||
export class GuildPersistedData extends BaseGuildRepository {
|
||||
private persistedData: Repository<PersistedData>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.persistedData = getRepository(PersistedData);
|
||||
}
|
||||
|
||||
async find(userId: string) {
|
||||
return this.persistedData.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async set(userId: string, data: IPartialPersistData = {}) {
|
||||
const finalData: any = {};
|
||||
if (data.roles) finalData.roles = data.roles.join(",");
|
||||
if (data.nickname) finalData.nickname = data.nickname;
|
||||
if (data.is_voice_muted != null) finalData.is_voice_muted = data.is_voice_muted ? 1 : 0;
|
||||
|
||||
const existing = await this.find(userId);
|
||||
if (existing) {
|
||||
await this.persistedData.update(
|
||||
{
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
finalData,
|
||||
);
|
||||
} else {
|
||||
await this.persistedData.insert({
|
||||
...finalData,
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async clear(userId: string) {
|
||||
await this.persistedData.delete({
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
}
|
55
backend/src/data/GuildPingableRoles.ts
Normal file
55
backend/src/data/GuildPingableRoles.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { PingableRole } from "./entities/PingableRole";
|
||||
|
||||
export class GuildPingableRoles extends BaseGuildRepository {
|
||||
private pingableRoles: Repository<PingableRole>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.pingableRoles = getRepository(PingableRole);
|
||||
}
|
||||
|
||||
async all(): Promise<PingableRole[]> {
|
||||
return this.pingableRoles.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getForChannel(channelId: string): Promise<PingableRole[]> {
|
||||
return this.pingableRoles.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByChannelAndRoleId(channelId: string, roleId: string): Promise<PingableRole> {
|
||||
return this.pingableRoles.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
role_id: roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(channelId: string, roleId: string) {
|
||||
await this.pingableRoles.delete({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
role_id: roleId,
|
||||
});
|
||||
}
|
||||
|
||||
async add(channelId: string, roleId: string) {
|
||||
await this.pingableRoles.insert({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
role_id: roleId,
|
||||
});
|
||||
}
|
||||
}
|
62
backend/src/data/GuildReactionRoles.ts
Normal file
62
backend/src/data/GuildReactionRoles.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { ReactionRole } from "./entities/ReactionRole";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
|
||||
export class GuildReactionRoles extends BaseGuildRepository {
|
||||
private reactionRoles: Repository<ReactionRole>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.reactionRoles = getRepository(ReactionRole);
|
||||
}
|
||||
|
||||
async all(): Promise<ReactionRole[]> {
|
||||
return this.reactionRoles.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getForMessage(messageId: string): Promise<ReactionRole[]> {
|
||||
return this.reactionRoles.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
message_id: messageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByMessageAndEmoji(messageId: string, emoji: string): Promise<ReactionRole> {
|
||||
return this.reactionRoles.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
message_id: messageId,
|
||||
emoji,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromMessage(messageId: string, emoji: string = null) {
|
||||
const criteria: any = {
|
||||
guild_id: this.guildId,
|
||||
message_id: messageId,
|
||||
};
|
||||
|
||||
if (emoji) {
|
||||
criteria.emoji = emoji;
|
||||
}
|
||||
|
||||
await this.reactionRoles.delete(criteria);
|
||||
}
|
||||
|
||||
async add(channelId: string, messageId: string, emoji: string, roleId: string) {
|
||||
await this.reactionRoles.insert({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
emoji,
|
||||
role_id: roleId,
|
||||
});
|
||||
}
|
||||
}
|
46
backend/src/data/GuildReminders.ts
Normal file
46
backend/src/data/GuildReminders.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { Reminder } from "./entities/Reminder";
|
||||
|
||||
export class GuildReminders extends BaseGuildRepository {
|
||||
private reminders: Repository<Reminder>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.reminders = getRepository(Reminder);
|
||||
}
|
||||
|
||||
async getDueReminders(): Promise<Reminder[]> {
|
||||
return this.reminders
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.andWhere("remind_at <= NOW()")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getRemindersByUserId(userId: string): Promise<Reminder[]> {
|
||||
return this.reminders.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
await this.reminders.delete({
|
||||
guild_id: this.guildId,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
async add(userId: string, channelId: string, remindAt: string, body: string) {
|
||||
await this.reminders.insert({
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
remind_at: remindAt,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
285
backend/src/data/GuildSavedMessages.ts
Normal file
285
backend/src/data/GuildSavedMessages.ts
Normal file
|
@ -0,0 +1,285 @@
|
|||
import { Brackets, getRepository, Repository } from "typeorm";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { ISavedMessageData, SavedMessage } from "./entities/SavedMessage";
|
||||
import { QueuedEventEmitter } from "../QueuedEventEmitter";
|
||||
import { GuildChannel, Message } from "eris";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 min
|
||||
|
||||
const RETENTION_PERIOD = 5 * 24 * 60 * 60 * 1000; // 5 days
|
||||
|
||||
async function cleanup() {
|
||||
const repository = getRepository(SavedMessage);
|
||||
await repository
|
||||
.createQueryBuilder("messages")
|
||||
.where(
|
||||
// Clear deleted messages
|
||||
new Brackets(qb => {
|
||||
qb.where("deleted_at IS NOT NULL");
|
||||
qb.andWhere(`deleted_at <= (NOW() - INTERVAL ${CLEANUP_INTERVAL}000 MICROSECOND)`);
|
||||
}),
|
||||
)
|
||||
.orWhere(
|
||||
// Clear old messages
|
||||
new Brackets(qb => {
|
||||
qb.where("is_permanent = 0");
|
||||
qb.andWhere(`posted_at <= (NOW() - INTERVAL ${RETENTION_PERIOD}000 MICROSECOND)`);
|
||||
}),
|
||||
)
|
||||
.delete()
|
||||
.execute();
|
||||
|
||||
setTimeout(cleanup, CLEANUP_INTERVAL);
|
||||
}
|
||||
|
||||
// Start first cleanup 30 seconds after startup
|
||||
setTimeout(cleanup, 30 * 1000);
|
||||
|
||||
export class GuildSavedMessages extends BaseGuildRepository {
|
||||
private messages: Repository<SavedMessage>;
|
||||
protected toBePermanent: Set<string>;
|
||||
|
||||
public events: QueuedEventEmitter;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.messages = getRepository(SavedMessage);
|
||||
this.events = new QueuedEventEmitter();
|
||||
|
||||
this.toBePermanent = new Set();
|
||||
}
|
||||
|
||||
public msgToSavedMessageData(msg: Message): ISavedMessageData {
|
||||
const data: ISavedMessageData = {
|
||||
author: {
|
||||
username: msg.author.username,
|
||||
discriminator: msg.author.discriminator,
|
||||
},
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
};
|
||||
|
||||
if (msg.attachments.length) data.attachments = msg.attachments;
|
||||
if (msg.embeds.length) data.embeds = msg.embeds;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
find(id) {
|
||||
return this.messages
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("id = :id", { id })
|
||||
.andWhere("deleted_at IS NULL")
|
||||
.getOne();
|
||||
}
|
||||
|
||||
getLatestBotMessagesByChannel(channelId, limit) {
|
||||
return this.messages
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("channel_id = :channel_id", { channel_id: channelId })
|
||||
.andWhere("is_bot = 1")
|
||||
.andWhere("deleted_at IS NULL")
|
||||
.orderBy("id", "DESC")
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
getLatestByChannelBeforeId(channelId, beforeId, limit) {
|
||||
return this.messages
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("channel_id = :channel_id", { channel_id: channelId })
|
||||
.andWhere("id < :beforeId", { beforeId })
|
||||
.andWhere("deleted_at IS NULL")
|
||||
.orderBy("id", "DESC")
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
getLatestByChannelAndUser(channelId, userId, limit) {
|
||||
return this.messages
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("channel_id = :channel_id", { channel_id: channelId })
|
||||
.andWhere("user_id = :user_id", { user_id: userId })
|
||||
.andWhere("deleted_at IS NULL")
|
||||
.orderBy("id", "DESC")
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
getUserMessagesByChannelAfterId(userId, channelId, afterId, limit = null) {
|
||||
let query = this.messages
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("user_id = :user_id", { user_id: userId })
|
||||
.andWhere("channel_id = :channel_id", { channel_id: channelId })
|
||||
.andWhere("id > :afterId", { afterId })
|
||||
.andWhere("deleted_at IS NULL");
|
||||
|
||||
if (limit != null) {
|
||||
query = query.limit(limit);
|
||||
}
|
||||
|
||||
return query.getMany();
|
||||
}
|
||||
|
||||
getMultiple(messageIds: string[]): Promise<SavedMessage[]> {
|
||||
return this.messages
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("id IN (:messageIds)", { messageIds })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async create(data) {
|
||||
const isPermanent = this.toBePermanent.has(data.id);
|
||||
if (isPermanent) {
|
||||
data.is_permanent = true;
|
||||
this.toBePermanent.delete(data.id);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.messages.insert(data);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = await this.messages.findOne(data.id);
|
||||
this.events.emit("create", [inserted]);
|
||||
this.events.emit(`create:${data.id}`, [inserted]);
|
||||
}
|
||||
|
||||
async createFromMsg(msg: Message, overrides = {}) {
|
||||
const existingSavedMsg = await this.find(msg.id);
|
||||
if (existingSavedMsg) return;
|
||||
|
||||
const savedMessageData = this.msgToSavedMessageData(msg);
|
||||
const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss.SSS");
|
||||
|
||||
const data = {
|
||||
id: msg.id,
|
||||
guild_id: (msg.channel as GuildChannel).guild.id,
|
||||
channel_id: msg.channel.id,
|
||||
user_id: msg.author.id,
|
||||
is_bot: msg.author.bot,
|
||||
data: savedMessageData,
|
||||
posted_at: postedAt,
|
||||
};
|
||||
|
||||
return this.create({ ...data, ...overrides });
|
||||
}
|
||||
|
||||
async markAsDeleted(id) {
|
||||
await this.messages
|
||||
.createQueryBuilder("messages")
|
||||
.update()
|
||||
.set({
|
||||
deleted_at: () => "NOW(3)",
|
||||
})
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("id = :id", { id })
|
||||
.execute();
|
||||
|
||||
const deleted = await this.messages.findOne(id);
|
||||
|
||||
if (deleted) {
|
||||
this.events.emit("delete", [deleted]);
|
||||
this.events.emit(`delete:${id}`, [deleted]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the specified messages as deleted in the database (if they weren't already marked before).
|
||||
* If any messages were marked as deleted, also emits the deleteBulk event.
|
||||
*/
|
||||
async markBulkAsDeleted(ids) {
|
||||
const deletedAt = moment().format("YYYY-MM-DD HH:mm:ss.SSS");
|
||||
|
||||
await this.messages
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ deleted_at: deletedAt })
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere("id IN (:ids)", { ids })
|
||||
.andWhere("deleted_at IS NULL")
|
||||
.execute();
|
||||
|
||||
const deleted = await this.messages
|
||||
.createQueryBuilder()
|
||||
.where("id IN (:ids)", { ids })
|
||||
.andWhere("deleted_at = :deletedAt", { deletedAt })
|
||||
.getMany();
|
||||
|
||||
if (deleted.length) {
|
||||
this.events.emit("deleteBulk", [deleted]);
|
||||
}
|
||||
}
|
||||
|
||||
async saveEdit(id, newData: ISavedMessageData) {
|
||||
const oldMessage = await this.messages.findOne(id);
|
||||
if (!oldMessage) return;
|
||||
|
||||
const newMessage = { ...oldMessage, data: newData };
|
||||
|
||||
await this.messages.update(
|
||||
{ id },
|
||||
{
|
||||
data: newData,
|
||||
},
|
||||
);
|
||||
|
||||
this.events.emit("update", [newMessage, oldMessage]);
|
||||
this.events.emit(`update:${id}`, [newMessage, oldMessage]);
|
||||
}
|
||||
|
||||
async saveEditFromMsg(msg: Message) {
|
||||
const newData = this.msgToSavedMessageData(msg);
|
||||
return this.saveEdit(msg.id, newData);
|
||||
}
|
||||
|
||||
async setPermanent(id: string) {
|
||||
const savedMsg = await this.find(id);
|
||||
if (savedMsg) {
|
||||
await this.messages.update(
|
||||
{ id },
|
||||
{
|
||||
is_permanent: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.toBePermanent.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
async onceMessageAvailable(id: string, handler: (msg: SavedMessage) => any, timeout: number = 60 * 1000) {
|
||||
let called = false;
|
||||
let onceEventListener;
|
||||
let timeoutFn;
|
||||
|
||||
const callHandler = async (msg: SavedMessage) => {
|
||||
this.events.off(`create:${id}`, onceEventListener);
|
||||
clearTimeout(timeoutFn);
|
||||
|
||||
if (called) return;
|
||||
called = true;
|
||||
|
||||
await handler(msg);
|
||||
};
|
||||
|
||||
onceEventListener = this.events.once(`create:${id}`, callHandler);
|
||||
timeoutFn = setTimeout(() => {
|
||||
called = true;
|
||||
callHandler(null);
|
||||
}, timeout);
|
||||
|
||||
const messageInDB = await this.find(id);
|
||||
if (messageInDB) {
|
||||
callHandler(messageInDB);
|
||||
}
|
||||
}
|
||||
}
|
41
backend/src/data/GuildScheduledPosts.ts
Normal file
41
backend/src/data/GuildScheduledPosts.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { ScheduledPost } from "./entities/ScheduledPost";
|
||||
|
||||
export class GuildScheduledPosts extends BaseGuildRepository {
|
||||
private scheduledPosts: Repository<ScheduledPost>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.scheduledPosts = getRepository(ScheduledPost);
|
||||
}
|
||||
|
||||
all(): Promise<ScheduledPost[]> {
|
||||
return this.scheduledPosts
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
getDueScheduledPosts(): Promise<ScheduledPost[]> {
|
||||
return this.scheduledPosts
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.andWhere("post_at <= NOW()")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
await this.scheduledPosts.delete({
|
||||
guild_id: this.guildId,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Partial<ScheduledPost>) {
|
||||
await this.scheduledPosts.insert({
|
||||
...data,
|
||||
guild_id: this.guildId,
|
||||
});
|
||||
}
|
||||
}
|
38
backend/src/data/GuildSelfGrantableRoles.ts
Normal file
38
backend/src/data/GuildSelfGrantableRoles.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { SelfGrantableRole } from "./entities/SelfGrantableRole";
|
||||
|
||||
export class GuildSelfGrantableRoles extends BaseGuildRepository {
|
||||
private selfGrantableRoles: Repository<SelfGrantableRole>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.selfGrantableRoles = getRepository(SelfGrantableRole);
|
||||
}
|
||||
|
||||
async getForChannel(channelId: string): Promise<SelfGrantableRole[]> {
|
||||
return this.selfGrantableRoles.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(channelId: string, roleId: string) {
|
||||
await this.selfGrantableRoles.delete({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
role_id: roleId,
|
||||
});
|
||||
}
|
||||
|
||||
async add(channelId: string, roleId: string, aliases: string[]) {
|
||||
await this.selfGrantableRoles.insert({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
role_id: roleId,
|
||||
aliases,
|
||||
});
|
||||
}
|
||||
}
|
121
backend/src/data/GuildSlowmodes.ts
Normal file
121
backend/src/data/GuildSlowmodes.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { SlowmodeChannel } from "./entities/SlowmodeChannel";
|
||||
import { SlowmodeUser } from "./entities/SlowmodeUser";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
export class GuildSlowmodes extends BaseGuildRepository {
|
||||
private slowmodeChannels: Repository<SlowmodeChannel>;
|
||||
private slowmodeUsers: Repository<SlowmodeUser>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.slowmodeChannels = getRepository(SlowmodeChannel);
|
||||
this.slowmodeUsers = getRepository(SlowmodeUser);
|
||||
}
|
||||
|
||||
async getChannelSlowmode(channelId): Promise<SlowmodeChannel> {
|
||||
return this.slowmodeChannels.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setChannelSlowmode(channelId, seconds): Promise<void> {
|
||||
const existingSlowmode = await this.getChannelSlowmode(channelId);
|
||||
if (existingSlowmode) {
|
||||
await this.slowmodeChannels.update(
|
||||
{
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
{
|
||||
slowmode_seconds: seconds,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await this.slowmodeChannels.insert({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
slowmode_seconds: seconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteChannelSlowmode(channelId): Promise<void> {
|
||||
await this.slowmodeChannels.delete({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
}
|
||||
|
||||
async getChannelSlowmodeUser(channelId, userId): Promise<SlowmodeUser> {
|
||||
return this.slowmodeUsers.findOne({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
|
||||
async userHasSlowmode(channelId, userId): Promise<boolean> {
|
||||
return (await this.getChannelSlowmodeUser(channelId, userId)) != null;
|
||||
}
|
||||
|
||||
async addSlowmodeUser(channelId, userId): Promise<void> {
|
||||
const slowmode = await this.getChannelSlowmode(channelId);
|
||||
if (!slowmode) return;
|
||||
|
||||
const expiresAt = moment()
|
||||
.add(slowmode.slowmode_seconds, "seconds")
|
||||
.format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
if (await this.userHasSlowmode(channelId, userId)) {
|
||||
// Update existing
|
||||
await this.slowmodeUsers.update(
|
||||
{
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
user_id: userId,
|
||||
},
|
||||
{
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Add new
|
||||
await this.slowmodeUsers.insert({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
user_id: userId,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async clearSlowmodeUser(channelId, userId): Promise<void> {
|
||||
await this.slowmodeUsers.delete({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
|
||||
async getChannelSlowmodeUsers(channelId): Promise<SlowmodeUser[]> {
|
||||
return this.slowmodeUsers.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getExpiredSlowmodeUsers(): Promise<SlowmodeUser[]> {
|
||||
return this.slowmodeUsers
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.andWhere("expires_at <= NOW()")
|
||||
.getMany();
|
||||
}
|
||||
}
|
84
backend/src/data/GuildStarboards.ts
Normal file
84
backend/src/data/GuildStarboards.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { Starboard } from "./entities/Starboard";
|
||||
import { StarboardMessage } from "./entities/StarboardMessage";
|
||||
|
||||
export class GuildStarboards extends BaseGuildRepository {
|
||||
private starboards: Repository<Starboard>;
|
||||
private starboardMessages: Repository<StarboardMessage>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.starboards = getRepository(Starboard);
|
||||
this.starboardMessages = getRepository(StarboardMessage);
|
||||
}
|
||||
|
||||
getStarboardByChannelId(channelId): Promise<Starboard> {
|
||||
return this.starboards.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStarboardsByEmoji(emoji): Promise<Starboard[]> {
|
||||
return this.starboards.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
emoji,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStarboardMessageByStarboardIdAndMessageId(starboardId, messageId): Promise<StarboardMessage> {
|
||||
return this.starboardMessages.findOne({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
starboard_id: starboardId,
|
||||
message_id: messageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStarboardMessagesByMessageId(id): Promise<StarboardMessage[]> {
|
||||
return this.starboardMessages.find({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
message_id: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createStarboardMessage(starboardId, messageId, starboardMessageId): Promise<void> {
|
||||
await this.starboardMessages.insert({
|
||||
starboard_id: starboardId,
|
||||
message_id: messageId,
|
||||
starboard_message_id: starboardMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteStarboardMessage(starboardId, messageId): Promise<void> {
|
||||
await this.starboardMessages.delete({
|
||||
starboard_id: starboardId,
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
async create(channelId: string, channelWhitelist: string[], emoji: string, reactionsRequired: number): Promise<void> {
|
||||
await this.starboards.insert({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
channel_whitelist: channelWhitelist ? channelWhitelist.join(",") : null,
|
||||
emoji,
|
||||
reactions_required: reactionsRequired,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(channelId: string): Promise<void> {
|
||||
await this.starboards.delete({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
}
|
||||
}
|
89
backend/src/data/GuildTags.ts
Normal file
89
backend/src/data/GuildTags.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { Tag } from "./entities/Tag";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { TagResponse } from "./entities/TagResponse";
|
||||
|
||||
export class GuildTags extends BaseGuildRepository {
|
||||
private tags: Repository<Tag>;
|
||||
private tagResponses: Repository<TagResponse>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.tags = getRepository(Tag);
|
||||
this.tagResponses = getRepository(TagResponse);
|
||||
}
|
||||
|
||||
async all(): Promise<Tag[]> {
|
||||
return this.tags.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async find(tag): Promise<Tag> {
|
||||
return this.tags.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
tag,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createOrUpdate(tag, body, userId) {
|
||||
const existingTag = await this.find(tag);
|
||||
if (existingTag) {
|
||||
await this.tags
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({
|
||||
body,
|
||||
user_id: userId,
|
||||
created_at: () => "NOW()",
|
||||
})
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.andWhere("tag = :tag", { tag })
|
||||
.execute();
|
||||
} else {
|
||||
await this.tags.insert({
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
tag,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async delete(tag) {
|
||||
await this.tags.delete({
|
||||
guild_id: this.guildId,
|
||||
tag,
|
||||
});
|
||||
}
|
||||
|
||||
async findResponseByCommandMessageId(messageId: string): Promise<TagResponse> {
|
||||
return this.tagResponses.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
command_message_id: messageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findResponseByResponseMessageId(messageId: string): Promise<TagResponse> {
|
||||
return this.tagResponses.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
response_message_id: messageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addResponse(cmdMessageId, responseMessageId) {
|
||||
await this.tagResponses.insert({
|
||||
guild_id: this.guildId,
|
||||
command_message_id: cmdMessageId,
|
||||
response_message_id: responseMessageId,
|
||||
});
|
||||
}
|
||||
}
|
63
backend/src/data/GuildVCAlerts.ts
Normal file
63
backend/src/data/GuildVCAlerts.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { VCAlert } from "./entities/VCAlert";
|
||||
|
||||
export class GuildVCAlerts extends BaseGuildRepository {
|
||||
private allAlerts: Repository<VCAlert>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.allAlerts = getRepository(VCAlert);
|
||||
}
|
||||
|
||||
async getOutdatedAlerts(): Promise<VCAlert[]> {
|
||||
return this.allAlerts
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.andWhere("expires_at <= NOW()")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getAllGuildAlerts(): Promise<VCAlert[]> {
|
||||
return this.allAlerts
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getAlertsByUserId(userId: string): Promise<VCAlert[]> {
|
||||
return this.allAlerts.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAlertsByRequestorId(requestorId: string): Promise<VCAlert[]> {
|
||||
return this.allAlerts.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
requestor_id: requestorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
await this.allAlerts.delete({
|
||||
guild_id: this.guildId,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
async add(requestorId: string, userId: string, channelId: string, expiresAt: string, body: string) {
|
||||
await this.allAlerts.insert({
|
||||
guild_id: this.guildId,
|
||||
requestor_id: requestorId,
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
expires_at: expiresAt,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
62
backend/src/data/LogType.ts
Normal file
62
backend/src/data/LogType.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
export enum LogType {
|
||||
MEMBER_WARN = 1,
|
||||
MEMBER_MUTE,
|
||||
MEMBER_UNMUTE,
|
||||
MEMBER_MUTE_EXPIRED,
|
||||
MEMBER_KICK,
|
||||
MEMBER_BAN,
|
||||
MEMBER_UNBAN,
|
||||
MEMBER_FORCEBAN,
|
||||
MEMBER_SOFTBAN,
|
||||
MEMBER_JOIN,
|
||||
MEMBER_LEAVE,
|
||||
MEMBER_ROLE_ADD,
|
||||
MEMBER_ROLE_REMOVE,
|
||||
MEMBER_NICK_CHANGE,
|
||||
MEMBER_USERNAME_CHANGE,
|
||||
MEMBER_RESTORE,
|
||||
|
||||
CHANNEL_CREATE,
|
||||
CHANNEL_DELETE,
|
||||
|
||||
ROLE_CREATE,
|
||||
ROLE_DELETE,
|
||||
|
||||
MESSAGE_EDIT,
|
||||
MESSAGE_DELETE,
|
||||
MESSAGE_DELETE_BULK,
|
||||
MESSAGE_DELETE_BARE,
|
||||
|
||||
VOICE_CHANNEL_JOIN,
|
||||
VOICE_CHANNEL_LEAVE,
|
||||
VOICE_CHANNEL_MOVE,
|
||||
|
||||
COMMAND,
|
||||
|
||||
MESSAGE_SPAM_DETECTED,
|
||||
CENSOR,
|
||||
CLEAN,
|
||||
|
||||
CASE_CREATE,
|
||||
|
||||
MASSBAN,
|
||||
|
||||
MEMBER_TIMED_MUTE,
|
||||
MEMBER_TIMED_UNMUTE,
|
||||
|
||||
MEMBER_JOIN_WITH_PRIOR_RECORDS,
|
||||
OTHER_SPAM_DETECTED,
|
||||
|
||||
MEMBER_ROLE_CHANGES,
|
||||
VOICE_CHANNEL_FORCE_MOVE,
|
||||
|
||||
CASE_UPDATE,
|
||||
|
||||
MEMBER_MUTE_REJOIN,
|
||||
|
||||
SCHEDULED_MESSAGE,
|
||||
POSTED_SCHEDULED_MESSAGE,
|
||||
|
||||
BOT_ALERT,
|
||||
AUTOMOD_ACTION,
|
||||
}
|
65
backend/src/data/UsernameHistory.ts
Normal file
65
backend/src/data/UsernameHistory.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { getRepository, Repository } from "typeorm";
|
||||
import { UsernameHistoryEntry } from "./entities/UsernameHistoryEntry";
|
||||
import { sorter } from "../utils";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
|
||||
export const MAX_USERNAME_ENTRIES_PER_USER = 10;
|
||||
|
||||
export class UsernameHistory extends BaseRepository {
|
||||
private usernameHistory: Repository<UsernameHistoryEntry>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.usernameHistory = getRepository(UsernameHistoryEntry);
|
||||
}
|
||||
|
||||
async getByUserId(userId): Promise<UsernameHistoryEntry[]> {
|
||||
return this.usernameHistory.find({
|
||||
where: {
|
||||
user_id: userId,
|
||||
},
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
take: MAX_USERNAME_ENTRIES_PER_USER,
|
||||
});
|
||||
}
|
||||
|
||||
getLastEntry(userId): Promise<UsernameHistoryEntry> {
|
||||
return this.usernameHistory.findOne({
|
||||
where: {
|
||||
user_id: userId,
|
||||
},
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addEntry(userId, username) {
|
||||
await this.usernameHistory.insert({
|
||||
user_id: userId,
|
||||
username,
|
||||
});
|
||||
|
||||
// Cleanup (leave only the last MAX_USERNAME_ENTRIES_PER_USER entries)
|
||||
const lastEntries = await this.getByUserId(userId);
|
||||
if (lastEntries.length > MAX_USERNAME_ENTRIES_PER_USER) {
|
||||
const earliestEntry = lastEntries
|
||||
.sort(sorter("timestamp", "DESC"))
|
||||
.slice(0, 10)
|
||||
.reduce((earliest, entry) => {
|
||||
if (earliest == null) return entry;
|
||||
if (entry.id < earliest.id) return entry;
|
||||
return earliest;
|
||||
}, null);
|
||||
|
||||
this.usernameHistory
|
||||
.createQueryBuilder()
|
||||
.andWhere("user_id = :userId", { userId })
|
||||
.andWhere("id < :id", { id: earliestEntry.id })
|
||||
.delete()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
}
|
117
backend/src/data/Zalgo.ts
Normal file
117
backend/src/data/Zalgo.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
// From https://github.com/b1naryth1ef/rowboat/blob/master/rowboat/util/zalgo.py
|
||||
const zalgoChars = [
|
||||
"\u030d",
|
||||
"\u030e",
|
||||
"\u0304",
|
||||
"\u0305",
|
||||
"\u033f",
|
||||
"\u0311",
|
||||
"\u0306",
|
||||
"\u0310",
|
||||
"\u0352",
|
||||
"\u0357",
|
||||
"\u0351",
|
||||
"\u0307",
|
||||
"\u0308",
|
||||
"\u030a",
|
||||
"\u0342",
|
||||
"\u0343",
|
||||
"\u0344",
|
||||
"\u034a",
|
||||
"\u034b",
|
||||
"\u034c",
|
||||
"\u0303",
|
||||
"\u0302",
|
||||
"\u030c",
|
||||
"\u0350",
|
||||
"\u0300",
|
||||
"\u030b",
|
||||
"\u030f",
|
||||
"\u0312",
|
||||
"\u0313",
|
||||
"\u0314",
|
||||
"\u033d",
|
||||
"\u0309",
|
||||
"\u0363",
|
||||
"\u0364",
|
||||
"\u0365",
|
||||
"\u0366",
|
||||
"\u0367",
|
||||
"\u0368",
|
||||
"\u0369",
|
||||
"\u036a",
|
||||
"\u036b",
|
||||
"\u036c",
|
||||
"\u036d",
|
||||
"\u036e",
|
||||
"\u036f",
|
||||
"\u033e",
|
||||
"\u035b",
|
||||
"\u0346",
|
||||
"\u031a",
|
||||
"\u0315",
|
||||
"\u031b",
|
||||
"\u0340",
|
||||
"\u0341",
|
||||
"\u0358",
|
||||
"\u0321",
|
||||
"\u0322",
|
||||
"\u0327",
|
||||
"\u0328",
|
||||
"\u0334",
|
||||
"\u0335",
|
||||
"\u0336",
|
||||
"\u034f",
|
||||
"\u035c",
|
||||
"\u035d",
|
||||
"\u035e",
|
||||
"\u035f",
|
||||
"\u0360",
|
||||
"\u0362",
|
||||
"\u0338",
|
||||
"\u0337",
|
||||
"\u0361",
|
||||
"\u0489",
|
||||
"\u0316",
|
||||
"\u0317",
|
||||
"\u0318",
|
||||
"\u0319",
|
||||
"\u031c",
|
||||
"\u031d",
|
||||
"\u031e",
|
||||
"\u031f",
|
||||
"\u0320",
|
||||
"\u0324",
|
||||
"\u0325",
|
||||
"\u0326",
|
||||
"\u0329",
|
||||
"\u032a",
|
||||
"\u032b",
|
||||
"\u032c",
|
||||
"\u032d",
|
||||
"\u032e",
|
||||
"\u032f",
|
||||
"\u0330",
|
||||
"\u0331",
|
||||
"\u0332",
|
||||
"\u0333",
|
||||
"\u0339",
|
||||
"\u033a",
|
||||
"\u033b",
|
||||
"\u033c",
|
||||
"\u0345",
|
||||
"\u0347",
|
||||
"\u0348",
|
||||
"\u0349",
|
||||
"\u034d",
|
||||
"\u034e",
|
||||
"\u0353",
|
||||
"\u0354",
|
||||
"\u0355",
|
||||
"\u0356",
|
||||
"\u0359",
|
||||
"\u035a",
|
||||
"\u0323",
|
||||
];
|
||||
|
||||
export const ZalgoRegex = new RegExp(zalgoChars.join("|"));
|
24
backend/src/data/db.ts
Normal file
24
backend/src/data/db.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { SimpleError } from "../SimpleError";
|
||||
import { Connection, createConnection } from "typeorm";
|
||||
|
||||
let connectionPromise: Promise<Connection>;
|
||||
|
||||
export let connection: Connection;
|
||||
|
||||
export function connect() {
|
||||
if (!connectionPromise) {
|
||||
connectionPromise = createConnection().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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return connectionPromise;
|
||||
}
|
17
backend/src/data/entities/AllowedGuild.ts
Normal file
17
backend/src/data/entities/AllowedGuild.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
|
||||
|
||||
@Entity("allowed_guilds")
|
||||
export class AllowedGuild {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
icon: string;
|
||||
|
||||
@Column()
|
||||
owner_id: string;
|
||||
}
|
25
backend/src/data/entities/ApiLogin.ts
Normal file
25
backend/src/data/entities/ApiLogin.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Entity, Column, PrimaryColumn, OneToOne, ManyToOne, JoinColumn } from "typeorm";
|
||||
import { ApiUserInfo } from "./ApiUserInfo";
|
||||
|
||||
@Entity("api_logins")
|
||||
export class ApiLogin {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
token: string;
|
||||
|
||||
@Column()
|
||||
user_id: string;
|
||||
|
||||
@Column()
|
||||
logged_in_at: string;
|
||||
|
||||
@Column()
|
||||
expires_at: string;
|
||||
|
||||
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.logins)
|
||||
@JoinColumn({ name: "user_id" })
|
||||
userInfo: ApiUserInfo;
|
||||
}
|
20
backend/src/data/entities/ApiPermission.ts
Normal file
20
backend/src/data/entities/ApiPermission.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from "typeorm";
|
||||
import { ApiUserInfo } from "./ApiUserInfo";
|
||||
|
||||
@Entity("api_permissions")
|
||||
export class ApiPermission {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
user_id: string;
|
||||
|
||||
@Column()
|
||||
role: string;
|
||||
|
||||
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.permissions)
|
||||
@JoinColumn({ name: "user_id" })
|
||||
userInfo: ApiUserInfo;
|
||||
}
|
28
backend/src/data/entities/ApiUserInfo.ts
Normal file
28
backend/src/data/entities/ApiUserInfo.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm";
|
||||
import { ApiLogin } from "./ApiLogin";
|
||||
import { ApiPermission } from "./ApiPermission";
|
||||
|
||||
export interface ApiUserInfoData {
|
||||
username: string;
|
||||
discriminator: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
@Entity("api_user_info")
|
||||
export class ApiUserInfo {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column("simple-json")
|
||||
data: ApiUserInfoData;
|
||||
|
||||
@Column()
|
||||
updated_at: string;
|
||||
|
||||
@OneToMany(type => ApiLogin, login => login.userInfo)
|
||||
logins: ApiLogin[];
|
||||
|
||||
@OneToMany(type => ApiPermission, perm => perm.userInfo)
|
||||
permissions: ApiPermission[];
|
||||
}
|
16
backend/src/data/entities/ArchiveEntry.ts
Normal file
16
backend/src/data/entities/ArchiveEntry.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity("archives")
|
||||
export class ArchiveEntry {
|
||||
@Column()
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id: string;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() body: string;
|
||||
|
||||
@Column() created_at: string;
|
||||
|
||||
@Column() expires_at: string;
|
||||
}
|
15
backend/src/data/entities/AutoReaction.ts
Normal file
15
backend/src/data/entities/AutoReaction.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
import { ISavedMessageData } from "./SavedMessage";
|
||||
|
||||
@Entity("auto_reactions")
|
||||
export class AutoReaction {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
channel_id: string;
|
||||
|
||||
@Column("simple-array") reactions: string[];
|
||||
}
|
34
backend/src/data/entities/Case.ts
Normal file
34
backend/src/data/entities/Case.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
|
||||
import { CaseNote } from "./CaseNote";
|
||||
|
||||
@Entity("cases")
|
||||
export class Case {
|
||||
@PrimaryGeneratedColumn() id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() case_number: number;
|
||||
|
||||
@Column() user_id: string;
|
||||
|
||||
@Column() user_name: string;
|
||||
|
||||
@Column() mod_id: string;
|
||||
|
||||
@Column() mod_name: string;
|
||||
|
||||
@Column() type: number;
|
||||
|
||||
@Column() audit_log_id: string;
|
||||
|
||||
@Column() created_at: string;
|
||||
|
||||
@Column() is_hidden: boolean;
|
||||
|
||||
@Column() pp_id: string;
|
||||
|
||||
@Column() pp_name: string;
|
||||
|
||||
@OneToMany(type => CaseNote, note => note.case)
|
||||
notes: CaseNote[];
|
||||
}
|
21
backend/src/data/entities/CaseNote.ts
Normal file
21
backend/src/data/entities/CaseNote.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from "typeorm";
|
||||
import { Case } from "./Case";
|
||||
|
||||
@Entity("case_notes")
|
||||
export class CaseNote {
|
||||
@PrimaryGeneratedColumn() id: number;
|
||||
|
||||
@Column() case_id: number;
|
||||
|
||||
@Column() mod_id: string;
|
||||
|
||||
@Column() mod_name: string;
|
||||
|
||||
@Column() body: string;
|
||||
|
||||
@Column() created_at: string;
|
||||
|
||||
@ManyToOne(type => Case, theCase => theCase.notes)
|
||||
@JoinColumn({ name: "case_id" })
|
||||
case: Case;
|
||||
}
|
28
backend/src/data/entities/Config.ts
Normal file
28
backend/src/data/entities/Config.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Entity, Column, PrimaryColumn, CreateDateColumn, ManyToOne, JoinColumn } from "typeorm";
|
||||
import { ApiUserInfo } from "./ApiUserInfo";
|
||||
|
||||
@Entity("configs")
|
||||
export class Config {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
key: string;
|
||||
|
||||
@Column()
|
||||
config: string;
|
||||
|
||||
@Column()
|
||||
is_active: boolean;
|
||||
|
||||
@Column()
|
||||
edited_by: string;
|
||||
|
||||
@Column()
|
||||
edited_at: string;
|
||||
|
||||
@ManyToOne(type => ApiUserInfo)
|
||||
@JoinColumn({ name: "edited_by" })
|
||||
userInfo: ApiUserInfo;
|
||||
}
|
18
backend/src/data/entities/Mute.ts
Normal file
18
backend/src/data/entities/Mute.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("mutes")
|
||||
export class Mute {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
user_id: string;
|
||||
|
||||
@Column() created_at: string;
|
||||
|
||||
@Column() expires_at: string;
|
||||
|
||||
@Column() case_id: number;
|
||||
}
|
16
backend/src/data/entities/NicknameHistoryEntry.ts
Normal file
16
backend/src/data/entities/NicknameHistoryEntry.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("nickname_history")
|
||||
export class NicknameHistoryEntry {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() user_id: string;
|
||||
|
||||
@Column() nickname: string;
|
||||
|
||||
@Column() timestamp: string;
|
||||
}
|
18
backend/src/data/entities/PersistedData.ts
Normal file
18
backend/src/data/entities/PersistedData.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("persisted_data")
|
||||
export class PersistedData {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
user_id: string;
|
||||
|
||||
@Column("simple-array") roles: string[];
|
||||
|
||||
@Column() nickname: string;
|
||||
|
||||
@Column() is_voice_muted: number;
|
||||
}
|
15
backend/src/data/entities/PingableRole.ts
Normal file
15
backend/src/data/entities/PingableRole.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
import { ISavedMessageData } from "./SavedMessage";
|
||||
|
||||
@Entity("pingable_roles")
|
||||
export class PingableRole {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() channel_id: string;
|
||||
|
||||
@Column() role_id: string;
|
||||
}
|
22
backend/src/data/entities/ReactionRole.ts
Normal file
22
backend/src/data/entities/ReactionRole.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("reaction_roles")
|
||||
export class ReactionRole {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
channel_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
message_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
emoji: string;
|
||||
|
||||
@Column() role_id: string;
|
||||
}
|
18
backend/src/data/entities/Reminder.ts
Normal file
18
backend/src/data/entities/Reminder.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("reminders")
|
||||
export class Reminder {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() user_id: string;
|
||||
|
||||
@Column() channel_id: string;
|
||||
|
||||
@Column() remind_at: string;
|
||||
|
||||
@Column() body: string;
|
||||
}
|
35
backend/src/data/entities/SavedMessage.ts
Normal file
35
backend/src/data/entities/SavedMessage.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
export interface ISavedMessageData {
|
||||
attachments?: object[];
|
||||
author: {
|
||||
username: string;
|
||||
discriminator: string;
|
||||
};
|
||||
content: string;
|
||||
embeds?: object[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Entity("messages")
|
||||
export class SavedMessage {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() channel_id: string;
|
||||
|
||||
@Column() user_id: string;
|
||||
|
||||
@Column() is_bot: boolean;
|
||||
|
||||
@Column("simple-json") data: ISavedMessageData;
|
||||
|
||||
@Column() posted_at: string;
|
||||
|
||||
@Column() deleted_at: string;
|
||||
|
||||
@Column() is_permanent: boolean;
|
||||
}
|
26
backend/src/data/entities/ScheduledPost.ts
Normal file
26
backend/src/data/entities/ScheduledPost.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
import { Attachment } from "eris";
|
||||
import { StrictMessageContent } from "../../utils";
|
||||
|
||||
@Entity("scheduled_posts")
|
||||
export class ScheduledPost {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() author_id: string;
|
||||
|
||||
@Column() author_name: string;
|
||||
|
||||
@Column() channel_id: string;
|
||||
|
||||
@Column("simple-json") content: StrictMessageContent;
|
||||
|
||||
@Column("simple-json") attachments: Attachment[];
|
||||
|
||||
@Column() post_at: string;
|
||||
|
||||
@Column() enable_mentions: boolean;
|
||||
}
|
16
backend/src/data/entities/SelfGrantableRole.ts
Normal file
16
backend/src/data/entities/SelfGrantableRole.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("self_grantable_roles")
|
||||
export class SelfGrantableRole {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() channel_id: string;
|
||||
|
||||
@Column() role_id: string;
|
||||
|
||||
@Column("simple-array") aliases: string[];
|
||||
}
|
14
backend/src/data/entities/SlowmodeChannel.ts
Normal file
14
backend/src/data/entities/SlowmodeChannel.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("slowmode_channels")
|
||||
export class SlowmodeChannel {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
channel_id: string;
|
||||
|
||||
@Column() slowmode_seconds: number;
|
||||
}
|
18
backend/src/data/entities/SlowmodeUser.ts
Normal file
18
backend/src/data/entities/SlowmodeUser.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("slowmode_users")
|
||||
export class SlowmodeUser {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
channel_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
user_id: string;
|
||||
|
||||
@Column() expires_at: string;
|
||||
}
|
23
backend/src/data/entities/Starboard.ts
Normal file
23
backend/src/data/entities/Starboard.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm";
|
||||
import { CaseNote } from "./CaseNote";
|
||||
import { StarboardMessage } from "./StarboardMessage";
|
||||
|
||||
@Entity("starboards")
|
||||
export class Starboard {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() channel_id: string;
|
||||
|
||||
@Column() channel_whitelist: string;
|
||||
|
||||
@Column() emoji: string;
|
||||
|
||||
@Column() reactions_required: number;
|
||||
|
||||
@OneToMany(type => StarboardMessage, msg => msg.starboard)
|
||||
starboardMessages: StarboardMessage[];
|
||||
}
|
25
backend/src/data/entities/StarboardMessage.ts
Normal file
25
backend/src/data/entities/StarboardMessage.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Entity, Column, PrimaryColumn, OneToMany, ManyToOne, JoinColumn, OneToOne } from "typeorm";
|
||||
import { Starboard } from "./Starboard";
|
||||
import { Case } from "./Case";
|
||||
import { SavedMessage } from "./SavedMessage";
|
||||
|
||||
@Entity("starboard_messages")
|
||||
export class StarboardMessage {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
starboard_id: number;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
message_id: string;
|
||||
|
||||
@Column() starboard_message_id: string;
|
||||
|
||||
@ManyToOne(type => Starboard, sb => sb.starboardMessages)
|
||||
@JoinColumn({ name: "starboard_id" })
|
||||
starboard: Starboard;
|
||||
|
||||
@OneToOne(type => SavedMessage)
|
||||
@JoinColumn({ name: "message_id" })
|
||||
message: SavedMessage;
|
||||
}
|
18
backend/src/data/entities/Tag.ts
Normal file
18
backend/src/data/entities/Tag.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
|
||||
|
||||
@Entity("tags")
|
||||
export class Tag {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
tag: string;
|
||||
|
||||
@Column() user_id: string;
|
||||
|
||||
@Column() body: string;
|
||||
|
||||
@Column() created_at: string;
|
||||
}
|
14
backend/src/data/entities/TagResponse.ts
Normal file
14
backend/src/data/entities/TagResponse.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
|
||||
|
||||
@Entity("tag_responses")
|
||||
export class TagResponse {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() command_message_id: string;
|
||||
|
||||
@Column() response_message_id: string;
|
||||
}
|
14
backend/src/data/entities/UsernameHistoryEntry.ts
Normal file
14
backend/src/data/entities/UsernameHistoryEntry.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("username_history")
|
||||
export class UsernameHistoryEntry {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column() user_id: string;
|
||||
|
||||
@Column() username: string;
|
||||
|
||||
@Column() timestamp: string;
|
||||
}
|
20
backend/src/data/entities/VCAlert.ts
Normal file
20
backend/src/data/entities/VCAlert.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("vc_alerts")
|
||||
export class VCAlert {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() requestor_id: string;
|
||||
|
||||
@Column() user_id: string;
|
||||
|
||||
@Column() channel_id: string;
|
||||
|
||||
@Column() expires_at: string;
|
||||
|
||||
@Column() body: string;
|
||||
}
|
178
backend/src/index.ts
Normal file
178
backend/src/index.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
import path from "path";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import fs from "fs";
|
||||
const fsp = fs.promises;
|
||||
|
||||
import { Knub, logger, PluginError, Plugin } from "knub";
|
||||
import { SimpleError } from "./SimpleError";
|
||||
|
||||
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
|
||||
import DiscordHTTPError from "eris/lib/errors/DiscordHTTPError"; // tslint:disable-line
|
||||
|
||||
import { Configs } from "./data/Configs";
|
||||
|
||||
require("dotenv").config({ path: path.resolve(__dirname, "..", "bot.env") });
|
||||
|
||||
// Error handling
|
||||
let recentPluginErrors = 0;
|
||||
const RECENT_PLUGIN_ERROR_EXIT_THRESHOLD = 5;
|
||||
|
||||
let recentDiscordErrors = 0;
|
||||
const RECENT_DISCORD_ERROR_EXIT_THRESHOLD = 5;
|
||||
|
||||
setInterval(() => (recentPluginErrors = Math.max(0, recentPluginErrors - 1)), 2500);
|
||||
setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), 2500);
|
||||
|
||||
function errorHandler(err) {
|
||||
// tslint:disable:no-console
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof PluginError) {
|
||||
// Tolerate a few recent plugin errors before crashing
|
||||
if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) {
|
||||
console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (err instanceof DiscordRESTError || err instanceof DiscordHTTPError) {
|
||||
// Discord API errors, usually safe to just log instead of crash
|
||||
// We still bail if we get a ton of them in a short amount of time
|
||||
if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) {
|
||||
console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// On other errors, crash immediately
|
||||
process.exit(1);
|
||||
}
|
||||
// tslint:enable:no-console
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", errorHandler);
|
||||
process.on("uncaughtException", errorHandler);
|
||||
|
||||
// Verify required Node.js version
|
||||
const REQUIRED_NODE_VERSION = "10.14.2";
|
||||
const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10));
|
||||
const actualVersionParts = process.versions.node.split(".").map(v => parseInt(v, 10));
|
||||
for (const [i, part] of actualVersionParts.entries()) {
|
||||
if (part > requiredParts[i]) break;
|
||||
if (part === requiredParts[i]) continue;
|
||||
throw new SimpleError(`Unsupported Node.js version! Must be at least ${REQUIRED_NODE_VERSION}`);
|
||||
}
|
||||
|
||||
// Always use UTC internally
|
||||
// This is also enforced for the database in data/db.ts
|
||||
import moment from "moment-timezone";
|
||||
moment.tz.setDefault("UTC");
|
||||
|
||||
import { Client } from "eris";
|
||||
import { connect } from "./data/db";
|
||||
import { availablePlugins, availableGlobalPlugins, basePlugins } from "./plugins/availablePlugins";
|
||||
import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
|
||||
import { customArgumentTypes } from "./customArgumentTypes";
|
||||
import { errorMessage, successMessage } from "./utils";
|
||||
import { startUptimeCounter } from "./uptime";
|
||||
import { AllowedGuilds } from "./data/AllowedGuilds";
|
||||
|
||||
logger.info("Connecting to database");
|
||||
connect().then(async conn => {
|
||||
const client = new Client(`Bot ${process.env.TOKEN}`, {
|
||||
getAllUsers: false,
|
||||
restMode: true,
|
||||
});
|
||||
client.setMaxListeners(100);
|
||||
|
||||
client.on("debug", message => {
|
||||
if (message.includes(" 429 ")) {
|
||||
logger.info(`[RATELIMITED] ${message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const allowedGuilds = new AllowedGuilds();
|
||||
const guildConfigs = new Configs();
|
||||
|
||||
const bot = new Knub(client, {
|
||||
plugins: availablePlugins,
|
||||
globalPlugins: availableGlobalPlugins,
|
||||
|
||||
options: {
|
||||
canLoadGuild(guildId): Promise<boolean> {
|
||||
return allowedGuilds.isAllowed(guildId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Plugins are enabled if they...
|
||||
* - are base plugins, i.e. always enabled, or
|
||||
* - are dependencies of other enabled plugins, or
|
||||
* - are explicitly enabled in the guild config
|
||||
*/
|
||||
async getEnabledPlugins(guildId, guildConfig): Promise<string[]> {
|
||||
const configuredPlugins = guildConfig.plugins || {};
|
||||
const pluginNames: string[] = Array.from(this.plugins.keys());
|
||||
const plugins: Array<typeof Plugin> = Array.from(this.plugins.values());
|
||||
const zeppelinPlugins: Array<typeof ZeppelinPlugin> = plugins.filter(
|
||||
p => p.prototype instanceof ZeppelinPlugin,
|
||||
) as Array<typeof ZeppelinPlugin>;
|
||||
|
||||
const enabledBasePlugins = pluginNames.filter(n => basePlugins.includes(n));
|
||||
const explicitlyEnabledPlugins = pluginNames.filter(pluginName => {
|
||||
return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false;
|
||||
});
|
||||
const enabledPlugins = new Set([...enabledBasePlugins, ...explicitlyEnabledPlugins]);
|
||||
|
||||
const pluginsEnabledAsDependencies = zeppelinPlugins.reduce((arr, pluginClass) => {
|
||||
if (!enabledPlugins.has(pluginClass.pluginName)) return arr;
|
||||
return arr.concat(pluginClass.dependencies);
|
||||
}, []);
|
||||
|
||||
const finalEnabledPlugins = new Set([
|
||||
...basePlugins,
|
||||
...pluginsEnabledAsDependencies,
|
||||
...explicitlyEnabledPlugins,
|
||||
]);
|
||||
return Array.from(finalEnabledPlugins.values());
|
||||
},
|
||||
|
||||
async getConfig(id) {
|
||||
const key = id === "global" ? "global" : `guild-${id}`;
|
||||
const row = await guildConfigs.getActiveByKey(key);
|
||||
if (row) {
|
||||
return yaml.safeLoad(row.config);
|
||||
}
|
||||
|
||||
logger.warn(`No config with key "${key}"`);
|
||||
return {};
|
||||
},
|
||||
|
||||
logFn: (level, msg) => {
|
||||
if (level === "debug") return;
|
||||
// tslint:disable-next-line
|
||||
console.log(`[${level.toUpperCase()}] ${msg}`);
|
||||
},
|
||||
|
||||
performanceDebug: {
|
||||
enabled: false,
|
||||
size: 30,
|
||||
threshold: 200,
|
||||
},
|
||||
|
||||
customArgumentTypes,
|
||||
|
||||
sendSuccessMessageFn(channel, body) {
|
||||
channel.createMessage(successMessage(body));
|
||||
},
|
||||
|
||||
sendErrorMessageFn(channel, body) {
|
||||
channel.createMessage(errorMessage(body));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
client.once("ready", () => {
|
||||
startUptimeCounter();
|
||||
});
|
||||
|
||||
logger.info("Starting the bot");
|
||||
bot.run();
|
||||
});
|
40
backend/src/migrateConfigsToDB.ts
Normal file
40
backend/src/migrateConfigsToDB.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
// tslint:disable:no-console
|
||||
import { connect } from "./data/db";
|
||||
import { Configs } from "./data/Configs";
|
||||
import path from "path";
|
||||
import * as _fs from "fs";
|
||||
const fs = _fs.promises;
|
||||
|
||||
const authorId = process.argv[2];
|
||||
if (!authorId) {
|
||||
console.error("No author id specified");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Connecting to database");
|
||||
connect().then(async () => {
|
||||
const configs = new Configs();
|
||||
|
||||
console.log("Loading config files");
|
||||
const configDir = path.join(__dirname, "..", "config");
|
||||
const configFiles = await fs.readdir(configDir);
|
||||
|
||||
console.log("Looping through config files");
|
||||
for (const configFile of configFiles) {
|
||||
const parts = configFile.split(".");
|
||||
const ext = parts[parts.length - 1];
|
||||
if (ext !== "yml") continue;
|
||||
|
||||
const id = parts.slice(0, -1).join(".");
|
||||
const key = id === "global" ? "global" : `guild-${id}`;
|
||||
if (await configs.hasConfig(key)) continue;
|
||||
|
||||
const content = await fs.readFile(path.join(configDir, configFile), { encoding: "utf8" });
|
||||
|
||||
console.log(`Migrating config for ${key}`);
|
||||
await configs.saveNewRevision(key, content, authorId);
|
||||
}
|
||||
|
||||
console.log("Done!");
|
||||
process.exit(0);
|
||||
});
|
112
backend/src/migrations/1540519249973-CreatePreTypeORMTables.ts
Normal file
112
backend/src/migrations/1540519249973-CreatePreTypeORMTables.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreatePreTypeORMTables1540519249973 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`archives\` (
|
||||
\`id\` VARCHAR(36) NOT NULL,
|
||||
\`guild_id\` VARCHAR(20) NOT NULL,
|
||||
\`body\` MEDIUMTEXT NOT NULL,
|
||||
\`created_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`expires_at\` DATETIME NULL DEFAULT NULL,
|
||||
PRIMARY KEY (\`id\`)
|
||||
)
|
||||
COLLATE='utf8mb4_general_ci'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`cases\` (
|
||||
\`id\` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
\`guild_id\` BIGINT(20) UNSIGNED NOT NULL,
|
||||
\`case_number\` INT(10) UNSIGNED NOT NULL,
|
||||
\`user_id\` BIGINT(20) UNSIGNED NOT NULL,
|
||||
\`user_name\` VARCHAR(128) NOT NULL,
|
||||
\`mod_id\` BIGINT(20) UNSIGNED NULL DEFAULT NULL,
|
||||
\`mod_name\` VARCHAR(128) NULL DEFAULT NULL,
|
||||
\`type\` INT(10) UNSIGNED NOT NULL,
|
||||
\`audit_log_id\` BIGINT(20) NULL DEFAULT NULL,
|
||||
\`created_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE INDEX \`mod_actions_guild_id_case_number_unique\` (\`guild_id\`, \`case_number\`),
|
||||
UNIQUE INDEX \`mod_actions_audit_log_id_unique\` (\`audit_log_id\`),
|
||||
INDEX \`mod_actions_user_id_index\` (\`user_id\`),
|
||||
INDEX \`mod_actions_mod_id_index\` (\`mod_id\`),
|
||||
INDEX \`mod_actions_created_at_index\` (\`created_at\`)
|
||||
)
|
||||
COLLATE = 'utf8mb4_general_ci'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`case_notes\` (
|
||||
\`id\` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
\`case_id\` INT(10) UNSIGNED NOT NULL,
|
||||
\`mod_id\` BIGINT(20) UNSIGNED NULL DEFAULT NULL,
|
||||
\`mod_name\` VARCHAR(128) NULL DEFAULT NULL,
|
||||
\`body\` TEXT NOT NULL,
|
||||
\`created_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`id\`),
|
||||
INDEX \`mod_action_notes_mod_action_id_index\` (\`case_id\`),
|
||||
INDEX \`mod_action_notes_mod_id_index\` (\`mod_id\`),
|
||||
INDEX \`mod_action_notes_created_at_index\` (\`created_at\`)
|
||||
)
|
||||
COLLATE = 'utf8mb4_general_ci'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`mutes\` (
|
||||
\`guild_id\` BIGINT(20) UNSIGNED NOT NULL,
|
||||
\`user_id\` BIGINT(20) UNSIGNED NOT NULL,
|
||||
\`created_at\` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`expires_at\` DATETIME NULL DEFAULT NULL,
|
||||
\`case_id\` INT(10) UNSIGNED NULL DEFAULT NULL,
|
||||
PRIMARY KEY (\`guild_id\`, \`user_id\`),
|
||||
INDEX \`mutes_expires_at_index\` (\`expires_at\`),
|
||||
INDEX \`mutes_case_id_foreign\` (\`case_id\`),
|
||||
CONSTRAINT \`mutes_case_id_foreign\` FOREIGN KEY (\`case_id\`) REFERENCES \`cases\` (\`id\`)
|
||||
ON DELETE SET NULL
|
||||
)
|
||||
COLLATE = 'utf8mb4_general_ci'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`persisted_data\` (
|
||||
\`guild_id\` VARCHAR(20) NOT NULL,
|
||||
\`user_id\` VARCHAR(20) NOT NULL,
|
||||
\`roles\` VARCHAR(1024) NULL DEFAULT NULL,
|
||||
\`nickname\` VARCHAR(255) NULL DEFAULT NULL,
|
||||
\`is_voice_muted\` INT(11) NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY (\`guild_id\`, \`user_id\`)
|
||||
)
|
||||
COLLATE = 'utf8mb4_general_ci'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`reaction_roles\` (
|
||||
\`guild_id\` VARCHAR(20) NOT NULL,
|
||||
\`channel_id\` VARCHAR(20) NOT NULL,
|
||||
\`message_id\` VARCHAR(20) NOT NULL,
|
||||
\`emoji\` VARCHAR(20) NOT NULL,
|
||||
\`role_id\` VARCHAR(20) NOT NULL,
|
||||
PRIMARY KEY (\`guild_id\`, \`channel_id\`, \`message_id\`, \`emoji\`),
|
||||
INDEX \`reaction_roles_message_id_emoji_index\` (\`message_id\`, \`emoji\`)
|
||||
)
|
||||
COLLATE = 'utf8mb4_general_ci'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`tags\` (
|
||||
\`guild_id\` BIGINT(20) UNSIGNED NOT NULL,
|
||||
\`tag\` VARCHAR(64) NOT NULL,
|
||||
\`user_id\` BIGINT(20) UNSIGNED NOT NULL,
|
||||
\`body\` TEXT NOT NULL,
|
||||
\`created_at\` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`guild_id\`, \`tag\`)
|
||||
)
|
||||
COLLATE = 'utf8mb4_general_ci'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
// No down function since we're migrating (hehe) from another migration system (knex)
|
||||
}
|
||||
}
|
72
backend/src/migrations/1543053430712-CreateMessagesTable.ts
Normal file
72
backend/src/migrations/1543053430712-CreateMessagesTable.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateMessagesTable1543053430712 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "messages",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "is_bot",
|
||||
type: "tinyint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "data",
|
||||
type: "mediumtext",
|
||||
},
|
||||
{
|
||||
name: "posted_at",
|
||||
type: "datetime(3)",
|
||||
},
|
||||
{
|
||||
name: "deleted_at",
|
||||
type: "datetime(3)",
|
||||
isNullable: true,
|
||||
default: null,
|
||||
},
|
||||
{
|
||||
name: "is_permanent",
|
||||
type: "tinyint",
|
||||
unsigned: true,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{ columnNames: ["guild_id"] },
|
||||
{ columnNames: ["channel_id"] },
|
||||
{ columnNames: ["user_id"] },
|
||||
{ columnNames: ["is_bot"] },
|
||||
{ columnNames: ["posted_at"] },
|
||||
{ columnNames: ["deleted_at"] },
|
||||
{ columnNames: ["is_permanent"] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("messages");
|
||||
}
|
||||
}
|
70
backend/src/migrations/1544877081073-CreateSlowmodeTables.ts
Normal file
70
backend/src/migrations/1544877081073-CreateSlowmodeTables.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateSlowmodeTables1544877081073 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "slowmode_channels",
|
||||
columns: [
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "slowmode_seconds",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
},
|
||||
],
|
||||
indices: [],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createPrimaryKey("slowmode_channels", ["guild_id", "channel_id"]);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "slowmode_users",
|
||||
columns: [
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "expires_at",
|
||||
type: "datetime",
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["expires_at"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createPrimaryKey("slowmode_users", ["guild_id", "channel_id", "user_id"]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await Promise.all([
|
||||
queryRunner.dropTable("slowmode_channels", true),
|
||||
queryRunner.dropTable("slowmode_users", true),
|
||||
]);
|
||||
}
|
||||
}
|
85
backend/src/migrations/1544887946307-CreateStarboardTable.ts
Normal file
85
backend/src/migrations/1544887946307-CreateStarboardTable.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateStarboardTable1544887946307 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "starboards",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_whitelist",
|
||||
type: "text",
|
||||
isNullable: true,
|
||||
default: null,
|
||||
},
|
||||
{
|
||||
name: "emoji",
|
||||
type: "varchar",
|
||||
length: "64",
|
||||
},
|
||||
{
|
||||
name: "reactions_required",
|
||||
type: "smallint",
|
||||
unsigned: true,
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id", "emoji"],
|
||||
},
|
||||
{
|
||||
columnNames: ["guild_id", "channel_id"],
|
||||
isUnique: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "starboard_messages",
|
||||
columns: [
|
||||
{
|
||||
name: "starboard_id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "message_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "starboard_message_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createPrimaryKey("starboard_messages", ["starboard_id", "message_id"]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("starboards", true);
|
||||
await queryRunner.dropTable("starboard_messages", true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateTagResponsesTable1546770935261 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "tag_responses",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "command_message_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "response_message_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id"],
|
||||
},
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ["command_message_id"],
|
||||
referencedTableName: "messages",
|
||||
referencedColumnNames: ["id"],
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
{
|
||||
columnNames: ["response_message_id"],
|
||||
referencedTableName: "messages",
|
||||
referencedColumnNames: ["id"],
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("tag_responses");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateNameHistoryTable1546778415930 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "name_history",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "tinyint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
type: "varchar",
|
||||
length: "128",
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: "timestamp",
|
||||
type: "datetime",
|
||||
default: "CURRENT_TIMESTAMP",
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id", "user_id"],
|
||||
},
|
||||
{
|
||||
columnNames: ["type"],
|
||||
},
|
||||
{
|
||||
columnNames: ["timestamp"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("name_history");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MakeNameHistoryValueLengthLonger1546788508314 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`name_history\`
|
||||
CHANGE COLUMN \`value\` \`value\` VARCHAR(160) NULL DEFAULT NULL COLLATE 'utf8mb4_swedish_ci' AFTER \`type\`;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`name_history\`
|
||||
CHANGE COLUMN \`value\` \`value\` VARCHAR(128) NULL DEFAULT NULL COLLATE 'utf8mb4_swedish_ci' AFTER \`type\`;
|
||||
`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateAutoReactionsTable1547290549908 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "auto_reactions",
|
||||
columns: [
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "reactions",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createPrimaryKey("auto_reactions", ["guild_id", "channel_id"]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("auto_reactions", true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreatePingableRolesTable1547293464842 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "pingable_roles",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "role_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id", "channel_id"],
|
||||
},
|
||||
{
|
||||
columnNames: ["guild_id", "channel_id", "role_id"],
|
||||
isUnique: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("pingable_roles", true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm";
|
||||
|
||||
export class AddIndexToArchivesExpiresAt1547392046629 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createIndex(
|
||||
"archives",
|
||||
new TableIndex({
|
||||
columnNames: ["expires_at"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropIndex(
|
||||
"archives",
|
||||
new TableIndex({
|
||||
columnNames: ["expires_at"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
25
backend/src/migrations/1547393619900-AddIsHiddenToCases.ts
Normal file
25
backend/src/migrations/1547393619900-AddIsHiddenToCases.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm";
|
||||
|
||||
export class AddIsHiddenToCases1547393619900 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.addColumn(
|
||||
"cases",
|
||||
new TableColumn({
|
||||
name: "is_hidden",
|
||||
type: "tinyint",
|
||||
unsigned: true,
|
||||
default: 0,
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"cases",
|
||||
new TableIndex({
|
||||
columnNames: ["is_hidden"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn("cases", "is_hidden");
|
||||
}
|
||||
}
|
19
backend/src/migrations/1549649586803-AddPPFieldsToCases.ts
Normal file
19
backend/src/migrations/1549649586803-AddPPFieldsToCases.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddPPFieldsToCases1549649586803 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`cases\`
|
||||
ADD COLUMN \`pp_id\` BIGINT NULL,
|
||||
ADD COLUMN \`pp_name\` VARCHAR(128) NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`cases\`
|
||||
DROP COLUMN \`pp_id\`,
|
||||
DROP COLUMN \`pp_name\`
|
||||
`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class FixEmojiIndexInReactionRoles1550409894008 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
// In utf8mb4_swedish_ci, different native emojis are counted as the same char for indexes, which means we can't
|
||||
// have multiple native emojis on a single message since the emoji field is part of the primary key
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`reaction_roles\`
|
||||
CHANGE COLUMN \`emoji\` \`emoji\` VARCHAR(64) NOT NULL COLLATE 'utf8mb4_bin' AFTER \`message_id\`
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`reaction_roles\`
|
||||
CHANGE COLUMN \`emoji\` \`emoji\` VARCHAR(64) NOT NULL AFTER \`message_id\`
|
||||
`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateSelfGrantableRolesTable1550521627877 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "self_grantable_roles",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "role_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "aliases",
|
||||
type: "varchar",
|
||||
length: "255",
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id", "channel_id", "role_id"],
|
||||
isUnique: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("self_grantable_roles", true);
|
||||
}
|
||||
}
|
53
backend/src/migrations/1550609900261-CreateRemindersTable.ts
Normal file
53
backend/src/migrations/1550609900261-CreateRemindersTable.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateRemindersTable1550609900261 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "reminders",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "remind_at",
|
||||
type: "datetime",
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id", "user_id"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("reminders", true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateUsernameHistoryTable1556908589679 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "username_history",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "username",
|
||||
type: "varchar",
|
||||
length: "160",
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: "timestamp",
|
||||
type: "datetime",
|
||||
default: "CURRENT_TIMESTAMP",
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["user_id"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("username_history", true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
const BATCH_SIZE = 200;
|
||||
|
||||
export class MigrateUsernamesToNewHistoryTable1556909512501 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
// Start by ending the migration transaction because this is gonna be a looooooooot of data
|
||||
await queryRunner.query("COMMIT");
|
||||
|
||||
const migratedUsernames = new Set();
|
||||
|
||||
await new Promise(async resolve => {
|
||||
const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history");
|
||||
stream.on("result", row => {
|
||||
migratedUsernames.add(row.key);
|
||||
});
|
||||
stream.on("end", resolve);
|
||||
});
|
||||
|
||||
const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => {
|
||||
return new Promise(async resolve => {
|
||||
const toInsert = [];
|
||||
const toDelete = [];
|
||||
|
||||
const stream = await queryRunner.stream(
|
||||
`SELECT * FROM name_history WHERE type=1 ORDER BY timestamp ASC LIMIT ${BATCH_SIZE}`,
|
||||
);
|
||||
stream.on("result", row => {
|
||||
const key = `${row.user_id}-${row.value}`;
|
||||
|
||||
if (!migratedUsernames.has(key)) {
|
||||
migratedUsernames.add(key);
|
||||
toInsert.push([row.user_id, row.value, row.timestamp]);
|
||||
}
|
||||
|
||||
toDelete.push(row.id);
|
||||
});
|
||||
stream.on("end", async () => {
|
||||
if (toInsert.length || toDelete.length) {
|
||||
await queryRunner.query("START TRANSACTION");
|
||||
|
||||
if (toInsert.length) {
|
||||
await queryRunner.query(
|
||||
"INSERT INTO username_history (user_id, username, timestamp) VALUES " +
|
||||
Array.from({ length: toInsert.length }, () => "(?, ?, ?)").join(","),
|
||||
toInsert.flat(),
|
||||
);
|
||||
}
|
||||
|
||||
if (toDelete.length) {
|
||||
await queryRunner.query(
|
||||
"DELETE FROM name_history WHERE id IN (" + Array.from("?".repeat(toDelete.length)).join(", ") + ")",
|
||||
toDelete,
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query("COMMIT");
|
||||
|
||||
resolve({ finished: false, migrated: toInsert.length });
|
||||
} else {
|
||||
resolve({ finished: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const result = await migrateNextBatch();
|
||||
if (result.finished) {
|
||||
break;
|
||||
} else {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.log(`Migrated ${result.migrated} usernames`);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query("START TRANSACTION");
|
||||
}
|
||||
|
||||
// tslint:disable-next-line:no-empty
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
|
||||
|
||||
export class TurnNameHistoryToNicknameHistory1556913287547 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn("name_history", "type");
|
||||
|
||||
// As a raw query because of some bug with renameColumn that generated an invalid query
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`name_history\`
|
||||
CHANGE COLUMN \`value\` \`nickname\` VARCHAR(160) NULL DEFAULT 'NULL' COLLATE 'utf8mb4_swedish_ci' AFTER \`user_id\`;
|
||||
`);
|
||||
|
||||
// Drop unneeded timestamp column index
|
||||
await queryRunner.dropIndex("name_history", "IDX_6bd0600f9d55d4e4a08b508999");
|
||||
|
||||
await queryRunner.renameTable("name_history", "nickname_history");
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.addColumn(
|
||||
"nickname_history",
|
||||
new TableColumn({
|
||||
name: "type",
|
||||
type: "tinyint",
|
||||
unsigned: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// As a raw query because of some bug with renameColumn that generated an invalid query
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`nickname_history\`
|
||||
CHANGE COLUMN \`nickname\` \`value\` VARCHAR(160) NULL DEFAULT 'NULL' COLLATE 'utf8mb4_swedish_ci' AFTER \`user_id\`
|
||||
`);
|
||||
|
||||
await queryRunner.renameTable("nickname_history", "name_history");
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue