3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-11 04:45:02 +00:00

WIP ModActions

This commit is contained in:
Dragory 2020-07-23 00:37:33 +03:00
parent a3d0ec03d9
commit ebcb28261b
No known key found for this signature in database
GPG key ID: 5F387BA66DF8AAC1
25 changed files with 1162 additions and 6 deletions

View file

@ -0,0 +1,107 @@
import { Member, Message, TextChannel, User } from "eris";
import { asSingleLine, isDiscordRESTError, UnknownUser } from "../../../utils";
import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { PluginData } from "knub";
import { ModActionsPluginType } from "../types";
import humanizeDuration from "humanize-duration";
import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
import { MuteResult } from "../../Mutes/types";
import { MutesPlugin } from "../../Mutes/MutesPlugin";
import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs";
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
import { logger } from "../../../logger";
/**
* The actual function run by both !mute and !forcemute.
* The only difference between the two commands is in target member validation.
*/
export async function actualMuteUserCmd(
pluginData: PluginData<ModActionsPluginType>,
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;
if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) {
sendErrorMessage(pluginData, msg.channel, "No permission for -mod");
return;
}
mod = args.mod;
pp = msg.author;
}
const timeUntilUnmute = args.time && humanizeDuration(args.time);
const reason = formatReasonWithAttachments(args.reason, msg.attachments);
let muteResult: MuteResult;
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
let contactMethods;
try {
contactMethods = readContactMethodsFromArgs(args);
} catch (e) {
sendErrorMessage(pluginData, msg.channel, e.message);
return;
}
try {
muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, {
contactMethods,
caseArgs: {
modId: mod.id,
ppId: pp && pp.id,
},
});
} catch (e) {
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
sendErrorMessage(pluginData, msg.channel, "Could not mute the user: no mute role set in config");
} else if (isDiscordRESTError(e) && e.code === 10007) {
sendErrorMessage(pluginData, msg.channel, "Could not mute the user: unknown member");
} else {
logger.error(`Failed to mute user ${user.id}: ${e.stack}`);
if (user.id == null) {
// tslint-disable-next-line:no-console
console.trace("[DEBUG] Null user.id for mute");
}
sendErrorMessage(pluginData, msg.channel, "Could not mute the user");
}
return;
}
// Confirm the action to the moderator
let response;
if (args.time) {
if (muteResult.updatedExistingMute) {
response = asSingleLine(`
Updated **${user.username}#${user.discriminator}**'s
mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number})
`);
} else {
response = asSingleLine(`
Muted **${user.username}#${user.discriminator}**
for ${timeUntilUnmute} (Case #${muteResult.case.case_number})
`);
}
} else {
if (muteResult.updatedExistingMute) {
response = asSingleLine(`
Updated **${user.username}#${user.discriminator}**'s
mute to indefinite (Case #${muteResult.case.case_number})
`);
} else {
response = asSingleLine(`
Muted **${user.username}#${user.discriminator}**
indefinitely (Case #${muteResult.case.case_number})
`);
}
}
if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`;
sendSuccessMessage(pluginData, msg.channel, response);
}

View file

@ -0,0 +1,75 @@
import { PluginData } from "knub";
import { BanOptions, BanResult, IgnoredEventType, ModActionsPluginType } from "../types";
import { notifyUser, resolveUser, stripObjectToScalars, ucfirst, UserNotificationResult } from "../../../utils";
import { User } from "eris";
import { renderTemplate } from "../../../templateFormatter";
import { getDefaultContactMethods } from "./getDefaultContactMethods";
import { LogType } from "../../../data/LogType";
import { ignoreEvent } from "./ignoreEvent";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes";
/**
* Ban the specified user id, whether or not they're actually on the server at the time. Generates a case.
*/
export async function banUserId(
pluginData: PluginData<ModActionsPluginType>,
userId: string,
reason: string = null,
banOptions: BanOptions = {},
): Promise<BanResult> {
const config = pluginData.config.get();
const user = await resolveUser(pluginData.client, userId);
// Attempt to message the user *before* banning them, as doing it after may not be possible
let notifyResult: UserNotificationResult = { method: null, success: true };
if (reason && user instanceof User) {
const banMessage = await renderTemplate(config.ban_message, {
guildName: pluginData.guild.name,
reason,
});
const contactMethods = banOptions?.contactMethods
? banOptions.contactMethods
: getDefaultContactMethods(pluginData, "ban");
notifyResult = await notifyUser(user, banMessage, contactMethods);
}
// (Try to) ban the user
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId);
ignoreEvent(pluginData, IgnoredEventType.Ban, userId);
try {
const deleteMessageDays = Math.min(30, Math.max(0, banOptions.deleteMessageDays ?? 1));
await pluginData.guild.banMember(userId, deleteMessageDays);
} catch (e) {
return {
status: "failed",
error: e.message,
};
}
// Create a case for this action
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const createdCase = await casesPlugin.createCase({
...(banOptions.caseArgs || {}),
userId,
modId: banOptions.caseArgs?.modId,
type: CaseTypes.Ban,
reason,
noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [],
});
// Log the action
const mod = await resolveUser(pluginData.client, banOptions.caseArgs?.modId);
pluginData.state.serverLogs.log(LogType.MEMBER_BAN, {
mod: stripObjectToScalars(mod),
user: stripObjectToScalars(user),
reason,
});
return {
status: "success",
case: createdCase,
notifyResult,
};
}

View file

@ -0,0 +1,13 @@
import { PluginData } from "knub";
import { IgnoredEventType, ModActionsPluginType } from "../types";
export function clearIgnoredEvent(
pluginData: PluginData<ModActionsPluginType>,
type: IgnoredEventType,
userId: string,
) {
pluginData.state.ignoredEvents.splice(
pluginData.state.ignoredEvents.findIndex(info => type === info.type && userId === info.userId),
1,
);
}

View file

@ -0,0 +1,6 @@
import { Attachment } from "eris";
export function formatReasonWithAttachments(reason: string, attachments: Attachment[]) {
const attachmentUrls = attachments.map(a => a.url);
return ((reason || "") + " " + attachmentUrls.join(" ")).trim();
}

View file

@ -0,0 +1,28 @@
import { PluginData } from "knub";
import { ModActionsPluginType } from "../types";
import { UserNotificationMethod } from "../../../utils";
import { TextChannel } from "eris";
export function getDefaultContactMethods(
pluginData: PluginData<ModActionsPluginType>,
type: "warn" | "kick" | "ban",
): UserNotificationMethod[] {
const methods: UserNotificationMethod[] = [];
const config = pluginData.config.get();
if (config[`dm_on_${type}`]) {
methods.push({ type: "dm" });
}
if (config[`message_on_${type}`] && config.message_channel) {
const channel = pluginData.guild.channels.get(config.message_channel);
if (channel instanceof TextChannel) {
methods.push({
type: "channel",
channel,
});
}
}
return methods;
}

View file

@ -0,0 +1,20 @@
import { PluginData } from "knub";
import { IgnoredEventType, ModActionsPluginType } from "../types";
import { SECONDS } from "../../../utils";
import { clearIgnoredEvent } from "./clearIgnoredEvents";
const DEFAULT_TIMEOUT = 15 * SECONDS;
export function ignoreEvent(
pluginData: PluginData<ModActionsPluginType>,
type: IgnoredEventType,
userId: string,
timeout = DEFAULT_TIMEOUT,
) {
pluginData.state.ignoredEvents.push({ type, userId });
// Clear after expiry (15sec by default)
setTimeout(() => {
clearIgnoredEvent(pluginData, type, userId);
}, timeout);
}

View file

@ -0,0 +1,16 @@
import { PluginData } from "knub";
import { ModActionsPluginType } from "../types";
import { isDiscordHTTPError } from "../../../utils";
export async function isBanned(pluginData: PluginData<ModActionsPluginType>, userId: string): Promise<boolean> {
try {
const bans = await pluginData.guild.getBans();
return bans.some(b => b.user.id === userId);
} catch (e) {
if (isDiscordHTTPError(e) && e.code === 500) {
return false;
}
throw e;
}
}

View file

@ -0,0 +1,6 @@
import { PluginData } from "knub";
import { IgnoredEventType, ModActionsPluginType } from "../types";
export function isEventIgnored(pluginData: PluginData<ModActionsPluginType>, type: IgnoredEventType, userId: string) {
return pluginData.state.ignoredEvents.some(info => type === info.type && userId === info.userId);
}

View file

@ -0,0 +1,73 @@
import { PluginData } from "knub";
import { IgnoredEventType, KickOptions, KickResult, ModActionsPluginType } from "../types";
import { Member } from "eris";
import { notifyUser, resolveUser, stripObjectToScalars, ucfirst, UserNotificationResult } from "../../../utils";
import { renderTemplate } from "../../../templateFormatter";
import { getDefaultContactMethods } from "./getDefaultContactMethods";
import { LogType } from "../../../data/LogType";
import { ignoreEvent } from "./ignoreEvent";
import { CaseTypes } from "../../../data/CaseTypes";
import { CasesPlugin } from "../../Cases/CasesPlugin";
/**
* Kick the specified server member. Generates a case.
*/
export async function kickMember(
pluginData: PluginData<ModActionsPluginType>,
member: Member,
reason: string = null,
kickOptions: KickOptions = {},
): Promise<KickResult> {
const config = pluginData.config.get();
// Attempt to message the user *before* kicking them, as doing it after may not be possible
let notifyResult: UserNotificationResult = { method: null, success: true };
if (reason) {
const kickMessage = await renderTemplate(config.kick_message, {
guildName: pluginData.guild.name,
reason,
});
const contactMethods = kickOptions?.contactMethods
? kickOptions.contactMethods
: getDefaultContactMethods(pluginData, "kick");
notifyResult = await notifyUser(member.user, kickMessage, contactMethods);
}
// Kick the user
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_KICK, member.id);
ignoreEvent(pluginData, IgnoredEventType.Kick, member.id);
try {
await member.kick();
} catch (e) {
return {
status: "failed",
error: e.message,
};
}
// Create a case for this action
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const createdCase = await casesPlugin.createCase({
...(kickOptions.caseArgs || {}),
userId: member.id,
modId: kickOptions.caseArgs?.modId,
type: CaseTypes.Kick,
reason,
noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [],
});
// Log the action
const mod = await resolveUser(pluginData.client, kickOptions.caseArgs?.modId);
pluginData.state.serverLogs.log(LogType.MEMBER_KICK, {
mod: stripObjectToScalars(mod),
user: stripObjectToScalars(member.user),
reason,
});
return {
status: "success",
case: createdCase,
notifyResult,
};
}

View file

@ -0,0 +1,25 @@
import { TextChannel } from "eris";
import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils";
export function 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;
}

View file

@ -0,0 +1,27 @@
import { findRelevantAuditLogEntry, isDiscordRESTError } from "../../../utils";
import { PluginData } from "knub";
import { ModActionsPluginType } from "../types";
import { LogType } from "../../../data/LogType";
/**
* Wrapper for findRelevantAuditLogEntry() that handles permission errors gracefully
*/
export async function safeFindRelevantAuditLogEntry(
pluginData: PluginData<ModActionsPluginType>,
actionType: number,
userId: string,
attempts?: number,
attemptDelay?: number,
) {
try {
return await findRelevantAuditLogEntry(pluginData.guild, actionType, userId, attempts, attemptDelay);
} catch (e) {
if (isDiscordRESTError(e) && e.code === 50013) {
pluginData.state.serverLogs.log(LogType.BOT_ALERT, {
body: "Missing permissions to read audit log",
});
} else {
throw e;
}
}
}

View file

@ -0,0 +1,68 @@
import { PluginData } from "knub";
import { ModActionsPluginType, WarnOptions, WarnResult } from "../types";
import { Member } from "eris";
import { getDefaultContactMethods } from "./getDefaultContactMethods";
import { notifyUser, resolveUser, stripObjectToScalars, ucfirst } from "../../../utils";
import { waitForReaction } from "knub/dist/helpers";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes";
import { LogType } from "../../../data/LogType";
export async function warnMember(
pluginData: PluginData<ModActionsPluginType>,
member: Member,
reason: string,
warnOptions: WarnOptions = {},
): Promise<WarnResult | null> {
const config = pluginData.config.get();
const warnMessage = config.warn_message.replace("{guildName}", pluginData.guild.name).replace("{reason}", reason);
const contactMethods = warnOptions?.contactMethods
? warnOptions.contactMethods
: getDefaultContactMethods(pluginData, "warn");
const notifyResult = await notifyUser(member.user, warnMessage, contactMethods);
if (!notifyResult.success) {
if (warnOptions.retryPromptChannel && pluginData.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(pluginData.client, failedMsg, ["✅", "❌"]);
failedMsg.delete();
if (!reply || reply.name === "❌") {
return {
status: "failed",
error: "Failed to message user",
};
}
} else {
return {
status: "failed",
error: "Failed to message user",
};
}
}
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const createdCase = await casesPlugin.createCase({
...(warnOptions.caseArgs || {}),
userId: member.id,
modId: warnOptions.caseArgs?.modId,
type: CaseTypes.Warn,
reason,
noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [],
});
const mod = await resolveUser(pluginData.client, warnOptions.caseArgs?.modId);
pluginData.state.serverLogs.log(LogType.MEMBER_WARN, {
mod: stripObjectToScalars(mod),
member: stripObjectToScalars(member, ["user", "roles"]),
reason,
});
return {
status: "success",
case: createdCase,
notifyResult,
};
}