diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 7bebd606..2a6cf870 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -235,14 +235,20 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", pluginData.state.modActionsListeners.set("note", (userId: string) => runAutomodOnModAction(pluginData, "note", userId), ); - pluginData.state.modActionsListeners.set("warn", (userId: string) => - runAutomodOnModAction(pluginData, "warn", userId), + pluginData.state.modActionsListeners.set( + "warn", + (userId: string, reason: string | undefined, isAutomodAction: boolean) => + runAutomodOnModAction(pluginData, "warn", userId, reason, isAutomodAction), ); - pluginData.state.modActionsListeners.set("kick", (userId: string) => - runAutomodOnModAction(pluginData, "kick", userId), + pluginData.state.modActionsListeners.set( + "kick", + (userId: string, reason: string | undefined, isAutomodAction: boolean) => + runAutomodOnModAction(pluginData, "kick", userId, reason, isAutomodAction), ); - pluginData.state.modActionsListeners.set("ban", (userId: string) => - runAutomodOnModAction(pluginData, "ban", userId), + pluginData.state.modActionsListeners.set( + "ban", + (userId: string, reason: string | undefined, isAutomodAction: boolean) => + runAutomodOnModAction(pluginData, "ban", userId, reason, isAutomodAction), ); pluginData.state.modActionsListeners.set("unban", (userId: string) => runAutomodOnModAction(pluginData, "unban", userId), @@ -251,7 +257,11 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter(); pluginData.state.mutesListeners = new Map(); - pluginData.state.mutesListeners.set("mute", (userId: string) => runAutomodOnModAction(pluginData, "mute", userId)); + pluginData.state.mutesListeners.set( + "mute", + (userId: string, reason: string | undefined, isAutomodAction: boolean) => + runAutomodOnModAction(pluginData, "mute", userId, reason, isAutomodAction), + ); pluginData.state.mutesListeners.set("unmute", (userId: string) => runAutomodOnModAction(pluginData, "unmute", userId), ); diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts index 9f193cef..931d901e 100644 --- a/backend/src/plugins/Automod/actions/ban.ts +++ b/backend/src/plugins/Automod/actions/ban.ts @@ -43,7 +43,12 @@ export const BanAction = automodAction({ const modActions = pluginData.getPlugin(ModActionsPlugin); for (const userId of userIdsToBan) { - await modActions.banUserId(userId, reason, { contactMethods, caseArgs, deleteMessageDays }, duration); + await modActions.banUserId(userId, reason, { + contactMethods, + caseArgs, + deleteMessageDays, + isAutomodAction: true, + }); } }, }); diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts index c25684f4..b11c44ae 100644 --- a/backend/src/plugins/Automod/actions/kick.ts +++ b/backend/src/plugins/Automod/actions/kick.ts @@ -33,7 +33,7 @@ export const KickAction = automodAction({ const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToKick) { if (!member) continue; - await modActions.kickMember(member, reason, { contactMethods, caseArgs }); + await modActions.kickMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts index ad964f44..f09fbe34 100644 --- a/backend/src/plugins/Automod/actions/mute.ts +++ b/backend/src/plugins/Automod/actions/mute.ts @@ -49,7 +49,14 @@ export const MuteAction = automodAction({ const mutes = pluginData.getPlugin(MutesPlugin); for (const userId of userIdsToMute) { try { - await mutes.muteUser(userId, duration, reason, { contactMethods, caseArgs }, rolesToRemove, rolesToRestore); + await mutes.muteUser( + userId, + duration, + reason, + { contactMethods, caseArgs, isAutomodAction: true }, + rolesToRemove, + rolesToRestore, + ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts index c705b77c..70240080 100644 --- a/backend/src/plugins/Automod/actions/warn.ts +++ b/backend/src/plugins/Automod/actions/warn.ts @@ -33,7 +33,7 @@ export const WarnAction = automodAction({ const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToWarn) { if (!member) continue; - await modActions.warnMember(member, reason, { contactMethods, caseArgs }); + await modActions.warnMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); diff --git a/backend/src/plugins/Automod/events/runAutomodOnModAction.ts b/backend/src/plugins/Automod/events/runAutomodOnModAction.ts index 5831f1eb..59b2b650 100644 --- a/backend/src/plugins/Automod/events/runAutomodOnModAction.ts +++ b/backend/src/plugins/Automod/events/runAutomodOnModAction.ts @@ -9,6 +9,7 @@ export async function runAutomodOnModAction( modAction: ModActionType, userId: string, reason?: string, + isAutomodAction: boolean = false, ) { const user = await resolveUser(pluginData.client, userId); @@ -18,6 +19,7 @@ export async function runAutomodOnModAction( modAction: { type: modAction, reason, + isAutomodAction, }, }; diff --git a/backend/src/plugins/Automod/info.ts b/backend/src/plugins/Automod/info.ts index c2c87dbf..9fcdced0 100644 --- a/backend/src/plugins/Automod/info.ts +++ b/backend/src/plugins/Automod/info.ts @@ -64,9 +64,9 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { reason: 'Auto-muted for spam' my_second_filter: triggers: - - message_spam: - amount: 5 - within: 10s + - emoji_spam: + amount: 2 + within: 5s actions: clean: true overrides: diff --git a/backend/src/plugins/Automod/triggers/ban.ts b/backend/src/plugins/Automod/triggers/ban.ts index 64559e74..a7c16742 100644 --- a/backend/src/plugins/Automod/triggers/ban.ts +++ b/backend/src/plugins/Automod/triggers/ban.ts @@ -5,13 +5,25 @@ import { automodTrigger } from "../helpers"; interface BanTriggerResultType {} export const BanTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configType: t.type({ + manual: t.boolean, + automatic: t.boolean, + }), - async match({ context }) { + defaultConfig: { + manual: true, + automatic: true, + }, + + async match({ context, triggerConfig }) { if (context.modAction?.type !== "ban") { return; } + console.log(context); + // If automatic && automatic turned off -> return + if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; + // If manual && manual turned off -> return + if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, diff --git a/backend/src/plugins/Automod/triggers/kick.ts b/backend/src/plugins/Automod/triggers/kick.ts index 284a867d..116f1252 100644 --- a/backend/src/plugins/Automod/triggers/kick.ts +++ b/backend/src/plugins/Automod/triggers/kick.ts @@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers"; interface KickTriggerResultType {} export const KickTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configType: t.type({ + manual: t.boolean, + automatic: t.boolean, + }), - async match({ context }) { + defaultConfig: { + manual: true, + automatic: true, + }, + + async match({ context, triggerConfig }) { if (context.modAction?.type !== "kick") { return; } + // If automatic && automatic turned off -> return + if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; + // If manual && manual turned off -> return + if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, diff --git a/backend/src/plugins/Automod/triggers/mute.ts b/backend/src/plugins/Automod/triggers/mute.ts index 94d14437..c5e2d2ba 100644 --- a/backend/src/plugins/Automod/triggers/mute.ts +++ b/backend/src/plugins/Automod/triggers/mute.ts @@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers"; interface MuteTriggerResultType {} export const MuteTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configType: t.type({ + manual: t.boolean, + automatic: t.boolean, + }), - async match({ context }) { + defaultConfig: { + manual: true, + automatic: true, + }, + + async match({ context, triggerConfig }) { if (context.modAction?.type !== "mute") { return; } + // If automatic && automatic turned off -> return + if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; + // If manual && manual turned off -> return + if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, diff --git a/backend/src/plugins/Automod/triggers/warn.ts b/backend/src/plugins/Automod/triggers/warn.ts index 711f5cd7..545c4437 100644 --- a/backend/src/plugins/Automod/triggers/warn.ts +++ b/backend/src/plugins/Automod/triggers/warn.ts @@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers"; interface WarnTriggerResultType {} export const WarnTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configType: t.type({ + manual: t.boolean, + automatic: t.boolean, + }), - async match({ context }) { + defaultConfig: { + manual: true, + automatic: true, + }, + + async match({ context, triggerConfig }) { if (context.modAction?.type !== "warn") { return; } + // If automatic && automatic turned off -> return + if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; + // If manual && manual turned off -> return + if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index f85b8429..cd7ebacf 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -122,6 +122,7 @@ export interface AutomodContext { modAction?: { type: ModActionType; reason?: string; + isAutomodAction: boolean; }; antiraid?: { level: string | null; diff --git a/backend/src/plugins/Counters/commands/SetCounterCmd.ts b/backend/src/plugins/Counters/commands/SetCounterCmd.ts index edabe2e7..f74c2045 100644 --- a/backend/src/plugins/Counters/commands/SetCounterCmd.ts +++ b/backend/src/plugins/Counters/commands/SetCounterCmd.ts @@ -67,7 +67,7 @@ export const SetCounterCmd = guildCommand()({ let channel = args.channel; if (!channel && counter.per_channel) { - message.channel.createMessage(`Which channel's counter value would you like to add to?`); + message.channel.createMessage(`Which channel's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { sendErrorMessage(pluginData, message.channel, "Cancelling"); @@ -85,7 +85,7 @@ export const SetCounterCmd = guildCommand()({ let user = args.user; if (!user && counter.per_user) { - message.channel.createMessage(`Which user's counter value would you like to add to?`); + message.channel.createMessage(`Which user's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { sendErrorMessage(pluginData, message.channel, "Cancelling"); @@ -103,7 +103,7 @@ export const SetCounterCmd = guildCommand()({ let value = args.value; if (!value) { - message.channel.createMessage("How much would you like to add to the counter's value?"); + message.channel.createMessage("What would you like to set the counter's value to?"); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { sendErrorMessage(pluginData, message.channel, "Cancelling"); @@ -111,7 +111,7 @@ export const SetCounterCmd = guildCommand()({ } const potentialValue = parseInt(reply.content, 10); - if (!potentialValue) { + if (Number.isNaN(potentialValue)) { sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); return; } diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index 6a5cab8f..a5660825 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -30,7 +30,7 @@ import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd"; import { warnMember } from "./functions/warnMember"; -import { Member } from "eris"; +import { Member, Message } from "eris"; import { kickMember } from "./functions/kickMember"; import { banUserId } from "./functions/banUserId"; import { MassmuteCmd } from "./commands/MassmuteCmd"; @@ -43,6 +43,7 @@ import { EventEmitter } from "events"; import { mapToPublicFn } from "../../pluginUtils"; import { onModActionsEvent } from "./functions/onModActionsEvent"; import { offModActionsEvent } from "./functions/offModActionsEvent"; +import { updateCase } from "./functions/updateCase"; const defaultOptions = { config: { @@ -170,6 +171,12 @@ export const ModActionsPlugin = zeppelinGuildPlugin()("mod }; }, + updateCase(pluginData) { + return (msg: Message, caseNumber: number | null, note: string) => { + updateCase(pluginData, msg, { caseNumber, note }); + }; + }, + on: mapToPublicFn(onModActionsEvent), off: mapToPublicFn(offModActionsEvent), getEventEmitter(pluginData) { diff --git a/backend/src/plugins/ModActions/commands/BanCmd.ts b/backend/src/plugins/ModActions/commands/BanCmd.ts index 9a9d2a16..8cc73d4c 100644 --- a/backend/src/plugins/ModActions/commands/BanCmd.ts +++ b/backend/src/plugins/ModActions/commands/BanCmd.ts @@ -66,8 +66,8 @@ export const BanCmd = modActionsCmd({ const lock = await pluginData.locks.acquire(banLock(user)); let forceban = false; const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); - const banned = await isBanned(pluginData, user.id); if (!memberToBan) { + const banned = await isBanned(pluginData, user.id); if (banned) { // Abort if trying to ban user indefinitely if they are already banned indefinitely if (!existingTempban && !time) { diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts index 6d84fc77..f9f4667d 100644 --- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts +++ b/backend/src/plugins/ModActions/commands/CasesModCmd.ts @@ -17,7 +17,7 @@ const opts = { const casesPerPage = 5; export const CasesModCmd = modActionsCmd({ - trigger: ["cases", "modlogs"], + trigger: ["cases", "modlogs", "infractions"], permission: "can_view", description: "Show the most recent 5 cases by the specified -mod", diff --git a/backend/src/plugins/ModActions/commands/UpdateCmd.ts b/backend/src/plugins/ModActions/commands/UpdateCmd.ts index 3a048b42..6c8d78ca 100644 --- a/backend/src/plugins/ModActions/commands/UpdateCmd.ts +++ b/backend/src/plugins/ModActions/commands/UpdateCmd.ts @@ -1,11 +1,6 @@ import { modActionsCmd } 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 { updateCase } from "../functions/updateCase"; export const UpdateCmd = modActionsCmd({ trigger: ["update", "reason"], @@ -24,39 +19,6 @@ export const UpdateCmd = modActionsCmd({ ], async run({ pluginData, message: msg, args }) { - let theCase: Case | undefined; - 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`); + await updateCase(pluginData, msg, args); }, }); diff --git a/backend/src/plugins/ModActions/commands/WarnCmd.ts b/backend/src/plugins/ModActions/commands/WarnCmd.ts index e36dbb86..45526a0c 100644 --- a/backend/src/plugins/ModActions/commands/WarnCmd.ts +++ b/backend/src/plugins/ModActions/commands/WarnCmd.ts @@ -112,7 +112,5 @@ export const WarnCmd = modActionsCmd({ msg.channel, `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`, ); - - pluginData.state.events.emit("warn", user.id, reason); }, }); diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts index 0e774e71..709ba994 100644 --- a/backend/src/plugins/ModActions/functions/banUserId.ts +++ b/backend/src/plugins/ModActions/functions/banUserId.ts @@ -137,7 +137,7 @@ export async function banUserId( banTime: banTime ? humanizeDuration(banTime) : null, }); - pluginData.state.events.emit("ban", user.id, reason); + pluginData.state.events.emit("ban", user.id, reason, banOptions.isAutomodAction); return { status: "success", diff --git a/backend/src/plugins/ModActions/functions/isBanned.ts b/backend/src/plugins/ModActions/functions/isBanned.ts index c809a30c..7bcd22d8 100644 --- a/backend/src/plugins/ModActions/functions/isBanned.ts +++ b/backend/src/plugins/ModActions/functions/isBanned.ts @@ -1,16 +1,44 @@ import { GuildPluginData } from "knub"; import { ModActionsPluginType } from "../types"; -import { isDiscordHTTPError } from "../../../utils"; +import { isDiscordHTTPError, isDiscordRESTError, SECONDS, sleep } from "../../../utils"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { LogType } from "../../../data/LogType"; +import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions"; +import { Constants } from "eris"; + +export async function isBanned( + pluginData: GuildPluginData, + userId: string, + timeout: number = 5 * SECONDS, +): Promise { + const botMember = pluginData.guild.members.get(pluginData.client.user.id); + if (botMember && !hasDiscordPermissions(botMember.permissions, Constants.Permissions.banMembers)) { + pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + body: `Missing "Ban Members" permission to check for existing bans`, + }); + return false; + } -export async function isBanned(pluginData: GuildPluginData, userId: string): Promise { try { - const bans = await pluginData.guild.getBans(); - return bans.some(b => b.user.id === userId); + const potentialBan = await Promise.race([pluginData.guild.getBan(userId), sleep(timeout)]); + return potentialBan != null; } catch (e) { - if (isDiscordHTTPError(e) && e.code === 500) { + if (isDiscordRESTError(e) && e.code === 10026) { + // [10026]: Unknown Ban return false; } + if (isDiscordHTTPError(e) && e.code === 500) { + // Internal server error, ignore + return false; + } + + if (isDiscordRESTError(e) && e.code === 50013) { + pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + body: `Missing "Ban Members" permission to check for existing bans`, + }); + } + throw e; } } diff --git a/backend/src/plugins/ModActions/functions/kickMember.ts b/backend/src/plugins/ModActions/functions/kickMember.ts index 9e297af8..b682018e 100644 --- a/backend/src/plugins/ModActions/functions/kickMember.ts +++ b/backend/src/plugins/ModActions/functions/kickMember.ts @@ -85,7 +85,7 @@ export async function kickMember( reason, }); - pluginData.state.events.emit("kick", member.id, reason); + pluginData.state.events.emit("kick", member.id, reason, kickOptions.isAutomodAction); return { status: "success", diff --git a/backend/src/plugins/ModActions/functions/updateCase.ts b/backend/src/plugins/ModActions/functions/updateCase.ts new file mode 100644 index 00000000..12257269 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/updateCase.ts @@ -0,0 +1,44 @@ +import { Message } from "eris"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { Case } from "../../../data/entities/Case"; +import { LogType } from "../../../data/LogType"; +import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; + +export async function updateCase(pluginData, msg: Message, args) { + let theCase: Case | undefined; + 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`); +} diff --git a/backend/src/plugins/ModActions/functions/warnMember.ts b/backend/src/plugins/ModActions/functions/warnMember.ts index 0c07c838..d61b183d 100644 --- a/backend/src/plugins/ModActions/functions/warnMember.ts +++ b/backend/src/plugins/ModActions/functions/warnMember.ts @@ -82,6 +82,8 @@ export async function warnMember( reason, }); + pluginData.state.events.emit("warn", member.id, reason, warnOptions.isAutomodAction); + return { status: "success", case: createdCase, diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index db9079f7..2aaee948 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -48,9 +48,9 @@ export type TConfigSchema = t.TypeOf; export interface ModActionsEvents { note: (userId: string, reason?: string) => void; - warn: (userId: string, reason?: string) => void; - kick: (userId: string, reason?: string) => void; - ban: (userId: string, reason?: string) => void; + warn: (userId: string, reason?: string, isAutomodAction?: boolean) => void; + kick: (userId: string, reason?: string, isAutomodAction?: boolean) => void; + ban: (userId: string, reason?: string, isAutomodAction?: boolean) => void; unban: (userId: string, reason?: string) => void; // mute/unmute are in the Mutes plugin } @@ -126,11 +126,13 @@ export interface WarnOptions { caseArgs?: Partial | null; contactMethods?: UserNotificationMethod[] | null; retryPromptChannel?: TextChannel | null; + isAutomodAction?: boolean; } export interface KickOptions { caseArgs?: Partial; contactMethods?: UserNotificationMethod[]; + isAutomodAction?: boolean; } export interface BanOptions { @@ -138,6 +140,7 @@ export interface BanOptions { contactMethods?: UserNotificationMethod[]; deleteMessageDays?: number; modId?: string; + isAutomodAction?: boolean; } export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban"; diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts index 29ba54ab..622a5b1a 100644 --- a/backend/src/plugins/Mutes/functions/muteUser.ts +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -247,7 +247,7 @@ export async function muteUser( lock.unlock(); - pluginData.state.events.emit("mute", user.id, reason); + pluginData.state.events.emit("mute", user.id, reason, muteOptions.isAutomodAction); return { case: theCase, diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts index 7da2cb0d..ea180e90 100644 --- a/backend/src/plugins/Mutes/types.ts +++ b/backend/src/plugins/Mutes/types.ts @@ -34,7 +34,7 @@ export const ConfigSchema = t.type({ export type TConfigSchema = t.TypeOf; export interface MutesEvents { - mute: (userId: string, reason?: string) => void; + mute: (userId: string, reason?: string, isAutomodAction?: boolean) => void; unmute: (userId: string, reason?: string) => void; } @@ -75,6 +75,7 @@ export type UnmuteResult = { export interface MuteOptions { caseArgs?: Partial; contactMethods?: UserNotificationMethod[]; + isAutomodAction?: boolean; } export const mutesCmd = guildCommand(); diff --git a/backend/src/plugins/Reminders/commands/RemindCmd.ts b/backend/src/plugins/Reminders/commands/RemindCmd.ts index 67a2cfc1..ad9cd355 100644 --- a/backend/src/plugins/Reminders/commands/RemindCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindCmd.ts @@ -7,7 +7,7 @@ import { remindersCmd } from "../types"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; export const RemindCmd = remindersCmd({ - trigger: ["remind", "remindme"], + trigger: ["remind", "remindme", "reminder"], usage: "!remind 3h Remind me of this in 3 hours please", permission: "can_use", diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index 90c44c49..e2ba30b0 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -35,6 +35,7 @@ import { SnowflakeInfoCmd } from "./commands/SnowflakeInfoCmd"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { VcdisconnectCmd } from "./commands/VcdisconnectCmd"; +import { ModActionsPlugin } from "../ModActions/ModActionsPlugin"; import { refreshMembersIfNeeded } from "./refreshMembers"; const defaultOptions: PluginOptions = { @@ -106,7 +107,7 @@ export const UtilityPlugin = zeppelinGuildPlugin()("utility", prettyName: "Utility", }, - dependencies: [TimeAndDatePlugin], + dependencies: [TimeAndDatePlugin, ModActionsPlugin], configSchema: ConfigSchema, defaultOptions, diff --git a/backend/src/plugins/Utility/commands/CleanCmd.ts b/backend/src/plugins/Utility/commands/CleanCmd.ts index e75da2f8..8a1a2328 100644 --- a/backend/src/plugins/Utility/commands/CleanCmd.ts +++ b/backend/src/plugins/Utility/commands/CleanCmd.ts @@ -8,6 +8,7 @@ import { GuildPluginData } from "knub"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { LogType } from "../../../data/LogType"; import { allowTimeout } from "../../../RegExpRunner"; +import { ModActionsPlugin } from "../../../plugins/ModActions/ModActionsPlugin"; const MAX_CLEAN_COUNT = 150; const MAX_CLEAN_TIME = 1 * DAYS; @@ -49,23 +50,36 @@ async function cleanMessages( return { archiveUrl }; } +const opts = { + user: ct.userId({ option: true, shortcut: "u" }), + channel: ct.channelId({ option: true, shortcut: "c" }), + bots: ct.switchOption({ shortcut: "b" }), + "delete-pins": ct.switchOption({ shortcut: "p" }), + "has-invites": ct.switchOption({ shortcut: "i" }), + match: ct.regex({ option: true, shortcut: "m" }), + "to-id": ct.anyId({ option: true, shortcut: "id" }), +}; + export const CleanCmd = utilityCmd({ trigger: ["clean", "clear"], description: "Remove a number of recent messages", usage: "!clean 20", permission: "can_clean", - signature: { - count: ct.number(), + signature: [ + { + count: ct.number(), + update: ct.number({ option: true, shortcut: "up" }), - user: ct.userId({ option: true, shortcut: "u" }), - channel: ct.channelId({ option: true, shortcut: "c" }), - bots: ct.switchOption({ shortcut: "b" }), - "delete-pins": ct.switchOption({ shortcut: "p" }), - "has-invites": ct.switchOption({ shortcut: "i" }), - match: ct.regex({ option: true, shortcut: "m" }), - "to-id": ct.anyId({ option: true, shortcut: "id" }), - }, + ...opts, + }, + { + count: ct.number(), + update: ct.switchOption({ shortcut: "up" }), + + ...opts, + }, + ], async run({ message: msg, args, pluginData }) { if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { @@ -155,6 +169,19 @@ export const CleanCmd = utilityCmd({ responseText += ` in <#${targetChannel.id}>\n${cleanResult.archiveUrl}`; } + if (args.update) { + const modActions = pluginData.getPlugin(ModActionsPlugin); + const channelId = targetChannel.id !== msg.channel.id ? targetChannel.id : msg.channel.id; + const updateMessage = `Cleaned ${messagesToClean.length} ${ + messagesToClean.length === 1 ? "message" : "messages" + } in <#${channelId}>: ${cleanResult.archiveUrl}`; + if (typeof args.update === "number") { + modActions.updateCase(msg, args.update, updateMessage); + } else { + modActions.updateCase(msg, null, updateMessage); + } + } + responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText); } else { responseMsg = await sendErrorMessage(pluginData, msg.channel, `Found no messages to clean!`); diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts index c369e7ba..ec7a6604 100644 --- a/backend/src/plugins/Utility/search.ts +++ b/backend/src/plugins/Utility/search.ts @@ -1,4 +1,4 @@ -import { Member, Message, User } from "eris"; +import { Constants, Member, Message, User } from "eris"; import moment from "moment-timezone"; import escapeStringRegexp from "escape-string-regexp"; import { isFullMessage, MINUTES, multiSorter, noop, sorter, trimLines } from "../../utils"; @@ -10,10 +10,11 @@ import { banSearchSignature } from "./commands/BanSearchCmd"; import { UtilityPluginType } from "./types"; import { refreshMembersIfNeeded } from "./refreshMembers"; import { getUserInfoEmbed } from "./functions/getUserInfoEmbed"; -import { allowTimeout } from "../../RegExpRunner"; +import { allowTimeout, RegExpRunner } from "../../RegExpRunner"; import { inputPatternToRegExp, InvalidRegexError } from "../../validatorUtils"; import { asyncFilter } from "../../utils/async"; import Timeout = NodeJS.Timeout; +import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions"; const SEARCH_RESULTS_PER_PAGE = 15; const SEARCH_ID_RESULTS_PER_PAGE = 50; @@ -29,6 +30,29 @@ class SearchError extends Error {} type MemberSearchParams = ArgsFromSignatureOrArray; type BanSearchParams = ArgsFromSignatureOrArray; +type RegexRunner = InstanceType["exec"]; +function getOptimizedRegExpRunner(pluginData: GuildPluginData, isSafeRegex: boolean): RegexRunner { + if (isSafeRegex) { + return async (regex: RegExp, str: string) => { + if (!regex.global) { + const singleMatch = regex.exec(str); + return singleMatch ? [singleMatch] : null; + } + + const matches: RegExpExecArray[] = []; + let match: RegExpExecArray | null; + // tslint:disable-next-line:no-conditional-assignment + while ((match = regex.exec(str)) != null) { + matches.push(match); + } + + return matches.length ? matches : null; + }; + } + + return pluginData.state.regexRunner.exec.bind(pluginData.state.regexRunner); +} + export async function displaySearch( pluginData: GuildPluginData, args: MemberSearchParams, @@ -270,59 +294,51 @@ async function performMemberSearch( } if (args.query) { + let isSafeRegex = true; let queryRegex: RegExp; if (args.regex) { const flags = args["case-sensitive"] ? "" : "i"; queryRegex = inputPatternToRegExp(args.query.trimStart()); queryRegex = new RegExp(queryRegex.source, flags); + isSafeRegex = false; } else { queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); } + const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex); + if (args["status-search"]) { matchingMembers = await asyncFilter(matchingMembers, async member => { if (member.game) { - if ( - member.game.name && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.name).catch(allowTimeout)) - ) { + if (member.game.name && (await execRegExp(queryRegex, member.game.name).catch(allowTimeout))) { return true; } - if ( - member.game.state && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.state).catch(allowTimeout)) - ) { + if (member.game.state && (await execRegExp(queryRegex, member.game.state).catch(allowTimeout))) { return true; } - if ( - member.game.details && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.details).catch(allowTimeout)) - ) { + if (member.game.details && (await execRegExp(queryRegex, member.game.details).catch(allowTimeout))) { return true; } if (member.game.assets) { if ( member.game.assets.small_text && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.assets.small_text).catch(allowTimeout)) + (await execRegExp(queryRegex, member.game.assets.small_text).catch(allowTimeout)) ) { return true; } if ( member.game.assets.large_text && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.assets.large_text).catch(allowTimeout)) + (await execRegExp(queryRegex, member.game.assets.large_text).catch(allowTimeout)) ) { return true; } } - if ( - member.game.emoji && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.emoji.name).catch(allowTimeout)) - ) { + if (member.game.emoji && (await execRegExp(queryRegex, member.game.emoji.name).catch(allowTimeout))) { return true; } } @@ -330,12 +346,12 @@ async function performMemberSearch( }); } else { matchingMembers = await asyncFilter(matchingMembers, async member => { - if (member.nick && (await pluginData.state.regexRunner.exec(queryRegex, member.nick).catch(allowTimeout))) { + if (member.nick && (await execRegExp(queryRegex, member.nick).catch(allowTimeout))) { return true; } const fullUsername = `${member.user.username}#${member.user.discriminator}`; - if (await pluginData.state.regexRunner.exec(queryRegex, fullUsername).catch(allowTimeout)) return true; + if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true; return false; }); @@ -380,21 +396,29 @@ async function performBanSearch( page = 1, perPage = SEARCH_RESULTS_PER_PAGE, ): Promise<{ results: User[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { + const member = pluginData.guild.members.get(pluginData.client.user.id); + if (member && !hasDiscordPermissions(member.permissions, Constants.Permissions.banMembers)) { + throw new SearchError(`Unable to search bans: missing "Ban Members" permission`); + } + let matchingBans = (await pluginData.guild.getBans()).map(x => x.user); if (args.query) { + let isSafeRegex = true; let queryRegex: RegExp; if (args.regex) { const flags = args["case-sensitive"] ? "" : "i"; queryRegex = inputPatternToRegExp(args.query.trimStart()); queryRegex = new RegExp(queryRegex.source, flags); + isSafeRegex = false; } else { queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); } + const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex); matchingBans = await asyncFilter(matchingBans, async user => { const fullUsername = `${user.username}#${user.discriminator}`; - if (await pluginData.state.regexRunner.exec(queryRegex, fullUsername).catch(allowTimeout)) return true; + if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true; return false; }); } diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 98ec406f..059ddac9 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -20,7 +20,7 @@ import { TextChannel, User, } from "eris"; -import url from "url"; +import { URL } from "url"; import tlds from "tlds"; import emojiRegex from "emoji-regex"; import * as t from "io-ts"; @@ -481,7 +481,7 @@ const plainLinkRegex = /((?!https?:\/\/)\S)+\.\S+/; // anything.anything, withou const urlRegex = new RegExp(`(${realLinkRegex.source}|${plainLinkRegex.source})`, "g"); const protocolRegex = /^[a-z]+:\/\//; -interface MatchedURL extends url.URL { +interface MatchedURL extends URL { input: string; } @@ -496,7 +496,7 @@ export function getUrlsInString(str: string, onlyUnique = false): MatchedURL[] { let matchUrl: MatchedURL; try { - matchUrl = new url.URL(withProtocol) as MatchedURL; + matchUrl = new URL(withProtocol) as MatchedURL; matchUrl.input = match; } catch (e) { return urls; @@ -520,9 +520,61 @@ export function parseInviteCodeInput(str: string): string { return getInviteCodesInString(str)[0]; } +export function isNotNull(value): value is Exclude { + return value != null; +} + +// discord.com/invite/ +// discordapp.com/invite/ +// discord.gg/invite/ +// discord.gg/ +const quickInviteDetection = /(?:discord.com|discordapp.com)\/invite\/([^\s\/#?]+)|discord.gg\/(?:\S+\/)?([^\s\/#?]+)/gi; + +const isInviteHostRegex = /(?:^|\.)(?:discord.gg|discord.com|discordapp.com)$/; +const longInvitePathRegex = /^\/invite\/([^\s\/]+)$/; + export function getInviteCodesInString(str: string): string[] { - const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite|discord.com\/invite)\/([a-z0-9\-]+)/gi; - return Array.from(str.matchAll(inviteCodeRegex)).map(m => m[1]); + const inviteCodes: string[] = []; + + // Clean up markdown + str = str.replace(/[|*_~]/g, ""); + + // Quick detection + const quickDetectionMatch = str.matchAll(quickInviteDetection); + if (quickDetectionMatch) { + inviteCodes.push(...[...quickDetectionMatch].map(m => m[1] || m[2])); + } + + // Deep detection via URL parsing + const linksInString = getUrlsInString(str, true); + const potentialInviteLinks = linksInString.filter(url => isInviteHostRegex.test(url.hostname)); + const withNormalizedPaths = potentialInviteLinks.map(url => { + url.pathname = url.pathname.replace(/\/{2,}/g, "/").replace(/\/+$/g, ""); + return url; + }); + + const codesFromInviteLinks = withNormalizedPaths + .map(url => { + // discord.gg/[anything/] + if (url.hostname === "discord.gg") { + const parts = url.pathname.split("/").filter(Boolean); + return parts[parts.length - 1]; + } + + // discord.com/invite/[/anything] + // discordapp.com/invite/[/anything] + const longInviteMatch = url.pathname.match(longInvitePathRegex); + if (longInviteMatch) { + return longInviteMatch[1]; + } + + return null; + }) + .filter(Boolean) as string[]; + + inviteCodes.push(...codesFromInviteLinks); + + return unique(inviteCodes); } export const unicodeEmojiRegex = emojiRegex();