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

@ -8,6 +8,7 @@ import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils
import { deepKeyIntersect, errorMessage, successMessage } from "./utils";
import { ZeppelinPluginBlueprint } from "./plugins/ZeppelinPluginBlueprint";
import { TZeppelinKnub } from "./types";
import { ExtendedMatchParams } from "knub/dist/config/PluginConfigManager"; // TODO: Export from Knub index
const { getMemberLevel } = helpers;
@ -21,6 +22,11 @@ export function canActOn(pluginData: PluginData<any>, member1: Member, member2:
return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel;
}
export function hasPermission(pluginData: PluginData<any>, permission: string, matchParams: ExtendedMatchParams) {
const config = pluginData.config.getMatchingConfig(matchParams);
return helpers.hasPermission(config, permission);
}
export function getPluginConfigPreprocessor(blueprint: ZeppelinPluginBlueprint) {
return (options: PluginOptions<any>) => {
const decodedConfig = blueprint.configSchema

View file

@ -7,6 +7,8 @@ import { GuildCases } from "../../data/GuildCases";
import { createCaseNote } from "./functions/createCaseNote";
import { Case } from "../../data/entities/Case";
import { postCaseToCaseLogChannel } from "./functions/postToCaseLogChannel";
import { CaseTypes } from "../../data/CaseTypes";
import { getCaseTypeAmountForUserId } from "./functions/getCaseTypeAmountForUserId";
const defaultOptions = {
config: {
@ -21,22 +23,28 @@ export const CasesPlugin = zeppelinPlugin<CasesPluginType>()("cases", {
public: {
createCase(pluginData) {
return async (args: CaseArgs) => {
return (args: CaseArgs) => {
return createCase(pluginData, args);
};
},
createCaseNote(pluginData) {
return async (args: CaseNoteArgs) => {
return (args: CaseNoteArgs) => {
return createCaseNote(pluginData, args);
};
},
postCaseToCaseLogChannel(pluginData) {
return async (caseOrCaseId: Case | number) => {
return (caseOrCaseId: Case | number) => {
return postCaseToCaseLogChannel(pluginData, caseOrCaseId);
};
},
getCaseTypeAmountForUserId(pluginData) {
return (userID: string, type: CaseTypes) => {
return getCaseTypeAmountForUserId(pluginData, userID, type);
};
},
},
onLoad(pluginData) {

View file

@ -0,0 +1,22 @@
import { PluginData } from "knub";
import { CasesPluginType } from "../types";
import { CaseTypes } from "../../../data/CaseTypes";
export async function getCaseTypeAmountForUserId(
pluginData: PluginData<CasesPluginType>,
userID: string,
type: CaseTypes,
): Promise<number> {
const cases = (await pluginData.state.cases.getByUserId(userID)).filter(c => !c.is_hidden);
let typeAmount = 0;
if (cases.length > 0) {
cases.forEach(singleCase => {
if (singleCase.type === type.valueOf()) {
typeAmount++;
}
});
}
return typeAmount;
}

View file

@ -0,0 +1,77 @@
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
import { CasesPlugin } from "../Cases/CasesPlugin";
import { MutesPlugin } from "../Mutes/MutesPlugin";
import { ConfigSchema, ModActionsPluginType } from "./types";
import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt";
import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt";
import { CreateKickCaseOnManualKickEvt } from "./events/CreateKickCaseOnManualKickEvt";
import { UpdateCmd } from "./commands/UpdateCmd";
import { NoteCmd } from "./commands/NoteCmd";
import { WarnCmd } from "./commands/WarnCmd";
import { MuteCmd } from "./commands/MuteCmd";
const defaultOptions = {
config: {
dm_on_warn: true,
dm_on_kick: false,
dm_on_ban: false,
message_on_warn: false,
message_on_kick: false,
message_on_ban: false,
message_channel: null,
warn_message: "You have received a warning on the {guildName} server: {reason}",
kick_message: "You have been kicked from the {guildName} server. Reason given: {reason}",
ban_message: "You have been banned from the {guildName} server. Reason given: {reason}",
alert_on_rejoin: false,
alert_channel: null,
warn_notify_enabled: false,
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?",
ban_delete_message_days: 1,
can_note: false,
can_warn: false,
can_mute: false,
can_kick: false,
can_ban: false,
can_view: false,
can_addcase: false,
can_massban: false,
can_hidecase: false,
can_act_as_other: false,
},
overrides: [
{
level: ">=50",
config: {
can_note: true,
can_warn: true,
can_mute: true,
can_kick: true,
can_ban: true,
can_view: true,
can_addcase: true,
},
},
{
level: ">=100",
config: {
can_massban: true,
can_hidecase: true,
can_act_as_other: true,
},
},
],
};
export const ModActionsPlugin = zeppelinPlugin<ModActionsPluginType>()("mod_actions", {
configSchema: ConfigSchema,
defaultOptions,
dependencies: [CasesPlugin, MutesPlugin],
events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, CreateKickCaseOnManualKickEvt],
commands: [UpdateCmd, NoteCmd, WarnCmd, MuteCmd],
});

View file

@ -0,0 +1,78 @@
import { modActionsCommand } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { Case } from "../../../data/entities/Case";
import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { LogType } from "../../../data/LogType";
import { CaseTypes } from "../../../data/CaseTypes";
import { errorMessage, resolveMember, resolveUser, stripObjectToScalars } from "../../../utils";
import { isBanned } from "../functions/isBanned";
import { waitForReaction } from "knub/dist/helpers";
import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs";
import { warnMember } from "../functions/warnMember";
import { TextChannel } from "eris";
import { actualMuteUserCmd } from "../functions/actualMuteUserCmd";
const opts = {
mod: ct.member({ option: true }),
notify: ct.string({ option: true }),
"notify-channel": ct.textChannel({ option: true }),
};
export const MuteCmd = modActionsCommand({
trigger: "mute",
permission: "can_mute",
description: "Mute the specified member",
signature: [
{
user: ct.string(),
time: ct.delay(),
reason: ct.string({ required: false, catchAll: true }),
...opts,
},
{
user: ct.string(),
reason: ct.string({ required: false, catchAll: true }),
...opts,
},
],
async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user);
if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`);
const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id);
if (!memberToMute) {
const _isBanned = await isBanned(pluginData, user.id);
const prefix = pluginData.guildConfig.prefix;
if (_isBanned) {
sendErrorMessage(
pluginData,
msg.channel,
`User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`,
);
} else {
sendErrorMessage(
pluginData,
msg.channel,
`User is not on the server. Use \`${prefix}forcemute\` if you want to mute them anyway.`,
);
}
return;
}
// Make sure we're allowed to mute this member
if (memberToMute && !canActOn(pluginData, msg.member, memberToMute)) {
sendErrorMessage(pluginData, msg.channel, "Cannot mute: insufficient permissions");
return;
}
actualMuteUserCmd(pluginData, user, msg, args);
},
});

View file

@ -0,0 +1,44 @@
import { modActionsCommand } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { Case } from "../../../data/entities/Case";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { LogType } from "../../../data/LogType";
import { CaseTypes } from "../../../data/CaseTypes";
import { resolveUser, stripObjectToScalars } from "../../../utils";
export const NoteCmd = modActionsCommand({
trigger: "note",
permission: "can_note",
description: "Add a note to the specified user",
signature: {
user: ct.string(),
note: ct.string({ catchAll: true }),
},
async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user);
if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`);
const userName = `${user.username}#${user.discriminator}`;
const reason = formatReasonWithAttachments(args.note, msg.attachments);
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const createdCase = await casesPlugin.createCase({
userId: user.id,
modId: msg.author.id,
type: CaseTypes.Note,
reason,
});
pluginData.state.serverLogs.log(LogType.MEMBER_NOTE, {
mod: stripObjectToScalars(msg.author),
user: stripObjectToScalars(user, ["user", "roles"]),
reason,
});
sendSuccessMessage(pluginData, msg.channel, `Note added on **${userName}** (Case #${createdCase.case_number})`);
},
});

View file

@ -0,0 +1,57 @@
import { modActionsCommand } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { Case } from "../../../data/entities/Case";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { LogType } from "../../../data/LogType";
import { CaseTypes } from "../../../data/CaseTypes";
export const UpdateCmd = modActionsCommand({
trigger: "update",
permission: "can_note",
description:
"Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it",
signature: {
caseNumber: ct.number(),
note: ct.string({ required: false, catchAll: true }),
},
async run({ pluginData, message: msg, args }) {
let theCase: Case;
if (args.caseNumber != null) {
theCase = await pluginData.state.cases.findByCaseNumber(args.caseNumber);
} else {
theCase = await pluginData.state.cases.findLatestByModId(msg.author.id);
}
if (!theCase) {
sendErrorMessage(pluginData, msg.channel, "Case not found");
return;
}
if (!args.note && msg.attachments.length === 0) {
sendErrorMessage(pluginData, msg.channel, "Text or attachment required");
return;
}
const note = formatReasonWithAttachments(args.note, msg.attachments);
const casesPlugin = pluginData.getPlugin(CasesPlugin);
await casesPlugin.createCaseNote({
caseId: theCase.id,
modId: msg.author.id,
body: note,
});
pluginData.state.serverLogs.log(LogType.CASE_UPDATE, {
mod: msg.author,
caseNumber: theCase.case_number,
caseType: CaseTypes[theCase.type],
note,
});
sendSuccessMessage(pluginData, msg.channel, `Case \`#${theCase.case_number}\` updated`);
},
});

View file

@ -0,0 +1,113 @@
import { modActionsCommand } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { Case } from "../../../data/entities/Case";
import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { LogType } from "../../../data/LogType";
import { CaseTypes } from "../../../data/CaseTypes";
import { errorMessage, resolveMember, resolveUser, stripObjectToScalars } from "../../../utils";
import { isBanned } from "../functions/isBanned";
import { waitForReaction } from "knub/dist/helpers";
import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs";
import { warnMember } from "../functions/warnMember";
import { TextChannel } from "eris";
export const WarnCmd = modActionsCommand({
trigger: "warn",
permission: "can_warn",
description: "Send a warning to the specified user",
signature: {
user: ct.string(),
reason: ct.string({ catchAll: true }),
mod: ct.member({ option: true }),
notify: ct.string({ option: true }),
"notify-channel": ct.textChannel({ option: true }),
},
async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user);
if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`);
const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id);
if (!memberToWarn) {
const _isBanned = await isBanned(pluginData, user.id);
if (_isBanned) {
sendErrorMessage(pluginData, msg.channel, `User is banned`);
} else {
sendErrorMessage(pluginData, msg.channel, `User not found on the server`);
}
return;
}
// Make sure we're allowed to warn this member
if (!canActOn(pluginData, msg.member, memberToWarn)) {
sendErrorMessage(pluginData, msg.channel, "Cannot warn: insufficient permissions");
return;
}
// The moderator who did the action is the message author or, if used, the specified -mod
let mod = msg.member;
if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) {
msg.channel.createMessage(errorMessage("No permission for -mod"));
return;
}
mod = args.mod;
}
const config = pluginData.config.get();
const reason = formatReasonWithAttachments(args.reason, msg.attachments);
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn);
if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) {
const tooManyWarningsMsg = await msg.channel.createMessage(
config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`),
);
const reply = await waitForReaction(pluginData.client, tooManyWarningsMsg, ["✅", "❌"]);
tooManyWarningsMsg.delete();
if (!reply || reply.name === "❌") {
msg.channel.createMessage(errorMessage("Warn cancelled by moderator"));
return;
}
}
let contactMethods;
try {
contactMethods = readContactMethodsFromArgs(args);
} catch (e) {
sendErrorMessage(pluginData, msg.channel, e.message);
return;
}
const warnResult = await warnMember(pluginData, memberToWarn, reason, {
contactMethods,
caseArgs: {
modId: mod.id,
ppId: mod.id !== msg.author.id ? msg.author.id : null,
reason,
},
retryPromptChannel: msg.channel as TextChannel,
});
if (warnResult.status === "failed") {
sendErrorMessage(pluginData, msg.channel, "Failed to warn user");
return;
}
const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : "";
sendSuccessMessage(
pluginData,
msg.channel,
`Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`,
);
},
});

View file

@ -0,0 +1,49 @@
import { eventListener } from "knub";
import { IgnoredEventType, ModActionsPluginType } from "../types";
import { isEventIgnored } from "../functions/isEventIgnored";
import { clearIgnoredEvent } from "../functions/clearIgnoredEvents";
import { Constants as ErisConstants } from "eris";
import { safeFindRelevantAuditLogEntry } from "../functions/safeFindRelevantAuditLogEntry";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes";
/**
* Create a BAN case automatically when a user is banned manually.
* Attempts to find the ban's details in the audit log.
*/
export const CreateBanCaseOnManualBanEvt = eventListener<ModActionsPluginType>()(
"guildBanAdd",
async ({ pluginData, args: { guild, user } }) => {
if (isEventIgnored(pluginData, IgnoredEventType.Ban, user.id)) {
clearIgnoredEvent(pluginData, IgnoredEventType.Ban, user.id);
return;
}
const relevantAuditLogEntry = await safeFindRelevantAuditLogEntry(
pluginData,
ErisConstants.AuditLogActions.MEMBER_BAN_ADD,
user.id,
);
const casesPlugin = pluginData.getPlugin(CasesPlugin);
if (relevantAuditLogEntry) {
const modId = relevantAuditLogEntry.user.id;
const auditLogId = relevantAuditLogEntry.id;
casesPlugin.createCase({
userId: user.id,
modId,
type: CaseTypes.Ban,
auditLogId,
reason: relevantAuditLogEntry.reason,
automatic: true,
});
} else {
casesPlugin.createCase({
userId: user.id,
modId: null,
type: CaseTypes.Ban,
});
}
},
);

View file

@ -0,0 +1,55 @@
import { eventListener } from "knub";
import { IgnoredEventType, ModActionsPluginType } from "../types";
import { isEventIgnored } from "../functions/isEventIgnored";
import { clearIgnoredEvent } from "../functions/clearIgnoredEvents";
import { Constants as ErisConstants } from "eris";
import { safeFindRelevantAuditLogEntry } from "../functions/safeFindRelevantAuditLogEntry";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes";
import { logger } from "../../../logger";
import { LogType } from "../../../data/LogType";
import { stripObjectToScalars } from "../../../utils";
/**
* Create a KICK case automatically when a user is kicked manually.
* Attempts to find the kick's details in the audit log.
*/
export const CreateKickCaseOnManualKickEvt = eventListener<ModActionsPluginType>()(
"guildMemberRemove",
async ({ pluginData, args: { member } }) => {
if (isEventIgnored(pluginData, IgnoredEventType.Kick, member.id)) {
clearIgnoredEvent(pluginData, IgnoredEventType.Kick, member.id);
return;
}
const kickAuditLogEntry = await safeFindRelevantAuditLogEntry(
pluginData,
ErisConstants.AuditLogActions.MEMBER_KICK,
member.id,
);
if (kickAuditLogEntry) {
const existingCaseForThisEntry = await pluginData.state.cases.findByAuditLogId(kickAuditLogEntry.id);
if (existingCaseForThisEntry) {
logger.warn(
`Tried to create duplicate case for audit log entry ${kickAuditLogEntry.id}, existing case id ${existingCaseForThisEntry.id}`,
);
} else {
const casesPlugin = pluginData.getPlugin(CasesPlugin);
casesPlugin.createCase({
userId: member.id,
modId: kickAuditLogEntry.user.id,
type: CaseTypes.Kick,
auditLogId: kickAuditLogEntry.id,
reason: kickAuditLogEntry.reason,
automatic: true,
});
}
pluginData.state.serverLogs.log(LogType.MEMBER_KICK, {
user: stripObjectToScalars(member.user),
mod: stripObjectToScalars(kickAuditLogEntry.user),
});
}
},
);

View file

@ -0,0 +1,49 @@
import { eventListener } from "knub";
import { IgnoredEventType, ModActionsPluginType } from "../types";
import { isEventIgnored } from "../functions/isEventIgnored";
import { clearIgnoredEvent } from "../functions/clearIgnoredEvents";
import { Constants as ErisConstants } from "eris";
import { safeFindRelevantAuditLogEntry } from "../functions/safeFindRelevantAuditLogEntry";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes";
/**
* Create an UNBAN case automatically when a user is unbanned manually.
* Attempts to find the unban's details in the audit log.
*/
export const CreateUnbanCaseOnManualUnbanEvt = eventListener<ModActionsPluginType>()(
"guildBanRemove",
async ({ pluginData, args: { guild, user } }) => {
if (isEventIgnored(pluginData, IgnoredEventType.Unban, user.id)) {
clearIgnoredEvent(pluginData, IgnoredEventType.Unban, user.id);
return;
}
const relevantAuditLogEntry = await safeFindRelevantAuditLogEntry(
pluginData,
ErisConstants.AuditLogActions.MEMBER_BAN_REMOVE,
user.id,
);
const casesPlugin = pluginData.getPlugin(CasesPlugin);
if (relevantAuditLogEntry) {
const modId = relevantAuditLogEntry.user.id;
const auditLogId = relevantAuditLogEntry.id;
casesPlugin.createCase({
userId: user.id,
modId,
type: CaseTypes.Unban,
auditLogId,
automatic: true,
});
} else {
casesPlugin.createCase({
userId: user.id,
modId: null,
type: CaseTypes.Unban,
automatic: true,
});
}
},
);

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,
};
}

View file

@ -0,0 +1,116 @@
import * as t from "io-ts";
import { tNullable, UserNotificationMethod, UserNotificationResult } from "../../utils";
import { BasePluginType, command } from "knub";
import { GuildMutes } from "../../data/GuildMutes";
import { GuildCases } from "../../data/GuildCases";
import { GuildLogs } from "../../data/GuildLogs";
import { GuildArchives } from "../../data/GuildArchives";
import { Case } from "../../data/entities/Case";
import { CaseArgs } from "../Cases/types";
import { TextChannel } from "eris";
export const ConfigSchema = t.type({
dm_on_warn: t.boolean,
dm_on_kick: t.boolean,
dm_on_ban: t.boolean,
message_on_warn: t.boolean,
message_on_kick: t.boolean,
message_on_ban: t.boolean,
message_channel: tNullable(t.string),
warn_message: tNullable(t.string),
kick_message: tNullable(t.string),
ban_message: tNullable(t.string),
alert_on_rejoin: t.boolean,
alert_channel: tNullable(t.string),
warn_notify_enabled: t.boolean,
warn_notify_threshold: t.number,
warn_notify_message: t.string,
ban_delete_message_days: t.number,
can_note: t.boolean,
can_warn: t.boolean,
can_mute: t.boolean,
can_kick: t.boolean,
can_ban: t.boolean,
can_view: t.boolean,
can_addcase: t.boolean,
can_massban: t.boolean,
can_hidecase: t.boolean,
can_act_as_other: t.boolean,
});
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface ModActionsPluginType extends BasePluginType {
config: TConfigSchema;
state: {
mutes: GuildMutes;
cases: GuildCases;
serverLogs: GuildLogs;
ignoredEvents: IIgnoredEvent[];
};
}
export enum IgnoredEventType {
Ban = 1,
Unban,
Kick,
}
export interface IIgnoredEvent {
type: IgnoredEventType;
userId: string;
}
export type WarnResult =
| {
status: "failed";
error: string;
}
| {
status: "success";
case: Case;
notifyResult: UserNotificationResult;
};
export type KickResult =
| {
status: "failed";
error: string;
}
| {
status: "success";
case: Case;
notifyResult: UserNotificationResult;
};
export type BanResult =
| {
status: "failed";
error: string;
}
| {
status: "success";
case: Case;
notifyResult: UserNotificationResult;
};
export 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[];
deleteMessageDays?: number;
}
export const modActionsCommand = command<ModActionsPluginType>();

View file

@ -1,5 +1,5 @@
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
import { ConfigSchema, MutesPluginType } from "./types";
import { ConfigSchema, MuteOptions, MutesPluginType } from "./types";
import { CasesPlugin } from "../Cases/CasesPlugin";
import { GuildMutes } from "../../data/GuildMutes";
import { GuildCases } from "../../data/GuildCases";
@ -11,6 +11,7 @@ import { ClearBannedMutesCmd } from "./commands/ClearBannedMutesCmd";
import { ClearActiveMuteOnRoleRemovalEvt } from "./events/ClearActiveMuteOnRoleRemovalEvt";
import { ClearMutesWithoutRoleCmd } from "./commands/ClearMutesWithoutRoleCmd";
import { ClearMutesCmd } from "./commands/ClearMutesCmd";
import { muteUser } from "./functions/muteUser";
const defaultOptions = {
config: {
@ -55,9 +56,26 @@ export const MutesPlugin = zeppelinPlugin<MutesPluginType>()("mutes", {
dependencies: [CasesPlugin],
commands: [MutesCmd, ClearBannedMutesCmd, ClearMutesWithoutRoleCmd, ClearMutesCmd],
// prettier-ignore
commands: [
MutesCmd,
ClearBannedMutesCmd,
ClearMutesWithoutRoleCmd,
ClearMutesCmd,
],
events: [ClearActiveMuteOnRoleRemovalEvt],
// prettier-ignore
events: [
ClearActiveMuteOnRoleRemovalEvt,
],
public: {
muteUser(pluginData) {
return (userId: string, muteTime: number = null, reason: string = null, muteOptions: MuteOptions = {}) => {
return muteUser(pluginData, userId, muteTime, reason, muteOptions);
};
},
},
onLoad(pluginData) {
pluginData.state.mutes = GuildMutes.getGuildInstance(pluginData.guild.id);