Notify user for spam detection mutes. Add notification status ('user notified in DMs' etc.) to the case. Log case updates. Add 'unmuted immediately' to the case for unmutes without a time.

This commit is contained in:
Dragory 2019-04-13 17:35:02 +03:00
parent 40cb74ee28
commit fe88766f02
10 changed files with 455 additions and 332 deletions

View file

@ -48,5 +48,7 @@
"MASSBAN": "⚒ {userMention(mod)} massbanned {count} users",
"MEMBER_JOIN_WITH_PRIOR_RECORDS": "⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}"
"MEMBER_JOIN_WITH_PRIOR_RECORDS": "⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}",
"CASE_UPDATE": "✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```"
}

View file

@ -1,6 +1,9 @@
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";
@ -9,30 +12,32 @@ type UnknownAction<T extends string> = T extends KnownActions ? never : T;
type ActionFn<T> = (args: T) => any | Promise<any>;
type MuteActionArgs = { member: Member; muteTime?: number; reason?: string };
type UnmuteActionArgs = { member: Member; unmuteTime?: number; reason?: string };
type CreateCaseActionArgs = {
userId: string;
modId: string;
type: CaseTypes;
auditLogId?: string;
reason?: string;
automatic?: boolean;
postInCaseLog?: boolean;
ppId?: string;
};
type MuteActionArgs = { member: Member; muteTime?: number; reason?: string; caseDetails?: ICaseDetails };
type UnmuteActionArgs = { member: Member; 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>>;
@ -63,8 +68,8 @@ export class GuildActions extends BaseRepository {
this.actions.delete(actionName);
}
public fire(actionName: "mute", args: MuteActionArgs): Promise<any>;
public fire(actionName: "unmute", args: UnmuteActionArgs): Promise<any>;
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>;

View file

@ -8,6 +8,22 @@ 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

@ -24,11 +24,16 @@ export class GuildMutes extends BaseRepository {
return this.mutes.findOne({
where: {
guild_id: this.guildId,
user_id: userId
}
user_id: userId,
},
});
}
async isMuted(userId: string): Promise<boolean> {
const mute = await this.findExistingMuteForUserId(userId);
return mute != null;
}
async addMute(userId, expiryTime): Promise<Mute> {
const expiresAt = expiryTime
? moment()
@ -39,7 +44,7 @@ export class GuildMutes extends BaseRepository {
const result = await this.mutes.insert({
guild_id: this.guildId,
user_id: userId,
expires_at: expiresAt
expires_at: expiresAt,
});
return this.mutes.findOne({ where: result.identifiers[0] });
@ -55,25 +60,14 @@ export class GuildMutes extends BaseRepository {
return this.mutes.update(
{
guild_id: this.guildId,
user_id: userId
user_id: userId,
},
{
expires_at: expiresAt
}
expires_at: expiresAt,
},
);
}
async addOrUpdateMute(userId, expiryTime): Promise<Mute> {
const existingMute = await this.findExistingMuteForUserId(userId);
if (existingMute) {
await this.updateExpiryTime(userId, expiryTime);
return this.findExistingMuteForUserId(userId);
} else {
return this.addMute(userId, expiryTime);
}
}
async getActiveMutes(): Promise<Mute[]> {
return this.mutes
.createQueryBuilder("mutes")
@ -81,7 +75,7 @@ export class GuildMutes extends BaseRepository {
.andWhere(
new Brackets(qb => {
qb.where("expires_at > NOW()").orWhere("expires_at IS NULL");
})
}),
)
.getMany();
}
@ -90,18 +84,18 @@ export class GuildMutes extends BaseRepository {
await this.mutes.update(
{
guild_id: this.guildId,
user_id: userId
user_id: userId,
},
{
case_id: caseId
}
case_id: caseId,
},
);
}
async clear(userId) {
await this.mutes.delete({
guild_id: this.guildId,
user_id: userId
user_id: userId,
});
}
}

View file

@ -49,4 +49,6 @@ export enum LogType {
MEMBER_ROLE_CHANGES,
VOICE_CHANNEL_FORCE_MOVE,
CASE_UPDATE,
}

View file

@ -1,5 +1,5 @@
import { Message, MessageContent, MessageFile, TextableChannel, TextChannel } from "eris";
import { GuildCases } from "../data/GuildCases";
import { GuildCases, ICaseDetails } from "../data/GuildCases";
import { CaseTypes } from "../data/CaseTypes";
import { Case } from "../data/entities/Case";
import moment from "moment-timezone";
@ -36,20 +36,18 @@ export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
this.archives = GuildArchives.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,
args.ppId,
);
return this.createCase(args);
});
this.actions.register("createCaseNote", args => {
return this.createCaseNote(args.caseId, args.modId, args.note, args.automatic, args.postInCaseLog);
return this.createCaseNote(
args.caseId,
args.modId,
args.note,
args.automatic,
args.postInCaseLog,
args.noteDetails,
);
});
this.actions.register("postCase", async args => {
@ -72,46 +70,47 @@ 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(
userId: string,
modId: string,
type: CaseTypes,
auditLogId: string = null,
reason: string = null,
automatic = false,
postInCaseLogOverride = null,
ppId = null,
): Promise<Case> {
const user = this.bot.users.get(userId);
public async createCase(opts: ICaseDetails): Promise<Case> {
const user = this.bot.users.get(opts.userId);
const userName = user ? `${user.username}#${user.discriminator}` : "Unknown#0000";
const mod = this.bot.users.get(modId);
const mod = this.bot.users.get(opts.modId);
const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000";
let ppName = null;
if (ppId) {
const pp = this.bot.users.get(ppId);
if (opts.ppId) {
const pp = this.bot.users.get(opts.ppId);
ppName = pp ? `${pp.username}#${pp.discriminator}` : "Unknown#0000";
}
const createdCase = await this.cases.create({
type,
user_id: userId,
type: opts.type,
user_id: opts.userId,
user_name: userName,
mod_id: modId,
mod_id: opts.modId,
mod_name: modName,
audit_log_id: auditLogId,
pp_id: ppId,
audit_log_id: opts.auditLogId,
pp_id: opts.ppId,
pp_name: ppName,
});
if (reason) {
await this.createCaseNote(createdCase, modId, reason, automatic, false);
if (opts.reason || opts.noteDetails.length) {
await this.createCaseNote(createdCase, opts.modId, opts.reason || "", opts.automatic, false, opts.noteDetails);
}
if (opts.extraNotes) {
for (const extraNote of opts.extraNotes) {
await this.createCaseNote(createdCase, opts.modId, extraNote, opts.automatic, false);
}
}
const config = this.getConfig();
if (config.case_log_channel && (!automatic || config.log_automatic_actions) && postInCaseLogOverride !== false) {
if (
config.case_log_channel &&
(!opts.automatic || config.log_automatic_actions) &&
opts.postInCaseLogOverride !== false
) {
try {
await this.postCaseToCaseLogChannel(createdCase);
} catch (e) {} // tslint:disable-line
@ -129,6 +128,7 @@ export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
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";
@ -138,6 +138,11 @@ export class CasesPlugin extends ZeppelinPlugin<ICasesPluginConfig> {
this.throwPluginRuntimeError(`Unknown case ID: ${caseOrCaseId}`);
}
// Add note details to the beginning of the note
if (noteDetails && noteDetails.length) {
body = noteDetails.map(d => `__[${d}]__`).join(" ") + " " + body;
}
await this.cases.createNote(theCase.id, {
mod_id: modId,
mod_name: modName,

View file

@ -3,11 +3,13 @@ import { Attachment, Constants as ErisConstants, Guild, Member, Message, TextCha
import humanizeDuration from "humanize-duration";
import { GuildCases } from "../data/GuildCases";
import {
convertDelayStringToMS,
asSingleLine,
createChunkedMessage,
errorMessage,
findRelevantAuditLogEntry,
asSingleLine,
INotifyUserResult,
notifyUser,
NotifyUserStatus,
stripObjectToScalars,
successMessage,
trimLines,
@ -17,9 +19,8 @@ import { CaseTypes } from "../data/CaseTypes";
import { GuildLogs } from "../data/GuildLogs";
import { LogType } from "../data/LogType";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildActions } from "../data/GuildActions";
import { GuildActions, MuteActionResult } from "../data/GuildActions";
import { Case } from "../data/entities/Case";
import { Mute } from "../data/entities/Mute";
import { renderTemplate } from "../templateFormatter";
enum IgnoredEventType {
@ -33,31 +34,15 @@ interface IIgnoredEvent {
userId: string;
}
enum MessageResultStatus {
Ignored = 1,
Failed,
DirectMessaged,
ChannelMessaged,
}
interface IMessageResult {
status: MessageResultStatus;
text?: string;
}
interface IModActionsPluginConfig {
dm_on_warn: boolean;
dm_on_mute: boolean;
dm_on_kick: boolean;
dm_on_ban: boolean;
message_on_warn: boolean;
message_on_mute: boolean;
message_on_kick: boolean;
message_on_ban: boolean;
message_channel: string;
warn_message: string;
mute_message: string;
timed_mute_message: string;
kick_message: string;
ban_message: string;
alert_on_rejoin: boolean;
@ -98,17 +83,13 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
return {
config: {
dm_on_warn: true,
dm_on_mute: false,
dm_on_kick: false,
dm_on_ban: false,
message_on_warn: false,
message_on_mute: false,
message_on_kick: false,
message_on_ban: false,
message_channel: null,
warn_message: "You have received a warning on {guildName}: {reason}",
mute_message: "You have been muted on {guildName}. Reason given: {reason}",
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}",
alert_on_rejoin: false,
@ -307,7 +288,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
overloads: ["<note:string$>"],
})
@d.permission("can_note")
async updateSpecificCmd(msg: Message, args: { caseNumber?: number; note: string }) {
async updateCmd(msg: Message, args: { caseNumber?: number; note: string }) {
let theCase: Case;
if (args.caseNumber != null) {
theCase = await this.cases.findByCaseNumber(args.caseNumber);
@ -326,6 +307,13 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
note: args.note,
});
this.serverLogs.log(LogType.CASE_UPDATE, {
mod: msg.author,
caseNumber: theCase.case_number,
caseType: CaseTypes[theCase.type],
note: args.note,
});
msg.channel.createMessage(successMessage(`Case \`#${theCase.case_number}\` updated`));
}
@ -373,14 +361,12 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason);
const userMessageResult = await this.tryToMessageUser(
args.member.user,
warnMessage,
config.dm_on_warn,
config.message_on_warn,
);
const userMessageResult = await notifyUser(this.bot, this.guild, args.member.user, warnMessage, {
useDM: config.dm_on_warn,
useChannel: config.message_on_warn,
});
if (userMessageResult.status === MessageResultStatus.Failed) {
if (userMessageResult.status === NotifyUserStatus.Failed) {
const failedMsg = await msg.channel.createMessage("Failed to message the user. Log the warning anyway?");
const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"], msg.author.id);
failedMsg.delete();
@ -427,6 +413,8 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
// The moderator who did the action is the message author or, if used, the specified --mod
let mod = msg.member;
let pp = null;
if (args.mod) {
if (!this.hasPermission("can_act_as_other", { message: msg })) {
msg.channel.createMessage(errorMessage("No permission for --mod"));
@ -434,116 +422,60 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
}
mod = args.mod;
pp = msg.author;
}
let userMessageResult: IMessageResult = { status: MessageResultStatus.Ignored };
const timeUntilUnmute = args.time && humanizeDuration(args.time);
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
// Apply "muted" role
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id);
const mute: Mute = await this.actions.fire("mute", {
member: args.member,
muteTime: args.time,
});
let muteResult: MuteActionResult;
if (!mute) {
try {
muteResult = await this.actions.fire("mute", {
member: args.member,
muteTime: args.time,
reason,
caseDetails: {
modId: mod.id,
ppId: pp && pp.id,
},
});
} catch (e) {
logger.error(`Failed to mute user ${args.member.id}: ${e.message}`);
msg.channel.createMessage(errorMessage("Could not mute the user"));
return;
}
const hasOldCase = mute.case_id != null;
let theCase;
if (hasOldCase) {
// Update old case
theCase = await this.cases.find(mute.case_id);
const caseNote = `__[Mute updated to ${args.time ? timeUntilUnmute : "indefinite"}]__ ${reason}`.trim();
await this.actions.fire("createCaseNote", {
caseId: mute.case_id,
modId: mod.id,
note: caseNote,
});
} else {
// Create new case
const caseNote = `__[Muted ${args.time ? `for ${timeUntilUnmute}` : "indefinitely"}]__ ${reason}`.trim();
theCase = await this.actions.fire("createCase", {
userId: args.member.id,
modId: mod.id,
type: CaseTypes.Mute,
reason: caseNote,
ppId: mod.id !== msg.author.id ? msg.author.id : null,
});
await this.mutes.setCaseId(args.member.id, theCase.id);
}
const config = this.getConfig();
// Message the user informing them of the mute
// Don't message them if we're updating an old mute
if (reason && !hasOldCase) {
const template = args.time ? config.timed_mute_message : config.mute_message;
const muteMessage = await renderTemplate(template, {
guildName: this.guild.name,
reason,
time: timeUntilUnmute,
});
userMessageResult = await this.tryToMessageUser(
args.member.user,
muteMessage,
config.dm_on_mute,
config.message_on_mute,
);
}
// Confirm the action to the moderator
let response;
if (args.time) {
if (hasOldCase) {
if (muteResult.updatedExistingMute) {
response = asSingleLine(`
Updated **${args.member.user.username}#${args.member.user.discriminator}**'s
mute to ${timeUntilUnmute} (Case #${theCase.case_number})
mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number})
`);
} else {
response = asSingleLine(`
Muted **${args.member.user.username}#${args.member.user.discriminator}**
for ${timeUntilUnmute} (Case #${theCase.case_number})
for ${timeUntilUnmute} (Case #${muteResult.case.case_number})
`);
}
} else {
if (hasOldCase) {
if (muteResult.updatedExistingMute) {
response = asSingleLine(`
Updated **${args.member.user.username}#${args.member.user.discriminator}**'s
mute to indefinite (Case #${theCase.case_number})
mute to indefinite (Case #${muteResult.case.case_number})
`);
} else {
response = asSingleLine(`
Muted **${args.member.user.username}#${args.member.user.discriminator}**
indefinitely (Case #${theCase.case_number})
indefinitely (Case #${muteResult.case.case_number})
`);
}
}
if (userMessageResult.text) response += ` (${userMessageResult.text})`;
if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`;
msg.channel.createMessage(successMessage(response));
// Log the action
if (args.time) {
this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, {
mod: stripObjectToScalars(mod.user),
member: stripObjectToScalars(args.member, ["user"]),
time: timeUntilUnmute,
});
} else {
this.serverLogs.log(LogType.MEMBER_MUTE, {
mod: stripObjectToScalars(mod.user),
member: stripObjectToScalars(args.member, ["user"]),
});
}
}
@d.command("unmute", "<member:Member> <time:delay> <reason:string$>", {
@ -559,76 +491,57 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
}
// The moderator who did the action is the message author or, if used, the specified --mod
let mod = msg.member;
let mod = msg.author;
let pp = null;
if (args.mod) {
if (!this.hasPermission("can_act_as_other", { message: msg })) {
msg.channel.createMessage(errorMessage("No permission for --mod"));
return;
}
mod = args.mod;
mod = args.mod.user;
pp = msg.author;
}
// Check if they're muted in the first place
const mute = await this.mutes.findExistingMuteForUserId(args.member.id);
if (!mute) {
if (!(await this.mutes.isMuted(args.member.id))) {
msg.channel.createMessage(errorMessage("Cannot unmute: member is not muted"));
return;
}
// Convert unmute time from e.g. "2h30m" to milliseconds
const timeUntilUnmute = args.time && humanizeDuration(args.time);
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
const caseNote = args.time ? `__[Scheduled unmute in ${timeUntilUnmute}]__ ${reason}` : reason;
// Create a case
const createdCase = await this.actions.fire("createCase", {
userId: args.member.id,
modId: mod.id,
type: CaseTypes.Unmute,
reason: caseNote,
ppId: mod.id !== msg.author.id ? msg.author.id : null,
const result = await this.actions.fire("unmute", {
member: args.member,
unmuteTime: args.time,
caseDetails: {
modId: mod.id,
ppId: pp && pp.id,
reason,
},
});
// Confirm the action to the moderator
if (args.time) {
// If we have an unmute time, just update the old mute to expire in that time
await this.actions.fire("unmute", { member: args.member, unmuteTime: args.time });
// Confirm the action to the moderator
const timeUntilUnmute = args.time && humanizeDuration(args.time);
msg.channel.createMessage(
successMessage(
`Unmuting **${args.member.user.username}#${args.member.user.discriminator}** in ${timeUntilUnmute} (Case #${
createdCase.case_number
})`,
asSingleLine(`
Unmuting **${args.member.user.username}#${args.member.user.discriminator}**
in ${timeUntilUnmute} (Case #${result.case.case_number})
`),
),
);
// Log the action
this.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, {
mod: stripObjectToScalars(mod.user),
member: stripObjectToScalars(args.member, ["user"]),
time: timeUntilUnmute,
});
} else {
// Otherwise remove "muted" role immediately
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id);
await this.actions.fire("unmute", { member: args.member });
// Confirm the action to the moderator
msg.channel.createMessage(
successMessage(
`Unmuted **${args.member.user.username}#${args.member.user.discriminator}** (Case #${
createdCase.case_number
})`,
asSingleLine(`
Unmuted **${args.member.user.username}#${args.member.user.discriminator}**
(Case #${result.case.case_number})
`),
),
);
// Log the action
this.serverLogs.log(LogType.MEMBER_UNMUTE, {
mod: stripObjectToScalars(msg.member.user),
member: stripObjectToScalars(args.member, ["user"]),
});
}
}
@ -658,19 +571,18 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
// Attempt to message the user *before* kicking them, as doing it after may not be possible
let userMessageResult: IMessageResult = { status: MessageResultStatus.Ignored };
let userMessageResult: INotifyUserResult = { status: NotifyUserStatus.Ignored };
if (args.reason) {
const kickMessage = await renderTemplate(config.kick_message, {
guildName: this.guild.name,
reason,
});
userMessageResult = await this.tryToMessageUser(
args.member.user,
kickMessage,
config.dm_on_kick,
config.message_on_kick,
);
userMessageResult = await notifyUser(this.bot, this.guild, args.member.user, kickMessage, {
useDM: config.dm_on_kick,
useChannel: config.message_on_kick,
channelId: config.message_channel,
});
}
// Kick the user
@ -728,19 +640,18 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
// Attempt to message the user *before* banning them, as doing it after may not be possible
let userMessageResult: IMessageResult = { status: MessageResultStatus.Ignored };
let userMessageResult: INotifyUserResult = { status: NotifyUserStatus.Ignored };
if (reason) {
const banMessage = await renderTemplate(config.ban_message, {
guildName: this.guild.name,
reason,
});
userMessageResult = await this.tryToMessageUser(
args.member.user,
banMessage,
config.dm_on_ban,
config.message_on_ban,
);
userMessageResult = await notifyUser(this.bot, this.guild, args.member.user, banMessage, {
useDM: config.dm_on_ban,
useChannel: config.message_on_ban,
channelId: config.message_channel,
});
}
// Ban the user
@ -1233,49 +1144,4 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig> {
await this.cases.setHidden(theCase.id, false);
msg.channel.createMessage(successMessage(`Case #${theCase.case_number} is no longer hidden!`));
}
/**
* Attempts to message the specified user through DMs and/or the message channel.
* Returns a promise that resolves to a status constant indicating the result.
*/
protected async tryToMessageUser(
user: User,
str: string,
useDM: boolean,
useChannel: boolean,
): Promise<IMessageResult> {
if (!useDM && !useChannel) {
return { status: MessageResultStatus.Ignored };
}
if (useDM) {
try {
const dmChannel = await this.bot.getDMChannel(user.id);
await dmChannel.createMessage(str);
logger.info(`Sent DM to ${user.id}: ${str}`);
return {
status: MessageResultStatus.DirectMessaged,
text: "user notified with a direct message",
};
} catch (e) {} // tslint:disable-line
}
const messageChannel = this.getConfig().message_channel;
if (useChannel && messageChannel) {
try {
const channel = this.guild.channels.get(messageChannel) as TextChannel;
await channel.createMessage(`<@!${user.id}> ${str}`);
return {
status: MessageResultStatus.ChannelMessaged,
text: `user notified in <#${channel.id}>`,
};
} catch (e) {} // tslint:disable-line
}
return {
status: MessageResultStatus.Failed,
text: "failed to message user",
};
}
}

View file

@ -1,15 +1,27 @@
import { Member, Message, User } from "eris";
import { GuildCases } from "../data/GuildCases";
import { GuildCases, ICaseDetails } 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, successMessage, errorMessage, sleep } from "../utils";
import {
chunkMessageLines,
DBDateFormat,
errorMessage,
INotifyUserResult,
notifyUser,
NotifyUserStatus,
stripObjectToScalars,
successMessage,
ucfirst,
} from "../utils";
import humanizeDuration from "humanize-duration";
import { LogType } from "../data/LogType";
import { GuildLogs } from "../data/GuildLogs";
import { decorators as d, IPluginOptions, logger } from "knub";
import { Mute } from "../data/entities/Mute";
import { renderTemplate } from "../templateFormatter";
import { CaseTypes } from "../data/CaseTypes";
interface IMuteWithDetails extends Mute {
member?: Member;
@ -20,6 +32,12 @@ interface IMutesPluginConfig {
mute_role: string;
move_to_voice_channel: string;
dm_on_mute: boolean;
message_on_mute: boolean;
message_channel: string;
mute_message: string;
timed_mute_message: string;
can_view_list: boolean;
can_cleanup: boolean;
}
@ -39,6 +57,12 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
mute_role: null,
move_to_voice_channel: null,
dm_on_mute: false,
message_on_mute: false,
message_channel: null,
mute_message: "You have been muted on {guildName}. Reason given: {reason}",
timed_mute_message: "You have been muted on {guildName} for {time}. Reason given: {reason}",
can_view_list: false,
can_cleanup: false,
},
@ -66,10 +90,10 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
this.serverLogs = new GuildLogs(this.guildId);
this.actions.register("mute", args => {
return this.muteMember(args.member, args.muteTime);
return this.muteMember(args.member, args.muteTime, args.reason, args.caseDetails);
});
this.actions.register("unmute", args => {
return this.unmuteMember(args.member, args.unmuteTime);
return this.unmuteMember(args.member, args.unmuteTime, args.caseDetails);
});
// Check for expired mutes every 5s
@ -84,33 +108,128 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
clearInterval(this.muteClearIntervalId);
}
public async muteMember(member: Member, muteTime: number = null) {
public async muteMember(
member: Member,
muteTime: number = null,
reason: string = null,
caseDetails: ICaseDetails = {},
) {
const muteRole = this.getConfig().mute_role;
if (!muteRole) return;
// Add muted role
await member.addRole(muteRole);
const timeUntilUnmute = muteTime && humanizeDuration(muteTime);
// No mod specified -> mark Zeppelin as the mod
if (!caseDetails.modId) {
caseDetails.modId = this.bot.user.id;
}
// Apply mute role if it's missing
if (!member.roles.includes(muteRole)) {
await member.addRole(muteRole);
}
// If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role)
const moveToVoiceChannelId = this.getConfig().move_to_voice_channel;
if (moveToVoiceChannelId && member.voiceState.channelID) {
try {
await member.edit({
channelID: moveToVoiceChannelId,
});
await member.edit({ channelID: moveToVoiceChannelId });
} catch (e) {
logger.warn(`Could not move user ${member.id} to voice channel ${moveToVoiceChannelId} when muting`);
}
}
// Create & return mute record
return this.mutes.addOrUpdateMute(member.id, muteTime);
// If the user is already muted, update the duration of their existing mute
const existingMute = await this.mutes.findExistingMuteForUserId(member.id);
let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored };
if (existingMute) {
await this.mutes.updateExpiryTime(member.id, muteTime);
} else {
await this.mutes.addMute(member.id, muteTime);
// If it's a new mute, attempt to message the user
const config = this.getMatchingConfig({ member });
const template = muteTime ? config.timed_mute_message : config.mute_message;
const muteMessage =
template &&
(await renderTemplate(template, {
guildName: this.guild.name,
reason,
time: timeUntilUnmute,
}));
if (muteMessage) {
notifyResult = await notifyUser(this.bot, this.guild, member.user, muteMessage, {
useDM: config.dm_on_mute,
useChannel: config.message_on_mute,
channelId: config.message_channel,
});
}
}
// Create/update a case
let theCase;
if (existingMute && existingMute.case_id) {
// Update old case
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,
});
} else {
// Create new case
const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`];
if (notifyResult.status !== NotifyUserStatus.Ignored) {
noteDetails.push(ucfirst(notifyResult.text));
}
theCase = await this.actions.fire("createCase", {
userId: member.id,
modId: caseDetails.modId,
type: CaseTypes.Mute,
reason,
ppId: caseDetails.ppId,
noteDetails,
extraNotes: caseDetails.extraNotes,
});
await this.mutes.setCaseId(member.id, theCase.id);
}
// Log the action
if (muteTime) {
this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, {
mod: stripObjectToScalars(caseDetails.modId),
member: stripObjectToScalars(member, ["user"]),
time: timeUntilUnmute,
});
} else {
this.serverLogs.log(LogType.MEMBER_MUTE, {
mod: stripObjectToScalars(caseDetails.modId),
member: stripObjectToScalars(member, ["user"]),
});
}
return {
case: theCase,
notifyResult,
updatedExistingMute: !!existingMute,
};
}
public async unmuteMember(member: Member, unmuteTime: number = null) {
public async unmuteMember(member: Member, unmuteTime: number = null, caseDetails: ICaseDetails = {}) {
const existingMute = await this.mutes.findExistingMuteForUserId(member.id);
if (!existingMute) return;
if (unmuteTime) {
await this.mutes.addOrUpdateMute(member.id, unmuteTime);
// Schedule timed unmute (= just set the mute's duration)
await this.mutes.updateExpiryTime(member.id, unmuteTime);
} else {
// Unmute immediately
const muteRole = this.getConfig().mute_role;
if (member.roles.includes(muteRole)) {
await member.removeRole(muteRole);
@ -118,6 +237,44 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
await this.mutes.clear(member.id);
}
const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime);
// Create a case
const noteDetails = [];
if (unmuteTime) {
noteDetails.push(`Scheduled unmute in ${timeUntilUnmute}`);
} else {
noteDetails.push(`Unmuted immediately`);
}
const createdCase = await this.actions.fire("createCase", {
userId: member.id,
modId: caseDetails.modId,
type: CaseTypes.Unmute,
reason: caseDetails.reason,
ppId: caseDetails.ppId,
noteDetails,
});
// Log the action
const mod = this.bot.users.get(caseDetails.modId);
if (unmuteTime) {
this.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, {
mod: stripObjectToScalars(mod),
member: stripObjectToScalars(member, ["user"]),
time: timeUntilUnmute,
});
} else {
this.serverLogs.log(LogType.MEMBER_UNMUTE, {
mod: stripObjectToScalars(mod),
member: stripObjectToScalars(member, ["user"]),
});
}
return {
case: createdCase,
};
}
@d.command("mutes", [], {
@ -361,7 +518,6 @@ export class MutesPlugin extends ZeppelinPlugin<IMutesPluginConfig> {
if (!member) continue;
try {
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id);
await member.removeRole(this.getConfig().mute_role);
} catch (e) {} // tslint:disable-line

View file

@ -1,7 +1,7 @@
import { decorators as d, IPluginOptions } from "knub";
import { decorators as d, IPluginOptions, logger } from "knub";
import { Channel, Member } from "eris";
import humanizeDuration from "humanize-duration";
import {
convertDelayStringToMS,
getEmojiInString,
getRoleMentions,
getUrlsInString,
@ -16,7 +16,7 @@ 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 { GuildActions, MuteActionResult } from "../data/GuildActions";
import { Case } from "../data/entities/Case";
import { GuildMutes } from "../data/GuildMutes";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
@ -191,7 +191,7 @@ export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
this.recentActions = this.recentActions.filter(action => action.timestamp >= expiryTimestamp);
}
async saveSpamArchives(savedMessages: SavedMessage[], channel: Channel) {
async saveSpamArchives(savedMessages: SavedMessage[]) {
const expiresAt = moment().add(SPAM_ARCHIVE_EXPIRY_DAYS, "days");
const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild, expiresAt);
@ -239,12 +239,17 @@ 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 timeUntilUnmute;
let muteResult: MuteActionResult;
if (spamConfig.mute && member) {
const muteTime = spamConfig.mute_time ? spamConfig.mute_time * 60 * 1000 : 120 * 1000;
timeUntilUnmute = humanizeDuration(muteTime);
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, savedMessage.user_id);
this.actions.fire("mute", { member, muteTime, reason: "Automatic spam detection" });
const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time) : 120 * 1000;
muteResult = await this.actions.fire("mute", {
member,
muteTime,
reason: "Automatic spam detection",
caseDetails: {
modId: this.bot.user.id,
},
});
}
// Get the offending message IDs
@ -285,19 +290,39 @@ export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
// Generate a log from the detected messages
const channel = this.guild.channels.get(savedMessage.channel_id);
const archiveUrl = await this.saveSpamArchives(uniqueMessages, channel);
const archiveUrl = await this.saveSpamArchives(uniqueMessages);
// Create a case and log the actions taken above
const caseType = spamConfig.mute ? CaseTypes.Mute : CaseTypes.Note;
let caseText = trimLines(`
Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
${archiveUrl}
`);
// Create a case
if (muteResult) {
// If the user was muted, the mute already generated a case - in that case, just update the case with extra details
const updateText = trimLines(`
Details: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
${archiveUrl}
`);
this.actions.fire("createCaseNote", {
caseId: muteResult.case.id,
modId: muteResult.case.mod_id,
note: updateText,
automatic: true,
postInCaseLogOverride: false,
});
} else {
// If the user was not muted, create a note case of the detected spam instead
const caseText = trimLines(`
Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
${archiveUrl}
`);
if (spamConfig.mute) {
caseText = `__[Muted for ${timeUntilUnmute}]__ ${caseText}`;
this.actions.fire("createCase", {
userId: savedMessage.user_id,
modId: this.bot.user.id,
type: CaseTypes.Note,
reason: caseText,
automatic: true,
});
}
// Create a log entry
this.logs.log(LogType.MESSAGE_SPAM_DETECTED, {
member: stripObjectToScalars(member, ["user"]),
channel: stripObjectToScalars(channel),
@ -306,24 +331,10 @@ export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
interval: spamConfig.interval,
archiveUrl,
});
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 this.mutes.setCaseId(savedMessage.user_id, theCase.id);
}
}
},
err => {
console.error("Error while detecting spam:");
console.error(err);
logger.error(`Error while detecting spam:\n${err}`);
},
);
}
@ -469,7 +480,7 @@ export class SpamPlugin extends ZeppelinPlugin<ISpamPluginConfig> {
);
}
// TODO: Max duplicates
// TODO: Max duplicates check
}
@d.event("voiceChannelJoin")

View file

@ -1,4 +1,4 @@
import { Emoji, Guild, GuildAuditLogEntry, TextableChannel } from "eris";
import { Client, Emoji, Guild, GuildAuditLogEntry, TextableChannel, TextChannel, User } from "eris";
import url from "url";
import tlds from "tlds";
import emojiRegex from "emoji-regex";
@ -8,6 +8,7 @@ const fsp = fs.promises;
import https from "https";
import tmp from "tmp";
import { logger } from "knub";
/**
* Turns a "delay string" such as "1h30m" to milliseconds
@ -428,3 +429,68 @@ export const DBDateFormat = "YYYY-MM-DD HH:mm:ss";
export type CustomEmoji = {
id: string;
} & Emoji;
export interface INotifyUserConfig {
useDM?: boolean;
useChannel?: boolean;
channelId?: string;
}
export enum NotifyUserStatus {
Ignored = 1,
Failed,
DirectMessaged,
ChannelMessaged,
}
export interface INotifyUserResult {
status: NotifyUserStatus;
text?: string;
}
export async function notifyUser(
bot: Client,
guild: Guild,
user: User,
body: string,
config: INotifyUserConfig,
): Promise<INotifyUserResult> {
if (!config.useDM && !config.useChannel) {
return { status: NotifyUserStatus.Ignored };
}
if (config.useDM) {
try {
const dmChannel = await bot.getDMChannel(user.id);
await dmChannel.createMessage(body);
logger.info(`Notified ${user.id} via DM: ${body}`);
return {
status: NotifyUserStatus.DirectMessaged,
text: "user notified with a direct message",
};
} catch (e) {} // tslint:disable-line
}
if (config.useChannel && config.channelId) {
try {
const channel = guild.channels.get(config.channelId);
if (channel instanceof TextChannel) {
await channel.createMessage(`<@!${user.id}> ${body}`);
return {
status: NotifyUserStatus.ChannelMessaged,
text: `user notified in <#${channel.id}>`,
};
}
} catch (e) {} // tslint:disable-line
}
return {
status: NotifyUserStatus.Failed,
text: "failed to message user",
};
}
export function ucfirst(str) {
if (typeof str !== "string" || str === "") return str;
return str[0].toUpperCase() + str.slice(1);
}