WIP ModActions
This commit is contained in:
parent
a3d0ec03d9
commit
ebcb28261b
25 changed files with 1162 additions and 6 deletions
107
backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts
Normal file
107
backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts
Normal 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);
|
||||
}
|
75
backend/src/plugins/ModActions/functions/banUserId.ts
Normal file
75
backend/src/plugins/ModActions/functions/banUserId.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
20
backend/src/plugins/ModActions/functions/ignoreEvent.ts
Normal file
20
backend/src/plugins/ModActions/functions/ignoreEvent.ts
Normal 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);
|
||||
}
|
16
backend/src/plugins/ModActions/functions/isBanned.ts
Normal file
16
backend/src/plugins/ModActions/functions/isBanned.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
73
backend/src/plugins/ModActions/functions/kickMember.ts
Normal file
73
backend/src/plugins/ModActions/functions/kickMember.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
68
backend/src/plugins/ModActions/functions/warnMember.ts
Normal file
68
backend/src/plugins/ModActions/functions/warnMember.ts
Normal 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,
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue