Reorganize project. Add folder for shared code between backend/dashboard. Switch from jest to ava for tests.

This commit is contained in:
Dragory 2019-11-02 22:11:26 +02:00
parent 80a82fe348
commit 16111bbe84
162 changed files with 11056 additions and 9900 deletions

3
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/.cache
/dist
/node_modules

9
backend/api.env.example Normal file
View 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
View file

@ -0,0 +1,5 @@
TOKEN=
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_DATABASE=

5
backend/nodemon-api.json Normal file
View 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
View file

@ -0,0 +1,5 @@
{
"watch": "src",
"ext": "ts",
"exec": "node -r ts-node/register ./src/index.ts"
}

60
backend/ormconfig.js Normal file
View 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

File diff suppressed because it is too large Load diff

83
backend/package.json Normal file
View 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"
]
}
}

View 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
View 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());
}
}

View 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;
}
}

View 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();
}
}

View 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}`;
}
}

View 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
View 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
View 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
View 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
View 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}`));
});

View 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" });
}

View 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;
}

View 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;
},
};

View 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 });
}
}

View 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),
},
);
}
}

View 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,
},
});
}
}

View file

@ -0,0 +1,6 @@
export enum ApiRoles {
Viewer = 1,
Editor,
Manager,
ServerOwner,
}

View 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 });
}
});
}
}

View 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>;
}
}

View 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;
}
}

View 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,
};

View file

@ -0,0 +1,11 @@
export enum CaseTypes {
Ban = 1,
Unban,
Note,
Warn,
Kick,
Mute,
Unmute,
Expunged,
Softban,
}

View 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,
});
});
}
}

View 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}"
}

View 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}`;
}
}

View 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,
});
}
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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,
});
}
}

View 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();
}
}
}

View 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,
});
}
}

View 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,
});
}
}

View 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,
});
}
}

View 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,
});
}
}

View 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);
}
}
}

View 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,
});
}
}

View 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,
});
}
}

View 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();
}
}

View 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,
});
}
}

View 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,
});
}
}

View 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,
});
}
}

View 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,
}

View 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
View 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
View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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[];
}

View 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;
}

View 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[];
}

View 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[];
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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[];
}

View 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;
}

View 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;
}

View 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[];
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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();
});

View 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);
});

View 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)
}
}

View 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");
}
}

View 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),
]);
}
}

View 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);
}
}

View file

@ -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");
}
}

View file

@ -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");
}
}

View file

@ -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\`;
`);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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"],
}),
);
}
}

View 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");
}
}

View 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\`
`);
}
}

View file

@ -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\`
`);
}
}

View file

@ -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);
}
}

View 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);
}
}

View file

@ -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);
}
}

View file

@ -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> {}
}

View file

@ -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