Fix merge conflict

This commit is contained in:
Dark 2020-02-16 17:30:17 +01:00
commit 318f80a26d
12 changed files with 440 additions and 216 deletions

View file

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

View file

@ -0,0 +1,28 @@
import { Guild } from "eris";
export enum ERRORS {
NO_MUTE_ROLE_IN_CONFIG = 1,
UNKNOWN_NOTE_CASE,
INVALID_EMOJI,
NO_USER_NOTIFICATION_CHANNEL,
INVALID_USER_NOTIFICATION_CHANNEL,
}
export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = {
[ERRORS.NO_MUTE_ROLE_IN_CONFIG]: "No mute role specified in config",
[ERRORS.UNKNOWN_NOTE_CASE]: "Tried to add a note to an unknown case",
[ERRORS.INVALID_EMOJI]: "Invalid emoji",
[ERRORS.NO_USER_NOTIFICATION_CHANNEL]: "No user notify channel specified",
[ERRORS.INVALID_USER_NOTIFICATION_CHANNEL]: "Invalid user notify channel specified",
};
export class RecoverablePluginError extends Error {
public readonly code: ERRORS;
public readonly guild?: Guild;
constructor(code: ERRORS, guild?: Guild) {
super(RECOVERABLE_PLUGIN_ERROR_MESSAGES[code]);
this.guild = guild;
this.code = code;
}
}

View file

@ -26,6 +26,19 @@ setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)),
if (process.env.NODE_ENV === "production") {
const errorHandler = err => {
if (err instanceof RecoverablePluginError) {
// Recoverable plugin errors can be, well, recovered from.
// Log it in the console as a warning and post a warning to the guild's log.
// tslint:disable:no-console
console.warn(`${err.guild.name}: [${err.code}] ${err.message}`);
const logs = new GuildLogs(err.guild.id);
logs.log(LogType.BOT_ALERT, { body: `\`[${err.code}]\` ${err.message}` });
return;
}
// tslint:disable:no-console
console.error(err);
@ -76,6 +89,9 @@ import { errorMessage, successMessage } from "./utils";
import { startUptimeCounter } from "./uptime";
import { AllowedGuilds } from "./data/AllowedGuilds";
import { IZeppelinGuildConfig, IZeppelinGlobalConfig } from "./types";
import { RecoverablePluginError } from "./RecoverablePluginError";
import { GuildLogs } from "./data/GuildLogs";
import { LogType } from "./data/LogType";
logger.info("Connecting to database");
connect().then(async conn => {

View file

@ -1,9 +1,10 @@
import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "../ZeppelinPlugin";
import { trimPluginDescription, ZeppelinPlugin } from "../ZeppelinPlugin";
import * as t from "io-ts";
import {
convertDelayStringToMS,
disableInlineCode,
disableLinkPreviews,
disableUserNotificationStrings,
getEmojiInString,
getInviteCodesInString,
getRoleMentions,
@ -15,12 +16,10 @@ import {
SECONDS,
stripObjectToScalars,
tDeepPartial,
tDelayString,
tNullable,
UnknownUser,
UserNotificationMethod,
verboseChannelMention,
} from "../../utils";
import { configUtils, CooldownManager, IPluginOptions, decorators as d, logger } from "knub";
import { configUtils, CooldownManager, decorators as d, IPluginOptions, logger } from "knub";
import { Member, Message, TextChannel, User } from "eris";
import escapeStringRegexp from "escape-string-regexp";
import { SimpleCache } from "../../SimpleCache";
@ -29,7 +28,6 @@ import { ModActionsPlugin } from "../ModActions";
import { MutesPlugin } from "../Mutes";
import { LogsPlugin } from "../Logs";
import { LogType } from "../../data/LogType";
import { TSafeRegex } from "../../validatorUtils";
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
import { GuildArchives } from "../../data/GuildArchives";
import { GuildLogs } from "../../data/GuildLogs";
@ -37,11 +35,9 @@ import { SavedMessage } from "../../data/entities/SavedMessage";
import moment from "moment-timezone";
import { renderTemplate } from "../../templateFormatter";
import { transliterate } from "transliteration";
import Timeout = NodeJS.Timeout;
import { IMatchParams } from "knub/dist/configUtils";
import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
import {
AnySpamTriggerMatchResult,
AnyTriggerMatchResult,
BaseTextSpamTrigger,
MessageInfo,
@ -66,6 +62,8 @@ import {
TRule,
} from "./types";
import { pluginInfo } from "./info";
import { ERRORS, RecoverablePluginError } from "../../RecoverablePluginError";
import Timeout = NodeJS.Timeout;
const unactioned = (action: TextRecentAction | OtherRecentAction) => !action.actioned;
@ -878,6 +876,30 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema, ICustomOverride
}
}
protected readContactMethodsFromAction(action: {
notify?: string;
notifyChannel?: string;
}): UserNotificationMethod[] | null {
if (action.notify === "dm") {
return [{ type: "dm" }];
} else if (action.notify === "channel") {
if (!action.notifyChannel) {
throw new RecoverablePluginError(ERRORS.NO_USER_NOTIFICATION_CHANNEL);
}
const channel = this.guild.channels.get(action.notifyChannel);
if (!(channel instanceof TextChannel)) {
throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL);
}
return [{ type: "channel", channel }];
} else if (action.notify && disableUserNotificationStrings.includes(action.notify)) {
return [];
}
return null;
}
/**
* Apply the actions of the specified rule on the matched message/member
*/
@ -945,6 +967,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema, ICustomOverride
if (rule.actions.warn) {
const reason = rule.actions.warn.reason || "Warned automatically";
const contactMethods = this.readContactMethodsFromAction(rule.actions.warn);
const caseArgs = {
modId: this.bot.user.id,
@ -962,7 +985,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema, ICustomOverride
if (membersToWarn.length) {
for (const member of membersToWarn) {
await this.getModActions().warnMember(member, reason, caseArgs);
await this.getModActions().warnMember(member, reason, { contactMethods, caseArgs });
}
actionsTaken.push("warn");
@ -976,6 +999,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema, ICustomOverride
modId: this.bot.user.id,
extraNotes: [caseExtraNote],
};
const contactMethods = this.readContactMethodsFromAction(rule.actions.mute);
let userIdsToMute = [];
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "other") {
@ -986,7 +1010,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema, ICustomOverride
if (userIdsToMute.length) {
for (const member of userIdsToMute) {
await this.getMutes().muteUser(member.id, duration, reason, caseArgs);
await this.getMutes().muteUser(member.id, duration, reason, { contactMethods, caseArgs });
}
actionsTaken.push("mute");
@ -999,6 +1023,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema, ICustomOverride
modId: this.bot.user.id,
extraNotes: [caseExtraNote],
};
const contactMethods = this.readContactMethodsFromAction(rule.actions.kick);
let membersToKick = [];
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "other") {
@ -1011,7 +1036,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema, ICustomOverride
if (membersToKick.length) {
for (const member of membersToKick) {
await this.getModActions().kickMember(member, reason, caseArgs);
await this.getModActions().kickMember(member, reason, { contactMethods, caseArgs });
}
actionsTaken.push("kick");
@ -1024,6 +1049,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema, ICustomOverride
modId: this.bot.user.id,
extraNotes: [caseExtraNote],
};
const contactMethods = this.readContactMethodsFromAction(rule.actions.ban);
let userIdsToBan = [];
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "other") {
@ -1034,7 +1060,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema, ICustomOverride
if (userIdsToBan.length) {
for (const userId of userIdsToBan) {
await this.getModActions().banUserId(userId, reason, caseArgs);
await this.getModActions().banUserId(userId, reason, { contactMethods, caseArgs });
}
actionsTaken.push("ban");

View file

@ -226,20 +226,28 @@ export type TMemberJoinSpamTrigger = t.TypeOf<typeof MemberJoinTrigger>;
export const CleanAction = t.boolean;
export const WarnAction = t.type({
reason: t.string,
reason: tNullable(t.string),
notify: tNullable(t.string),
notifyChannel: tNullable(t.string),
});
export const MuteAction = t.type({
duration: t.string,
reason: tNullable(t.string),
duration: tNullable(tDelayString),
notify: tNullable(t.string),
notifyChannel: tNullable(t.string),
});
export const KickAction = t.type({
reason: tNullable(t.string),
notify: tNullable(t.string),
notifyChannel: tNullable(t.string),
});
export const BanAction = t.type({
reason: tNullable(t.string),
notify: tNullable(t.string),
notifyChannel: tNullable(t.string),
});
export const AlertAction = t.type({

View file

@ -11,6 +11,7 @@ import { GuildLogs } from "../data/GuildLogs";
import { LogType } from "../data/LogType";
import * as t from "io-ts";
import { tNullable } from "../utils";
import { ERRORS } from "../RecoverablePluginError";
const ConfigSchema = t.type({
log_automatic_actions: t.boolean,
@ -146,7 +147,7 @@ export class CasesPlugin extends ZeppelinPlugin<TConfigSchema> {
public async createCaseNote(args: CaseNoteArgs): Promise<void> {
const theCase = await this.cases.find(this.resolveCaseId(args.caseId));
if (!theCase) {
this.throwPluginRuntimeError(`Unknown case ID: ${args.caseId}`);
this.throwRecoverablePluginError(ERRORS.UNKNOWN_NOTE_CASE);
}
const mod = await this.resolveUser(args.modId);

View file

@ -1,5 +1,4 @@
import { GlobalPlugin, IBasePluginConfig, IPluginOptions, logger, configUtils } from "knub";
import { PluginRuntimeError } from "../PluginRuntimeError";
import * as t from "io-ts";
import { pipe } from "fp-ts/lib/pipeable";
import { fold } from "fp-ts/lib/Either";

View file

@ -7,20 +7,18 @@ import { GuildCases } from "../data/GuildCases";
import {
asSingleLine,
createChunkedMessage,
disableUserNotificationStrings,
errorMessage,
findRelevantAuditLogEntry,
INotifyUserResult,
multiSorter,
notifyUser,
NotifyUserStatus,
stripObjectToScalars,
successMessage,
tNullable,
trimEmptyStartEndLines,
trimIndents,
trimLines,
ucfirst,
UnknownUser,
UserNotificationMethod,
UserNotificationResult,
} from "../utils";
import { GuildMutes } from "../data/GuildMutes";
import { CaseTypes } from "../data/CaseTypes";
@ -32,6 +30,7 @@ import { renderTemplate } from "../templateFormatter";
import { CaseArgs, CasesPlugin } from "./Cases";
import { MuteResult, MutesPlugin } from "./Mutes";
import * as t from "io-ts";
import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError";
const ConfigSchema = t.type({
dm_on_warn: t.boolean,
@ -80,7 +79,7 @@ export type WarnResult =
| {
status: "success";
case: Case;
notifyResult: INotifyUserResult;
notifyResult: UserNotificationResult;
};
export type KickResult =
@ -91,7 +90,7 @@ export type KickResult =
| {
status: "success";
case: Case;
notifyResult: INotifyUserResult;
notifyResult: UserNotificationResult;
};
export type BanResult =
@ -102,11 +101,27 @@ export type BanResult =
| {
status: "success";
case: Case;
notifyResult: INotifyUserResult;
notifyResult: UserNotificationResult;
};
type WarnMemberNotifyRetryCallback = () => boolean | Promise<boolean>;
export interface WarnOptions {
caseArgs?: Partial<CaseArgs>;
contactMethods?: UserNotificationMethod[];
retryPromptChannel?: TextChannel;
}
export interface KickOptions {
caseArgs?: Partial<CaseArgs>;
contactMethods?: UserNotificationMethod[];
}
export interface BanOptions {
caseArgs?: Partial<CaseArgs>;
contactMethods?: UserNotificationMethod[];
}
export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "mod_actions";
public static dependencies = ["cases", "mutes"];
@ -148,7 +163,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
ban_message: "You have been banned from the {guildName} server. Reason given: {reason}",
alert_on_rejoin: false,
alert_channel: null,
warn_notify_threshold: 1,
warn_notify_threshold: 5,
warn_notify_message:
"The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?",
@ -202,7 +217,10 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}
clearIgnoredEvent(type: IgnoredEventType, userId: any) {
this.ignoredEvents.splice(this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId), 1);
this.ignoredEvents.splice(
this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId),
1,
);
}
formatReasonWithAttachments(reason: string, attachments: Attachment[]) {
@ -210,6 +228,50 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
return ((reason || "") + " " + attachmentUrls.join(" ")).trim();
}
getDefaultContactMethods(type: "warn" | "kick" | "ban"): UserNotificationMethod[] {
const methods: UserNotificationMethod[] = [];
const config = this.getConfig();
if (config[`dm_on_${type}`]) {
methods.push({ type: "dm" });
}
if (config[`message_on_${type}`] && config.message_channel) {
const channel = this.guild.channels.get(config.message_channel);
if (channel instanceof TextChannel) {
methods.push({
type: "channel",
channel,
});
}
}
return methods;
}
readContactMethodsFromArgs(args: {
notify?: string;
"notify-channel"?: TextChannel;
}): null | UserNotificationMethod[] {
if (args.notify) {
if (args.notify === "dm") {
return [{ type: "dm" }];
} else if (args.notify === "channel") {
if (!args["notify-channel"]) {
throw new Error("No `-notify-channel` specified");
}
return [{ type: "channel", channel: args["notify-channel"] }];
} else if (disableUserNotificationStrings.includes(args.notify)) {
return [];
} else {
throw new Error("Unknown contact method");
}
}
return null;
}
async isBanned(userId): Promise<boolean> {
try {
const bans = (await this.guild.getBans()) as any;
@ -330,9 +392,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
if (actions.length) {
const alertChannel: any = this.guild.channels.get(alertChannelId);
alertChannel.send(
`<@!${member.id}> (${member.user.username}#${member.user.discriminator} \`${member.id}\`) joined with ${
actions.length
} prior record(s)`,
`<@!${member.id}> (${member.user.username}#${member.user.discriminator} \`${member.id}\`) joined with ${actions.length} prior record(s)`,
);
}
}
@ -353,9 +413,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
const existingCaseForThisEntry = await this.cases.findByAuditLogId(kickAuditLogEntry.id);
if (existingCaseForThisEntry) {
logger.warn(
`Tried to create duplicate case for audit log entry ${kickAuditLogEntry.id}, existing case id ${
existingCaseForThisEntry.id
}`,
`Tried to create duplicate case for audit log entry ${kickAuditLogEntry.id}, existing case id ${existingCaseForThisEntry.id}`,
);
} else {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
@ -379,22 +437,21 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
/**
* Kick the specified server member. Generates a case.
*/
async kickMember(member: Member, reason: string = null, caseArgs: Partial<CaseArgs> = {}): Promise<KickResult> {
async kickMember(member: Member, reason: string = null, kickOptions: KickOptions = {}): Promise<KickResult> {
const config = this.getConfig();
// Attempt to message the user *before* kicking them, as doing it after may not be possible
let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored };
let notifyResult: UserNotificationResult = { method: null, success: true };
if (reason) {
const kickMessage = await renderTemplate(config.kick_message, {
guildName: this.guild.name,
reason,
});
notifyResult = await notifyUser(this.bot, this.guild, member.user, kickMessage, {
useDM: config.dm_on_kick,
useChannel: config.message_on_kick,
channelId: config.message_channel,
});
const contactMethods = kickOptions?.contactMethods
? kickOptions.contactMethods
: this.getDefaultContactMethods("kick");
notifyResult = await notifyUser(member.user, kickMessage, contactMethods);
}
// Kick the user
@ -412,16 +469,16 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
// Create a case for this action
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
...caseArgs,
...(kickOptions.caseArgs || {}),
userId: member.id,
modId: caseArgs.modId,
modId: kickOptions.caseArgs?.modId,
type: CaseTypes.Kick,
reason,
noteDetails: notifyResult.status !== NotifyUserStatus.Ignored ? [ucfirst(notifyResult.text)] : [],
noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [],
});
// Log the action
const mod = await this.resolveUser(caseArgs.modId);
const mod = await this.resolveUser(kickOptions.caseArgs?.modId);
this.serverLogs.log(LogType.MEMBER_KICK, {
mod: stripObjectToScalars(mod),
user: stripObjectToScalars(member.user),
@ -437,22 +494,22 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
/**
* Ban the specified user id, whether or not they're actually on the server at the time. Generates a case.
*/
async banUserId(userId: string, reason: string = null, caseArgs: Partial<CaseArgs> = {}): Promise<BanResult> {
async banUserId(userId: string, reason: string = null, banOptions: BanOptions = {}): Promise<BanResult> {
const config = this.getConfig();
const user = await this.resolveUser(userId);
// Attempt to message the user *before* banning them, as doing it after may not be possible
let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored };
let notifyResult: UserNotificationResult = { method: null, success: true };
if (reason && user instanceof User) {
const banMessage = await renderTemplate(config.ban_message, {
guildName: this.guild.name,
reason,
});
notifyResult = await notifyUser(this.bot, this.guild, user, banMessage, {
useDM: config.dm_on_ban,
useChannel: config.message_on_ban,
channelId: config.message_channel,
});
const contactMethods = banOptions?.contactMethods
? banOptions.contactMethods
: this.getDefaultContactMethods("ban");
notifyResult = await notifyUser(user, banMessage, contactMethods);
}
// (Try to) ban the user
@ -470,16 +527,16 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
// Create a case for this action
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
...caseArgs,
...(banOptions.caseArgs || {}),
userId,
modId: caseArgs.modId,
modId: banOptions.caseArgs?.modId,
type: CaseTypes.Ban,
reason,
noteDetails: notifyResult.status !== NotifyUserStatus.Ignored ? [ucfirst(notifyResult.text)] : [],
noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [],
});
// Log the action
const mod = await this.resolveUser(caseArgs.modId);
const mod = await this.resolveUser(banOptions.caseArgs?.modId);
this.serverLogs.log(LogType.MEMBER_BAN, {
mod: stripObjectToScalars(mod),
user: stripObjectToScalars(user),
@ -566,7 +623,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}
@d.command("warn", "<user:string> <reason:string$>", {
options: [{ name: "mod", type: "member" }],
options: [
{ name: "mod", type: "member" },
{ name: "notify", type: "string" },
{ name: "notify-channel", type: "channel" },
],
extra: {
info: {
description: "Send a warning to the specified user",
@ -574,7 +635,10 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
},
})
@d.permission("can_warn")
async warnCmd(msg: Message, args: { user: string; reason: string; mod?: Member }) {
async warnCmd(
msg: Message,
args: { user: string; reason: string; mod?: Member; notify?: string; "notify-channel"?: TextChannel },
) {
const user = await this.resolveUser(args.user);
if (!user) return this.sendErrorMessage(msg.channel, `User not found`);
@ -626,20 +690,26 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}
}
const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason);
const warnResult = await this.warnMember(
memberToWarn,
warnMessage,
{
let contactMethods;
try {
contactMethods = this.readContactMethodsFromArgs(args);
} catch (e) {
this.sendErrorMessage(msg.channel, e.message);
return;
}
const warnResult = await this.warnMember(memberToWarn, reason, {
contactMethods,
caseArgs: {
modId: mod.id,
ppId: mod.id !== msg.author.id ? msg.author.id : null,
reason,
},
msg.channel as TextChannel,
);
retryPromptChannel: msg.channel as TextChannel,
});
if (warnResult.status === "failed") {
msg.channel.createMessage(errorMessage("Failed to warn user"));
this.sendErrorMessage(msg.channel, "Failed to warn user");
return;
}
@ -647,28 +717,24 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
this.sendSuccessMessage(
msg.channel,
`Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${
warnResult.case.case_number
})${messageResultText}`,
`Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`,
);
}
async warnMember(
member: Member,
warnMessage: string,
caseArgs: Partial<CaseArgs> = {},
retryPromptChannel: TextChannel = null,
): Promise<WarnResult | null> {
async warnMember(member: Member, reason: string, warnOptions: WarnOptions = {}): Promise<WarnResult | null> {
const config = this.getConfig();
const notifyResult = await notifyUser(this.bot, this.guild, member.user, warnMessage, {
useDM: config.dm_on_warn,
useChannel: config.message_on_warn,
});
const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason);
const contactMethods = warnOptions?.contactMethods
? warnOptions.contactMethods
: this.getDefaultContactMethods("warn");
const notifyResult = await notifyUser(member.user, warnMessage, contactMethods);
if (notifyResult.status === NotifyUserStatus.Failed) {
if (retryPromptChannel && this.guild.channels.has(retryPromptChannel.id)) {
const failedMsg = await retryPromptChannel.createMessage("Failed to message the user. Log the warning anyway?");
if (!notifyResult.success) {
if (warnOptions.retryPromptChannel && this.guild.channels.has(warnOptions.retryPromptChannel.id)) {
const failedMsg = await warnOptions.retryPromptChannel.createMessage(
"Failed to message the user. Log the warning anyway?",
);
const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"]);
failedMsg.delete();
if (!reply || reply.name === "❌") {
@ -687,15 +753,15 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
...caseArgs,
...(warnOptions.caseArgs || {}),
userId: member.id,
modId: caseArgs.modId,
modId: warnOptions.caseArgs?.modId,
type: CaseTypes.Warn,
reason: caseArgs.reason || warnMessage,
noteDetails: notifyResult.status !== NotifyUserStatus.Ignored ? [ucfirst(notifyResult.text)] : [],
reason,
noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [],
});
const mod = await this.resolveUser(caseArgs.modId);
const mod = await this.resolveUser(warnOptions.caseArgs?.modId);
this.serverLogs.log(LogType.MEMBER_WARN, {
mod: stripObjectToScalars(mod),
member: stripObjectToScalars(member, ["user", "roles"]),
@ -712,7 +778,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
* The actual function run by both !mute and !forcemute.
* The only difference between the two commands is in target member validation.
*/
async actualMuteCmd(user: User | UnknownUser, msg: Message, args: { time?: number; reason?: string; mod: Member }) {
async actualMuteCmd(
user: User | UnknownUser,
msg: Message,
args: { time?: number; reason?: string; mod: Member; notify?: string; "notify-channel"?: TextChannel },
) {
// The moderator who did the action is the message author or, if used, the specified -mod
let mod = msg.member;
let pp = null;
@ -733,17 +803,30 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
let muteResult: MuteResult;
const mutesPlugin = this.getPlugin<MutesPlugin>("mutes");
let contactMethods;
try {
contactMethods = this.readContactMethodsFromArgs(args);
} catch (e) {
this.sendErrorMessage(msg.channel, e.message);
return;
}
try {
muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, {
modId: mod.id,
ppId: pp && pp.id,
contactMethods,
caseArgs: {
modId: mod.id,
ppId: pp && pp.id,
},
});
} catch (e) {
if (e instanceof DiscordRESTError && e.code === 10007) {
msg.channel.createMessage(errorMessage("Could not mute the user: unknown member"));
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
this.sendErrorMessage(msg.channel, "Could not mute the user: no mute role set in config");
} else if (e instanceof DiscordRESTError && e.code === 10007) {
this.sendErrorMessage(msg.channel, "Could not mute the user: unknown member");
} else {
logger.error(`Failed to mute user ${user.id}: ${e.stack}`);
msg.channel.createMessage(errorMessage("Could not mute the user"));
this.sendErrorMessage(msg.channel, "Could not mute the user");
}
return;
@ -783,7 +866,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("mute", "<user:string> <time:delay> <reason:string$>", {
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
options: [{ name: "mod", type: "member" }],
options: [
{ name: "mod", type: "member" },
{ name: "notify", type: "string" },
{ name: "notify-channel", type: "channel" },
],
extra: {
info: {
description: "Mute the specified member",
@ -791,7 +878,17 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
},
})
@d.permission("can_mute")
async muteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) {
async muteCmd(
msg: Message,
args: {
user: string;
time?: number;
reason?: string;
mod: Member;
notify?: string;
"notify-channel"?: TextChannel;
},
) {
const user = await this.resolveUser(args.user);
if (!user) return this.sendErrorMessage(msg.channel, `User not found`);
@ -826,7 +923,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("forcemute", "<user:string> <time:delay> <reason:string$>", {
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
options: [{ name: "mod", type: "member" }],
options: [
{ name: "mod", type: "member" },
{ name: "notify", type: "string" },
{ name: "notify-channel", type: "channel" },
],
extra: {
info: {
description: "Force-mute the specified user, even if they're not on the server",
@ -834,7 +935,17 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
},
})
@d.permission("can_mute")
async forcemuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) {
async forcemuteCmd(
msg: Message,
args: {
user: string;
time?: number;
reason?: string;
mod: Member;
notify?: string;
"notify-channel"?: TextChannel;
},
) {
const user = await this.resolveUser(args.user);
if (!user) return this.sendErrorMessage(msg.channel, `User not found`);
@ -982,7 +1093,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}
@d.command("kick", "<user:string> [reason:string$]", {
options: [{ name: "mod", type: "member" }],
options: [
{ name: "mod", type: "member" },
{ name: "notify", type: "string" },
{ name: "notify-channel", type: "channel" },
],
extra: {
info: {
description: "Kick the specified member",
@ -990,7 +1105,10 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
},
})
@d.permission("can_kick")
async kickCmd(msg, args: { user: string; reason: string; mod: Member }) {
async kickCmd(
msg,
args: { user: string; reason: string; mod: Member; notify?: string; "notify-channel"?: TextChannel },
) {
const user = await this.resolveUser(args.user);
if (!user) return this.sendErrorMessage(msg.channel, `User not found`);
@ -1024,10 +1142,21 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
mod = args.mod;
}
let contactMethods;
try {
contactMethods = this.readContactMethodsFromArgs(args);
} catch (e) {
this.sendErrorMessage(msg.channel, e.message);
return;
}
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
const kickResult = await this.kickMember(memberToKick, reason, {
modId: mod.id,
ppId: mod.id !== msg.author.id ? msg.author.id : null,
contactMethods,
caseArgs: {
modId: mod.id,
ppId: mod.id !== msg.author.id ? msg.author.id : null,
},
});
if (kickResult.status === "failed") {
@ -1036,16 +1165,18 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}
// Confirm the action to the moderator
let response = `Kicked **${memberToKick.user.username}#${memberToKick.user.discriminator}** (Case #${
kickResult.case.case_number
})`;
let response = `Kicked **${memberToKick.user.username}#${memberToKick.user.discriminator}** (Case #${kickResult.case.case_number})`;
if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`;
this.sendSuccessMessage(msg.channel, response);
}
@d.command("ban", "<user:string> [reason:string$]", {
options: [{ name: "mod", type: "member" }],
options: [
{ name: "mod", type: "member" },
{ name: "notify", type: "string" },
{ name: "notify-channel", type: "channel" },
],
extra: {
info: {
description: "Ban the specified member",
@ -1053,7 +1184,10 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
},
})
@d.permission("can_ban")
async banCmd(msg, args: { user: string; reason?: string; mod?: Member }) {
async banCmd(
msg,
args: { user: string; reason?: string; mod?: Member; notify?: string; "notify-channel"?: TextChannel },
) {
const user = await this.resolveUser(args.user);
if (!user) return this.sendErrorMessage(msg.channel, `User not found`);
@ -1087,10 +1221,21 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
mod = args.mod;
}
let contactMethods;
try {
contactMethods = this.readContactMethodsFromArgs(args);
} catch (e) {
this.sendErrorMessage(msg.channel, e.message);
return;
}
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
const banResult = await this.banUserId(memberToBan.id, reason, {
modId: mod.id,
ppId: mod.id !== msg.author.id ? msg.author.id : null,
contactMethods,
caseArgs: {
modId: mod.id,
ppId: mod.id !== msg.author.id ? msg.author.id : null,
},
});
if (banResult.status === "failed") {
@ -1099,9 +1244,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}
// Confirm the action to the moderator
let response = `Banned **${memberToBan.user.username}#${memberToBan.user.discriminator}** (Case #${
banResult.case.case_number
})`;
let response = `Banned **${memberToBan.user.username}#${memberToBan.user.discriminator}** (Case #${banResult.case.case_number})`;
if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`;
this.sendSuccessMessage(msg.channel, response);
@ -1186,9 +1329,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
// Confirm the action to the moderator
this.sendSuccessMessage(
msg.channel,
`Softbanned **${memberToSoftban.user.username}#${memberToSoftban.user.discriminator}** (Case #${
createdCase.case_number
})`,
`Softbanned **${memberToSoftban.user.username}#${memberToSoftban.user.discriminator}** (Case #${createdCase.case_number})`,
);
// Log the action

View file

@ -1,4 +1,4 @@
import { Member, Message, User } from "eris";
import { Member, Message, TextChannel, User } from "eris";
import { GuildCases } from "../data/GuildCases";
import moment from "moment-timezone";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
@ -7,15 +7,15 @@ import {
chunkMessageLines,
DBDateFormat,
errorMessage,
INotifyUserResult,
UserNotificationResult,
noop,
notifyUser,
NotifyUserStatus,
stripObjectToScalars,
successMessage,
tNullable,
ucfirst,
UnknownUser,
UserNotificationMethod,
} from "../utils";
import humanizeDuration from "humanize-duration";
import { LogType } from "../data/LogType";
@ -27,6 +27,7 @@ import { CaseTypes } from "../data/CaseTypes";
import { CaseArgs, CasesPlugin } from "./Cases";
import { Case } from "../data/entities/Case";
import * as t from "io-ts";
import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError";
const ConfigSchema = t.type({
mute_role: tNullable(t.string),
@ -53,7 +54,7 @@ interface IMuteWithDetails extends Mute {
export type MuteResult = {
case: Case;
notifyResult: INotifyUserResult;
notifyResult: UserNotificationResult;
updatedExistingMute: boolean;
};
@ -61,6 +62,11 @@ export type UnmuteResult = {
case: Case;
};
export interface MuteOptions {
caseArgs?: Partial<CaseArgs>;
contactMethods?: UserNotificationMethod[];
}
const EXPIRED_MUTE_CHECK_INTERVAL = 60 * 1000;
let FIRST_CHECK_TIME = Date.now();
const FIRST_CHECK_INCREMENT = 5 * 1000;
@ -136,16 +142,19 @@ export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
userId: string,
muteTime: number = null,
reason: string = null,
caseArgs: Partial<CaseArgs> = {},
muteOptions: MuteOptions = {},
): Promise<MuteResult> {
const muteRole = this.getConfig().mute_role;
if (!muteRole) return;
if (!muteRole) {
this.throwRecoverablePluginError(ERRORS.NO_MUTE_ROLE_IN_CONFIG);
}
const timeUntilUnmute = muteTime ? humanizeDuration(muteTime) : "indefinite";
// No mod specified -> mark Zeppelin as the mod
if (!caseArgs.modId) {
caseArgs.modId = this.bot.user.id;
if (!muteOptions.caseArgs?.modId) {
muteOptions.caseArgs = muteOptions.caseArgs ?? {};
muteOptions.caseArgs.modId = this.bot.user.id;
}
const user = await this.resolveUser(userId);
@ -170,7 +179,7 @@ export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
// If the user is already muted, update the duration of their existing mute
const existingMute = await this.mutes.findExistingMuteForUserId(user.id);
let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored };
let notifyResult: UserNotificationResult = { method: null, success: true };
if (existingMute) {
await this.mutes.updateExpiryTime(user.id, muteTime);
@ -192,19 +201,27 @@ export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
time: timeUntilUnmute,
}));
if (muteMessage) {
const useDm = existingMute ? config.dm_on_update : config.dm_on_mute;
const useChannel = existingMute ? config.message_on_update : config.message_on_mute;
if (user instanceof User) {
notifyResult = await notifyUser(this.bot, this.guild, user, muteMessage, {
useDM: useDm,
useChannel,
channelId: config.message_channel,
});
if (muteMessage && user instanceof User) {
let contactMethods = [];
if (muteOptions?.contactMethods) {
contactMethods = muteOptions.contactMethods;
} else {
notifyResult = { status: NotifyUserStatus.Failed };
const useDm = existingMute ? config.dm_on_update : config.dm_on_mute;
if (useDm) {
contactMethods.push({ type: "dm" });
}
const useChannel = existingMute ? config.message_on_update : config.message_on_mute;
const channel = config.message_channel && this.guild.channels.get(config.message_channel);
if (useChannel && channel instanceof TextChannel) {
contactMethods.push({ type: "channel", channel });
}
}
notifyResult = await notifyUser(user, muteMessage, contactMethods);
}
// Create/update a case
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
let theCase;
@ -215,31 +232,31 @@ export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
// 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"}`];
const reasons = [reason, ...(caseArgs.extraNotes || [])];
const reasons = [reason, ...(muteOptions.caseArgs?.extraNotes || [])];
for (const noteReason of reasons) {
await casesPlugin.createCaseNote({
caseId: existingMute.case_id,
modId: caseArgs.modId,
modId: muteOptions.caseArgs?.modId,
body: noteReason,
noteDetails,
postInCaseLogOverride: false,
});
}
if (caseArgs.postInCaseLogOverride !== false) {
if (muteOptions.caseArgs?.postInCaseLogOverride !== false) {
casesPlugin.postCaseToCaseLogChannel(existingMute.case_id);
}
} else {
// Create new case
const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`];
if (notifyResult.status !== NotifyUserStatus.Ignored) {
if (notifyResult.text) {
noteDetails.push(ucfirst(notifyResult.text));
}
theCase = await casesPlugin.createCase({
...caseArgs,
...(muteOptions.caseArgs || {}),
userId,
modId: caseArgs.modId,
modId: muteOptions.caseArgs?.modId,
type: CaseTypes.Mute,
reason,
noteDetails,
@ -248,7 +265,7 @@ export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
}
// Log the action
const mod = await this.resolveUser(caseArgs.modId);
const mod = await this.resolveUser(muteOptions.caseArgs?.modId);
if (muteTime) {
this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, {
mod: stripObjectToScalars(mod),

View file

@ -254,8 +254,10 @@ export class SpamPlugin extends ZeppelinPlugin<TConfigSchema> {
? convertDelayStringToMS(spamConfig.mute_time.toString())
: 120 * 1000;
muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", {
modId: this.bot.user.id,
postInCaseLogOverride: false,
caseArgs: {
modId: this.bot.user.id,
postInCaseLogOverride: false,
},
});
}
@ -374,8 +376,10 @@ export class SpamPlugin extends ZeppelinPlugin<TConfigSchema> {
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}`],
caseArgs: {
modId: this.bot.user.id,
extraNotes: [`Details: ${details}`],
},
});
} else {
// If we're not muting the user, just add a note on them

View file

@ -1,30 +1,27 @@
import { IBasePluginConfig, IPluginOptions, logger, Plugin, configUtils } from "knub";
import { PluginRuntimeError } from "../PluginRuntimeError";
import { configUtils, IBasePluginConfig, IPluginOptions, logger, Plugin } from "knub";
import * as t from "io-ts";
import { pipe } from "fp-ts/lib/pipeable";
import { fold } from "fp-ts/lib/Either";
import { PathReporter } from "io-ts/lib/PathReporter";
import {
deepKeyIntersect,
isSnowflake,
isUnicodeEmoji,
MINUTES,
Not,
resolveMember,
resolveRoleId,
resolveUser,
resolveUserId,
tDeepPartial,
trimEmptyStartEndLines,
trimIndents,
UnknownUser,
resolveRoleId,
} from "../utils";
import { Invite, Member, User } from "eris";
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
import { performance } from "perf_hooks";
import { decodeAndValidateStrict, StrictValidationError, validate } from "../validatorUtils";
import { SimpleCache } from "../SimpleCache";
import { Knub } from "knub/dist/Knub";
import { TZeppelinKnub } from "../types";
import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError";
const SLOW_RESOLVE_THRESHOLD = 1500;
@ -74,8 +71,8 @@ export class ZeppelinPlugin<
protected readonly knub: TZeppelinKnub;
protected throwPluginRuntimeError(message: string) {
throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId);
protected throwRecoverablePluginError(code: ERRORS) {
throw new RecoverablePluginError(code, this.guild);
}
protected canActOn(member1: Member, member2: Member, allowSameLevel = false) {
@ -217,7 +214,7 @@ export class ZeppelinPlugin<
}
}
} else {
throw new PluginRuntimeError(`Invalid emoji: ${snowflake}`, this.runtimePluginName, this.guildId);
this.throwRecoverablePluginError(ERRORS.INVALID_EMOJI);
}
}
@ -237,7 +234,9 @@ export class ZeppelinPlugin<
* Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
* If the user is not found in the cache, it's fetched from the API.
*/
async resolveUser(userResolvable: string): Promise<User | UnknownUser> {
async resolveUser(userResolvable: string): Promise<User | UnknownUser>;
async resolveUser<T>(userResolvable: Not<T, string>): Promise<UnknownUser>;
async resolveUser(userResolvable) {
const start = performance.now();
const user = await resolveUser(this.bot, userResolvable);
const time = performance.now() - start;

View file

@ -118,6 +118,9 @@ function tDeepPartialProp(prop: any) {
}
}
// https://stackoverflow.com/a/49262929/316944
export type Not<T, E> = T & Exclude<T, E>;
/**
* Mirrors EmbedOptions from Eris
*/
@ -743,63 +746,64 @@ export type CustomEmoji = {
id: string;
} & Emoji;
export interface INotifyUserConfig {
useDM?: boolean;
useChannel?: boolean;
channelId?: string;
}
export type UserNotificationMethod = { type: "dm" } | { type: "channel"; channel: TextChannel };
export enum NotifyUserStatus {
Ignored = 1,
Failed,
DirectMessaged,
ChannelMessaged,
}
export const disableUserNotificationStrings = ["no", "none", "off"];
export interface INotifyUserResult {
status: NotifyUserStatus;
export interface UserNotificationResult {
method: UserNotificationMethod | null;
success: boolean;
text?: string;
}
/**
* Attempts to notify the user using one of the specified methods. Only the first one that succeeds will be used.
* @param methods List of methods to try, in priority order
*/
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 };
methods: UserNotificationMethod[],
): Promise<UserNotificationResult> {
if (methods.length === 0) {
return { method: null, success: true };
}
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
}
let lastError: Error = null;
if (config.useChannel && config.channelId) {
try {
const channel = guild.channels.get(config.channelId);
if (channel instanceof TextChannel) {
await channel.createMessage(`<@!${user.id}> ${body}`);
for (const method of methods) {
if (method.type === "dm") {
try {
const dmChannel = await user.getDMChannel();
await dmChannel.createMessage(body);
return {
status: NotifyUserStatus.ChannelMessaged,
text: `user notified in <#${channel.id}>`,
method,
success: true,
text: "user notified with a direct message",
};
} catch (e) {
lastError = e;
}
} catch (e) {} // tslint:disable-line
} else if (method.type === "channel") {
try {
await method.channel.createMessage(`<@!${user.id}> ${body}`);
return {
method,
success: true,
text: `user notified in <#${method.channel.id}>`,
};
} catch (e) {
lastError = e;
}
}
}
const errorText = lastError ? `failed to message user: ${lastError.message}` : `failed to message user`;
return {
status: NotifyUserStatus.Failed,
text: "failed to message user",
method: null,
success: false,
text: errorText,
};
}
@ -893,8 +897,10 @@ export function resolveUserId(bot: Client, value: string) {
return null;
}
export async function resolveUser(bot: Client, value: string): Promise<User | UnknownUser> {
if (value == null || typeof value !== "string") {
export async function resolveUser(bot: Client, value: string): Promise<User | UnknownUser>;
export async function resolveUser<T>(bot: Client, value: Not<T, string>): Promise<UnknownUser>;
export async function resolveUser<T>(bot, value) {
if (typeof value !== "string") {
return new UnknownUser();
}