Deprecate GuildActions. Fix double case posting when muting a user for message spam. Update to new mute/case style when muting a user for "other" spam.

GuildActions turned out to be a fairly pointless abstraction in the end.
It didn't really solve the problems it was meant to solve (that is,
reduce code spaghetti by having all inter-plugin calls go through a
single service, and allow easier ways to replace core plugins with
alternatives that share the same interface) any better than simply
using `this.getPlugin()` when needed, and introduced extra complexity
and made static analysis messier.
This commit is contained in:
Dragory 2019-04-20 17:36:28 +03:00
parent 89ce0555a7
commit 608f17c532
10 changed files with 319 additions and 376 deletions

View file

@ -1,81 +0,0 @@
import { BaseRepository } from "./BaseRepository";
import { Member, TextableChannel } from "eris";
import { CaseTypes } from "./CaseTypes";
import { ICaseDetails } from "./GuildCases";
import { Case } from "./entities/Case";
import { INotifyUserResult } from "../utils";
type KnownActions = "mute" | "unmute";
// https://github.com/Microsoft/TypeScript/issues/4183#issuecomment-382867018
type UnknownAction<T extends string> = T extends KnownActions ? never : T;
type ActionFn<T> = (args: T) => any | Promise<any>;
type MuteActionArgs = { userId: string; muteTime?: number; reason?: string; caseDetails?: ICaseDetails };
type UnmuteActionArgs = { userId: string; unmuteTime?: number; reason?: string; caseDetails?: ICaseDetails };
type CreateCaseActionArgs = ICaseDetails;
type CreateCaseNoteActionArgs = {
caseId: number;
modId: string;
note: string;
automatic?: boolean;
postInCaseLog?: boolean;
noteDetails?: string[];
};
type PostCaseActionArgs = {
caseId: number;
channel: TextableChannel;
};
export type MuteActionResult = {
case: Case;
notifyResult: INotifyUserResult;
updatedExistingMute: boolean;
};
export type UnmuteActionResult = {
case: Case;
};
export class GuildActions extends BaseRepository {
private actions: Map<string, ActionFn<any>>;
constructor(guildId) {
super(guildId);
this.actions = new Map();
}
public register(actionName: "mute", actionFn: ActionFn<MuteActionArgs>): void;
public register(actionName: "unmute", actionFn: ActionFn<UnmuteActionArgs>): void;
public register(actionName: "createCase", actionFn: ActionFn<CreateCaseActionArgs>): void;
public register(actionName: "createCaseNote", actionFn: ActionFn<CreateCaseNoteActionArgs>): void;
public register(actionName: "postCase", actionFn: ActionFn<PostCaseActionArgs>): void;
// https://github.com/Microsoft/TypeScript/issues/4183#issuecomment-382867018
public register<T extends string & UnknownAction<U>, U extends string = T>(
actionName: T,
actionFn: ActionFn<any>,
): void;
public register(actionName, actionFn): void {
if (this.actions.has(actionName)) {
throw new Error("Action is already registered!");
}
this.actions.set(actionName, actionFn);
}
public unregister(actionName: string): void {
this.actions.delete(actionName);
}
public fire(actionName: "mute", args: MuteActionArgs): Promise<MuteActionResult>;
public fire(actionName: "unmute", args: UnmuteActionArgs): Promise<UnmuteActionResult>;
public fire(actionName: "createCase", args: CreateCaseActionArgs): Promise<any>;
public fire(actionName: "createCaseNote", args: CreateCaseNoteActionArgs): Promise<any>;
public fire(actionName: "postCase", args: PostCaseActionArgs): Promise<any>;
// https://github.com/Microsoft/TypeScript/issues/4183#issuecomment-382867018
public fire<T extends string & UnknownAction<U>, U extends string = T>(actionName: T, args: any): Promise<any>;
public fire(actionName, args): Promise<any> {
return this.actions.has(actionName) ? this.actions.get(actionName)(args) : null;
}
}

View file

@ -8,22 +8,6 @@ import moment = require("moment-timezone");
const CASE_SUMMARY_REASON_MAX_LENGTH = 300;
/**
* Used as a config object for functions that create cases
*/
export interface ICaseDetails {
userId?: string;
modId?: string;
ppId?: string;
type?: CaseTypes;
auditLogId?: string;
reason?: string;
automatic?: boolean;
postInCaseLogOverride?: boolean;
noteDetails?: string[];
extraNotes?: string[];
}
export class GuildCases extends BaseRepository {
private cases: Repository<Case>;
private caseNotes: Repository<CaseNote>;

View file

@ -4,7 +4,7 @@ import yaml from "js-yaml";
import fs from "fs";
const fsp = fs.promises;
import { Knub, logger, PluginError, CommandArgumentTypeError } from "knub";
import { Knub, logger, PluginError, CommandArgumentTypeError, Plugin } from "knub";
import { SimpleError } from "./SimpleError";
require("dotenv").config();
@ -75,6 +75,7 @@ import { PingableRolesPlugin } from "./plugins/PingableRolesPlugin";
import { SelfGrantableRolesPlugin } from "./plugins/SelfGrantableRolesPlugin";
import { RemindersPlugin } from "./plugins/Reminders";
import { convertDelayStringToMS, errorMessage, successMessage } from "./utils";
import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
// Run latest database migrations
logger.info("Running database migrations");
@ -125,11 +126,30 @@ connect().then(async conn => {
options: {
getEnabledPlugins(guildId, guildConfig): string[] {
const plugins = guildConfig.plugins || {};
const keys: string[] = Array.from(this.plugins.keys());
return keys.filter(pluginName => {
return basePlugins.includes(pluginName) || (plugins[pluginName] && plugins[pluginName].enabled !== false);
const configuredPlugins = guildConfig.plugins || {};
const pluginNames: string[] = Array.from(this.plugins.keys());
const plugins: Array<typeof Plugin> = Array.from(this.plugins.values());
const zeppelinPlugins: Array<typeof ZeppelinPlugin> = plugins.filter(
p => p.prototype instanceof ZeppelinPlugin,
) as Array<typeof ZeppelinPlugin>;
const enabledBasePlugins = pluginNames.filter(n => basePlugins.includes(n));
const explicitlyEnabledPlugins = pluginNames.filter(pluginName => {
return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false;
});
const enabledPlugins = new Set([...enabledBasePlugins, ...explicitlyEnabledPlugins]);
const pluginsEnabledAsDependencies = zeppelinPlugins.reduce((arr, pluginClass) => {
if (!enabledPlugins.has(pluginClass.pluginName)) return arr;
return arr.concat(pluginClass.dependencies);
}, []);
const finalEnabledPlugins = new Set([
...basePlugins,
...pluginsEnabledAsDependencies,
...explicitlyEnabledPlugins,
]);
return Array.from(finalEnabledPlugins.values());
},
async getConfig(id) {

View file

@ -1,11 +1,10 @@
import { Message, MessageContent, MessageFile, TextableChannel, TextChannel } from "eris";
import { GuildCases, ICaseDetails } from "../data/GuildCases";
import { Message, MessageContent, MessageFile, TextChannel } from "eris";
import { GuildCases } from "../data/GuildCases";
import { CaseTypes } from "../data/CaseTypes";
import { Case } from "../data/entities/Case";
import moment from "moment-timezone";
import { CaseTypeColors } from "../data/CaseTypeColors";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildActions } from "../data/GuildActions";
import { GuildArchives } from "../data/GuildArchives";
import { IPluginOptions } from "knub";
@ -14,10 +13,34 @@ interface ICasesPluginConfig {
case_log_channel: string;
}
/**
* Can also be used as a config object for functions that create cases
*/
export type CaseArgs = {
userId: string;
modId: string;
ppId?: string;
type: CaseTypes;
auditLogId?: string;
reason?: string;
automatic?: boolean;
postInCaseLogOverride?: boolean;
noteDetails?: string[];
extraNotes?: string[];
};
export type CaseNoteArgs = {
caseId: number;
modId: string;
body: string;
automatic?: boolean;
postInCaseLogOverride?: boolean;
noteDetails?: string[];
};
export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
public static pluginName = "cases";
protected actions: GuildActions;
protected cases: GuildCases;
protected archives: GuildArchives;
@ -31,35 +54,13 @@ export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
}
onLoad() {
this.actions = GuildActions.getInstance(this.guildId);
this.cases = GuildCases.getInstance(this.guildId);
this.archives = GuildArchives.getInstance(this.guildId);
this.actions.register("createCase", args => {
return this.createCase(args);
});
this.actions.register("createCaseNote", args => {
return this.createCaseNote(
args.caseId,
args.modId,
args.note,
args.automatic,
args.postInCaseLog,
args.noteDetails,
);
});
this.actions.register("postCase", async args => {
const embed = await this.getCaseEmbed(args.caseId);
return (args.channel as TextableChannel).createMessage(embed);
});
}
onUnload() {
this.actions.unregister("createCase");
this.actions.unregister("createCaseNote");
this.actions.unregister("postCase");
// this.actions.register("postCase", async args => {
// const embed = await this.getCaseEmbed(args.caseId);
// return (args.channel as TextableChannel).createMessage(embed);
// });
}
protected resolveCaseId(caseOrCaseId: Case | number): number {
@ -68,39 +69,51 @@ export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
/**
* Creates a new case and, depending on config, posts it in the case log channel
* @return {Number} The ID of the created case
*/
public async createCase(opts: ICaseDetails): Promise<Case> {
const user = this.bot.users.get(opts.userId);
const userName = user ? `${user.username}#${user.discriminator}` : "Unknown#0000";
public async createCase(args: CaseArgs): Promise<Case> {
const user = await this.resolveUser(args.userId);
const userName = `${user.username}#${user.discriminator}`;
const mod = this.bot.users.get(opts.modId);
const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000";
const mod = await this.resolveUser(args.modId);
const modName = `${mod.username}#${mod.discriminator}`;
let ppName = null;
if (opts.ppId) {
const pp = this.bot.users.get(opts.ppId);
ppName = pp ? `${pp.username}#${pp.discriminator}` : "Unknown#0000";
if (args.ppId) {
const pp = await this.resolveUser(args.ppId);
ppName = `${pp.username}#${pp.discriminator}`;
}
const createdCase = await this.cases.create({
type: opts.type,
user_id: opts.userId,
type: args.type,
user_id: args.userId,
user_name: userName,
mod_id: opts.modId,
mod_id: args.modId,
mod_name: modName,
audit_log_id: opts.auditLogId,
pp_id: opts.ppId,
audit_log_id: args.auditLogId,
pp_id: args.ppId,
pp_name: ppName,
});
if (opts.reason || (opts.noteDetails && opts.noteDetails.length)) {
await this.createCaseNote(createdCase, opts.modId, opts.reason || "", opts.automatic, false, opts.noteDetails);
if (args.reason || (args.noteDetails && args.noteDetails.length)) {
await this.createCaseNote({
caseId: createdCase.id,
modId: args.modId,
body: args.reason || "",
automatic: args.automatic,
postInCaseLogOverride: false,
noteDetails: args.noteDetails,
});
}
if (opts.extraNotes) {
for (const extraNote of opts.extraNotes) {
await this.createCaseNote(createdCase, opts.modId, extraNote, opts.automatic, false);
if (args.extraNotes) {
for (const extraNote of args.extraNotes) {
await this.createCaseNote({
caseId: createdCase.id,
modId: args.modId,
body: extraNote,
automatic: args.automatic,
postInCaseLogOverride: false,
});
}
}
@ -108,8 +121,8 @@ export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
if (
config.case_log_channel &&
(!opts.automatic || config.log_automatic_actions) &&
opts.postInCaseLogOverride !== false
(!args.automatic || config.log_automatic_actions) &&
args.postInCaseLogOverride !== false
) {
try {
await this.postCaseToCaseLogChannel(createdCase);
@ -122,29 +135,24 @@ export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
/**
* Adds a case note to an existing case and, depending on config, posts the updated case in the case log channel
*/
public async createCaseNote(
caseOrCaseId: Case | number,
modId: string,
body: string,
automatic = false,
postInCaseLogOverride = null,
noteDetails: string[] = null,
): Promise<void> {
const mod = this.bot.users.get(modId);
const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000";
const theCase = await this.cases.find(this.resolveCaseId(caseOrCaseId));
public async createCaseNote(args: CaseNoteArgs): Promise<void> {
const theCase = await this.cases.find(this.resolveCaseId(args.caseId));
if (!theCase) {
this.throwPluginRuntimeError(`Unknown case ID: ${caseOrCaseId}`);
this.throwPluginRuntimeError(`Unknown case ID: ${args.caseId}`);
}
const mod = await this.resolveUser(args.modId);
const modName = `${mod.username}#${mod.discriminator}`;
let body = args.body;
// Add note details to the beginning of the note
if (noteDetails && noteDetails.length) {
body = noteDetails.map(d => `__[${d}]__`).join(" ") + " " + body;
if (args.noteDetails && args.noteDetails.length) {
body = args.noteDetails.map(d => `__[${d}]__`).join(" ") + " " + body;
}
await this.cases.createNote(theCase.id, {
mod_id: modId,
mod_id: mod.id,
mod_name: modName,
body: body || "",
});
@ -152,7 +160,7 @@ export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
if (theCase.mod_id == null) {
// If the case has no moderator information, assume the first one to add a note to it did the action
await this.cases.update(theCase.id, {
mod_id: modId,
mod_id: mod.id,
mod_name: modName,
});
}
@ -163,7 +171,7 @@ export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
this.archives.makePermanent(archiveId);
}
if ((!automatic || this.getConfig().log_automatic_actions) && postInCaseLogOverride !== false) {
if ((!args.automatic || this.getConfig().log_automatic_actions) && args.postInCaseLogOverride !== false) {
try {
await this.postCaseToCaseLogChannel(theCase.id);
} catch (e) {} // tslint:disable-line

View file

@ -5,7 +5,6 @@ import { GuildCases } from "../data/GuildCases";
import {
asSingleLine,
createChunkedMessage,
createUnknownUser,
errorMessage,
findRelevantAuditLogEntry,
INotifyUserResult,
@ -22,9 +21,10 @@ import { CaseTypes } from "../data/CaseTypes";
import { GuildLogs } from "../data/GuildLogs";
import { LogType } from "../data/LogType";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildActions, MuteActionResult } from "../data/GuildActions";
import { Case } from "../data/entities/Case";
import { renderTemplate } from "../templateFormatter";
import { CasesPlugin } from "./Cases";
import { MuteResult, MutesPlugin } from "./Mutes";
enum IgnoredEventType {
Ban = 1,
@ -65,8 +65,8 @@ interface IModActionsPluginConfig {
export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
public static pluginName = "mod_actions";
public static dependencies = ["cases", "mutes"];
protected actions: GuildActions;
protected mutes: GuildMutes;
protected cases: GuildCases;
protected serverLogs: GuildLogs;
@ -74,7 +74,6 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
protected ignoredEvents: IIgnoredEvent[];
async onLoad() {
this.actions = GuildActions.getInstance(this.guildId);
this.mutes = GuildMutes.getInstance(this.guildId);
this.cases = GuildCases.getInstance(this.guildId);
this.serverLogs = new GuildLogs(this.guildId);
@ -156,62 +155,6 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
return ((reason || "") + " " + attachmentUrls.join(" ")).trim();
}
/**
* Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
*/
async resolveUser(userResolvable: string): Promise<User | UnknownUser> {
let userId;
// A user mention?
const mentionMatch = userResolvable.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
userId = mentionMatch[1];
}
// A non-mention, full username?
if (!userId) {
const usernameMatch = userResolvable.match(/^@?([^#]+)#(\d{4})$/);
if (usernameMatch) {
const user = this.bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]);
userId = user.id;
}
}
// Just a user ID?
if (!userId) {
const idMatch = userResolvable.match(/^\d+$/);
if (!idMatch) {
return null;
}
userId = userResolvable;
}
const cachedUser = this.bot.users.find(u => u.id === userId);
if (cachedUser) return cachedUser;
try {
const freshUser = await this.bot.getRESTUser(userId);
return freshUser;
} catch (e) {} // tslint:disable-line
return createUnknownUser({ id: userId });
}
async getMember(userId: string): Promise<Member> {
// See if we have the member cached...
let member = this.guild.members.get(userId);
// If not, fetch it from the API
if (!member) {
try {
member = await this.bot.getRESTGuildMember(this.guildId, userId);
} catch (e) {} // tslint:disable-line
}
return member;
}
async isBanned(userId): Promise<boolean> {
const bans = (await this.guild.getBans()) as any;
return bans.some(b => b.user.id === userId);
@ -234,11 +177,12 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
user.id,
);
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
if (relevantAuditLogEntry) {
const modId = relevantAuditLogEntry.user.id;
const auditLogId = relevantAuditLogEntry.id;
this.actions.fire("createCase", {
casesPlugin.createCase({
userId: user.id,
modId,
type: CaseTypes.Ban,
@ -247,8 +191,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
automatic: true,
});
} else {
this.actions.fire("createCase", {
casesPlugin.createCase({
userId: user.id,
modId: null,
type: CaseTypes.Ban,
});
}
@ -271,11 +216,12 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
user.id,
);
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
if (relevantAuditLogEntry) {
const modId = relevantAuditLogEntry.user.id;
const auditLogId = relevantAuditLogEntry.id;
this.actions.fire("createCase", {
casesPlugin.createCase({
userId: user.id,
modId,
type: CaseTypes.Unban,
@ -283,8 +229,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
automatic: true,
});
} else {
this.actions.fire("createCase", {
casesPlugin.createCase({
userId: user.id,
modId: null,
type: CaseTypes.Unban,
automatic: true,
});
@ -337,7 +284,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
}`,
);
} else {
this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
casesPlugin.createCase({
userId: member.id,
modId: kickAuditLogEntry.user.id,
type: CaseTypes.Kick,
@ -374,10 +322,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
return;
}
await this.actions.fire("createCaseNote", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
await casesPlugin.createCaseNote({
caseId: theCase.id,
modId: msg.author.id,
note: args.note,
body: args.note,
});
this.serverLogs.log(LogType.CASE_UPDATE, {
@ -399,7 +348,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
const userName = `${user.username}#${user.discriminator}`;
const reason = this.formatReasonWithAttachments(args.note, msg.attachments);
const createdCase = await this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
userId: user.id,
modId: msg.author.id,
type: CaseTypes.Note,
@ -466,7 +416,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
}
}
const createdCase: Case = await this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
userId: memberToWarn.id,
modId: mod.id,
type: CaseTypes.Warn,
@ -513,17 +464,13 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
const timeUntilUnmute = args.time && humanizeDuration(args.time);
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
let muteResult: MuteActionResult;
let muteResult: MuteResult;
const mutesPlugin = this.getPlugin<MutesPlugin>("mutes");
try {
muteResult = await this.actions.fire("mute", {
userId: user.id,
muteTime: args.time,
reason,
caseDetails: {
modId: mod.id,
ppId: pp && pp.id,
},
muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, {
modId: mod.id,
ppId: pp && pp.id,
});
} catch (e) {
logger.error(`Failed to mute user ${user.id}: ${e.stack}`);
@ -646,14 +593,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
const result = await this.actions.fire("unmute", {
userId: user.id,
unmuteTime: args.time,
caseDetails: {
modId: mod.id,
ppId: pp && pp.id,
reason,
},
const mutesPlugin = this.getPlugin<MutesPlugin>("mutes");
const result = await mutesPlugin.unmuteUser(user.id, args.time, {
modId: mod.id,
ppId: pp && pp.id,
reason,
});
// Confirm the action to the moderator
@ -810,7 +754,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
memberToKick.kick(reason);
// Create a case for this action
const createdCase = await this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
userId: memberToKick.id,
modId: mod.id,
type: CaseTypes.Kick,
@ -896,7 +841,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
memberToBan.ban(1, reason);
// Create a case for this action
const createdCase = await this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
userId: memberToBan.id,
modId: mod.id,
type: CaseTypes.Ban,
@ -970,7 +916,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
await this.guild.unbanMember(memberToSoftban.id);
// Create a case for this action
const createdCase = await this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
userId: memberToSoftban.id,
modId: mod.id,
type: CaseTypes.Softban,
@ -1026,7 +973,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
// Create a case
const createdCase = await this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
userId: user.id,
modId: mod.id,
type: CaseTypes.Unban,
@ -1083,7 +1031,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
}
// Create a case
const createdCase = await this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
userId: user.id,
modId: mod.id,
type: CaseTypes.Ban,
@ -1142,16 +1091,17 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
// Ban each user and count failed bans (if any)
const failedBans = [];
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
for (const userId of args.userIds) {
try {
await this.guild.banMember(userId);
await this.actions.fire("createCase", {
await casesPlugin.createCase({
userId,
modId: msg.author.id,
type: CaseTypes.Ban,
reason: `Mass ban: ${banReason}`,
postInCaseLog: false,
postInCaseLogOverride: false,
});
} catch (e) {
failedBans.push(userId);
@ -1218,7 +1168,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
// Create the case
const theCase: Case = await this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const theCase: Case = await casesPlugin.createCase({
userId: user.id,
modId: mod.id,
type: CaseTypes[type],
@ -1259,10 +1210,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
return;
}
await this.actions.fire("postCase", {
caseId: theCase.id,
channel: msg.channel,
});
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const embed = await casesPlugin.getCaseEmbed(theCase.id);
msg.channel.createMessage(embed);
}
@d.command("cases", "<user:string> [opts:string$]", {
@ -1303,11 +1253,10 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
}
// Expanded view (= individual case embeds)
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
for (const theCase of casesToDisplay) {
await this.actions.fire("postCase", {
caseId: theCase.id,
channel: msg.channel,
});
const embed = await casesPlugin.getCaseEmbed(theCase.id);
msg.channel.createMessage(embed);
}
} else {
// Compact view (= regular message with a preview of each case)

View file

@ -1,8 +1,7 @@
import { Member, Message, User } from "eris";
import { GuildCases, ICaseDetails } from "../data/GuildCases";
import { GuildCases } from "../data/GuildCases";
import moment from "moment-timezone";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildActions } from "../data/GuildActions";
import { GuildMutes } from "../data/GuildMutes";
import {
chunkMessageLines,
@ -14,7 +13,6 @@ import {
stripObjectToScalars,
successMessage,
ucfirst,
unknownUser,
} from "../utils";
import humanizeDuration from "humanize-duration";
import { LogType } from "../data/LogType";
@ -23,6 +21,8 @@ import { decorators as d, IPluginOptions, logger } from "knub";
import { Mute } from "../data/entities/Mute";
import { renderTemplate } from "../templateFormatter";
import { CaseTypes } from "../data/CaseTypes";
import { CaseArgs, CasesPlugin } from "./Cases";
import { Case } from "../data/entities/Case";
interface IMuteWithDetails extends Mute {
member?: Member;
@ -43,10 +43,19 @@ interface IMutesPluginConfig {
can_cleanup: boolean;
}
export type MuteResult = {
case: Case;
notifyResult: INotifyUserResult;
updatedExistingMute: boolean;
};
export type UnmuteResult = {
case: Case;
};
export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
public static pluginName = "mutes";
protected actions: GuildActions;
protected mutes: GuildMutes;
protected cases: GuildCases;
protected serverLogs: GuildLogs;
@ -84,62 +93,38 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
};
}
onLoad() {
this.actions = GuildActions.getInstance(this.guildId);
protected onLoad() {
this.mutes = GuildMutes.getInstance(this.guildId);
this.cases = GuildCases.getInstance(this.guildId);
this.serverLogs = new GuildLogs(this.guildId);
this.actions.register("mute", args => {
return this.muteUser(args.userId, args.muteTime, args.reason, args.caseDetails);
});
this.actions.register("unmute", args => {
return this.unmuteUser(args.userId, args.unmuteTime, args.caseDetails);
});
// Check for expired mutes every 5s
this.clearExpiredMutes();
this.muteClearIntervalId = setInterval(() => this.clearExpiredMutes(), 5000);
}
onUnload() {
this.actions.unregister("mute");
this.actions.unregister("unmute");
protected onUnload() {
clearInterval(this.muteClearIntervalId);
}
async resolveMember(userId: string) {
if (this.guild.members.has(userId)) {
return this.guild.members.get(userId);
}
try {
const member = await this.bot.getRESTGuildMember(this.guildId, userId);
return member;
} catch (e) {
return null;
}
}
public async muteUser(
userId: string,
muteTime: number = null,
reason: string = null,
caseDetails: ICaseDetails = {},
) {
caseArgs: Partial<CaseArgs> = {},
): Promise<MuteResult> {
const muteRole = this.getConfig().mute_role;
if (!muteRole) return;
const timeUntilUnmute = muteTime && humanizeDuration(muteTime);
// No mod specified -> mark Zeppelin as the mod
if (!caseDetails.modId) {
caseDetails.modId = this.bot.user.id;
if (!caseArgs.modId) {
caseArgs.modId = this.bot.user.id;
}
const user = this.bot.users.get(userId) || { ...unknownUser, id: userId };
const member = await this.resolveMember(userId);
const user = await this.resolveUser(userId);
const member = await this.getMember(user.id);
if (member) {
// Apply mute role if it's missing
@ -193,17 +178,29 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
}
// Create/update a case
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
let theCase;
if (existingMute && existingMute.case_id) {
// Update old case
// Since mutes can often have multiple notes (extraNotes), we won't post each case note individually,
// but instead we'll post the entire case afterwards
theCase = await this.cases.find(existingMute.case_id);
const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmute : "indefinite"}`];
await this.actions.fire("createCaseNote", {
caseId: existingMute.case_id,
modId: caseDetails.modId,
note: reason,
noteDetails,
});
const reasons = [reason, ...(caseArgs.extraNotes || [])];
for (const noteReason of reasons) {
await casesPlugin.createCaseNote({
caseId: existingMute.case_id,
modId: caseArgs.modId,
body: noteReason,
noteDetails,
postInCaseLogOverride: false,
});
}
if (caseArgs.postInCaseLogOverride !== false) {
casesPlugin.postCaseToCaseLogChannel(existingMute.case_id);
}
} else {
// Create new case
const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`];
@ -211,20 +208,19 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
noteDetails.push(ucfirst(notifyResult.text));
}
theCase = await this.actions.fire("createCase", {
theCase = await casesPlugin.createCase({
...caseArgs,
userId,
modId: caseDetails.modId,
modId: caseArgs.modId,
type: CaseTypes.Mute,
reason,
ppId: caseDetails.ppId,
noteDetails,
extraNotes: caseDetails.extraNotes,
});
await this.mutes.setCaseId(user.id, theCase.id);
}
// Log the action
const mod = this.bot.users.get(caseDetails.modId) || { ...unknownUser, id: caseDetails.modId };
const mod = await this.resolveUser(caseArgs.modId);
if (muteTime) {
this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, {
mod: stripObjectToScalars(mod),
@ -245,12 +241,16 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
};
}
public async unmuteUser(userId: string, unmuteTime: number = null, caseDetails: ICaseDetails = {}) {
public async unmuteUser(
userId: string,
unmuteTime: number = null,
caseArgs: Partial<CaseArgs> = {},
): Promise<UnmuteResult> {
const existingMute = await this.mutes.findExistingMuteForUserId(userId);
if (!existingMute) return;
const user = this.bot.users.get(userId) || { ...unknownUser, id: userId };
const member = await this.resolveMember(userId);
const user = await this.resolveUser(userId);
const member = await this.getMember(userId);
if (unmuteTime) {
// Schedule timed unmute (= just set the mute's duration)
@ -277,17 +277,17 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
noteDetails.push(`Unmuted immediately`);
}
const createdCase = await this.actions.fire("createCase", {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
...caseArgs,
userId,
modId: caseDetails.modId,
modId: caseArgs.modId,
type: CaseTypes.Unmute,
reason: caseDetails.reason,
ppId: caseDetails.ppId,
noteDetails,
});
// Log the action
const mod = this.bot.users.get(caseDetails.modId);
const mod = this.bot.users.get(caseArgs.modId);
if (unmuteTime) {
this.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, {
mod: stripObjectToScalars(mod),
@ -310,7 +310,7 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
options: [{ name: "age", type: "delay" }, { name: "left", type: "boolean" }],
})
@d.permission("can_view_list")
public async muteListCmd(msg: Message, args: { age?: number; left?: boolean }) {
protected async muteListCmd(msg: Message, args: { age?: number; left?: boolean }) {
const lines = [];
// Active, logged mutes
@ -449,7 +449,7 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
* Reapply active mutes on join
*/
@d.event("guildMemberAdd")
async onGuildMemberAdd(_, member: Member) {
protected async onGuildMemberAdd(_, member: Member) {
const mute = await this.mutes.findExistingMuteForUserId(member.id);
if (mute) {
const muteRole = this.getConfig().mute_role;
@ -465,7 +465,7 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
* Clear active mute from the member if the member is banned
*/
@d.event("guildBanAdd")
async onGuildBanAdd(_, user: User) {
protected async onGuildBanAdd(_, user: User) {
const mute = await this.mutes.findExistingMuteForUserId(user.id);
if (mute) {
this.mutes.clear(user.id);
@ -477,7 +477,7 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
*/
@d.command("clear_banned_mutes")
@d.permission("can_cleanup")
async clearBannedMutesCmd(msg: Message) {
protected async clearBannedMutesCmd(msg: Message) {
await msg.channel.createMessage("Clearing mutes from banned users...");
const activeMutes = await this.mutes.getActiveMutes();
@ -505,7 +505,7 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
* Clear active mute from the member if the mute role is removed
*/
@d.event("guildMemberUpdate")
async onGuildMemberUpdate(_, member: Member) {
protected async onGuildMemberUpdate(_, member: Member) {
const muteRole = this.getConfig().mute_role;
if (!muteRole) return;
@ -522,7 +522,7 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
*/
@d.command("clear_mutes_without_role")
@d.permission("can_cleanup")
async clearMutesWithoutRoleCmd(msg: Message) {
protected async clearMutesWithoutRoleCmd(msg: Message) {
const activeMutes = await this.mutes.getActiveMutes();
const muteRole = this.getConfig().mute_role;
if (!muteRole) return;
@ -545,7 +545,7 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
@d.command("clear_mute", "<userId:string>")
@d.permission("can_cleanup")
async clearMuteCmd(msg: Message, args: { userId: string }) {
protected async clearMuteCmd(msg: Message, args: { userId: string }) {
const mute = await this.mutes.findExistingMuteForUserId(args.userId);
if (!mute) {
msg.channel.createMessage(errorMessage("No active mutes found for that user id"));

View file

@ -8,6 +8,7 @@ import { SavedMessage } from "../data/entities/SavedMessage";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
const NATIVE_SLOWMODE_LIMIT = 6 * 60 * 60; // 6 hours
const MAX_SLOWMODE = 60 * 60 * 24 * 365 * 100; // 100 years
interface ISlowmodePluginConfig {
use_native_slowmode: boolean;
@ -238,6 +239,15 @@ export class SlowmodePlugin extends ZeppelinPlugin<ISlowmodePluginConfig> {
const seconds = Math.ceil(convertDelayStringToMS(args.time, "s") / 1000);
const useNativeSlowmode = this.getConfigForChannel(channel).use_native_slowmode && seconds <= NATIVE_SLOWMODE_LIMIT;
if (seconds === 0) {
return this.disableSlowmodeCmd(msg, { channel: args.channel });
}
if (seconds > MAX_SLOWMODE) {
this.sendErrorMessage(msg.channel, `Sorry, slowmodes can be at most 100 years long. Maybe 99 would be enough?`);
return;
}
if (useNativeSlowmode) {
// Native slowmode

View file

@ -17,10 +17,10 @@ import { GuildArchives } from "../data/GuildArchives";
import moment from "moment-timezone";
import { SavedMessage } from "../data/entities/SavedMessage";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { GuildActions, MuteActionResult } from "../data/GuildActions";
import { Case } from "../data/entities/Case";
import { GuildMutes } from "../data/GuildMutes";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { MuteResult, MutesPlugin } from "./Mutes";
import { CasesPlugin } from "./Cases";
enum RecentActionType {
Message = 1,
@ -71,7 +71,6 @@ interface ISpamPluginConfig {
export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
public static pluginName = "spam";
protected actions: GuildActions;
protected logs: GuildLogs;
protected archives: GuildArchives;
protected savedMessages: GuildSavedMessages;
@ -128,7 +127,6 @@ export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
}
onLoad() {
this.actions = GuildActions.getInstance(this.guildId);
this.logs = new GuildLogs(this.guildId);
this.archives = GuildArchives.getInstance(this.guildId);
this.savedMessages = GuildSavedMessages.getInstance(this.guildId);
@ -240,18 +238,15 @@ export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
const recentActions = this.getRecentActions(type, savedMessage.user_id, savedMessage.channel_id, since);
// Start by muting them, if enabled
let muteResult: MuteActionResult;
let muteResult: MuteResult;
if (spamConfig.mute && member) {
const mutesPlugin = this.getPlugin<MutesPlugin>("mutes");
const muteTime = spamConfig.mute_time
? convertDelayStringToMS(spamConfig.mute_time.toString())
: 120 * 1000;
muteResult = await this.actions.fire("mute", {
userId: member.id,
muteTime,
reason: "Automatic spam detection",
caseDetails: {
modId: this.bot.user.id,
},
muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", {
modId: this.bot.user.id,
postInCaseLogOverride: false,
});
}
@ -296,18 +291,20 @@ export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
const archiveUrl = await this.saveSpamArchives(uniqueMessages);
// Create a case
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
if (muteResult) {
// If the user was muted, the mute already generated a case - in that case, just update the case with extra details
// This will also post the case in the case log channel, which we didn't do with the mute initially to avoid
// posting the case on the channel twice: once with the initial reason, and then again with the note from here
const updateText = trimLines(`
Details: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
${archiveUrl}
`);
this.actions.fire("createCaseNote", {
casesPlugin.createCaseNote({
caseId: muteResult.case.id,
modId: muteResult.case.mod_id,
note: updateText,
body: updateText,
automatic: true,
postInCaseLogOverride: false,
});
} else {
// If the user was not muted, create a note case of the detected spam instead
@ -316,7 +313,7 @@ export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
${archiveUrl}
`);
this.actions.fire("createCase", {
casesPlugin.createCase({
userId: savedMessage.user_id,
modId: this.bot.user.id,
type: CaseTypes.Note,
@ -362,42 +359,35 @@ export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
if (recentActionsCount > spamConfig.count) {
const member = this.guild.members.get(userId);
const details = `${description} (over ${spamConfig.count} in ${spamConfig.interval}s)`;
// Start by muting them, if enabled
if (spamConfig.mute && member) {
const muteTime = spamConfig.mute_time ? spamConfig.mute_time * 60 * 1000 : 120 * 1000;
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, userId);
this.actions.fire("mute", { userId: member.id, muteTime, reason: "Automatic spam detection" });
const mutesPlugin = this.getPlugin<MutesPlugin>("mutes");
const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000;
await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", {
modId: this.bot.user.id,
extraNotes: [`Details: ${details}`],
});
} else {
// If we're not muting the user, just add a note on them
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
await casesPlugin.createCase({
userId,
modId: this.bot.user.id,
type: CaseTypes.Note,
reason: `Automatic spam detection: ${details}`,
});
}
// Clear recent cases
this.clearRecentUserActions(RecentActionType.VoiceChannelMove, userId, actionGroupId);
// Create a case and log the actions taken above
const caseType = spamConfig.mute ? CaseTypes.Mute : CaseTypes.Note;
const caseText = trimLines(`
Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
`);
this.logs.log(LogType.OTHER_SPAM_DETECTED, {
member: stripObjectToScalars(member, ["user"]),
description,
limit: spamConfig.count,
interval: spamConfig.interval,
});
const theCase: Case = await this.actions.fire("createCase", {
userId,
modId: this.bot.user.id,
type: caseType,
reason: caseText,
automatic: true,
});
// For mutes, also set the mute's case id (for !mutes)
if (spamConfig.mute && member) {
await this.mutes.setCaseId(userId, theCase.id);
}
}
});
}

View file

@ -1,12 +1,15 @@
import { IBasePluginConfig, IPluginOptions, Plugin } from "knub";
import { PluginRuntimeError } from "../PluginRuntimeError";
import Ajv, { ErrorObject } from "ajv";
import { isSnowflake, isUnicodeEmoji } from "../utils";
import { createUnknownUser, isSnowflake, isUnicodeEmoji, UnknownUser } from "../utils";
import { Member, User } from "eris";
export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plugin<TConfig> {
protected configSchema: any;
protected permissionsSchema: any;
public static dependencies = [];
protected throwPluginRuntimeError(message: string) {
throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId);
}
@ -73,4 +76,64 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
public getRegisteredCommands() {
return this.commands.commands;
}
/**
* Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
*/
async resolveUser(userResolvable: string): Promise<User | UnknownUser> {
if (userResolvable == null) {
return createUnknownUser();
}
let userId;
// A user mention?
const mentionMatch = userResolvable.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
userId = mentionMatch[1];
}
// A non-mention, full username?
if (!userId) {
const usernameMatch = userResolvable.match(/^@?([^#]+)#(\d{4})$/);
if (usernameMatch) {
const user = this.bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]);
userId = user.id;
}
}
// Just a user ID?
if (!userId) {
const idMatch = userResolvable.match(/^\d+$/);
if (!idMatch) {
return null;
}
userId = userResolvable;
}
const cachedUser = this.bot.users.find(u => u.id === userId);
if (cachedUser) return cachedUser;
try {
const freshUser = await this.bot.getRESTUser(userId);
return freshUser;
} catch (e) {} // tslint:disable-line
return createUnknownUser({ id: userId });
}
async getMember(userId: string): Promise<Member> {
// See if we have the member cached...
let member = this.guild.members.get(userId);
// If not, fetch it from the API
if (!member) {
try {
member = await this.bot.getRESTGuildMember(this.guildId, userId);
} catch (e) {} // tslint:disable-line
}
return member;
}
}

View file

@ -504,7 +504,7 @@ export type UnknownUser = {
};
export const unknownUser: UnknownUser = {
id: "0",
id: null,
username: "Unknown",
discriminator: "0000",
};