3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-03-15 05:41:51 +00:00

Use actions/events for plugin interoperability. Move base case and mute functionality to their own plugins.

This commit is contained in:
Dragory 2018-11-25 17:04:26 +02:00
parent 22c515be38
commit 2e30a3b9e7
14 changed files with 674 additions and 332 deletions

17
src/PluginRuntimeError.ts Normal file
View file

@ -0,0 +1,17 @@
import util from "util";
export class PluginRuntimeError {
public message: string;
public pluginName: string;
public guildId: string;
constructor(message: string, pluginName: string, guildId: string) {
this.message = message;
this.pluginName = pluginName;
this.guildId = guildId;
}
[util.inspect.custom](depth, options) {
return `PRE [${this.pluginName}] [${this.guildId}] ${this.message}`;
}
}

View file

@ -28,11 +28,14 @@ export class QueuedEventEmitter {
listeners.splice(listeners.indexOf(listener), 1);
}
emit(eventName: string, args: any[] = []) {
emit(eventName: string, args: any[] = []): Promise<void> {
const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])];
let promise: Promise<any> = Promise.resolve();
listeners.forEach(listener => {
this.queue.add(listener.bind(null, ...args));
promise = this.queue.add(listener.bind(null, ...args));
});
return promise;
}
}

24
src/data/GuildActions.ts Normal file
View file

@ -0,0 +1,24 @@
import { BaseRepository } from "./BaseRepository";
type ActionFn = (...args: any[]) => any | Promise<any>;
export class GuildActions extends BaseRepository {
private actions: Map<string, ActionFn>;
constructor(guildId) {
super(guildId);
this.actions = new Map();
}
public register(actionName: string, actionFn: ActionFn) {
this.actions.set(actionName, actionFn);
}
public unregister(actionName: string) {
this.actions.delete(actionName);
}
public fire(actionName: string, ...args: any[]): Promise<any> {
return this.actions.has(actionName) ? this.actions.get(actionName)(...args) : null;
}
}

View file

@ -53,26 +53,24 @@ export class GuildCases extends BaseRepository {
});
}
async create(data): Promise<number> {
async create(data): Promise<Case> {
const result = await this.cases.insert({
...data,
guild_id: this.guildId,
case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`
});
return result.identifiers[0].id;
return this.find(result.identifiers[0].id);
}
update(id, data) {
return this.cases.update(id, data);
}
async createNote(caseId: number, data: any): Promise<number> {
const result = await this.caseNotes.insert({
async createNote(caseId: number, data: any): Promise<void> {
await this.caseNotes.insert({
...data,
case_id: caseId
});
return result.identifiers[0].id;
}
}

42
src/data/GuildEvents.ts Normal file
View file

@ -0,0 +1,42 @@
import { BaseRepository } from "./BaseRepository";
import { QueuedEventEmitter } from "../QueuedEventEmitter";
export class GuildEvents extends BaseRepository {
private queuedEventEmitter: QueuedEventEmitter;
private pluginListeners: Map<string, Map<string, any[]>>;
constructor(guildId) {
super(guildId);
this.queuedEventEmitter = new QueuedEventEmitter();
}
public on(pluginName: string, eventName: string, fn) {
this.queuedEventEmitter.on(eventName, fn);
if (!this.pluginListeners.has(pluginName)) {
this.pluginListeners.set(pluginName, new Map());
}
const pluginListeners = this.pluginListeners.get(pluginName);
if (!pluginListeners.has(eventName)) {
pluginListeners.set(eventName, []);
}
const pluginEventListeners = pluginListeners.get(eventName);
pluginEventListeners.push(fn);
}
public offPlugin(pluginName: string) {
const pluginListeners = this.pluginListeners.get(pluginName) || new Map();
for (const [eventName, listeners] of Array.from(pluginListeners.entries())) {
for (const listener of listeners) {
this.queuedEventEmitter.off(eventName, listener);
}
}
this.pluginListeners.delete(pluginName);
}
public emit(eventName: string, args: any[] = []) {
return this.queuedEventEmitter.emit(eventName, args);
}
}

View file

@ -29,18 +29,20 @@ export class GuildMutes extends BaseRepository {
});
}
async addMute(userId, expiryTime) {
async addMute(userId, expiryTime): Promise<Mute> {
const expiresAt = expiryTime
? moment()
.add(expiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss")
: null;
return this.mutes.insert({
const result = await this.mutes.insert({
guild_id: this.guildId,
user_id: userId,
expires_at: expiresAt
});
return this.mutes.findOne(result.identifiers[0].id);
}
async updateExpiryTime(userId, newExpiryTime) {
@ -61,11 +63,12 @@ export class GuildMutes extends BaseRepository {
);
}
async addOrUpdateMute(userId, expiryTime) {
async addOrUpdateMute(userId, expiryTime): Promise<Mute> {
const existingMute = await this.findExistingMuteForUserId(userId);
if (existingMute) {
return this.updateExpiryTime(userId, expiryTime);
await this.updateExpiryTime(userId, expiryTime);
return this.findExistingMuteForUserId(userId);
} else {
return this.addMute(userId, expiryTime);
}
@ -83,7 +86,7 @@ export class GuildMutes extends BaseRepository {
.getMany();
}
async setCaseId(userId, caseId) {
async setCaseId(userId: string, caseId: number) {
await this.mutes.update(
{
guild_id: this.guildId,

View file

@ -45,6 +45,8 @@ import { PersistPlugin } from "./plugins/Persist";
import { SpamPlugin } from "./plugins/Spam";
import { TagsPlugin } from "./plugins/Tags";
import { MessageSaverPlugin } from "./plugins/MessageSaver";
import { CasesPlugin } from "./plugins/Cases";
import { MutesPlugin } from "./plugins/Mutes";
// Run latest database migrations
logger.info("Running database migrations");
@ -56,10 +58,16 @@ connect().then(async conn => {
});
client.setMaxListeners(100);
const basePlugins = ["message_saver", "cases", "mutes"];
const bot = new Knub(client, {
plugins: {
messageSaver: MessageSaverPlugin,
// Base plugins (always enabled)
message_saver: MessageSaverPlugin,
cases: CasesPlugin,
mutes: MutesPlugin,
// Regular plugins
utility: UtilityPlugin,
mod_actions: ModActionsPlugin,
logs: LogsPlugin,
@ -80,7 +88,7 @@ connect().then(async conn => {
const plugins = guildConfig.plugins || {};
const keys: string[] = Array.from(this.plugins.keys());
return keys.filter(pluginName => {
return (plugins[pluginName] && plugins[pluginName].enabled !== false) || pluginName === "messageSaver";
return basePlugins.includes(pluginName) || (plugins[pluginName] && plugins[pluginName].enabled !== false);
});
},

217
src/plugins/Cases.ts Normal file
View file

@ -0,0 +1,217 @@
import { Message, MessageContent, MessageFile, TextableChannel, 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";
export class CasesPlugin extends ZeppelinPlugin {
protected actions: GuildActions;
protected cases: GuildCases;
getDefaultOptions() {
return {
config: {
log_automatic_actions: true,
case_log_channel: null
}
};
}
onLoad() {
this.actions = GuildActions.getInstance(this.guildId);
this.cases = GuildCases.getInstance(this.guildId);
this.actions.register("createCase", args => {
return this.createCase(
args.userId,
args.modId,
args.type,
args.auditLogId,
args.reason,
args.automatic,
args.postInCaseLog
);
});
this.actions.register("createCaseNote", args => {
return this.createCaseNote(args.case || args.caseId, args.modId, args.note, args.automatic, args.postInCaseLog);
});
this.actions.register("postCase", async args => {
const embed = await this.getCaseEmbed(args.case || args.caseId);
return (args.channel as TextableChannel).createMessage(embed);
});
}
onUnload() {
this.actions.unregister("createCase");
this.actions.unregister("createCaseNote");
this.actions.unregister("postCase");
}
protected async resolveCase(caseOrCaseId: Case | number): Promise<Case> {
return caseOrCaseId instanceof Case ? caseOrCaseId : this.cases.with("notes").find(caseOrCaseId);
}
/**
* 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(
userId: string,
modId: string,
type: CaseTypes,
auditLogId: string = null,
reason: string = null,
automatic = false,
postInCaseLogOverride = null
): Promise<Case> {
const user = this.bot.users.get(userId);
const userName = user ? `${user.username}#${user.discriminator}` : "Unknown#0000";
const mod = this.bot.users.get(modId);
const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000";
const createdCase = await this.cases.create({
type,
user_id: userId,
user_name: userName,
mod_id: modId,
mod_name: modName,
audit_log_id: auditLogId
});
if (reason) {
await this.createCaseNote(createdCase, modId, reason);
}
if (
this.configValue("case_log_channel") &&
(!automatic || this.configValue("log_automatic_actions")) &&
postInCaseLogOverride !== false
) {
try {
await this.postCaseToCaseLogChannel(createdCase);
} catch (e) {} // tslint:disable-line
}
return createdCase;
}
/**
* 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
): Promise<void> {
const mod = this.bot.users.get(modId);
const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000";
const theCase = await this.resolveCase(caseOrCaseId);
if (!theCase) {
this.throwPluginRuntimeError(`Unknown case ID: ${caseOrCaseId}`);
}
await this.cases.createNote(theCase.id, {
mod_id: modId,
mod_name: modName,
body: body || ""
});
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_name: modName
});
}
if ((!automatic || this.configValue("log_automatic_actions")) && postInCaseLogOverride !== false) {
try {
await this.postCaseToCaseLogChannel(theCase.id);
} catch (e) {} // tslint:disable-line
}
}
/**
* Returns a Discord embed for the specified case
*/
public async getCaseEmbed(caseOrCaseId: Case | number): Promise<MessageContent> {
const theCase = await this.resolveCase(caseOrCaseId);
if (!theCase) return null;
const createdAt = moment(theCase.created_at);
const actionTypeStr = CaseTypes[theCase.type].toUpperCase();
const embed: any = {
title: `${actionTypeStr} - Case #${theCase.case_number}`,
footer: {
text: `Case created at ${createdAt.format("YYYY-MM-DD [at] HH:mm")}`
},
fields: [
{
name: "User",
value: `${theCase.user_name}\n<@!${theCase.user_id}>`,
inline: true
},
{
name: "Moderator",
value: `${theCase.mod_name}\n<@!${theCase.mod_id}>`,
inline: true
}
]
};
if (CaseTypeColors[theCase.type]) {
embed.color = CaseTypeColors[theCase.type];
}
if (theCase.notes.length) {
theCase.notes.forEach((note: any) => {
const noteDate = moment(note.created_at);
embed.fields.push({
name: `${note.mod_name} at ${noteDate.format("YYYY-MM-DD [at] HH:mm")}:`,
value: note.body
});
});
} else {
embed.fields.push({
name: "!!! THIS CASE HAS NO NOTES !!!",
value: "\u200B"
});
}
return { embed };
}
/**
* A helper for posting to the case log channel.
* Returns silently if the case log channel isn't specified or is invalid.
*/
public postToCaseLogChannel(content: MessageContent, file: MessageFile = null): Promise<Message> {
const caseLogChannelId = this.configValue("case_log_channel");
if (!caseLogChannelId) return;
const caseLogChannel = this.guild.channels.get(caseLogChannelId);
if (!caseLogChannel || !(caseLogChannel instanceof TextChannel)) return;
return caseLogChannel.createMessage(content, file);
}
/**
* A helper to post a case embed to the case log channel
*/
public async postCaseToCaseLogChannel(caseOrCaseId: Case | number): Promise<Message> {
const caseEmbed = await this.getCaseEmbed(caseOrCaseId);
if (!caseEmbed) return;
return this.postToCaseLogChannel(caseEmbed);
}
}

View file

@ -3,7 +3,13 @@ import { Invite, Message } from "eris";
import escapeStringRegexp from "escape-string-regexp";
import { GuildLogs } from "../data/GuildLogs";
import { LogType } from "../data/LogType";
import { getInviteCodesInString, getUrlsInString, stripObjectToScalars } from "../utils";
import {
deactivateMentions,
disableCodeBlocks,
getInviteCodesInString,
getUrlsInString,
stripObjectToScalars
} from "../utils";
import { ZalgoRegex } from "../data/Zalgo";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { SavedMessage } from "../data/entities/SavedMessage";
@ -81,7 +87,7 @@ export class CensorPlugin extends Plugin {
member: stripObjectToScalars(member, ["user"]),
channel: stripObjectToScalars(channel),
reason,
messageText: savedMessage.data.content
messageText: disableCodeBlocks(deactivateMentions(savedMessage.data.content))
});
}

View file

@ -1,14 +1,10 @@
import { decorators as d, Plugin, waitForReaction, waitForReply } from "knub";
import { decorators as d, waitForReaction, waitForReply } from "knub";
import { Constants as ErisConstants, Guild, Member, Message, TextChannel, User } from "eris";
import moment from "moment-timezone";
import humanizeDuration from "humanize-duration";
import chunk from "lodash.chunk";
import { GuildCases } from "../data/GuildCases";
import {
chunkLines,
chunkMessageLines,
convertDelayStringToMS,
DBDateFormat,
disableLinkPreviews,
errorMessage,
findRelevantAuditLogEntry,
@ -18,12 +14,14 @@ import {
trimLines
} from "../utils";
import { GuildMutes } from "../data/GuildMutes";
import { Case } from "../data/entities/Case";
import { CaseTypes } from "../data/CaseTypes";
import { GuildLogs } from "../data/GuildLogs";
import { LogType } from "../data/LogType";
import Timer = NodeJS.Timer;
import { CaseTypeColors } from "../data/CaseTypeColors";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildActions } from "../data/GuildActions";
import { Case } from "../data/entities/Case";
import { Mute } from "../data/entities/Mute";
enum IgnoredEventType {
Ban = 1,
@ -38,35 +36,26 @@ interface IIgnoredEvent {
const CASE_LIST_REASON_MAX_LENGTH = 80;
export class ModActionsPlugin extends Plugin {
public mutes: GuildMutes;
export class ModActionsPlugin extends ZeppelinPlugin {
protected actions: GuildActions;
protected mutes: GuildMutes;
protected cases: GuildCases;
protected serverLogs: GuildLogs;
protected muteClearIntervalId: Timer;
protected ignoredEvents: IIgnoredEvent[];
async onLoad() {
this.cases = GuildCases.getInstance(this.guildId);
this.actions = GuildActions.getInstance(this.guildId);
this.mutes = GuildMutes.getInstance(this.guildId);
this.cases = GuildCases.getInstance(this.guildId);
this.serverLogs = new GuildLogs(this.guildId);
this.ignoredEvents = [];
// Check for expired mutes every 5s
this.clearExpiredMutes();
this.muteClearIntervalId = setInterval(() => this.clearExpiredMutes(), 5000);
}
async onUnload() {
clearInterval(this.muteClearIntervalId);
}
getDefaultOptions() {
return {
config: {
mute_role: null,
dm_on_warn: true,
dm_on_mute: false,
dm_on_kick: false,
@ -81,8 +70,6 @@ export class ModActionsPlugin extends Plugin {
timed_mute_message: "You have been muted on {guildName} for {time}. Reason given: {reason}",
kick_message: "You have been kicked from {guildName}. Reason given: {reason}",
ban_message: "You have been banned from {guildName}. Reason given: {reason}",
log_automatic_actions: true,
case_log_channel: null,
alert_on_rejoin: false,
alert_channel: null
},
@ -157,9 +144,19 @@ export class ModActionsPlugin extends Plugin {
const modId = relevantAuditLogEntry.user.id;
const auditLogId = relevantAuditLogEntry.id;
await this.createCase(user.id, modId, CaseTypes.Ban, auditLogId, relevantAuditLogEntry.reason, true);
this.actions.fire("createCase", {
userId: user.id,
modId,
type: CaseTypes.Ban,
auditLogId,
reason: relevantAuditLogEntry.reason,
automatic: true
});
} else {
await this.createCase(user.id, null, CaseTypes.Ban);
this.actions.fire("createCase", {
userId: user.id,
type: CaseTypes.Ban
});
}
}
@ -184,9 +181,19 @@ export class ModActionsPlugin extends Plugin {
const modId = relevantAuditLogEntry.user.id;
const auditLogId = relevantAuditLogEntry.id;
await this.createCase(user.id, modId, CaseTypes.Unban, auditLogId, null, true);
this.actions.fire("createCase", {
userId: user.id,
modId,
type: CaseTypes.Unban,
auditLogId,
automatic: true
});
} else {
await this.createCase(user.id, null, CaseTypes.Unban);
this.actions.fire("createCase", {
userId: user.id,
type: CaseTypes.Unban,
automatic: true
});
}
}
@ -226,14 +233,15 @@ export class ModActionsPlugin extends Plugin {
);
if (kickAuditLogEntry) {
this.createCase(
member.id,
kickAuditLogEntry.user.id,
CaseTypes.Kick,
kickAuditLogEntry.id,
kickAuditLogEntry.reason,
true
);
this.actions.fire("createCase", {
userId: member.id,
modId: kickAuditLogEntry.user.id,
type: CaseTypes.Kick,
auditLogId: kickAuditLogEntry.id,
reason: kickAuditLogEntry.reason,
automatic: true
});
this.serverLogs.log(LogType.MEMBER_KICK, {
user: stripObjectToScalars(member.user),
mod: stripObjectToScalars(kickAuditLogEntry.user)
@ -249,24 +257,16 @@ export class ModActionsPlugin extends Plugin {
async updateCmd(msg: Message, args: any) {
const theCase = await this.cases.findByCaseNumber(args.caseNumber);
if (!theCase) {
msg.channel.createMessage("Case not found!");
msg.channel.createMessage(errorMessage("Case not found"));
return;
}
if (theCase.mod_id === null) {
// If the action has no moderator information, assume the first one to update it did the action
await this.cases.update(theCase.id, {
mod_id: msg.author.id,
mod_name: `${msg.author.username}#${msg.author.discriminator}`
});
}
await this.actions.fire("createCaseNote", theCase, {
modId: msg.author.id,
note: args.note
});
await this.createCaseNote(theCase.id, msg.author.id, args.note);
this.postCaseToCaseLog(theCase.id); // Post updated case to case log
if (msg.channel.id !== this.configValue("case_log_channel")) {
msg.channel.createMessage(successMessage(`Case \`#${theCase.case_number}\` updated`));
}
msg.channel.createMessage(successMessage(`Case \`#${theCase.case_number}\` updated`));
}
@d.command("note", "<userId:userId> <note:string$>")
@ -275,7 +275,13 @@ export class ModActionsPlugin extends Plugin {
const user = await this.bot.users.get(args.userId);
const userName = user ? `${user.username}#${user.discriminator}` : "member";
await this.createCase(args.userId, msg.author.id, CaseTypes.Note, null, args.note);
await this.actions.fire("createCase", {
userId: args.userId,
modId: msg.author.id,
type: CaseTypes.Note,
reason: args.note
});
msg.channel.createMessage(successMessage(`Note added on ${userName}`));
}
@ -309,7 +315,12 @@ export class ModActionsPlugin extends Plugin {
}
}
await this.createCase(args.member.id, msg.author.id, CaseTypes.Warn, null, args.reason);
await this.actions.fire("createCase", {
userId: args.member.id,
modId: msg.author.id,
type: CaseTypes.Warn,
reason: args.reason
});
msg.channel.createMessage(
successMessage(`Warned **${args.member.user.username}#${args.member.user.discriminator}**`)
@ -321,11 +332,6 @@ export class ModActionsPlugin extends Plugin {
});
}
public async muteMember(member: Member, muteTime: number = null, reason: string = null) {
await member.addRole(this.configValue("mute_role"));
await this.mutes.addOrUpdateMute(member.id, muteTime);
}
@d.command("mute", "<member:Member> [time:string] [reason:string$]")
@d.permission("mute")
async muteCmd(msg: Message, args: any) {
@ -353,20 +359,35 @@ export class ModActionsPlugin extends Plugin {
// Apply "muted" role
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id);
await this.muteMember(args.member, muteTime, args.reason);
const mute: Mute = await this.actions.fire("mute", {
member: args.member,
muteTime
});
const mute = await this.mutes.findExistingMuteForUserId(args.member.id);
const hasOldCase = mute && mute.case_id != null;
if (!mute) {
msg.channel.createMessage(errorMessage("Could not mute the user"));
return;
}
const hasOldCase = mute.case_id != null;
if (hasOldCase) {
if (args.reason) {
await this.createCaseNote(mute.case_id, msg.author.id, args.reason);
this.postCaseToCaseLog(mute.case_id);
// Update old case
await this.actions.fire("createCaseNote", mute.case_id, {
modId: msg.author.id,
note: args.reason
});
}
} else {
// Create a case
const caseId = await this.createCase(args.member.id, msg.author.id, CaseTypes.Mute, null, args.reason);
await this.mutes.setCaseId(args.member.id, caseId);
// Create new case
const theCase: Case = await this.actions.fire("createCase", {
userId: args.member.id,
modId: msg.author.id,
type: CaseTypes.Mute,
reason: args.reason
});
await this.mutes.setCaseId(args.member.id, theCase.id);
}
// Message the user informing them of the mute
@ -438,7 +459,7 @@ export class ModActionsPlugin extends Plugin {
if (unmuteTime) {
// If we have an unmute time, just update the old mute to expire in that time
const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime);
this.mutes.addOrUpdateMute(args.member.id, unmuteTime);
await this.actions.fire("unmute", { member: args.member, unmuteTime });
args.reason = args.reason ? `Timed unmute: ${args.reason}` : "Timed unmute";
// Confirm the action to the moderator
@ -450,8 +471,7 @@ export class ModActionsPlugin extends Plugin {
} else {
// Otherwise remove "muted" role immediately
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id);
await args.member.removeRole(this.configValue("mute_role"));
await this.mutes.clear(args.member.id);
await this.actions.fire("unmute", { member: args.member });
// Confirm the action to the moderator
msg.channel.createMessage(
@ -460,7 +480,12 @@ export class ModActionsPlugin extends Plugin {
}
// Create a case
await this.createCase(args.member.id, msg.author.id, CaseTypes.Unmute, null, args.reason);
await this.actions.fire("createCase", {
userId: args.member.id,
modId: msg.author.id,
type: CaseTypes.Unmute,
reason: args.reason
});
// Log the action
this.serverLogs.log(LogType.MEMBER_UNMUTE, {
@ -472,76 +497,12 @@ export class ModActionsPlugin extends Plugin {
@d.command("mutes")
@d.permission("view")
async mutesCmd(msg: Message) {
const lines = [];
// Active, logged mutes
const activeMutes = await this.mutes.getActiveMutes();
activeMutes.sort((a, b) => {
if (a.expires_at == null && b.expires_at != null) return 1;
if (b.expires_at == null && a.expires_at != null) return -1;
if (a.expires_at == null && b.expires_at == null) {
return a.created_at > b.created_at ? -1 : 1;
}
return a.expires_at > b.expires_at ? 1 : -1;
});
const caseIds = activeMutes.map(m => m.case_id).filter(v => !!v);
const cases = caseIds.length ? await this.cases.get(caseIds) : [];
const casesById = cases.reduce((map, c) => map.set(c.id, c), new Map());
lines.push(
...activeMutes.map(mute => {
const user = this.bot.users.get(mute.user_id);
const username = user ? `${user.username}#${user.discriminator}` : "Unknown#0000";
const theCase = casesById[mute.case_id] || null;
const caseName = theCase ? `Case #${theCase.case_number}` : "No case";
let line = `\`${caseName}\` **${username}** (\`${mute.user_id}\`)`;
if (mute.expires_at) {
const timeUntilExpiry = moment().diff(moment(mute.expires_at, DBDateFormat));
const humanizedTime = humanizeDuration(timeUntilExpiry, { largest: 2, round: true });
line += ` (expires in ${humanizedTime})`;
} else {
line += ` (doesn't expire)`;
}
const mutedAt = moment(mute.created_at, DBDateFormat);
line += ` (muted at ${mutedAt.format("YYYY-MM-DD HH:mm:ss")})`;
return line;
})
);
// Manually added mute roles
const muteUserIds = activeMutes.reduce((set, m) => set.add(m.user_id), new Set());
const manuallyMutedMembers = [];
const muteRole = this.configValue("mute_role");
if (muteRole) {
this.guild.members.forEach(member => {
if (muteUserIds.has(member.id)) return;
if (member.roles.includes(muteRole)) manuallyMutedMembers.push(member);
});
}
lines.push(
...manuallyMutedMembers.map(member => {
return `\`Manual mute\` **${member.user.username}#${member.user.discriminator}** (\`${member.id}\`)`;
})
);
const chunks = chunk(lines, 15);
for (const [i, chunkLines] of chunks.entries()) {
let body = chunkLines.join("\n");
if (i === 0) body = `Active mutes:\n\n${body}`;
msg.channel.createMessage(body);
}
this.actions.fire("postMuteList", msg.channel);
}
@d.command("kick", "<member:Member> [reason:string$]")
@d.permission("kick")
async kickCmd(msg, args) {
async kickCmd(msg, args: { member: Member; reason: string }) {
// Make sure we're allowed to kick this member
if (!this.canActOn(msg.member, args.member)) {
msg.channel.createMessage(errorMessage("Cannot kick: insufficient permissions"));
@ -570,7 +531,12 @@ export class ModActionsPlugin extends Plugin {
args.member.kick(args.reason);
// Create a case for this action
await this.createCase(args.member.id, msg.author.id, CaseTypes.Kick, null, args.reason);
await this.actions.fire("createCase", {
userId: args.member.id,
modId: msg.author.id,
type: CaseTypes.Kick,
reason: args.reason
});
// Confirm the action to the moderator
let response = `Kicked **${args.member.user.username}#${args.member.user.discriminator}**`;
@ -615,7 +581,12 @@ export class ModActionsPlugin extends Plugin {
args.member.ban(1, args.reason);
// Create a case for this action
await this.createCase(args.member.id, msg.author.id, CaseTypes.Ban, null, args.reason);
await this.actions.fire("createCase", {
userId: args.member.id,
modId: msg.author.id,
type: CaseTypes.Ban,
reason: args.reason
});
// Confirm the action to the moderator
let response = `Banned **${args.member.user.username}#${args.member.user.discriminator}**`;
@ -648,7 +619,12 @@ export class ModActionsPlugin extends Plugin {
await this.guild.unbanMember(args.member.id);
// Create a case for this action
await this.createCase(args.member.id, msg.author.id, CaseTypes.Softban, null, args.reason);
await this.actions.fire("createCase", {
userId: args.member.id,
modId: msg.author.id,
type: CaseTypes.Softban,
reason: args.reason
});
// Confirm the action to the moderator
msg.channel.createMessage(
@ -679,7 +655,12 @@ export class ModActionsPlugin extends Plugin {
msg.channel.createMessage(successMessage("Member unbanned!"));
// Create a case
this.createCase(args.userId, msg.author.id, CaseTypes.Unban, null, args.reason);
await this.actions.fire("createCase", {
userId: args.member.id,
modId: msg.author.id,
type: CaseTypes.Unban,
reason: args.reason
});
// Log the action
this.serverLogs.log(LogType.MEMBER_UNBAN, {
@ -712,7 +693,12 @@ export class ModActionsPlugin extends Plugin {
msg.channel.createMessage(successMessage("Member forcebanned!"));
// Create a case
this.createCase(args.userId, msg.author.id, CaseTypes.Ban, null, args.reason);
await this.actions.fire("createCase", {
userId: args.userId,
modId: msg.author.id,
type: CaseTypes.Ban,
reason: args.reason
});
// Log the action
this.serverLogs.log(LogType.MEMBER_FORCEBAN, {
@ -766,7 +752,14 @@ export class ModActionsPlugin extends Plugin {
for (const userId of args.userIds) {
try {
await this.guild.banMember(userId);
await this.createCase(userId, msg.author.id, CaseTypes.Ban, null, `Mass ban: ${banReason}`, false, false);
await this.actions.fire("createCase", {
userId,
modId: msg.author.id,
type: CaseTypes.Ban,
reason: `Mass ban: ${banReason}`,
postInCaseLog: false
});
} catch (e) {
failedBans.push(userId);
}
@ -820,11 +813,16 @@ export class ModActionsPlugin extends Plugin {
}
// Create the case
const caseId = await this.createCase(args.target, msg.author.id, CaseTypes[type], null, args.reason);
const theCase = await this.cases.find(caseId);
const theCase: Case = await this.actions.fire("createCase", {
userId: args.target,
modId: msg.author.id,
type: CaseTypes[type],
reason: args.reason
});
msg.channel.createMessage(successMessage("Case created!"));
// Log the action
msg.channel.createMessage(successMessage("Case created!"));
this.serverLogs.log(LogType.CASE_CREATE, {
mod: stripObjectToScalars(msg.member.user),
userId: args.userId,
@ -845,11 +843,14 @@ export class ModActionsPlugin extends Plugin {
const theCase = await this.cases.findByCaseNumber(args.caseNumber);
if (!theCase) {
msg.channel.createMessage("Case not found!");
msg.channel.createMessage(errorMessage("Case not found"));
return;
}
this.displayCase(theCase.id, msg.channel.id);
await this.actions.fire("postCase", {
caseId: theCase.id,
channel: msg.channel
});
}
@d.command(/cases|usercases/, "<userId:userId> [expanded:string]")
@ -871,7 +872,10 @@ export class ModActionsPlugin extends Plugin {
// Expanded view (= individual case embeds)
for (const theCase of cases) {
await this.displayCase(theCase.id, msg.channel.id);
await this.actions.fire("postCase", {
caseId: theCase.id,
channel: msg.channel
});
}
} else {
// Compact view (= regular message with a preview of each case)
@ -949,146 +953,4 @@ export class ModActionsPlugin extends Plugin {
return messageSent;
}
/**
* Shows information about the specified action in a message embed.
* If no channelId is specified, uses the channel id from config.
*/
protected async displayCase(caseOrCaseId: Case | number, channelId: string) {
let theCase: Case;
if (typeof caseOrCaseId === "number") {
theCase = await this.cases.with("notes").find(caseOrCaseId);
} else {
theCase = caseOrCaseId;
}
if (!theCase) return;
if (!this.guild.channels.get(channelId)) return;
const createdAt = moment(theCase.created_at);
const actionTypeStr = CaseTypes[theCase.type].toUpperCase();
const embed: any = {
title: `${actionTypeStr} - Case #${theCase.case_number}`,
footer: {
text: `Case created at ${createdAt.format("YYYY-MM-DD [at] HH:mm")}`
},
fields: [
{
name: "User",
value: `${theCase.user_name}\n<@!${theCase.user_id}>`,
inline: true
},
{
name: "Moderator",
value: `${theCase.mod_name}\n<@!${theCase.mod_id}>`,
inline: true
}
]
};
if (CaseTypeColors[theCase.type]) {
embed.color = CaseTypeColors[theCase.type];
}
if (theCase.notes.length) {
theCase.notes.forEach((note: any) => {
const noteDate = moment(note.created_at);
embed.fields.push({
name: `${note.mod_name} at ${noteDate.format("YYYY-MM-DD [at] HH:mm")}:`,
value: note.body
});
});
} else {
embed.fields.push({
name: "!!! THIS CASE HAS NO NOTES !!!",
value: "\u200B"
});
}
const channel = this.guild.channels.get(channelId) as TextChannel;
await channel.createMessage({ embed });
}
/**
* Posts the specified mod action to the guild's action log channel
*/
protected async postCaseToCaseLog(caseOrCaseId: Case | number) {
const caseLogChannelId = this.configValue("case_log_channel");
if (!caseLogChannelId) return;
if (!this.guild.channels.get(caseLogChannelId)) return;
return this.displayCase(caseOrCaseId, caseLogChannelId);
}
public async createCase(
userId: string,
modId: string,
caseType: CaseTypes,
auditLogId: string = null,
reason: string = null,
automatic = false,
postInCaseLogOverride = null
): Promise<number> {
const user = this.bot.users.get(userId);
const userName = user ? `${user.username}#${user.discriminator}` : "Unknown#0000";
const mod = this.bot.users.get(modId);
const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000";
const createdId = await this.cases.create({
type: caseType,
user_id: userId,
user_name: userName,
mod_id: modId,
mod_name: modName,
audit_log_id: auditLogId
});
if (reason) {
await this.createCaseNote(createdId, modId, reason);
}
if (
this.configValue("case_log_channel") &&
(!automatic || this.configValue("log_automatic_actions")) &&
postInCaseLogOverride !== false
) {
try {
await this.postCaseToCaseLog(createdId);
} catch (e) {} // tslint:disable-line
}
return createdId;
}
protected async createCaseNote(caseId: number, modId: string, body: string) {
const mod = this.bot.users.get(modId);
const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000";
return this.cases.createNote(caseId, {
mod_id: modId,
mod_name: modName,
body: body || ""
});
}
protected async clearExpiredMutes() {
const expiredMutes = await this.mutes.getExpiredMutes();
for (const mute of expiredMutes) {
const member = this.guild.members.get(mute.user_id);
if (!member) continue;
try {
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id);
await member.removeRole(this.configValue("mute_role"));
} catch (e) {} // tslint:disable-line
await this.mutes.clear(member.id);
this.serverLogs.log(LogType.MEMBER_MUTE_EXPIRED, {
member: stripObjectToScalars(member, ["user"])
});
}
}
}

158
src/plugins/Mutes.ts Normal file
View file

@ -0,0 +1,158 @@
import { Member, TextableChannel } from "eris";
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 { DBDateFormat, chunkMessageLines, stripObjectToScalars } from "../utils";
import humanizeDuration from "humanize-duration";
import { LogType } from "../data/LogType";
import { GuildLogs } from "../data/GuildLogs";
export class MutesPlugin extends ZeppelinPlugin {
protected actions: GuildActions;
protected mutes: GuildMutes;
protected cases: GuildCases;
protected serverLogs: GuildLogs;
private muteClearIntervalId: NodeJS.Timer;
getDefaultOptions() {
return {
config: {
mute_role: null
}
};
}
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);
this.actions.register("mute", args => {
return this.muteMember(args.member, args.muteTime);
});
this.actions.register("unmute", args => {
return this.unmuteMember(args.member, args.unmuteTime);
});
this.actions.register("postMuteList", args => {
return this.postMuteList(args.channel);
});
// Check for expired mutes every 5s
this.clearExpiredMutes();
this.muteClearIntervalId = setInterval(() => this.clearExpiredMutes(), 5000);
}
onUnload() {
this.actions.unregister("mute");
this.actions.unregister("unmute");
this.actions.unregister("postMuteList");
clearInterval(this.muteClearIntervalId);
}
public async muteMember(member: Member, muteTime: number = null) {
const muteRole = this.configValue("mute_role");
if (!muteRole) return;
await member.addRole(muteRole);
return this.mutes.addOrUpdateMute(member.id, muteTime);
}
public async unmuteMember(member: Member, unmuteTime: number = null) {
if (unmuteTime) {
await this.mutes.addOrUpdateMute(member.id, unmuteTime);
} else {
await member.removeRole(this.configValue("mute_role"));
await this.mutes.clear(member.id);
}
}
public async postMuteList(channel: TextableChannel) {
const lines = [];
// Active, logged mutes
const activeMutes = await this.mutes.getActiveMutes();
activeMutes.sort((a, b) => {
if (a.expires_at == null && b.expires_at != null) return 1;
if (b.expires_at == null && a.expires_at != null) return -1;
if (a.expires_at == null && b.expires_at == null) {
return a.created_at > b.created_at ? -1 : 1;
}
return a.expires_at > b.expires_at ? 1 : -1;
});
const caseIds = activeMutes.map(m => m.case_id).filter(v => !!v);
const muteCases = caseIds.length ? await this.cases.get(caseIds) : [];
const muteCasesById = muteCases.reduce((map, c) => map.set(c.id, c), new Map());
lines.push(
...activeMutes.map(mute => {
const user = this.bot.users.get(mute.user_id);
const username = user ? `${user.username}#${user.discriminator}` : "Unknown#0000";
const theCase = muteCasesById[mute.case_id] || null;
const caseName = theCase ? `Case #${theCase.case_number}` : "No case";
let line = `\`${caseName}\` **${username}** (\`${mute.user_id}\`)`;
if (mute.expires_at) {
const timeUntilExpiry = moment().diff(moment(mute.expires_at, DBDateFormat));
const humanizedTime = humanizeDuration(timeUntilExpiry, { largest: 2, round: true });
line += ` (expires in ${humanizedTime})`;
} else {
line += ` (doesn't expire)`;
}
const mutedAt = moment(mute.created_at, DBDateFormat);
line += ` (muted at ${mutedAt.format("YYYY-MM-DD HH:mm:ss")})`;
return line;
})
);
// Manually added mute roles
const muteUserIds = activeMutes.reduce((set, m) => set.add(m.user_id), new Set());
const manuallyMutedMembers = [];
const muteRole = this.configValue("mute_role");
if (muteRole) {
this.guild.members.forEach(member => {
if (muteUserIds.has(member.id)) return;
if (member.roles.includes(muteRole)) manuallyMutedMembers.push(member);
});
}
lines.push(
...manuallyMutedMembers.map(member => {
return `\`Manual mute\` **${member.user.username}#${member.user.discriminator}** (\`${member.id}\`)`;
})
);
const message = `Active mutes:\n\n${lines.join("\n")}`;
const chunks = chunkMessageLines(message);
for (const chunk of chunks) {
channel.createMessage(chunk);
}
}
protected async clearExpiredMutes() {
const expiredMutes = await this.mutes.getExpiredMutes();
for (const mute of expiredMutes) {
const member = this.guild.members.get(mute.user_id);
if (!member) continue;
try {
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id);
await member.removeRole(this.configValue("mute_role"));
} catch (e) {} // tslint:disable-line
await this.mutes.clear(member.id);
this.serverLogs.log(LogType.MEMBER_MUTE_EXPIRED, {
member: stripObjectToScalars(member, ["user"])
});
}
}
}

View file

@ -63,6 +63,6 @@ export class PostPlugin extends Plugin {
return;
}
const edited = await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, args.content);
await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, args.content);
}
}

View file

@ -1,7 +1,6 @@
import { decorators as d, Plugin } from "knub";
import { Channel, Message, User } from "eris";
import { Plugin } from "knub";
import { Channel, User } from "eris";
import {
formatTemplateString,
getEmojiInString,
getRoleMentions,
getUrlsInString,
@ -11,12 +10,14 @@ import {
} from "../utils";
import { LogType } from "../data/LogType";
import { GuildLogs } from "../data/GuildLogs";
import { ModActionsPlugin } from "./ModActions";
import { CaseTypes } from "../data/CaseTypes";
import { GuildArchives } from "../data/GuildArchives";
import moment from "moment-timezone";
import { SavedMessage } from "../data/entities/SavedMessage";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { GuildActions } from "../data/GuildActions";
import { Case } from "../data/entities/Case";
import { GuildMutes } from "../data/GuildMutes";
enum RecentActionType {
Message = 1,
@ -42,9 +43,11 @@ const MAX_INTERVAL = 300;
const SPAM_ARCHIVE_EXPIRY_DAYS = 90;
export class SpamPlugin extends Plugin {
protected actions: GuildActions;
protected logs: GuildLogs;
protected archives: GuildArchives;
protected savedMessages: GuildSavedMessages;
protected mutes: GuildMutes;
private onMessageCreateFn;
@ -90,9 +93,11 @@ export class SpamPlugin extends Plugin {
}
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);
this.mutes = GuildMutes.getInstance(this.guildId);
this.recentActions = [];
this.expiryInterval = setInterval(() => this.clearOldRecentActions(), 1000 * 60);
@ -198,22 +203,12 @@ export class SpamPlugin extends Plugin {
// If the user tripped the spam filter...
if (recentActionsCount > spamConfig.count) {
const recentActions = this.getRecentActions(type, savedMessage.user_id, savedMessage.channel_id, since);
let modActionsPlugin;
// Start by muting them, if enabled
if (spamConfig.mute) {
// We use the ModActions plugin for muting the user
// This means that spam mute functionality requires the ModActions plugin to be loaded
const guildData = this.knub.getGuildData(this.guildId);
modActionsPlugin = guildData.loadedPlugins.get("mod_actions") as ModActionsPlugin;
if (!modActionsPlugin) return;
if (spamConfig.mute && member) {
const muteTime = spamConfig.mute_time ? spamConfig.mute_time * 60 * 1000 : 120 * 1000;
if (member) {
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, savedMessage.user_id);
modActionsPlugin.muteMember(member, muteTime, "Automatic spam detection");
}
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, savedMessage.user_id);
this.actions.fire("mute", { member, muteTime, reason: "Automatic spam detection" });
}
// Get the offending message IDs
@ -273,18 +268,17 @@ export class SpamPlugin extends Plugin {
archiveUrl
});
const caseId = await modActionsPlugin.createCase(
savedMessage.user_id,
this.bot.user.id,
caseType,
null,
caseText,
true
);
const theCase: Case = await this.actions.fire("createCase", {
userId: savedMessage.user_id,
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 modActionsPlugin.mutes.setCaseId(savedMessage.user_id, caseId);
await this.mutes.setCaseId(savedMessage.user_id, theCase.id);
}
}
},

View file

@ -0,0 +1,10 @@
import { Plugin } from "knub";
import { PluginRuntimeError } from "../PluginRuntimeError";
import { TextableChannel } from "eris";
import { errorMessage, successMessage } from "../utils";
export class ZeppelinPlugin extends Plugin {
protected throwPluginRuntimeError(message: string) {
throw new PluginRuntimeError(message, this.pluginName, this.guildId);
}
}