3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-23 17:45:03 +00:00

Merge branch 'master' into setup-documentation

This commit is contained in:
Bluenix 2021-09-24 20:01:55 +02:00 committed by GitHub
commit 6a3007562e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
429 changed files with 8120 additions and 2717 deletions

5
.prettierignore Normal file
View file

@ -0,0 +1,5 @@
.github
.idea
node_modules
/assets
/debug

1
backend/.prettierignore Normal file
View file

@ -0,0 +1 @@
/dist

View file

@ -1,24 +1,24 @@
const fs = require('fs');
const path = require('path');
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') });
fs.accessSync(path.resolve(__dirname, "bot.env"));
require("dotenv").config({ path: path.resolve(__dirname, "bot.env") });
} catch {
try {
fs.accessSync(path.resolve(__dirname, 'api.env'));
require('dotenv').config({ path: path.resolve(__dirname, 'api.env') });
fs.accessSync(path.resolve(__dirname, "api.env"));
require("dotenv").config({ path: path.resolve(__dirname, "api.env") });
} catch {
throw new Error("bot.env or api.env required");
}
}
const moment = require('moment-timezone');
moment.tz.setDefault('UTC');
const moment = require("moment-timezone");
moment.tz.setDefault("UTC");
const entities = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js'));
const migrations = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js'));
const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations'));
const entities = path.relative(process.cwd(), path.resolve(__dirname, "dist/backend/src/data/entities/*.js"));
const migrations = path.relative(process.cwd(), path.resolve(__dirname, "dist/backend/src/migrations/*.js"));
const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, "src/migrations"));
module.exports = {
type: "mysql",
@ -26,26 +26,29 @@ module.exports = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
charset: 'utf8mb4',
charset: "utf8mb4",
supportBigNumbers: true,
bigNumberStrings: true,
dateStrings: true,
synchronize: false,
connectTimeout: 2000,
logging: ["error", "warn"],
maxQueryExecutionTime: 250,
// Entities
entities: [entities],
// Pool options
extra: {
typeCast(field, next) {
if (field.type === 'DATETIME') {
if (field.type === "DATETIME") {
const val = field.string();
return val != null ? moment.utc(val).format('YYYY-MM-DD HH:mm:ss') : null;
return val != null ? moment.utc(val).format("YYYY-MM-DD HH:mm:ss") : null;
}
return next();
}
},
},
// Migrations

View file

@ -24,7 +24,7 @@
"humanize-duration": "^3.15.0",
"io-ts": "^2.0.0",
"js-yaml": "^3.13.1",
"knub": "^30.0.0-beta.39",
"knub": "^30.0.0-beta.45",
"knub-command-manager": "^9.1.0",
"last-commit-log": "^2.1.0",
"lodash.chunk": "^4.2.0",
@ -53,7 +53,8 @@
"utf-8-validate": "^5.0.5",
"uuid": "^3.3.2",
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
"zlib-sync": "^0.1.7"
"zlib-sync": "^0.1.7",
"zod": "^3.7.2"
},
"devDependencies": {
"@types/cors": "^2.8.5",
@ -3042,9 +3043,9 @@
}
},
"node_modules/knub": {
"version": "30.0.0-beta.39",
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz",
"integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==",
"version": "30.0.0-beta.45",
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz",
"integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==",
"dependencies": {
"discord-api-types": "^0.22.0",
"discord.js": "^13.0.1",
@ -5965,6 +5966,14 @@
"dependencies": {
"nan": "^2.14.0"
}
},
"node_modules/zod": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.7.2.tgz",
"integrity": "sha512-JhYYcj+TS/a0+3kqxnbmuXMVtA+QkJUPu91beQTo1Y3xA891pHeMPQgVOSu97FdzAd056Yp87lpEi8Xvmd3zhw==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
},
"dependencies": {
@ -8281,9 +8290,9 @@
}
},
"knub": {
"version": "30.0.0-beta.39",
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz",
"integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==",
"version": "30.0.0-beta.45",
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz",
"integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==",
"requires": {
"discord-api-types": "^0.22.0",
"discord.js": "^13.0.1",
@ -10527,6 +10536,11 @@
"requires": {
"nan": "^2.14.0"
}
},
"zod": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.7.2.tgz",
"integrity": "sha512-JhYYcj+TS/a0+3kqxnbmuXMVtA+QkJUPu91beQTo1Y3xA891pHeMPQgVOSu97FdzAd056Yp87lpEi8Xvmd3zhw=="
}
}
}

View file

@ -7,11 +7,11 @@
"watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"",
"watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"",
"build": "rimraf dist && tsc",
"start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9229 dist/backend/src/index.js init",
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js",
"start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9229 dist/backend/src/index.js init",
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/index.js",
"watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"",
"start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9239 dist/backend/src/api/index.js init",
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js",
"start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9239 dist/backend/src/api/index.js init",
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/api/index.js",
"watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"",
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
"migrate-prod": "npm run typeorm -- migration:run",
@ -39,7 +39,7 @@
"humanize-duration": "^3.15.0",
"io-ts": "^2.0.0",
"js-yaml": "^3.13.1",
"knub": "^30.0.0-beta.39",
"knub": "^30.0.0-beta.45",
"knub-command-manager": "^9.1.0",
"last-commit-log": "^2.1.0",
"lodash.chunk": "^4.2.0",
@ -68,7 +68,8 @@
"utf-8-validate": "^5.0.5",
"uuid": "^3.3.2",
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
"zlib-sync": "^0.1.7"
"zlib-sync": "^0.1.7",
"zod": "^3.7.2"
},
"devDependencies": {
"@types/cors": "^2.8.5",

View file

@ -6,9 +6,9 @@
* https://github.com/TypeStrong/ts-node/pull/254
*/
const path = require('path');
const tsconfig = require('./tsconfig.json');
const tsconfigPaths = require('tsconfig-paths');
const path = require("path");
const tsconfig = require("./tsconfig.json");
const tsconfigPaths = require("tsconfig-paths");
// E.g. ./dist/backend
const baseUrl = path.resolve(tsconfig.compilerOptions.outDir, path.basename(__dirname));

View file

@ -28,11 +28,11 @@ export class Queue<TQueueFunction extends AnyFn = AnyFn> {
return this.queue.length + (this.running ? 1 : 0);
}
public add(fn: TQueueFunction): Promise<void> {
const promise = new Promise<void>(resolve => {
public add(fn: TQueueFunction): Promise<any> {
const promise = new Promise<any>((resolve) => {
this.queue.push(async () => {
await fn();
resolve();
const result = await fn();
resolve(result);
});
if (!this.running) this.next();
@ -50,7 +50,7 @@ export class Queue<TQueueFunction extends AnyFn = AnyFn> {
}
const fn = this.queue.shift()!;
new Promise(resolve => {
new Promise((resolve) => {
// Either fn() completes or the timeout is reached
void fn().then(resolve);
setTimeout(resolve, this._timeout);

View file

@ -42,7 +42,7 @@ export class QueuedEventEmitter {
const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])];
let promise: Promise<any> = Promise.resolve();
listeners.forEach(listener => {
listeners.forEach((listener) => {
promise = this.queue.add(listener.bind(null, ...args));
});

View file

@ -27,7 +27,7 @@ const INITIAL_REGEX_TIMEOUT = 5 * SECONDS;
const INITIAL_REGEX_TIMEOUT_DURATION = 30 * SECONDS;
const FINAL_REGEX_TIMEOUT = 5 * SECONDS;
const regexTimeoutUpgradePromise = new Promise(resolve => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION));
const regexTimeoutUpgradePromise = new Promise((resolve) => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION));
let newWorkerTimeout = INITIAL_REGEX_TIMEOUT;
regexTimeoutUpgradePromise.then(() => (newWorkerTimeout = FINAL_REGEX_TIMEOUT));

View file

@ -33,21 +33,21 @@ function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
Authorization: `Bearer ${bearerToken}`,
},
},
res => {
(res) => {
if (res.statusCode !== 200) {
reject(new Error(`Discord API error ${res.statusCode}`));
return;
}
let rawData = "";
res.on("data", data => (rawData += data));
res.on("data", (data) => (rawData += data));
res.on("end", () => {
resolve(JSON.parse(rawData));
});
},
);
request.on("error", err => reject(err));
request.on("error", (err) => reject(err));
});
}
@ -149,7 +149,7 @@ export function initAuth(app: express.Express) {
return res.json({ valid: false });
}
res.json({ valid: true });
res.json({ valid: true, userId });
});
app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => {
await apiLogins.expireApiKey(req.user!.apiKey);

View file

@ -22,21 +22,21 @@ function formatConfigSchema(schema) {
} else if (schema.name.startsWith("Optional<")) {
return `Optional<${formatConfigSchema(schema.types[0])}>`;
} else {
return schema.types.map(t => formatConfigSchema(t)).join(" | ");
return schema.types.map((t) => formatConfigSchema(t)).join(" | ");
}
} else if (schema._tag === "IntersectionType") {
return schema.types.map(t => formatConfigSchema(t)).join(" & ");
return schema.types.map((t) => formatConfigSchema(t)).join(" & ");
} else {
return schema.name;
}
}
export function initDocs(app: express.Express) {
const docsPlugins = guildPlugins.filter(plugin => plugin.showInDocs);
const docsPlugins = guildPlugins.filter((plugin) => plugin.showInDocs);
app.get("/docs/plugins", (req: express.Request, res: express.Response) => {
res.json(
docsPlugins.map(plugin => {
docsPlugins.map((plugin) => {
const thinInfo = plugin.info ? { prettyName: plugin.info.prettyName, legacy: plugin.info.legacy ?? false } : {};
return {
name: plugin.name,
@ -56,7 +56,7 @@ export function initDocs(app: express.Express) {
const name = plugin.name;
const info = plugin.info || {};
const commands = (plugin.commands || []).map(cmd => ({
const commands = (plugin.commands || []).map((cmd) => ({
trigger: cmd.trigger,
permission: cmd.permission,
signature: cmd.signature,

View file

@ -3,15 +3,21 @@ import express, { Request, Response } from "express";
import { YAMLException } from "js-yaml";
import { validateGuildConfig } from "../configValidator";
import { AllowedGuilds } from "../data/AllowedGuilds";
import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments";
import { ApiPermissionAssignments, ApiPermissionTypes } from "../data/ApiPermissionAssignments";
import { Configs } from "../data/Configs";
import { apiTokenAuthHandlers } from "./auth";
import { hasGuildPermission, requireGuildPermission } from "./permissions";
import { clientError, ok, serverError, unauthorized } from "./responses";
import { loadYamlSafely } from "../utils/loadYamlSafely";
import { ObjectAliasError } from "../utils/validateNoObjectAliases";
import { isSnowflake } from "../utils";
import moment from "moment-timezone";
import { ApiAuditLog } from "../data/ApiAuditLog";
import { AuditLogEventTypes } from "../data/apiAuditLogTypes";
import { Queue } from "../Queue";
const apiPermissionAssignments = new ApiPermissionAssignments();
const auditLog = new ApiAuditLog();
export function initGuildsAPI(app: express.Express) {
const allowedGuilds = new AllowedGuilds();
@ -25,6 +31,14 @@ export function initGuildsAPI(app: express.Express) {
res.json(guilds);
});
guildRouter.get(
"/my-permissions", // a
async (req: Request, res: Response) => {
const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId);
res.json(permissions);
},
);
guildRouter.get("/:guildId", async (req: Request, res: Response) => {
if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) {
return unauthorized(res);
@ -101,5 +115,65 @@ export function initGuildsAPI(app: express.Express) {
},
);
const permissionManagementQueue = new Queue();
guildRouter.post(
"/:guildId/set-target-permissions",
requireGuildPermission(ApiPermissions.ManageAccess),
async (req: Request, res: Response) => {
await permissionManagementQueue.add(async () => {
const { type, targetId, permissions, expiresAt } = req.body;
if (type !== ApiPermissionTypes.User) {
return clientError(res, "Invalid type");
}
if (!isSnowflake(targetId)) {
return clientError(res, "Invalid targetId");
}
const validPermissions = new Set(Object.values(ApiPermissions));
validPermissions.delete(ApiPermissions.Owner);
if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) {
return clientError(res, "Invalid permissions");
}
if (expiresAt != null && !moment.utc(expiresAt).isValid()) {
return clientError(res, "Invalid expiresAt");
}
const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);
if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) {
return clientError(res, "Can't change owner permissions");
}
if (permissions.length === 0) {
await apiPermissionAssignments.removeUser(req.params.guildId, targetId);
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: targetId,
});
} else {
const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);
if (existing) {
await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions);
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: targetId,
permissions,
expires_at: existing.expires_at,
});
} else {
await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt);
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: targetId,
permissions,
expires_at: expiresAt,
});
}
}
ok(res);
});
},
);
app.use("/guilds", guildRouter);
}

View file

@ -6,6 +6,7 @@ import { initAuth } from "./auth";
import { initDocs } from "./docs";
import { initGuildsAPI } from "./guilds";
import { clientError, error, notFound } from "./responses";
import { startBackgroundTasks } from "./tasks";
const app = express();
@ -14,7 +15,11 @@ app.use(
origin: process.env.DASHBOARD_URL,
}),
);
app.use(express.json());
app.use(
express.json({
limit: "10mb",
}),
);
initAuth(app);
initGuildsAPI(app);
@ -43,3 +48,5 @@ app.use((req, res, next) => {
const port = (process.env.PORT && parseInt(process.env.PORT, 10)) || 3000;
app.listen(port, "0.0.0.0", () => console.log(`API server listening on port ${port}`)); // tslint:disable-line
startBackgroundTasks();

10
backend/src/api/tasks.ts Normal file
View file

@ -0,0 +1,10 @@
import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments";
import { MINUTES } from "../utils";
export function startBackgroundTasks() {
// Clear expired API permissions every minute
const apiPermissions = new ApiPermissionAssignments();
setInterval(() => {
apiPermissions.clearExpiredPermissions();
}, 1 * MINUTES);
}

View file

@ -36,7 +36,7 @@ export async function validateGuildConfig(config: any): Promise<string | null> {
const plugin = pluginNameToPlugin.get(pluginName)!;
try {
const mergedOptions = configUtils.mergeConfig(plugin.defaultOptions || {}, pluginOptions);
await plugin.configPreprocessor?.((mergedOptions as unknown) as PluginOptions<any>, true);
await plugin.configPreprocessor?.(mergedOptions as unknown as PluginOptions<any>, true);
} catch (err) {
if (err instanceof ConfigValidationError || err instanceof StrictValidationError) {
return `${pluginName}: ${err.message}`;

View file

@ -2,6 +2,8 @@ import { getRepository, Repository } from "typeorm";
import { ApiPermissionTypes } from "./ApiPermissionAssignments";
import { BaseRepository } from "./BaseRepository";
import { AllowedGuild } from "./entities/AllowedGuild";
import moment from "moment-timezone";
import { DBDateFormat } from "../utils";
export class AllowedGuilds extends BaseRepository {
private allowedGuilds: Repository<AllowedGuild>;
@ -37,7 +39,10 @@ export class AllowedGuilds extends BaseRepository {
}
updateInfo(id, name, icon, ownerId) {
return this.allowedGuilds.update({ id }, { name, icon, owner_id: ownerId });
return this.allowedGuilds.update(
{ id },
{ name, icon, owner_id: ownerId, updated_at: moment.utc().format(DBDateFormat) },
);
}
add(id, data: Partial<Omit<AllowedGuild, "id">> = {}) {

View file

@ -0,0 +1,28 @@
import { BaseRepository } from "./BaseRepository";
import { getRepository, Repository } from "typeorm/index";
import { ApiAuditLogEntry } from "./entities/ApiAuditLogEntry";
import { ApiLogin } from "./entities/ApiLogin";
import { AuditLogEventData, AuditLogEventType } from "./apiAuditLogTypes";
export class ApiAuditLog extends BaseRepository {
private auditLog: Repository<ApiAuditLogEntry<any>>;
constructor() {
super();
this.auditLog = getRepository(ApiAuditLogEntry);
}
addEntry<TEventType extends AuditLogEventType>(
guildId: string,
authorId: string,
eventType: TEventType,
eventData: AuditLogEventData[TEventType],
) {
this.auditLog.insert({
guild_id: guildId,
author_id: authorId,
event_type: eventType as any,
event_data: eventData as any,
});
}
}

View file

@ -68,10 +68,7 @@ export class ApiLogins extends BaseRepository {
token: hashedToken,
user_id: userId,
logged_in_at: moment.utc().format(DBDateFormat),
expires_at: moment
.utc()
.add(LOGIN_EXPIRY_TIME, "ms")
.format(DBDateFormat),
expires_at: moment.utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
});
return `${loginId}.${token}`;
@ -96,10 +93,7 @@ export class ApiLogins extends BaseRepository {
await this.apiLogins.update(
{ id: loginId },
{
expires_at: moment()
.utc()
.add(LOGIN_EXPIRY_TIME, "ms")
.format(DBDateFormat),
expires_at: moment().utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
},
);
}

View file

@ -2,6 +2,9 @@ import { ApiPermissions } from "@shared/apiPermissions";
import { getRepository, Repository } from "typeorm";
import { BaseRepository } from "./BaseRepository";
import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment";
import { Permissions } from "discord.js";
import { ApiAuditLog } from "./ApiAuditLog";
import { AuditLogEventTypes } from "./apiAuditLogTypes";
export enum ApiPermissionTypes {
User = "USER",
@ -10,10 +13,12 @@ export enum ApiPermissionTypes {
export class ApiPermissionAssignments extends BaseRepository {
private apiPermissions: Repository<ApiPermissionAssignment>;
private auditLogs: ApiAuditLog;
constructor() {
super();
this.apiPermissions = getRepository(ApiPermissionAssignment);
this.auditLogs = new ApiAuditLog();
}
getByGuildId(guildId) {
@ -43,16 +48,100 @@ export class ApiPermissionAssignments extends BaseRepository {
});
}
addUser(guildId, userId, permissions: ApiPermissions[]) {
addUser(guildId, userId, permissions: ApiPermissions[], expiresAt: string | null = null) {
return this.apiPermissions.insert({
guild_id: guildId,
type: ApiPermissionTypes.User,
target_id: userId,
permissions,
expires_at: expiresAt,
});
}
removeUser(guildId, userId) {
return this.apiPermissions.delete({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId });
}
async updateUserPermissions(guildId: string, userId: string, permissions: ApiPermissions[]): Promise<void> {
await this.apiPermissions.update(
{
guild_id: guildId,
type: ApiPermissionTypes.User,
target_id: userId,
},
{
permissions,
},
);
}
async clearExpiredPermissions() {
await this.apiPermissions
.createQueryBuilder()
.where("expires_at IS NOT NULL")
.andWhere("expires_at <= NOW()")
.delete();
}
async applyOwnerChange(guildId: string, newOwnerId: string) {
const existingPermissions = await this.getByGuildId(guildId);
let updatedOwner = false;
for (const perm of existingPermissions) {
let hasChanges = false;
// Remove owner permission from anyone who currently has it
if (perm.permissions.includes(ApiPermissions.Owner)) {
perm.permissions.splice(perm.permissions.indexOf(ApiPermissions.Owner), 1);
hasChanges = true;
}
// Add owner permission if we encounter the new owner
if (perm.type === ApiPermissionTypes.User && perm.target_id === newOwnerId) {
perm.permissions.push(ApiPermissions.Owner);
updatedOwner = true;
hasChanges = true;
}
if (hasChanges) {
const criteria = {
guild_id: perm.guild_id,
type: perm.type,
target_id: perm.target_id,
};
if (perm.permissions.length === 0) {
// No remaining permissions -> remove entry
this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.REMOVE_API_PERMISSION, {
type: perm.type,
target_id: perm.target_id,
});
await this.apiPermissions.delete(criteria);
} else {
this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.EDIT_API_PERMISSION, {
type: perm.type,
target_id: perm.target_id,
permissions: perm.permissions,
expires_at: perm.expires_at,
});
await this.apiPermissions.update(criteria, {
permissions: perm.permissions,
});
}
}
}
if (!updatedOwner) {
this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.ADD_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: newOwnerId,
permissions: [ApiPermissions.Owner],
expires_at: null,
});
await this.apiPermissions.insert({
guild_id: guildId,
type: ApiPermissionTypes.User,
target_id: newOwnerId,
permissions: [ApiPermissions.Owner],
});
}
}
}

View file

@ -22,7 +22,7 @@ export class ApiUserInfo extends BaseRepository {
}
update(id, data: ApiUserInfoData) {
return connection.transaction(async entityManager => {
return connection.transaction(async (entityManager) => {
const repo = entityManager.getRepository(ApiUserInfoEntity);
const existingInfo = await repo.findOne({ where: { id } });

View file

@ -41,11 +41,7 @@ export class Configs extends BaseRepository {
}
getActiveLargerThanId(id) {
return this.configs
.createQueryBuilder()
.where("id > :id", { id })
.andWhere("is_active = 1")
.getMany();
return this.configs.createQueryBuilder().where("id > :id", { id }).andWhere("is_active = 1").getMany();
}
async hasConfig(key) {
@ -65,7 +61,7 @@ export class Configs extends BaseRepository {
}
async saveNewRevision(key, config, editedBy) {
return connection.transaction(async entityManager => {
return connection.transaction(async (entityManager) => {
const repo = entityManager.getRepository(Config);
// Mark all old revisions inactive
await repo.update({ key }, { is_active: false });

View file

@ -13,9 +13,9 @@
"MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}",
"MEMBER_JOIN": "📥 {new} {userMention(member)} joined (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_ROLE_ADD": "🔑 {userMention(member)} received roles: **{roles}**",
"MEMBER_ROLE_REMOVE": "🔑 {userMention(member)} lost roles: **{roles}**",
"MEMBER_ROLE_CHANGES": "🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**",
"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",
@ -50,9 +50,9 @@
"STAGE_INSTANCE_DELETE": "📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>",
"STAGE_INSTANCE_UPDATE": "📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}",
"EMOJI_CREATE": "<{emoji.identifier}> Emoji `{emoji.name} ({emoji.id})` was created",
"EMOJI_DELETE": "👋 Emoji `{emoji.name} ({emoji.id})` was deleted",
"EMOJI_UPDATE": "<{newEmoji.identifier}> Emoji `{newEmoji.name} ({newEmoji.id})` was updated. Changes:\n{differenceString}",
"EMOJI_CREATE": "{emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created",
"EMOJI_DELETE": "👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted",
"EMOJI_UPDATE": "{newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}",
"STICKER_CREATE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}",
"STICKER_DELETE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.",

View file

@ -1,12 +1,17 @@
import { Guild, Snowflake } from "discord.js";
import { Guild, Snowflake, User } from "discord.js";
import moment from "moment-timezone";
import { isDefaultSticker } from "src/utils/isDefaultSticker";
import { getRepository, Repository } from "typeorm";
import { renderTemplate } from "../templateFormatter";
import { renderTemplate, TemplateSafeValueContainer } from "../templateFormatter";
import { trimLines } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { ArchiveEntry } from "./entities/ArchiveEntry";
import { SavedMessage } from "./entities/SavedMessage";
import {
channelToTemplateSafeChannel,
guildToTemplateSafeGuild,
userToTemplateSafeUser,
} from "../utils/templateSafeObjects";
const DEFAULT_EXPIRY_DAYS = 30;
@ -75,21 +80,25 @@ export class GuildArchives extends BaseGuildRepository {
const msgLines: string[] = [];
for (const msg of savedMessages) {
const channel = guild.channels.cache.get(msg.channel_id as Snowflake);
const user = { ...msg.data.author, id: msg.user_id };
const partialUser = new TemplateSafeValueContainer({ ...msg.data.author, id: msg.user_id });
const line = await renderTemplate(MESSAGE_ARCHIVE_MESSAGE_FORMAT, {
const line = await renderTemplate(
MESSAGE_ARCHIVE_MESSAGE_FORMAT,
new TemplateSafeValueContainer({
id: msg.id,
timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"),
content: msg.data.content,
attachments: msg.data.attachments?.map(att => {
attachments: msg.data.attachments?.map((att) => {
return JSON.stringify({ name: att.name, url: att.url, type: att.contentType });
}),
stickers: msg.data.stickers?.map(sti => {
stickers: msg.data.stickers?.map((sti) => {
return JSON.stringify({ name: sti.name, id: sti.id, isDefault: isDefaultSticker(sti.id) });
}),
user,
channel,
});
user: partialUser,
channel: channel ? channelToTemplateSafeChannel(channel) : null,
}),
);
msgLines.push(line);
}
return msgLines;
@ -100,7 +109,12 @@ export class GuildArchives extends BaseGuildRepository {
expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days");
}
const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild });
const headerStr = await renderTemplate(
MESSAGE_ARCHIVE_HEADER_FORMAT,
new TemplateSafeValueContainer({
guild: guildToTemplateSafeGuild(guild),
}),
);
const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild);
const messagesStr = msgLines.join("\n");

View file

@ -1,4 +1,4 @@
import { getRepository, In, Repository } from "typeorm";
import { getRepository, In, InsertResult, Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { CaseTypes } from "./CaseTypes";
import { connection } from "./db";
@ -116,13 +116,30 @@ export class GuildCases extends BaseGuildRepository {
);
}
async create(data): Promise<Case> {
const result = await this.cases.insert({
async createInternal(data): Promise<InsertResult> {
return 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})`,
})
.catch((err) => {
if (err?.code === "ER_DUP_ENTRY") {
if (data.audit_log_id) {
console.trace(`Tried to insert case with duplicate audit_log_id`);
return this.createInternal({
...data,
audit_log_id: undefined,
});
}
}
throw err;
});
}
async create(data): Promise<Case> {
const result = await this.createInternal(data);
return (await this.find(result.identifiers[0].id))!;
}
@ -131,7 +148,7 @@ export class GuildCases extends BaseGuildRepository {
}
async softDelete(id: number, deletedById: string, deletedByName: string, deletedByText: string) {
return connection.transaction(async entityManager => {
return connection.transaction(async (entityManager) => {
const cases = entityManager.getRepository(Case);
const caseNotes = entityManager.getRepository(CaseNote);

View file

@ -17,19 +17,11 @@ const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT
const decayQueue = new Queue();
async function deleteCountersMarkedToBeDeleted(): Promise<void> {
await getRepository(Counter)
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
await getRepository(Counter).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
}
async function deleteTriggersMarkedToBeDeleted(): Promise<void> {
await getRepository(CounterTrigger)
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
await getRepository(CounterTrigger).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
}
setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS);
@ -97,10 +89,7 @@ export class GuildCounters extends BaseGuildRepository {
criteria.id = Not(In(idsToKeep));
}
const deleteAt = moment
.utc()
.add(DELETE_UNUSED_COUNTERS_AFTER, "ms")
.format(DBDateFormat);
const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTERS_AFTER, "ms").format(DBDateFormat);
await this.counters.update(criteria, {
delete_at: deleteAt,
@ -108,11 +97,7 @@ export class GuildCounters extends BaseGuildRepository {
}
async deleteCountersMarkedToBeDeleted(): Promise<void> {
await this.counters
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
await this.counters.createQueryBuilder().where("delete_at <= NOW()").delete().execute();
}
async changeCounterValue(
@ -230,14 +215,11 @@ export class GuildCounters extends BaseGuildRepository {
const triggersToMark = await triggersToMarkQuery.getMany();
if (triggersToMark.length) {
const deleteAt = moment
.utc()
.add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms")
.format(DBDateFormat);
const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms").format(DBDateFormat);
await this.counterTriggers.update(
{
id: In(triggersToMark.map(t => t.id)),
id: In(triggersToMark.map((t) => t.id)),
},
{
delete_at: deleteAt,
@ -247,11 +229,7 @@ export class GuildCounters extends BaseGuildRepository {
}
async deleteTriggersMarkedToBeDeleted(): Promise<void> {
await this.counterTriggers
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
await this.counterTriggers.createQueryBuilder().where("delete_at <= NOW()").delete().execute();
}
async initCounterTrigger(
@ -278,7 +256,7 @@ export class GuildCounters extends BaseGuildRepository {
throw new Error(`Invalid comparison value: ${reverseComparisonValue}`);
}
return connection.transaction(async entityManager => {
return connection.transaction(async (entityManager) => {
const existing = await entityManager.findOne(CounterTrigger, {
counter_id: counterId,
name: triggerName,
@ -330,7 +308,7 @@ export class GuildCounters extends BaseGuildRepository {
channelId = channelId || "0";
userId = userId || "0";
return connection.transaction(async entityManager => {
return connection.transaction(async (entityManager) => {
const previouslyTriggered = await entityManager.findOne(CounterTriggerState, {
trigger_id: counterTrigger.id,
user_id: userId!,
@ -378,7 +356,7 @@ export class GuildCounters extends BaseGuildRepository {
async checkAllValuesForTrigger(
counterTrigger: CounterTrigger,
): Promise<Array<{ channelId: string; userId: string }>> {
return connection.transaction(async entityManager => {
return connection.transaction(async (entityManager) => {
const matchingValues = await entityManager
.createQueryBuilder(CounterValue, "cv")
.leftJoin(
@ -395,7 +373,7 @@ export class GuildCounters extends BaseGuildRepository {
if (matchingValues.length) {
await entityManager.insert(
CounterTriggerState,
matchingValues.map(row => ({
matchingValues.map((row) => ({
trigger_id: counterTrigger.id,
channel_id: row.channel_id,
user_id: row.user_id,
@ -403,7 +381,7 @@ export class GuildCounters extends BaseGuildRepository {
);
}
return matchingValues.map(row => ({
return matchingValues.map((row) => ({
channelId: row.channel_id,
userId: row.user_id,
}));
@ -429,7 +407,7 @@ export class GuildCounters extends BaseGuildRepository {
channelId = channelId || "0";
userId = userId || "0";
return connection.transaction(async entityManager => {
return connection.transaction(async (entityManager) => {
const matchingValue = await entityManager
.createQueryBuilder(CounterValue, "cv")
.innerJoin(
@ -468,7 +446,7 @@ export class GuildCounters extends BaseGuildRepository {
async checkAllValuesForReverseTrigger(
counterTrigger: CounterTrigger,
): Promise<Array<{ channelId: string; userId: string }>> {
return connection.transaction(async entityManager => {
return connection.transaction(async (entityManager) => {
const matchingValues: Array<{
id: string;
triggerStateId: string;
@ -496,11 +474,11 @@ export class GuildCounters extends BaseGuildRepository {
if (matchingValues.length) {
await entityManager.delete(CounterTriggerState, {
id: In(matchingValues.map(v => v.triggerStateId)),
id: In(matchingValues.map((v) => v.triggerStateId)),
});
}
return matchingValues.map(row => ({
return matchingValues.map((row) => ({
channelId: row.channel_id,
userId: row.user_id,
}));

View file

@ -1,42 +1,54 @@
import { QueuedEventEmitter } from "../QueuedEventEmitter";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { Mute } from "./entities/Mute";
import { ScheduledPost } from "./entities/ScheduledPost";
import { Reminder } from "./entities/Reminder";
export class GuildEvents extends BaseGuildRepository {
private queuedEventEmitter: QueuedEventEmitter;
private pluginListeners: Map<string, Map<string, any[]>>;
constructor(guildId) {
super(guildId);
this.queuedEventEmitter = new QueuedEventEmitter();
interface GuildEventArgs extends Record<string, unknown[]> {
expiredMutes: [Mute[]];
scheduledPosts: [ScheduledPost[]];
reminders: [Reminder[]];
}
public on(pluginName: string, eventName: string, fn) {
this.queuedEventEmitter.on(eventName, fn);
type GuildEvent = keyof GuildEventArgs;
if (!this.pluginListeners.has(pluginName)) {
this.pluginListeners.set(pluginName, new Map());
type GuildEventListener<K extends GuildEvent> = (...args: GuildEventArgs[K]) => void;
type ListenerMap = {
[K in GuildEvent]?: Array<GuildEventListener<K>>;
};
const guildListeners: Map<string, ListenerMap> = new Map();
/**
* @return - Function to unregister the listener
*/
export function onGuildEvent<K extends GuildEvent>(
guildId: string,
eventName: K,
listener: GuildEventListener<K>,
): () => void {
if (!guildListeners.has(guildId)) {
guildListeners.set(guildId, {});
}
const listenerMap = guildListeners.get(guildId)!;
if (listenerMap[eventName] == null) {
listenerMap[eventName] = [];
}
listenerMap[eventName]!.push(listener);
return () => {
listenerMap[eventName]!.splice(listenerMap[eventName]!.indexOf(listener), 1);
};
}
const pluginListeners = this.pluginListeners.get(pluginName)!;
if (!pluginListeners.has(eventName)) {
pluginListeners.set(eventName, []);
export function emitGuildEvent<K extends GuildEvent>(guildId: string, eventName: K, args: GuildEventArgs[K]): void {
if (!guildListeners.has(guildId)) {
return;
}
const pluginEventListeners = pluginListeners.get(eventName)!;
pluginEventListeners.push(fn);
const listenerMap = guildListeners.get(guildId)!;
if (listenerMap[eventName] == null) {
return;
}
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);
for (const listener of listenerMap[eventName]!) {
listener(...args);
}
}

View file

@ -46,12 +46,12 @@ export class GuildLogs extends events.EventEmitter {
}
isLogIgnored(type: LogType, ignoreId: any) {
return this.ignoredLogs.some(info => type === info.type && ignoreId === info.ignoreId);
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),
this.ignoredLogs.findIndex((info) => type === info.type && ignoreId === info.ignoreId),
1,
);
}

View file

@ -19,7 +19,7 @@ export class GuildMemberTimezones extends BaseGuildRepository {
}
async set(memberId, timezone: string) {
await connection.transaction(async entityManager => {
await connection.transaction(async (entityManager) => {
const repo = entityManager.getRepository(MemberTimezone);
const existingRow = await repo.findOne({
guild_id: this.guildId,

View file

@ -35,12 +35,7 @@ export class GuildMutes extends BaseGuildRepository {
}
async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> {
const expiresAt = expiryTime
? moment
.utc()
.add(expiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss")
: null;
const expiresAt = expiryTime ? moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
const result = await this.mutes.insert({
guild_id: this.guildId,
@ -53,12 +48,7 @@ export class GuildMutes extends BaseGuildRepository {
}
async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) {
const expiresAt = newExpiryTime
? moment
.utc()
.add(newExpiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss")
: null;
const expiresAt = newExpiryTime ? moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
if (rolesToRestore && rolesToRestore.length) {
return this.mutes.update(
@ -89,7 +79,7 @@ export class GuildMutes extends BaseGuildRepository {
.createQueryBuilder("mutes")
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere(
new Brackets(qb => {
new Brackets((qb) => {
qb.where("expires_at > NOW()").orWhere("expires_at IS NULL");
}),
)

View file

@ -70,7 +70,7 @@ export class GuildNicknameHistory extends BaseGuildRepository {
if (toDelete.length > 0) {
await this.nicknameHistory.delete({
id: In(toDelete.map(v => v.id)),
id: In(toDelete.map((v) => v.id)),
});
}
}

View file

@ -45,9 +45,90 @@ export class GuildSavedMessages extends BaseGuildRepository {
timestamp: msg.createdTimestamp,
};
if (msg.attachments.size) data.attachments = [...msg.attachments.values()];
if (msg.embeds.length) data.embeds = msg.embeds;
if (msg.stickers?.size) data.stickers = [...msg.stickers.values()];
if (msg.attachments.size) {
data.attachments = Array.from(msg.attachments.values()).map((att) => ({
id: att.id,
contentType: att.contentType,
name: att.name,
proxyURL: att.proxyURL,
size: att.size,
spoiler: att.spoiler,
url: att.url,
width: att.width,
}));
}
if (msg.embeds.length) {
data.embeds = msg.embeds.map((embed) => ({
title: embed.title,
description: embed.description,
url: embed.url,
timestamp: embed.timestamp,
color: embed.color,
fields: embed.fields.map((field) => ({
name: field.name,
value: field.value,
inline: field.inline,
})),
author: embed.author
? {
name: embed.author.name,
url: embed.author.url,
iconURL: embed.author.iconURL,
proxyIconURL: embed.author.proxyIconURL,
}
: undefined,
thumbnail: embed.thumbnail
? {
url: embed.thumbnail.url,
proxyURL: embed.thumbnail.proxyURL,
height: embed.thumbnail.height,
width: embed.thumbnail.width,
}
: undefined,
image: embed.image
? {
url: embed.image.url,
proxyURL: embed.image.proxyURL,
height: embed.image.height,
width: embed.image.width,
}
: undefined,
video: embed.video
? {
url: embed.video.url,
proxyURL: embed.video.proxyURL,
height: embed.video.height,
width: embed.video.width,
}
: undefined,
footer: embed.footer
? {
text: embed.footer.text,
iconURL: embed.footer.iconURL,
proxyIconURL: embed.footer.proxyIconURL,
}
: undefined,
}));
}
if (msg.stickers?.size) {
data.stickers = Array.from(msg.stickers.values()).map((sticker) => ({
format: sticker.format,
guildId: sticker.guildId,
id: sticker.id,
name: sticker.name,
description: sticker.description,
available: sticker.available,
type: sticker.type,
}));
}
return data;
}
@ -131,18 +212,24 @@ export class GuildSavedMessages extends BaseGuildRepository {
try {
await this.messages.insert(data);
} catch (e) {
console.warn(e); // tslint:disable-line
if (e?.code === "ER_DUP_ENTRY") {
console.trace(`Tried to insert duplicate message ID: ${data.id}`);
return;
}
throw e;
}
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;
// FIXME: Hotfix
if (!msg.channel) {
return;
}
const savedMessageData = this.msgToSavedMessageData(msg);
const postedAt = moment.utc(msg.createdTimestamp, "x").format("YYYY-MM-DD HH:mm:ss");

View file

@ -11,10 +11,7 @@ export class GuildScheduledPosts extends BaseGuildRepository {
}
all(): Promise<ScheduledPost[]> {
return this.scheduledPosts
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.getMany();
return this.scheduledPosts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany();
}
getDueScheduledPosts(): Promise<ScheduledPost[]> {

View file

@ -67,10 +67,7 @@ export class GuildSlowmodes extends BaseGuildRepository {
const slowmode = await this.getChannelSlowmode(channelId);
if (!slowmode) return;
const expiresAt = moment
.utc()
.add(slowmode.slowmode_seconds, "seconds")
.format("YYYY-MM-DD HH:mm:ss");
const expiresAt = moment.utc().add(slowmode.slowmode_seconds, "seconds").format("YYYY-MM-DD HH:mm:ss");
if (await this.userHasSlowmode(channelId, userId)) {
// Update existing

View file

@ -30,10 +30,7 @@ export class GuildTempbans extends BaseGuildRepository {
}
async addTempban(userId, expiryTime, modId): Promise<Tempban> {
const expiresAt = moment
.utc()
.add(expiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss");
const expiresAt = moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss");
const result = await this.tempbans.insert({
guild_id: this.guildId,
@ -47,10 +44,7 @@ export class GuildTempbans extends BaseGuildRepository {
}
async updateExpiryTime(userId, newExpiryTime, modId) {
const expiresAt = moment
.utc()
.add(newExpiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss");
const expiresAt = moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss");
return this.tempbans.update(
{

View file

@ -19,10 +19,7 @@ export class GuildVCAlerts extends BaseGuildRepository {
}
async getAllGuildAlerts(): Promise<VCAlert[]> {
return this.allAlerts
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.getMany();
return this.allAlerts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany();
}
async getAlertsByUserId(userId: string): Promise<VCAlert[]> {

View file

@ -67,7 +67,7 @@ export class UsernameHistory extends BaseRepository {
if (toDelete.length > 0) {
await this.usernameHistory.delete({
id: In(toDelete.map(v => v.id)),
id: In(toDelete.map((v) => v.id)),
});
}
}

View file

@ -0,0 +1,47 @@
import { ApiPermissionTypes } from "./ApiPermissionAssignments";
export const AuditLogEventTypes = {
ADD_API_PERMISSION: "ADD_API_PERMISSION" as const,
EDIT_API_PERMISSION: "EDIT_API_PERMISSION" as const,
REMOVE_API_PERMISSION: "REMOVE_API_PERMISSION" as const,
EDIT_CONFIG: "EDIT_CONFIG" as const,
};
export type AuditLogEventType = keyof typeof AuditLogEventTypes;
export type AddApiPermissionEventData = {
target_id: string;
permissions: string[];
expires_at: string | null;
};
export type RemoveApiPermissionEventData = {
target_id: string;
};
export type EditConfigEventData = {};
export interface AuditLogEventData extends Record<AuditLogEventType, unknown> {
ADD_API_PERMISSION: {
type: ApiPermissionTypes;
target_id: string;
permissions: string[];
expires_at: string | null;
};
EDIT_API_PERMISSION: {
type: ApiPermissionTypes;
target_id: string;
permissions: string[];
expires_at: string | null;
};
REMOVE_API_PERMISSION: {
type: ApiPermissionTypes;
target_id: string;
};
EDIT_CONFIG: {};
}
export type AnyAuditLogEventData = AuditLogEventData[AuditLogEventType];

View file

@ -13,10 +13,7 @@ export async function cleanupConfigs() {
let rows;
// >1 month old: 1 config retained per month
const oneMonthCutoff = moment
.utc()
.subtract(30, "days")
.format(DBDateFormat);
const oneMonthCutoff = moment.utc().subtract(30, "days").format(DBDateFormat);
do {
rows = await connection.query(
`
@ -46,7 +43,7 @@ export async function cleanupConfigs() {
if (rows.length > 0) {
await configRepository.delete({
id: In(rows.map(r => r.id)),
id: In(rows.map((r) => r.id)),
});
}
@ -54,10 +51,7 @@ export async function cleanupConfigs() {
} while (rows.length === CLEAN_PER_LOOP);
// >2 weeks old: 1 config retained per day
const twoWeekCutoff = moment
.utc()
.subtract(2, "weeks")
.format(DBDateFormat);
const twoWeekCutoff = moment.utc().subtract(2, "weeks").format(DBDateFormat);
do {
rows = await connection.query(
`
@ -87,7 +81,7 @@ export async function cleanupConfigs() {
if (rows.length > 0) {
await configRepository.delete({
id: In(rows.map(r => r.id)),
id: In(rows.map((r) => r.id)),
});
}

View file

@ -11,32 +11,23 @@ import { SavedMessage } from "../entities/SavedMessage";
const RETENTION_PERIOD = 1 * DAYS;
const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES;
const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES;
const CLEAN_PER_LOOP = 500;
const CLEAN_PER_LOOP = 200;
export async function cleanupMessages(): Promise<number> {
let cleaned = 0;
const messagesRepository = getRepository(SavedMessage);
const deletedAtThreshold = moment
.utc()
.subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms")
.format(DBDateFormat);
const postedAtThreshold = moment
.utc()
.subtract(RETENTION_PERIOD, "ms")
.format(DBDateFormat);
const botPostedAtThreshold = moment
.utc()
.subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms")
.format(DBDateFormat);
const deletedAtThreshold = moment.utc().subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat);
const postedAtThreshold = moment.utc().subtract(RETENTION_PERIOD, "ms").format(DBDateFormat);
const botPostedAtThreshold = moment.utc().subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat);
// SELECT + DELETE messages in batches
// This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below
// when a message was being inserted at the same time
let rows;
let ids: string[];
do {
rows = await connection.query(
const deletedMessageRows = await connection.query(
`
SELECT id
FROM messages
@ -44,28 +35,54 @@ export async function cleanupMessages(): Promise<number> {
deleted_at IS NOT NULL
AND deleted_at <= ?
)
OR (
LIMIT ${CLEAN_PER_LOOP}
`,
[deletedAtThreshold],
);
const oldPostedRows = await connection.query(
`
SELECT id
FROM messages
WHERE (
posted_at <= ?
AND is_permanent = 0
)
OR (
LIMIT ${CLEAN_PER_LOOP}
`,
[postedAtThreshold],
);
const oldBotPostedRows = await connection.query(
`
SELECT id
FROM messages
WHERE (
is_bot = 1
AND posted_at <= ?
AND is_permanent = 0
)
LIMIT ${CLEAN_PER_LOOP}
`,
[deletedAtThreshold, postedAtThreshold, botPostedAtThreshold],
[botPostedAtThreshold],
);
if (rows.length > 0) {
ids = Array.from(
new Set([
...deletedMessageRows.map((r) => r.id),
...oldPostedRows.map((r) => r.id),
...oldBotPostedRows.map((r) => r.id),
]),
);
if (ids.length > 0) {
await messagesRepository.delete({
id: In(rows.map(r => r.id)),
id: In(ids),
});
}
cleaned += rows.length;
} while (rows.length === CLEAN_PER_LOOP);
cleaned += ids.length;
} while (ids.length > 0);
return cleaned;
}

View file

@ -11,10 +11,7 @@ export async function cleanupNicknames(): Promise<number> {
let cleaned = 0;
const nicknameHistoryRepository = getRepository(NicknameHistoryEntry);
const dateThreshold = moment
.utc()
.subtract(NICKNAME_RETENTION_PERIOD, "ms")
.format(DBDateFormat);
const dateThreshold = moment.utc().subtract(NICKNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
// Clean old nicknames (NICKNAME_RETENTION_PERIOD)
let rows;
@ -31,7 +28,7 @@ export async function cleanupNicknames(): Promise<number> {
if (rows.length > 0) {
await nicknameHistoryRepository.delete({
id: In(rows.map(r => r.id)),
id: In(rows.map((r) => r.id)),
});
}

View file

@ -11,10 +11,7 @@ export async function cleanupUsernames(): Promise<number> {
let cleaned = 0;
const usernameHistoryRepository = getRepository(UsernameHistoryEntry);
const dateThreshold = moment
.utc()
.subtract(USERNAME_RETENTION_PERIOD, "ms")
.format(DBDateFormat);
const dateThreshold = moment.utc().subtract(USERNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
// Clean old usernames (USERNAME_RETENTION_PERIOD)
let rows;
@ -31,7 +28,7 @@ export async function cleanupUsernames(): Promise<number> {
if (rows.length > 0) {
await usernameHistoryRepository.delete({
id: In(rows.map(r => r.id)),
id: In(rows.map((r) => r.id)),
});
}

View file

@ -14,4 +14,10 @@ export class AllowedGuild {
@Column()
owner_id: string;
@Column()
created_at: string;
@Column()
updated_at: string;
}

View file

@ -0,0 +1,25 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm";
import { ApiUserInfo } from "./ApiUserInfo";
import { AuditLogEventData, AuditLogEventType } from "../apiAuditLogTypes";
@Entity("api_audit_log")
export class ApiAuditLogEntry<TEventType extends AuditLogEventType> {
@Column()
@PrimaryColumn()
id: number;
@Column()
guild_id: string;
@Column()
author_id: string;
@Column({ type: String })
event_type: TEventType;
@Column("simple-json")
event_data: AuditLogEventData[TEventType];
@Column()
created_at: string;
}

View file

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

View file

@ -1,5 +1,6 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm";
import { ApiUserInfo } from "./ApiUserInfo";
import { ApiPermissionTypes } from "../ApiPermissionAssignments";
@Entity("api_permissions")
export class ApiPermissionAssignment {
@ -7,9 +8,9 @@ export class ApiPermissionAssignment {
@PrimaryColumn()
guild_id: string;
@Column()
@Column({ type: String })
@PrimaryColumn()
type: string;
type: ApiPermissionTypes;
@Column()
@PrimaryColumn()
@ -18,10 +19,10 @@ export class ApiPermissionAssignment {
@Column("simple-array")
permissions: string[];
@ManyToOne(
type => ApiUserInfo,
userInfo => userInfo.permissionAssignments,
)
@Column({ type: String, nullable: true })
expires_at: string | null;
@ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.permissionAssignments)
@JoinColumn({ name: "target_id" })
userInfo: ApiUserInfo;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +1,79 @@
import { MessageAttachment, Sticker } from "discord.js";
import { Snowflake } from "discord.js";
import { Column, Entity, PrimaryColumn } from "typeorm";
import { createEncryptedJsonTransformer } from "../encryptedJsonTransformer";
export interface ISavedMessageAttachmentData {
id: Snowflake;
contentType: string | null;
name: string | null;
proxyURL: string;
size: number;
spoiler: boolean;
url: string;
width: number | null;
}
export interface ISavedMessageEmbedData {
title: string | null;
description: string | null;
url: string | null;
timestamp: number | null;
color: number | null;
fields: Array<{
name: string;
value: string;
inline: boolean;
}>;
author?: {
name?: string;
url?: string;
iconURL?: string;
proxyIconURL?: string;
};
thumbnail?: {
url: string;
proxyURL?: string;
height?: number;
width?: number;
};
image?: {
url: string;
proxyURL?: string;
height?: number;
width?: number;
};
video?: {
url?: string;
proxyURL?: string;
height?: number;
width?: number;
};
footer?: {
text?: string;
iconURL?: string;
proxyIconURL?: string;
};
}
export interface ISavedMessageStickerData {
format: string;
guildId: Snowflake | null;
id: Snowflake;
name: string;
description: string | null;
available: boolean | null;
type: string | null;
}
export interface ISavedMessageData {
attachments?: MessageAttachment[];
attachments?: ISavedMessageAttachmentData[];
author: {
username: string;
discriminator: string;
};
content: string;
embeds?: object[];
stickers?: Sticker[];
embeds?: ISavedMessageEmbedData[];
stickers?: ISavedMessageStickerData[];
timestamp: number;
}

View file

@ -16,7 +16,7 @@ export class StarboardMessage {
@Column()
guild_id: string;
@OneToOne(type => SavedMessage)
@OneToOne((type) => SavedMessage)
@JoinColumn({ name: "message_id" })
message: SavedMessage;
}

View file

@ -16,7 +16,7 @@ export class StarboardReaction {
@Column()
reactor_id: string;
@OneToOne(type => SavedMessage)
@OneToOne((type) => SavedMessage)
@JoinColumn({ name: "message_id" })
message: SavedMessage;
}

View file

@ -1,5 +1,4 @@
import { Client, Intents, TextChannel } from "discord.js";
import yaml from "js-yaml";
import { Client, Constants, Intents, TextChannel, ThreadChannel } from "discord.js";
import { Knub, PluginError } from "knub";
import { PluginLoadError } from "knub/dist/plugins/PluginLoadError";
// Always use UTC internally
@ -18,8 +17,12 @@ import { RecoverablePluginError } from "./RecoverablePluginError";
import { SimpleError } from "./SimpleError";
import { ZeppelinGlobalConfig, ZeppelinGuildConfig } from "./types";
import { startUptimeCounter } from "./uptime";
import { errorMessage, isDiscordAPIError, isDiscordHTTPError, successMessage } from "./utils";
import { errorMessage, isDiscordAPIError, isDiscordHTTPError, SECONDS, successMessage } from "./utils";
import { loadYamlSafely } from "./utils/loadYamlSafely";
import { DecayingCounter } from "./utils/DecayingCounter";
import { PluginNotLoadedError } from "knub/dist/plugins/PluginNotLoadedError";
import { logRestCall } from "./restCallStats";
import { logRateLimit } from "./rateLimitStats";
if (!process.env.KEY) {
// tslint:disable-next-line:no-console
@ -94,6 +97,25 @@ function errorHandler(err) {
return;
}
// FIXME: Hotfix
if (err.message && err.message.startsWith("Unknown custom override criteria")) {
// console.warn(err.message);
return;
}
// FIXME: Hotfix
if (err.message && err.message.startsWith("Unknown override criteria")) {
// console.warn(err.message);
return;
}
if (err instanceof PluginNotLoadedError) {
// We don't want to crash the bot here, although this *should not happen*
// TODO: Proper system for preventing plugin load/unload race conditions
console.error(err);
return;
}
// tslint:disable:no-console
console.error(err);
@ -124,8 +146,8 @@ if (process.env.NODE_ENV === "production") {
// Verify required Node.js version
const REQUIRED_NODE_VERSION = "14.0.0";
const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10));
const actualVersionParts = process.versions.node.split(".").map(v => parseInt(v, 10));
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;
@ -136,10 +158,21 @@ moment.tz.setDefault("UTC");
logger.info("Connecting to database");
connect().then(async () => {
const RequestHandler = require("discord.js/src/rest/RequestHandler.js");
const originalPush = RequestHandler.prototype.push;
// tslint:disable-next-line:only-arrow-functions
RequestHandler.prototype.push = function (...args) {
const request = args[0];
logRestCall(request.method, request.path);
return originalPush.call(this, ...args);
};
const client = new Client({
partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE", "REACTION"],
restTimeOffset: 150,
restGlobalRateLimit: 50,
// restTimeOffset: 1000,
// Disable mentions by default
allowedMentions: {
parse: [],
@ -166,11 +199,31 @@ connect().then(async () => {
});
client.setMaxListeners(200);
client.on("rateLimit", rateLimitData => {
logger.info(`[429] ${JSON.stringify(rateLimitData)}`);
client.on(Constants.Events.RATE_LIMIT, (data) => {
// tslint:disable-next-line:no-console
// console.log(`[DEBUG] [RATE_LIMIT] ${JSON.stringify(data)}`);
});
client.on("error", err => {
const safe429DecayInterval = 5 * SECONDS;
const safe429MaxCount = 5;
const safe429Counter = new DecayingCounter(safe429DecayInterval);
client.on(Constants.Events.DEBUG, (errorText) => {
if (!errorText.includes("429")) {
return;
}
// tslint:disable-next-line:no-console
console.warn(`[DEBUG] [WARN] [429] ${errorText}`);
const value = safe429Counter.add(1);
if (value > safe429MaxCount) {
// tslint:disable-next-line:no-console
console.error(`Too many 429s (over ${safe429MaxCount} in ${safe429MaxCount * safe429DecayInterval}ms), exiting`);
process.exit(1);
}
});
client.on("error", (err) => {
errorHandler(new DiscordJSError(err.message, (err as any).code, 0));
});
@ -198,9 +251,9 @@ connect().then(async () => {
}
const configuredPlugins = ctx.config.plugins;
const basePluginNames = baseGuildPlugins.map(p => p.name);
const basePluginNames = baseGuildPlugins.map((p) => p.name);
return Array.from(plugins.keys()).filter(pluginName => {
return Array.from(plugins.keys()).filter((pluginName) => {
if (basePluginNames.includes(pluginName)) return true;
return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false;
});
@ -208,6 +261,13 @@ connect().then(async () => {
async getConfig(id) {
const key = id === "global" ? "global" : `guild-${id}`;
if (id !== "global") {
const allowedGuild = await allowedGuilds.find(id);
if (!allowedGuild) {
return {};
}
}
const row = await guildConfigs.getActiveByKey(key);
if (row) {
try {
@ -239,13 +299,15 @@ connect().then(async () => {
},
sendSuccessMessageFn(channel, body) {
const guildId = channel instanceof TextChannel ? channel.guild.id : undefined;
const guildId =
channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined;
const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.success_emoji : undefined;
channel.send(successMessage(body, emoji));
},
sendErrorMessageFn(channel, body) {
const guildId = channel instanceof TextChannel ? channel.guild.id : undefined;
const guildId =
channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined;
const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.error_emoji : undefined;
channel.send(errorMessage(body, emoji));
},
@ -256,6 +318,10 @@ connect().then(async () => {
startUptimeCounter();
});
client.on(Constants.Events.RATE_LIMIT, (data) => {
logRateLimit(data);
});
bot.initialize();
logger.info("Bot Initialized");
logger.info("Logging in...");

View file

@ -9,7 +9,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
const migratedUsernames = new Set();
await new Promise(async resolve => {
await new Promise(async (resolve) => {
const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history");
stream.on("data", (row: any) => {
migratedUsernames.add(row.key);
@ -18,7 +18,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
});
const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => {
return new Promise(async resolve => {
return new Promise(async (resolve) => {
const toInsert: any[][] = [];
const toDelete: number[] = [];

View file

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
export class AddExpiresAtToApiPermissions1630837386329 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns("api_permissions", [
new TableColumn({
name: "expires_at",
type: "datetime",
isNullable: true,
default: null,
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn("api_permissions", "expires_at");
}
}

View file

@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";
export class CreateApiAuditLogTable1630837718830 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "api_audit_log",
columns: [
{
name: "id",
type: "int",
unsigned: true,
isPrimary: true,
isGenerated: true,
generationStrategy: "increment",
},
{
name: "guild_id",
type: "bigint",
},
{
name: "author_id",
type: "bigint",
},
{
name: "event_type",
type: "varchar",
length: "255",
},
{
name: "event_data",
type: "longtext",
},
{
name: "created_at",
type: "datetime",
default: "(NOW())",
},
],
indices: [
new TableIndex({
columnNames: ["guild_id", "author_id"],
}),
new TableIndex({
columnNames: ["guild_id", "event_type"],
}),
new TableIndex({
columnNames: ["created_at"],
}),
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("api_audit_log");
}
}

View file

@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
export class AddTimestampsToAllowedGuilds1630840428694 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns("allowed_guilds", [
new TableColumn({
name: "created_at",
type: "datetime",
default: "(NOW())",
}),
new TableColumn({
name: "updated_at",
type: "datetime",
default: "(NOW())",
onUpdate: "CURRENT_TIMESTAMP",
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn("allowed_guilds", "updated_at");
await queryRunner.dropColumn("allowed_guilds", "created_at");
}
}

View file

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner, TableIndex } from "typeorm";
export class AddIndexToIsBot1631474131804 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createIndex(
"messages",
new TableIndex({
columnNames: ["is_bot"],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex(
"messages",
new TableIndex({
columnNames: ["is_bot"],
}),
);
}
}

View file

@ -62,6 +62,8 @@ const PluginOverrideCriteriaType: t.Type<PluginOverrideCriteria<unknown>> = t.re
const validTopLevelOverrideKeys = [
"channel",
"category",
"thread",
"is_thread",
"level",
"user",
"role",
@ -83,7 +85,7 @@ export function strictValidationErrorToConfigValidationError(err: StrictValidati
return new ConfigValidationError(
err
.getErrors()
.map(e => e.toString())
.map((e) => e.toString())
.join("\n"),
);
}
@ -197,7 +199,7 @@ export async function sendSuccessMessage(
return channel
.send({ ...content }) // Force line break
.catch(err => {
.catch((err) => {
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`);
return undefined;
@ -218,7 +220,7 @@ export async function sendErrorMessage(
return channel
.send({ ...content }) // Force line break
.catch(err => {
.catch((err) => {
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`);
return undefined;
@ -232,7 +234,7 @@ export function getBaseUrl(pluginData: AnyPluginData<any>) {
export function isOwner(pluginData: AnyPluginData<any>, userId: string) {
const knub = pluginData.getKnubInstance() as TZeppelinKnub;
const owners = knub.getGlobalConfig().owners;
const owners = knub.getGlobalConfig()?.owners;
if (!owners) {
return false;
}
@ -250,7 +252,7 @@ type AnyFn = (...args: any[]) => any;
* Creates a public plugin function out of a function with pluginData as the first parameter
*/
export function mapToPublicFn<T extends AnyFn>(inputFn: T) {
return pluginData => {
return (pluginData) => {
return (...args: Tail<Parameters<typeof inputFn>>): ReturnType<typeof inputFn> => {
return inputFn(pluginData, ...args);
};

View file

@ -25,7 +25,7 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
configurationGuide: "Maximum deletion delay is currently 5 minutes",
},
dependencies: [TimeAndDatePlugin, LogsPlugin],
dependencies: () => [TimeAndDatePlugin, LogsPlugin],
configSchema: ConfigSchema,
defaultOptions,
@ -45,13 +45,13 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
afterLoad(pluginData) {
const { state, guild } = pluginData;
state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg);
state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg);
state.guildSavedMessages.events.on("create", state.onMessageCreateFn);
state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg);
state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg);
state.guildSavedMessages.events.on("delete", state.onMessageDeleteFn);
state.onMessageDeleteBulkFn = msgs => onMessageDeleteBulk(pluginData, msgs);
state.onMessageDeleteBulkFn = (msgs) => onMessageDeleteBulk(pluginData, msgs);
state.guildSavedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn);
},

View file

@ -1,7 +1,7 @@
import { Permissions, Snowflake, TextChannel } from "discord.js";
import { GuildPluginData } from "knub";
import moment from "moment-timezone";
import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects";
import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { LogType } from "../../../data/LogType";
import { logger } from "../../../logger";
import { resolveUser, verboseChannelMention } from "../../../utils";
@ -27,7 +27,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
const perms = channel.permissionsFor(pluginData.client.user!.id);
if (!hasDiscordPermissions(perms, Permissions.FLAGS.VIEW_CHANNEL | Permissions.FLAGS.READ_MESSAGE_HISTORY)) {
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Missing permissions to read messages or message history in auto-delete channel ${verboseChannelMention(
channel,
)}`,
@ -36,7 +36,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
}
if (!hasDiscordPermissions(perms, Permissions.FLAGS.MANAGE_MESSAGES)) {
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Missing permissions to delete messages in auto-delete channel ${verboseChannelMention(channel)}`,
});
return;
@ -45,7 +45,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id);
(channel as TextChannel).messages.delete(itemToDelete.message.id as Snowflake).catch(err => {
(channel as TextChannel).messages.delete(itemToDelete.message.id as Snowflake).catch((err) => {
if (err.code === 10008) {
// "Unknown Message", probably already deleted by automod or another bot, ignore
return;
@ -59,10 +59,10 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
.inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x"))
.format(timeAndDate.getDateFormat("pretty_datetime"));
pluginData.state.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, {
pluginData.getPlugin(LogsPlugin).logMessageDeleteAuto({
message: itemToDelete.message,
user: userToConfigAccessibleUser(user),
channel: channelToConfigAccessibleChannel(channel),
user,
channel,
messageDate,
});
}

View file

@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType";
import { convertDelayStringToMS, resolveMember } from "../../../utils";
import { AutoDeletePluginType, MAX_DELAY } from "../types";
import { addMessageToDeletionQueue } from "./addMessageToDeletionQueue";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {
const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);
@ -14,7 +15,7 @@ export async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePlug
if (delay > MAX_DELAY) {
delay = MAX_DELAY;
if (!pluginData.state.maxDelayWarningSent) {
pluginData.state.guildLogs.log(LogType.BOT_ALERT, {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`,
});
pluginData.state.maxDelayWarningSent = true;

View file

@ -4,7 +4,7 @@ import { AutoDeletePluginType } from "../types";
import { scheduleNextDeletion } from "./scheduleNextDeletion";
export function onMessageDelete(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {
const indexToDelete = pluginData.state.deletionQueue.findIndex(item => item.message.id === msg.id);
const indexToDelete = pluginData.state.deletionQueue.findIndex((item) => item.message.id === msg.id);
if (indexToDelete > -1) {
pluginData.state.deletionQueue.splice(indexToDelete, 1);
scheduleNextDeletion(pluginData);

View file

@ -33,7 +33,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin<AutoReactionsPluginType>(
`),
},
dependencies: [LogsPlugin],
dependencies: () => [LogsPlugin],
configSchema: ConfigSchema,
defaultOptions,

View file

@ -26,7 +26,7 @@ export const AddReactionsEvt = autoReactionsEvt({
);
if (missingPermissions) {
const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Cannot apply auto-reactions in <#${message.channel.id}>. ${missingPermissionError(missingPermissions)}`,
});
return;
@ -39,11 +39,11 @@ export const AddReactionsEvt = autoReactionsEvt({
if (isDiscordAPIError(e)) {
const logs = pluginData.getPlugin(LogsPlugin);
if (e.code === 10008) {
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Make sure nothing is deleting the message before the reactions are applied.`,
});
} else {
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Error code ${e.code}.`,
});
}

View file

@ -57,7 +57,7 @@ const defaultOptions = {
/**
* Config preprocessor to set default values for triggers and perform extra validation
*/
const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = (options) => {
if (options.config?.rules) {
// Loop through each rule
for (const [name, rule] of Object.entries(options.config.rules)) {
@ -114,6 +114,21 @@ const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
]);
}
}
if (triggerObj[triggerName].match_mime_type) {
const white = triggerObj[triggerName].match_mime_type.whitelist_enabled;
const black = triggerObj[triggerName].match_mime_type.blacklist_enabled;
if (white && black) {
throw new StrictValidationError([
`Cannot have both blacklist and whitelist enabled at rule <${rule.name}/match_mime_type>`,
]);
} else if (!white && !black) {
throw new StrictValidationError([
`Must have either blacklist or whitelist enabled at rule <${rule.name}/match_mime_type>`,
]);
}
}
}
}
}
@ -159,7 +174,7 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
info: pluginInfo,
// prettier-ignore
dependencies: [
dependencies: () => [
LogsPlugin,
ModActionsPlugin,
MutesPlugin,
@ -217,10 +232,10 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
30 * SECONDS,
);
pluginData.state.onMessageCreateFn = message => runAutomodOnMessage(pluginData, message, false);
pluginData.state.onMessageCreateFn = (message) => runAutomodOnMessage(pluginData, message, false);
pluginData.state.savedMessages.events.on("create", pluginData.state.onMessageCreateFn);
pluginData.state.onMessageUpdateFn = message => runAutomodOnMessage(pluginData, message, true);
pluginData.state.onMessageUpdateFn = (message) => runAutomodOnMessage(pluginData, message, true);
pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn);
const countersPlugin = pluginData.getPlugin(CountersPlugin);

View file

@ -17,13 +17,13 @@ export const AddRolesAction = automodAction({
defaultConfig: [],
async apply({ pluginData, contexts, actionConfig, ruleName }) {
const members = unique(contexts.map(c => c.member).filter(nonNullish));
const members = unique(contexts.map((c) => c.member).filter(nonNullish));
const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
if (missingPermissions) {
const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
});
return;
@ -41,10 +41,10 @@ export const AddRolesAction = automodAction({
if (rolesWeCannotAssign.length) {
const roleNamesWeCannotAssign = rolesWeCannotAssign.map(
roleId => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
(roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
);
const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Unable to assign the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotAssign.join(
"**, **",
)}**`,
@ -52,7 +52,7 @@ export const AddRolesAction = automodAction({
}
await Promise.all(
members.map(async member => {
members.map(async (member) => {
const memberRoles = new Set(member.roles.cache.keys());
for (const roleId of rolesToAssign) {
memberRoles.add(roleId as Snowflake);

View file

@ -2,6 +2,7 @@ import * as t from "io-ts";
import { LogType } from "../../../data/LogType";
import { CountersPlugin } from "../../Counters/CountersPlugin";
import { automodAction } from "../helpers";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export const AddToCounterAction = automodAction({
configType: t.type({
@ -14,7 +15,7 @@ export const AddToCounterAction = automodAction({
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
const countersPlugin = pluginData.getPlugin(CountersPlugin);
if (!countersPlugin.counterExists(actionConfig.counter)) {
pluginData.state.logs.log(LogType.BOT_ALERT, {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``,
});
return;

View file

@ -1,18 +1,27 @@
import { Snowflake, TextChannel } from "discord.js";
import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
import * as t from "io-ts";
import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
import { LogType } from "../../../data/LogType";
import { renderTemplate, TemplateParseError } from "../../../templateFormatter";
import {
createTypedTemplateSafeValueContainer,
renderTemplate,
TemplateParseError,
TemplateSafeValueContainer,
} from "../../../templateFormatter";
import {
createChunkedMessage,
messageLink,
stripObjectToScalars,
tAllowedMentions,
tNormalizedNullOptional,
isTruthy,
verboseChannelMention,
validateAndParseMessageContent,
} from "../../../utils";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { automodAction } from "../helpers";
import { TemplateSafeUser, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { messageIsEmpty } from "../../../utils/messageIsEmpty";
export const AlertAction = automodAction({
configType: t.type({
@ -27,26 +36,31 @@ export const AlertAction = automodAction({
const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake);
const logs = pluginData.getPlugin(LogsPlugin);
if (channel && channel instanceof TextChannel) {
if (channel && (channel instanceof TextChannel || channel instanceof ThreadChannel)) {
const text = actionConfig.text;
const theMessageLink =
contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id);
const safeUsers = contexts.map(c => c.user && stripObjectToScalars(c.user)).filter(Boolean);
const safeUsers = contexts.map((c) => (c.user ? userToTemplateSafeUser(c.user) : null)).filter(isTruthy);
const safeUser = safeUsers[0];
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
const logMessage = await logs.getLogMessage(LogType.AUTOMOD_ACTION, {
const logMessage = await logs.getLogMessage(
LogType.AUTOMOD_ACTION,
createTypedTemplateSafeValueContainer({
rule: ruleName,
user: safeUser,
users: safeUsers,
actionsTaken,
matchSummary: matchResult.summary,
});
matchSummary: matchResult.summary ?? "",
}),
);
let rendered;
try {
rendered = await renderTemplate(actionConfig.text, {
rendered = await renderTemplate(
actionConfig.text,
new TemplateSafeValueContainer({
rule: ruleName,
user: safeUser,
users: safeUsers,
@ -54,11 +68,12 @@ export const AlertAction = automodAction({
actionsTaken,
matchSummary: matchResult.summary,
messageLink: theMessageLink,
logMessage,
});
logMessage: validateAndParseMessageContent(logMessage)?.content,
}),
);
} catch (err) {
if (err instanceof TemplateParseError) {
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Error in alert format of automod rule ${ruleName}: ${err.message}`,
});
return;
@ -67,6 +82,13 @@ export const AlertAction = automodAction({
throw err;
}
if (messageIsEmpty(rendered)) {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Tried to send alert with an empty message for automod rule ${ruleName}`,
});
return;
}
try {
await createChunkedMessage(
channel,
@ -75,13 +97,13 @@ export const AlertAction = automodAction({
);
} catch (err) {
if (err.code === 50001) {
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Missing access to send alert to channel ${verboseChannelMention(
channel,
)} in automod rule **${ruleName}**`,
});
} else {
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Error ${err.code || "UNKNOWN"} when sending alert to channel ${verboseChannelMention(
channel,
)} in automod rule **${ruleName}**`,
@ -89,7 +111,7 @@ export const AlertAction = automodAction({
}
}
} else {
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`,
});
}

View file

@ -0,0 +1,20 @@
import { ThreadChannel } from "discord.js";
import * as t from "io-ts";
import { noop } from "../../../utils";
import { automodAction } from "../helpers";
export const ArchiveThreadAction = automodAction({
configType: t.type({}),
defaultConfig: {},
async apply({ pluginData, contexts }) {
const threads = contexts
.filter((c) => c.message?.channel_id)
.map((c) => pluginData.guild.channels.cache.get(c.message!.channel_id))
.filter((c): c is ThreadChannel => c?.isThread() ?? false);
for (const thread of threads) {
await thread.setArchived().catch(noop);
}
},
});

View file

@ -3,6 +3,7 @@ import { AutomodActionBlueprint } from "../helpers";
import { AddRolesAction } from "./addRoles";
import { AddToCounterAction } from "./addToCounter";
import { AlertAction } from "./alert";
import { ArchiveThreadAction } from "./archiveThread";
import { BanAction } from "./ban";
import { ChangeNicknameAction } from "./changeNickname";
import { CleanAction } from "./clean";
@ -32,6 +33,7 @@ export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
add_to_counter: AddToCounterAction,
set_counter: SetCounterAction,
set_slowmode: SetSlowmodeAction,
archive_thread: ArchiveThreadAction,
};
export const AvailableActions = t.type({
@ -50,4 +52,5 @@ export const AvailableActions = t.type({
add_to_counter: AddToCounterAction.configType,
set_counter: SetCounterAction.configType,
set_slowmode: SetSlowmodeAction.configType,
archive_thread: ArchiveThreadAction.configType,
});

View file

@ -35,7 +35,7 @@ export const BanAction = automodAction({
hide: Boolean(actionConfig.hide_case),
};
const userIdsToBan = unique(contexts.map(c => c.user?.id).filter(nonNullish));
const userIdsToBan = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const userId of userIdsToBan) {

View file

@ -15,14 +15,14 @@ export const ChangeNicknameAction = automodAction({
defaultConfig: {},
async apply({ pluginData, contexts, actionConfig }) {
const members = unique(contexts.map(c => c.member).filter(nonNullish));
const members = unique(contexts.map((c) => c.member).filter(nonNullish));
for (const member of members) {
if (pluginData.state.recentNicknameChanges.has(member.id)) continue;
const newName = typeof actionConfig === "string" ? actionConfig : actionConfig.name;
member.edit({ nick: newName }).catch(err => {
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {
member.edit({ nick: newName }).catch((err) => {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Failed to change the nickname of \`${member.id}\``,
});
});

View file

@ -31,8 +31,8 @@ export const KickAction = automodAction({
hide: Boolean(actionConfig.hide_case),
};
const userIdsToKick = unique(contexts.map(c => c.user?.id).filter(nonNullish));
const membersToKick = await asyncMap(userIdsToKick, id => resolveMember(pluginData.client, pluginData.guild, id));
const userIdsToKick = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
const membersToKick = await asyncMap(userIdsToKick, (id) => resolveMember(pluginData.client, pluginData.guild, id));
const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToKick) {

View file

@ -1,26 +1,25 @@
import * as t from "io-ts";
import { LogType } from "../../../data/LogType";
import { stripObjectToScalars, unique } from "../../../utils";
import { isTruthy, stripObjectToScalars, unique } from "../../../utils";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { automodAction } from "../helpers";
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
export const LogAction = automodAction({
configType: t.boolean,
defaultConfig: true,
async apply({ pluginData, contexts, ruleName, matchResult }) {
const safeUsers = unique(contexts.map(c => c.user))
.filter(Boolean)
.map(user => stripObjectToScalars(user));
const safeUser = safeUsers[0];
const users = unique(contexts.map((c) => c.user)).filter(isTruthy);
const user = users[0];
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
pluginData.getPlugin(LogsPlugin).log(LogType.AUTOMOD_ACTION, {
pluginData.getPlugin(LogsPlugin).logAutomodAction({
rule: ruleName,
user: safeUser,
users: safeUsers,
user,
users,
actionsTaken,
matchSummary: matchResult.summary,
matchSummary: matchResult.summary ?? "",
});
},
});

View file

@ -40,7 +40,7 @@ export const MuteAction = automodAction({
hide: Boolean(actionConfig.hide_case),
};
const userIdsToMute = unique(contexts.map(c => c.user?.id).filter(nonNullish));
const userIdsToMute = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
const mutes = pluginData.getPlugin(MutesPlugin);
for (const userId of userIdsToMute) {
@ -55,7 +55,7 @@ export const MuteAction = automodAction({
);
} catch (e) {
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Failed to mute <@!${userId}> in Automod rule \`${ruleName}\` because a mute role has not been specified in server config`,
});
} else {

View file

@ -18,13 +18,13 @@ export const RemoveRolesAction = automodAction({
defaultConfig: [],
async apply({ pluginData, contexts, actionConfig, ruleName }) {
const members = unique(contexts.map(c => c.member).filter(nonNullish));
const members = unique(contexts.map((c) => c.member).filter(nonNullish));
const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
if (missingPermissions) {
const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
});
return;
@ -42,10 +42,10 @@ export const RemoveRolesAction = automodAction({
if (rolesWeCannotRemove.length) {
const roleNamesWeCannotRemove = rolesWeCannotRemove.map(
roleId => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
(roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
);
const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, {
logs.logBotAlert({
body: `Unable to remove the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotRemove.join(
"**, **",
)}**`,
@ -53,7 +53,7 @@ export const RemoveRolesAction = automodAction({
}
await Promise.all(
members.map(async member => {
members.map(async (member) => {
const memberRoles = new Set(member.roles.cache.keys());
for (const roleId of rolesToRemove) {
memberRoles.delete(roleId as Snowflake);

View file

@ -1,8 +1,7 @@
import { MessageOptions, Permissions, Snowflake, TextChannel, User } from "discord.js";
import { MessageOptions, Permissions, Snowflake, TextChannel, ThreadChannel, User } from "discord.js";
import * as t from "io-ts";
import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects";
import { LogType } from "../../../data/LogType";
import { renderTemplate } from "../../../templateFormatter";
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
import {
convertDelayStringToMS,
noop,
@ -11,11 +10,13 @@ import {
tMessageContent,
tNullable,
unique,
validateAndParseMessageContent,
verboseChannelMention,
} from "../../../utils";
import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
import { automodAction } from "../helpers";
import { AutomodContext } from "../types";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export const ReplyAction = automodAction({
configType: t.union([
@ -23,6 +24,7 @@ export const ReplyAction = automodAction({
t.type({
text: tMessageContent,
auto_delete: tNullable(t.union([tDelayString, t.number])),
inline: tNullable(t.boolean),
}),
]),
@ -30,8 +32,11 @@ export const ReplyAction = automodAction({
async apply({ pluginData, contexts, actionConfig, ruleName }) {
const contextsWithTextChannels = contexts
.filter(c => c.message?.channel_id)
.filter(c => pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake) instanceof TextChannel);
.filter((c) => c.message?.channel_id)
.filter((c) => {
const channel = pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake);
return channel instanceof TextChannel || channel instanceof ThreadChannel;
});
const contextsByChannelId = contextsWithTextChannels.reduce((map: Map<string, AutomodContext[]>, context) => {
if (!map.has(context.message!.channel_id)) {
@ -43,13 +48,17 @@ export const ReplyAction = automodAction({
}, new Map());
for (const [channelId, _contexts] of contextsByChannelId.entries()) {
const users = unique(Array.from(new Set(_contexts.map(c => c.user).filter(Boolean)))) as User[];
const users = unique(Array.from(new Set(_contexts.map((c) => c.user).filter(Boolean)))) as User[];
const user = users[0];
const renderReplyText = async str =>
renderTemplate(str, {
user: userToConfigAccessibleUser(user),
});
const renderReplyText = async (str: string) =>
renderTemplate(
str,
new TemplateSafeValueContainer({
user: userToTemplateSafeUser(user),
}),
);
const formatted =
typeof actionConfig === "string"
? await renderReplyText(actionConfig)
@ -65,7 +74,7 @@ export const ReplyAction = automodAction({
Permissions.FLAGS.SEND_MESSAGES | Permissions.FLAGS.VIEW_CHANNEL,
)
) {
pluginData.state.logs.log(LogType.BOT_ALERT, {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Missing permissions to reply in ${verboseChannelMention(channel)} in Automod rule \`${ruleName}\``,
});
continue;
@ -76,7 +85,7 @@ export const ReplyAction = automodAction({
typeof formatted !== "string" &&
!hasDiscordPermissions(channel.permissionsFor(pluginData.client.user!.id), Permissions.FLAGS.EMBED_LINKS)
) {
pluginData.state.logs.log(LogType.BOT_ALERT, {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Missing permissions to reply **with an embed** in ${verboseChannelMention(
channel,
)} in Automod rule \`${ruleName}\``,
@ -84,17 +93,27 @@ export const ReplyAction = automodAction({
continue;
}
const messageContent: MessageOptions = typeof formatted === "string" ? { content: formatted } : formatted;
const replyMsg = await channel.send({
const messageContent = validateAndParseMessageContent(formatted);
const messageOpts: MessageOptions = {
...messageContent,
allowedMentions: {
users: [user.id],
},
});
};
if (typeof actionConfig !== "string" && actionConfig.inline) {
messageOpts.reply = {
failIfNotExists: false,
messageReference: _contexts[0].message!.id,
};
}
const replyMsg = await channel.send(messageOpts);
if (typeof actionConfig === "object" && actionConfig.auto_delete) {
const delay = convertDelayStringToMS(String(actionConfig.auto_delete))!;
setTimeout(() => replyMsg.delete().catch(noop), delay);
setTimeout(() => !replyMsg.deleted && replyMsg.delete().catch(noop), delay);
}
}
}

View file

@ -2,6 +2,7 @@ import * as t from "io-ts";
import { LogType } from "../../../data/LogType";
import { CountersPlugin } from "../../Counters/CountersPlugin";
import { automodAction } from "../helpers";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export const SetCounterAction = automodAction({
configType: t.type({
@ -14,7 +15,7 @@ export const SetCounterAction = automodAction({
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
const countersPlugin = pluginData.getPlugin(CountersPlugin);
if (!countersPlugin.counterExists(actionConfig.counter)) {
pluginData.state.logs.log(LogType.BOT_ALERT, {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``,
});
return;

View file

@ -4,6 +4,7 @@ import { ChannelTypeStrings } from "src/types";
import { LogType } from "../../../data/LogType";
import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils";
import { automodAction } from "../helpers";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export const SetSlowmodeAction = automodAction({
configType: t.type({
@ -53,7 +54,7 @@ export const SetSlowmodeAction = automodAction({
? `Duration is greater than maximum native slowmode duration`
: e.message;
pluginData.state.logs.log(LogType.BOT_ALERT, {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Unable to set slowmode for channel ${channel.id} to ${slowmodeSeconds} seconds: ${errorMessage}`,
});
}

View file

@ -31,8 +31,8 @@ export const WarnAction = automodAction({
hide: Boolean(actionConfig.hide_case),
};
const userIdsToWarn = unique(contexts.map(c => c.user?.id).filter(nonNullish));
const membersToWarn = await asyncMap(userIdsToWarn, id => resolveMember(pluginData.client, pluginData.guild, id));
const userIdsToWarn = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
const membersToWarn = await asyncMap(userIdsToWarn, (id) => resolveMember(pluginData.client, pluginData.guild, id));
const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToWarn) {

View file

@ -8,11 +8,15 @@ export const RunAutomodOnMemberUpdate = typedGuildEventListener<AutomodPluginTyp
event: "guildMemberUpdate",
listener({ pluginData, args: { oldMember, newMember } }) {
if (!oldMember) return;
if (oldMember.partial) return;
if (isEqual(oldMember.roles, newMember.roles)) return;
const oldRoles = [...oldMember.roles.cache.keys()];
const newRoles = [...newMember.roles.cache.keys()];
const addedRoles = diff(newMember.roles, oldMember.roles);
const removedRoles = diff(oldMember.roles, newMember.roles);
if (isEqual(oldRoles, newRoles)) return;
const addedRoles = diff(newRoles, oldRoles);
const removedRoles = diff(oldRoles, newRoles);
if (addedRoles.length || removedRoles.length) {
const context: AutomodContext = {

View file

@ -4,7 +4,7 @@ import { AutomodPluginType } from "../types";
export function clearOldRecentActions(pluginData: GuildPluginData<AutomodPluginType>) {
const now = Date.now();
pluginData.state.recentActions = pluginData.state.recentActions.filter(info => {
pluginData.state.recentActions = pluginData.state.recentActions.filter((info) => {
return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now;
});
}

View file

@ -4,7 +4,7 @@ import { AutomodPluginType } from "../types";
export function clearOldRecentSpam(pluginData: GuildPluginData<AutomodPluginType>) {
const now = Date.now();
pluginData.state.recentSpam = pluginData.state.recentSpam.filter(spam => {
pluginData.state.recentSpam = pluginData.state.recentSpam.filter((spam) => {
return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now;
});
}

View file

@ -6,7 +6,7 @@ export function clearRecentActionsForMessage(pluginData: GuildPluginData<Automod
const globalIdentifier = message.user_id;
const perChannelIdentifier = `${message.channel_id}-${message.user_id}`;
pluginData.state.recentActions = pluginData.state.recentActions.filter(act => {
pluginData.state.recentActions = pluginData.state.recentActions.filter((act) => {
return act.identifier !== globalIdentifier && act.identifier !== perChannelIdentifier;
});
}

View file

@ -60,7 +60,7 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName:
if (matchedSpam) {
const messages = matchedSpam.recentActions
.map(action => action.context.message)
.map((action) => action.context.message)
.filter(Boolean)
.sort(sorter("posted_at")) as SavedMessage[];
@ -75,8 +75,8 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName:
return {
extraContexts: matchedSpam.recentActions
.map(action => action.context)
.filter(_context => _context !== context),
.map((action) => action.context)
.filter((_context) => _context !== context),
extra: {
archiveId,

View file

@ -7,7 +7,7 @@ export function findRecentSpam(
type: RecentActionType,
identifier?: string,
) {
return pluginData.state.recentSpam.find(spam => {
return pluginData.state.recentSpam.find((spam) => {
return spam.type === type && (!identifier || spam.identifiers.includes(identifier));
});
}

View file

@ -11,7 +11,7 @@ export function getMatchingRecentActions(
) {
to = to || Date.now();
return pluginData.state.recentActions.filter(action => {
return pluginData.state.recentActions.filter((action) => {
return (
action.type === type &&
(!identifier || action.identifier === identifier) &&

View file

@ -29,6 +29,6 @@ export function getTextMatchPartialSummary(
const visibleName = context.member?.nickname || context.user!.username;
return `visible name: ${visibleName}`;
} else if (type === "customstatus") {
return `custom status: ${context.member!.presence?.activities.find(a => a.type === "CUSTOM")?.name}`;
return `custom status: ${context.member!.presence?.activities.find((a) => a.type === "CUSTOM")?.name}`;
}
}

View file

@ -1,4 +1,4 @@
import { Constants } from "discord.js";
import { Constants, MessageEmbed } from "discord.js";
import { GuildPluginData } from "knub";
import { SavedMessage } from "../../../data/entities/SavedMessage";
import { resolveMember } from "../../../utils";
@ -32,9 +32,9 @@ export async function* matchMultipleTextTypesOnMessage(
yield ["message", msg.data.content];
}
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
const copiedEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
if (copiedEmbed.type === "video") {
if (trigger.match_embeds && msg.data.embeds?.length) {
const copiedEmbed: MessageEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
if (copiedEmbed.video) {
copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched
}
yield ["embed", JSON.stringify(copiedEmbed)];

View file

@ -1,4 +1,4 @@
import { Snowflake, TextChannel } from "discord.js";
import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils";
@ -19,7 +19,7 @@ export function resolveActionContactMethods(
}
const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake);
if (!(channel instanceof TextChannel)) {
if (!(channel instanceof TextChannel || channel instanceof ThreadChannel)) {
throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL);
}

View file

@ -1,4 +1,4 @@
import { Snowflake, TextChannel } from "discord.js";
import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { availableActions } from "../actions/availableActions";
import { CleanAction } from "../actions/clean";
@ -11,13 +11,25 @@ export async function runAutomod(pluginData: GuildPluginData<AutomodPluginType>,
const userId = context.user?.id || context.member?.id || context.message?.user_id;
const user = context.user || (userId && pluginData.client.users!.cache.get(userId as Snowflake));
const member = context.member || (userId && pluginData.guild.members.cache.get(userId as Snowflake)) || null;
const channelId = context.message?.channel_id;
const channel = channelId ? (pluginData.guild.channels.cache.get(channelId as Snowflake) as TextChannel) : null;
const channelIdOrThreadId = context.message?.channel_id;
const channelOrThread = channelIdOrThreadId
? (pluginData.guild.channels.cache.get(channelIdOrThreadId as Snowflake) as TextChannel | ThreadChannel)
: null;
const channelId = channelOrThread?.isThread() ? channelOrThread.parent?.id : channelIdOrThreadId;
const threadId = channelOrThread?.isThread() ? channelOrThread.id : null;
const channel = channelOrThread?.isThread() ? channelOrThread.parent : channelOrThread;
const categoryId = channel?.parentId;
// Don't apply Automod on Zeppelin itself
if (userId && userId === pluginData.client.user?.id) {
return;
}
const config = await pluginData.config.getMatchingConfig({
channelId,
categoryId,
threadId,
userId,
member,
});

View file

@ -1,6 +1,6 @@
import { User } from "discord.js";
import { GuildPluginData } from "knub";
import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects";
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { LogType } from "../../../data/LogType";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel";
@ -19,12 +19,12 @@ export async function setAntiraidLevel(
const logs = pluginData.getPlugin(LogsPlugin);
if (user) {
logs.log(LogType.SET_ANTIRAID_USER, {
logs.logSetAntiraidUser({
level: newLevel ?? "off",
user: userToConfigAccessibleUser(user),
user,
});
} else {
logs.log(LogType.SET_ANTIRAID_AUTO, {
logs.logSetAntiraidAuto({
level: newLevel ?? "off",
});
}

View file

@ -11,6 +11,7 @@ import { KickTrigger } from "./kick";
import { LineSpamTrigger } from "./lineSpam";
import { LinkSpamTrigger } from "./linkSpam";
import { MatchAttachmentTypeTrigger } from "./matchAttachmentType";
import { MatchMimeTypeTrigger } from "./matchMimeType";
import { MatchInvitesTrigger } from "./matchInvites";
import { MatchLinksTrigger } from "./matchLinks";
import { MatchRegexTrigger } from "./matchRegex";
@ -37,6 +38,7 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
match_invites: MatchInvitesTrigger,
match_links: MatchLinksTrigger,
match_attachment_type: MatchAttachmentTypeTrigger,
match_mime_type: MatchMimeTypeTrigger,
member_join: MemberJoinTrigger,
role_added: RoleAddedTrigger,
role_removed: RoleRemovedTrigger,
@ -72,6 +74,7 @@ export const AvailableTriggers = t.type({
match_invites: MatchInvitesTrigger.configType,
match_links: MatchLinksTrigger.configType,
match_attachment_type: MatchAttachmentTypeTrigger.configType,
match_mime_type: MatchMimeTypeTrigger.configType,
member_join: MemberJoinTrigger.configType,
member_leave: MemberLeaveTrigger.configType,
role_added: RoleAddedTrigger.configType,

View file

@ -15,7 +15,7 @@ export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
},
async match({ triggerConfig, context }) {
const foundFruit = triggerConfig.allowedFruits.find(fruit => context.message?.data.content === fruit);
const foundFruit = triggerConfig.allowedFruits.find((fruit) => context.message?.data.content === fruit);
if (foundFruit) {
return {
extra: {

View file

@ -28,17 +28,15 @@ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
return;
}
if (!context.message.data.attachments) return null;
const attachments: any[] = context.message.data.attachments;
if (!context.message.data.attachments) {
return null;
}
for (const attachment of attachments) {
const attachmentType = attachment.filename
.split(".")
.pop()
.toLowerCase();
for (const attachment of context.message.data.attachments) {
const attachmentType = attachment.url.split(".").pop()!.toLowerCase();
const blacklist = trigger.blacklist_enabled
? (trigger.filetype_blacklist || []).map(_t => _t.toLowerCase())
? (trigger.filetype_blacklist || []).map((_t) => _t.toLowerCase())
: null;
if (blacklist && blacklist.includes(attachmentType)) {
@ -51,7 +49,7 @@ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
}
const whitelist = trigger.whitelist_enabled
? (trigger.filetype_whitelist || []).map(_t => _t.toLowerCase())
? (trigger.filetype_whitelist || []).map((_t) => _t.toLowerCase())
: null;
if (whitelist && !whitelist.includes(attachmentType)) {

View file

@ -0,0 +1,80 @@
import { automodTrigger } from "../helpers";
import * as t from "io-ts";
import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils";
import { GuildChannel, Util } from "discord.js";
interface MatchResultType {
matchedType: string;
mode: "blacklist" | "whitelist";
}
export const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({
configType: t.type({
mime_type_blacklist: t.array(t.string),
blacklist_enabled: t.boolean,
mime_type_whitelist: t.array(t.string),
whitelist_enabled: t.boolean,
}),
defaultConfig: {
mime_type_blacklist: [],
blacklist_enabled: false,
mime_type_whitelist: [],
whitelist_enabled: false,
},
async match({ context, triggerConfig: trigger }) {
if (!context.message) return;
const { attachments } = context.message.data;
if (!attachments) return null;
for (const attachment of attachments) {
const { contentType: rawContentType } = attachment;
const contentType = (rawContentType || "").split(";")[0]; // Remove "; charset=utf8" and similar from the end
const blacklist = trigger.blacklist_enabled
? (trigger.mime_type_blacklist ?? []).map((_t) => _t.toLowerCase())
: null;
if (contentType && blacklist?.includes(contentType)) {
return {
extra: {
matchedType: contentType,
mode: "blacklist",
},
};
}
const whitelist = trigger.whitelist_enabled
? (trigger.mime_type_whitelist ?? []).map((_t) => _t.toLowerCase())
: null;
if (whitelist && (!contentType || !whitelist.includes(contentType))) {
return {
extra: {
matchedType: contentType || "<unknown>",
mode: "whitelist",
},
};
}
return null;
}
},
renderMatchInformation({ pluginData, contexts, matchResult }) {
const { message } = contexts[0];
const channel = pluginData.guild.channels.resolve(message!.channel_id);
const prettyChannel = verboseChannelMention(channel as GuildChannel);
const { matchedType, mode } = matchResult.extra;
return (
asSingleLine(`
Matched MIME type \`${Util.escapeInlineCode(matchedType)}\`
(${mode === "blacklist" ? "blacklisted" : "not in whitelist"})
in message (\`${message!.id}\`) in ${prettyChannel}
`) + messageSummary(message!)
);
},
});

View file

@ -64,7 +64,7 @@ export const MatchWordsTrigger = automodTrigger<MatchResultType>()({
// When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other
// characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a
let pattern = trigger.loose_matching
? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
? [...word].map((c) => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
: escapeStringRegexp(word);
if (trigger.only_full_words) {

View file

@ -30,7 +30,7 @@ export const MemberJoinSpamTrigger = automodTrigger<unknown>()({
const totalCount = sumRecentActionCounts(matchingActions);
if (totalCount >= triggerConfig.amount) {
const extraContexts = matchingActions.map(a => a.context).filter(c => c !== context);
const extraContexts = matchingActions.map((a) => a.context).filter((c) => c !== context);
pluginData.state.recentSpam.push({
type: RecentActionType.MemberJoin,

View file

@ -18,11 +18,19 @@ import { ReloadServerCmd } from "./commands/ReloadServerCmd";
import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd";
import { ServersCmd } from "./commands/ServersCmd";
import { BotControlPluginType, ConfigSchema } from "./types";
import { PluginPerformanceCmd } from "./commands/PluginPerformanceCmd";
import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd";
import { ChannelToServerCmd } from "./commands/ChannelToServerCmd";
import { RestPerformanceCmd } from "./commands/RestPerformanceCmd";
import { RateLimitPerformanceCmd } from "./commands/RateLimitPerformanceCmd";
const defaultOptions = {
config: {
can_use: false,
can_eligible: false,
can_performance: false,
can_add_server_from_invite: false,
can_list_dashboard_perms: false,
update_cmd: null,
},
};
@ -45,6 +53,11 @@ export const BotControlPlugin = zeppelinGlobalPlugin<BotControlPluginType>()({
ListDashboardUsersCmd,
ListDashboardPermsCmd,
EligibleCmd,
PluginPerformanceCmd,
RestPerformanceCmd,
RateLimitPerformanceCmd,
AddServerFromInviteCmd,
ChannelToServerCmd,
],
async afterLoad(pluginData) {

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