3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-20 16:25:03 +00:00

Merge branch 'master' into fix_automodBanDuration

This commit is contained in:
Dark 2021-04-28 21:26:33 +02:00
commit b3f5011f5d
No known key found for this signature in database
GPG key ID: 384C4B4F5B1E25A8
31 changed files with 346 additions and 127 deletions

View file

@ -235,14 +235,20 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()("automod",
pluginData.state.modActionsListeners.set("note", (userId: string) => pluginData.state.modActionsListeners.set("note", (userId: string) =>
runAutomodOnModAction(pluginData, "note", userId), runAutomodOnModAction(pluginData, "note", userId),
); );
pluginData.state.modActionsListeners.set("warn", (userId: string) => pluginData.state.modActionsListeners.set(
runAutomodOnModAction(pluginData, "warn", userId), "warn",
(userId: string, reason: string | undefined, isAutomodAction: boolean) =>
runAutomodOnModAction(pluginData, "warn", userId, reason, isAutomodAction),
); );
pluginData.state.modActionsListeners.set("kick", (userId: string) => pluginData.state.modActionsListeners.set(
runAutomodOnModAction(pluginData, "kick", userId), "kick",
(userId: string, reason: string | undefined, isAutomodAction: boolean) =>
runAutomodOnModAction(pluginData, "kick", userId, reason, isAutomodAction),
); );
pluginData.state.modActionsListeners.set("ban", (userId: string) => pluginData.state.modActionsListeners.set(
runAutomodOnModAction(pluginData, "ban", userId), "ban",
(userId: string, reason: string | undefined, isAutomodAction: boolean) =>
runAutomodOnModAction(pluginData, "ban", userId, reason, isAutomodAction),
); );
pluginData.state.modActionsListeners.set("unban", (userId: string) => pluginData.state.modActionsListeners.set("unban", (userId: string) =>
runAutomodOnModAction(pluginData, "unban", userId), runAutomodOnModAction(pluginData, "unban", userId),
@ -251,7 +257,11 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()("automod",
const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter(); const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter();
pluginData.state.mutesListeners = new Map(); 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) => pluginData.state.mutesListeners.set("unmute", (userId: string) =>
runAutomodOnModAction(pluginData, "unmute", userId), runAutomodOnModAction(pluginData, "unmute", userId),
); );

View file

@ -43,7 +43,12 @@ export const BanAction = automodAction({
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const userId of userIdsToBan) { for (const userId of userIdsToBan) {
await modActions.banUserId(userId, reason, { contactMethods, caseArgs, deleteMessageDays }, duration); await modActions.banUserId(userId, reason, {
contactMethods,
caseArgs,
deleteMessageDays,
isAutomodAction: true,
});
} }
}, },
}); });

View file

@ -33,7 +33,7 @@ export const KickAction = automodAction({
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToKick) { for (const member of membersToKick) {
if (!member) continue; if (!member) continue;
await modActions.kickMember(member, reason, { contactMethods, caseArgs }); await modActions.kickMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true });
} }
}, },
}); });

View file

@ -49,7 +49,14 @@ export const MuteAction = automodAction({
const mutes = pluginData.getPlugin(MutesPlugin); const mutes = pluginData.getPlugin(MutesPlugin);
for (const userId of userIdsToMute) { for (const userId of userIdsToMute) {
try { 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) { } catch (e) {
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {

View file

@ -33,7 +33,7 @@ export const WarnAction = automodAction({
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToWarn) { for (const member of membersToWarn) {
if (!member) continue; if (!member) continue;
await modActions.warnMember(member, reason, { contactMethods, caseArgs }); await modActions.warnMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true });
} }
}, },
}); });

View file

@ -9,6 +9,7 @@ export async function runAutomodOnModAction(
modAction: ModActionType, modAction: ModActionType,
userId: string, userId: string,
reason?: string, reason?: string,
isAutomodAction: boolean = false,
) { ) {
const user = await resolveUser(pluginData.client, userId); const user = await resolveUser(pluginData.client, userId);
@ -18,6 +19,7 @@ export async function runAutomodOnModAction(
modAction: { modAction: {
type: modAction, type: modAction,
reason, reason,
isAutomodAction,
}, },
}; };

View file

@ -64,9 +64,9 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
reason: 'Auto-muted for spam' reason: 'Auto-muted for spam'
my_second_filter: my_second_filter:
triggers: triggers:
- message_spam: - emoji_spam:
amount: 5 amount: 2
within: 10s within: 5s
actions: actions:
clean: true clean: true
overrides: overrides:

View file

@ -5,13 +5,25 @@ import { automodTrigger } from "../helpers";
interface BanTriggerResultType {} interface BanTriggerResultType {}
export const BanTrigger = automodTrigger<BanTriggerResultType>()({ export const BanTrigger = automodTrigger<BanTriggerResultType>()({
configType: t.type({}), configType: t.type({
defaultConfig: {}, manual: t.boolean,
automatic: t.boolean,
}),
async match({ context }) { defaultConfig: {
manual: true,
automatic: true,
},
async match({ context, triggerConfig }) {
if (context.modAction?.type !== "ban") { if (context.modAction?.type !== "ban") {
return; 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 { return {
extra: {}, extra: {},

View file

@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers";
interface KickTriggerResultType {} interface KickTriggerResultType {}
export const KickTrigger = automodTrigger<KickTriggerResultType>()({ export const KickTrigger = automodTrigger<KickTriggerResultType>()({
configType: t.type({}), configType: t.type({
defaultConfig: {}, manual: t.boolean,
automatic: t.boolean,
}),
async match({ context }) { defaultConfig: {
manual: true,
automatic: true,
},
async match({ context, triggerConfig }) {
if (context.modAction?.type !== "kick") { if (context.modAction?.type !== "kick") {
return; 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 { return {
extra: {}, extra: {},

View file

@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers";
interface MuteTriggerResultType {} interface MuteTriggerResultType {}
export const MuteTrigger = automodTrigger<MuteTriggerResultType>()({ export const MuteTrigger = automodTrigger<MuteTriggerResultType>()({
configType: t.type({}), configType: t.type({
defaultConfig: {}, manual: t.boolean,
automatic: t.boolean,
}),
async match({ context }) { defaultConfig: {
manual: true,
automatic: true,
},
async match({ context, triggerConfig }) {
if (context.modAction?.type !== "mute") { if (context.modAction?.type !== "mute") {
return; 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 { return {
extra: {}, extra: {},

View file

@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers";
interface WarnTriggerResultType {} interface WarnTriggerResultType {}
export const WarnTrigger = automodTrigger<WarnTriggerResultType>()({ export const WarnTrigger = automodTrigger<WarnTriggerResultType>()({
configType: t.type({}), configType: t.type({
defaultConfig: {}, manual: t.boolean,
automatic: t.boolean,
}),
async match({ context }) { defaultConfig: {
manual: true,
automatic: true,
},
async match({ context, triggerConfig }) {
if (context.modAction?.type !== "warn") { if (context.modAction?.type !== "warn") {
return; 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 { return {
extra: {}, extra: {},

View file

@ -122,6 +122,7 @@ export interface AutomodContext {
modAction?: { modAction?: {
type: ModActionType; type: ModActionType;
reason?: string; reason?: string;
isAutomodAction: boolean;
}; };
antiraid?: { antiraid?: {
level: string | null; level: string | null;

View file

@ -67,7 +67,7 @@ export const SetCounterCmd = guildCommand<CountersPluginType>()({
let channel = args.channel; let channel = args.channel;
if (!channel && counter.per_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); const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) { if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling"); sendErrorMessage(pluginData, message.channel, "Cancelling");
@ -85,7 +85,7 @@ export const SetCounterCmd = guildCommand<CountersPluginType>()({
let user = args.user; let user = args.user;
if (!user && counter.per_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); const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) { if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling"); sendErrorMessage(pluginData, message.channel, "Cancelling");
@ -103,7 +103,7 @@ export const SetCounterCmd = guildCommand<CountersPluginType>()({
let value = args.value; let value = args.value;
if (!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); const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) { if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling"); sendErrorMessage(pluginData, message.channel, "Cancelling");
@ -111,7 +111,7 @@ export const SetCounterCmd = guildCommand<CountersPluginType>()({
} }
const potentialValue = parseInt(reply.content, 10); const potentialValue = parseInt(reply.content, 10);
if (!potentialValue) { if (Number.isNaN(potentialValue)) {
sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); sendErrorMessage(pluginData, message.channel, "Not a number, cancelling");
return; return;
} }

View file

@ -30,7 +30,7 @@ import { GuildCases } from "../../data/GuildCases";
import { GuildLogs } from "../../data/GuildLogs"; import { GuildLogs } from "../../data/GuildLogs";
import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd"; import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd";
import { warnMember } from "./functions/warnMember"; import { warnMember } from "./functions/warnMember";
import { Member } from "eris"; import { Member, Message } from "eris";
import { kickMember } from "./functions/kickMember"; import { kickMember } from "./functions/kickMember";
import { banUserId } from "./functions/banUserId"; import { banUserId } from "./functions/banUserId";
import { MassmuteCmd } from "./commands/MassmuteCmd"; import { MassmuteCmd } from "./commands/MassmuteCmd";
@ -43,6 +43,7 @@ import { EventEmitter } from "events";
import { mapToPublicFn } from "../../pluginUtils"; import { mapToPublicFn } from "../../pluginUtils";
import { onModActionsEvent } from "./functions/onModActionsEvent"; import { onModActionsEvent } from "./functions/onModActionsEvent";
import { offModActionsEvent } from "./functions/offModActionsEvent"; import { offModActionsEvent } from "./functions/offModActionsEvent";
import { updateCase } from "./functions/updateCase";
const defaultOptions = { const defaultOptions = {
config: { config: {
@ -170,6 +171,12 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()("mod
}; };
}, },
updateCase(pluginData) {
return (msg: Message, caseNumber: number | null, note: string) => {
updateCase(pluginData, msg, { caseNumber, note });
};
},
on: mapToPublicFn(onModActionsEvent), on: mapToPublicFn(onModActionsEvent),
off: mapToPublicFn(offModActionsEvent), off: mapToPublicFn(offModActionsEvent),
getEventEmitter(pluginData) { getEventEmitter(pluginData) {

View file

@ -66,8 +66,8 @@ export const BanCmd = modActionsCmd({
const lock = await pluginData.locks.acquire(banLock(user)); const lock = await pluginData.locks.acquire(banLock(user));
let forceban = false; let forceban = false;
const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id);
const banned = await isBanned(pluginData, user.id);
if (!memberToBan) { if (!memberToBan) {
const banned = await isBanned(pluginData, user.id);
if (banned) { if (banned) {
// Abort if trying to ban user indefinitely if they are already banned indefinitely // Abort if trying to ban user indefinitely if they are already banned indefinitely
if (!existingTempban && !time) { if (!existingTempban && !time) {

View file

@ -17,7 +17,7 @@ const opts = {
const casesPerPage = 5; const casesPerPage = 5;
export const CasesModCmd = modActionsCmd({ export const CasesModCmd = modActionsCmd({
trigger: ["cases", "modlogs"], trigger: ["cases", "modlogs", "infractions"],
permission: "can_view", permission: "can_view",
description: "Show the most recent 5 cases by the specified -mod", description: "Show the most recent 5 cases by the specified -mod",

View file

@ -1,11 +1,6 @@
import { modActionsCmd } from "../types"; import { modActionsCmd } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes"; import { commandTypeHelpers as ct } from "../../../commandTypes";
import { Case } from "../../../data/entities/Case"; import { updateCase } from "../functions/updateCase";
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 = modActionsCmd({ export const UpdateCmd = modActionsCmd({
trigger: ["update", "reason"], trigger: ["update", "reason"],
@ -24,39 +19,6 @@ export const UpdateCmd = modActionsCmd({
], ],
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
let theCase: Case | undefined; await updateCase(pluginData, msg, args);
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

@ -112,7 +112,5 @@ export const WarnCmd = modActionsCmd({
msg.channel, msg.channel,
`Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`, `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`,
); );
pluginData.state.events.emit("warn", user.id, reason);
}, },
}); });

View file

@ -137,7 +137,7 @@ export async function banUserId(
banTime: banTime ? humanizeDuration(banTime) : null, banTime: banTime ? humanizeDuration(banTime) : null,
}); });
pluginData.state.events.emit("ban", user.id, reason); pluginData.state.events.emit("ban", user.id, reason, banOptions.isAutomodAction);
return { return {
status: "success", status: "success",

View file

@ -1,16 +1,44 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { ModActionsPluginType } from "../types"; 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<ModActionsPluginType>,
userId: string,
timeout: number = 5 * SECONDS,
): Promise<boolean> {
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<ModActionsPluginType>, userId: string): Promise<boolean> {
try { try {
const bans = await pluginData.guild.getBans(); const potentialBan = await Promise.race([pluginData.guild.getBan(userId), sleep(timeout)]);
return bans.some(b => b.user.id === userId); return potentialBan != null;
} catch (e) { } catch (e) {
if (isDiscordHTTPError(e) && e.code === 500) { if (isDiscordRESTError(e) && e.code === 10026) {
// [10026]: Unknown Ban
return false; 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; throw e;
} }
} }

View file

@ -85,7 +85,7 @@ export async function kickMember(
reason, reason,
}); });
pluginData.state.events.emit("kick", member.id, reason); pluginData.state.events.emit("kick", member.id, reason, kickOptions.isAutomodAction);
return { return {
status: "success", status: "success",

View file

@ -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`);
}

View file

@ -82,6 +82,8 @@ export async function warnMember(
reason, reason,
}); });
pluginData.state.events.emit("warn", member.id, reason, warnOptions.isAutomodAction);
return { return {
status: "success", status: "success",
case: createdCase, case: createdCase,

View file

@ -48,9 +48,9 @@ export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface ModActionsEvents { export interface ModActionsEvents {
note: (userId: string, reason?: string) => void; note: (userId: string, reason?: string) => void;
warn: (userId: string, reason?: string) => void; warn: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
kick: (userId: string, reason?: string) => void; kick: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
ban: (userId: string, reason?: string) => void; ban: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
unban: (userId: string, reason?: string) => void; unban: (userId: string, reason?: string) => void;
// mute/unmute are in the Mutes plugin // mute/unmute are in the Mutes plugin
} }
@ -126,11 +126,13 @@ export interface WarnOptions {
caseArgs?: Partial<CaseArgs> | null; caseArgs?: Partial<CaseArgs> | null;
contactMethods?: UserNotificationMethod[] | null; contactMethods?: UserNotificationMethod[] | null;
retryPromptChannel?: TextChannel | null; retryPromptChannel?: TextChannel | null;
isAutomodAction?: boolean;
} }
export interface KickOptions { export interface KickOptions {
caseArgs?: Partial<CaseArgs>; caseArgs?: Partial<CaseArgs>;
contactMethods?: UserNotificationMethod[]; contactMethods?: UserNotificationMethod[];
isAutomodAction?: boolean;
} }
export interface BanOptions { export interface BanOptions {
@ -138,6 +140,7 @@ export interface BanOptions {
contactMethods?: UserNotificationMethod[]; contactMethods?: UserNotificationMethod[];
deleteMessageDays?: number; deleteMessageDays?: number;
modId?: string; modId?: string;
isAutomodAction?: boolean;
} }
export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban"; export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban";

View file

@ -247,7 +247,7 @@ export async function muteUser(
lock.unlock(); lock.unlock();
pluginData.state.events.emit("mute", user.id, reason); pluginData.state.events.emit("mute", user.id, reason, muteOptions.isAutomodAction);
return { return {
case: theCase, case: theCase,

View file

@ -34,7 +34,7 @@ export const ConfigSchema = t.type({
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>; export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface MutesEvents { export interface MutesEvents {
mute: (userId: string, reason?: string) => void; mute: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
unmute: (userId: string, reason?: string) => void; unmute: (userId: string, reason?: string) => void;
} }
@ -75,6 +75,7 @@ export type UnmuteResult = {
export interface MuteOptions { export interface MuteOptions {
caseArgs?: Partial<CaseArgs>; caseArgs?: Partial<CaseArgs>;
contactMethods?: UserNotificationMethod[]; contactMethods?: UserNotificationMethod[];
isAutomodAction?: boolean;
} }
export const mutesCmd = guildCommand<MutesPluginType>(); export const mutesCmd = guildCommand<MutesPluginType>();

View file

@ -7,7 +7,7 @@ import { remindersCmd } from "../types";
import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
export const RemindCmd = remindersCmd({ export const RemindCmd = remindersCmd({
trigger: ["remind", "remindme"], trigger: ["remind", "remindme", "reminder"],
usage: "!remind 3h Remind me of this in 3 hours please", usage: "!remind 3h Remind me of this in 3 hours please",
permission: "can_use", permission: "can_use",

View file

@ -35,6 +35,7 @@ import { SnowflakeInfoCmd } from "./commands/SnowflakeInfoCmd";
import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
import { VcdisconnectCmd } from "./commands/VcdisconnectCmd"; import { VcdisconnectCmd } from "./commands/VcdisconnectCmd";
import { ModActionsPlugin } from "../ModActions/ModActionsPlugin";
import { refreshMembersIfNeeded } from "./refreshMembers"; import { refreshMembersIfNeeded } from "./refreshMembers";
const defaultOptions: PluginOptions<UtilityPluginType> = { const defaultOptions: PluginOptions<UtilityPluginType> = {
@ -106,7 +107,7 @@ export const UtilityPlugin = zeppelinGuildPlugin<UtilityPluginType>()("utility",
prettyName: "Utility", prettyName: "Utility",
}, },
dependencies: [TimeAndDatePlugin], dependencies: [TimeAndDatePlugin, ModActionsPlugin],
configSchema: ConfigSchema, configSchema: ConfigSchema,
defaultOptions, defaultOptions,

View file

@ -8,6 +8,7 @@ import { GuildPluginData } from "knub";
import { SavedMessage } from "../../../data/entities/SavedMessage"; import { SavedMessage } from "../../../data/entities/SavedMessage";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { allowTimeout } from "../../../RegExpRunner"; import { allowTimeout } from "../../../RegExpRunner";
import { ModActionsPlugin } from "../../../plugins/ModActions/ModActionsPlugin";
const MAX_CLEAN_COUNT = 150; const MAX_CLEAN_COUNT = 150;
const MAX_CLEAN_TIME = 1 * DAYS; const MAX_CLEAN_TIME = 1 * DAYS;
@ -49,23 +50,36 @@ async function cleanMessages(
return { archiveUrl }; 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({ export const CleanCmd = utilityCmd({
trigger: ["clean", "clear"], trigger: ["clean", "clear"],
description: "Remove a number of recent messages", description: "Remove a number of recent messages",
usage: "!clean 20", usage: "!clean 20",
permission: "can_clean", permission: "can_clean",
signature: { signature: [
count: ct.number(), {
count: ct.number(),
update: ct.number({ option: true, shortcut: "up" }),
user: ct.userId({ option: true, shortcut: "u" }), ...opts,
channel: ct.channelId({ option: true, shortcut: "c" }), },
bots: ct.switchOption({ shortcut: "b" }), {
"delete-pins": ct.switchOption({ shortcut: "p" }), count: ct.number(),
"has-invites": ct.switchOption({ shortcut: "i" }), update: ct.switchOption({ shortcut: "up" }),
match: ct.regex({ option: true, shortcut: "m" }),
"to-id": ct.anyId({ option: true, shortcut: "id" }), ...opts,
}, },
],
async run({ message: msg, args, pluginData }) { async run({ message: msg, args, pluginData }) {
if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { if (args.count > MAX_CLEAN_COUNT || args.count <= 0) {
@ -155,6 +169,19 @@ export const CleanCmd = utilityCmd({
responseText += ` in <#${targetChannel.id}>\n${cleanResult.archiveUrl}`; 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); responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText);
} else { } else {
responseMsg = await sendErrorMessage(pluginData, msg.channel, `Found no messages to clean!`); responseMsg = await sendErrorMessage(pluginData, msg.channel, `Found no messages to clean!`);

View file

@ -1,4 +1,4 @@
import { Member, Message, User } from "eris"; import { Constants, Member, Message, User } from "eris";
import moment from "moment-timezone"; import moment from "moment-timezone";
import escapeStringRegexp from "escape-string-regexp"; import escapeStringRegexp from "escape-string-regexp";
import { isFullMessage, MINUTES, multiSorter, noop, sorter, trimLines } from "../../utils"; import { isFullMessage, MINUTES, multiSorter, noop, sorter, trimLines } from "../../utils";
@ -10,10 +10,11 @@ import { banSearchSignature } from "./commands/BanSearchCmd";
import { UtilityPluginType } from "./types"; import { UtilityPluginType } from "./types";
import { refreshMembersIfNeeded } from "./refreshMembers"; import { refreshMembersIfNeeded } from "./refreshMembers";
import { getUserInfoEmbed } from "./functions/getUserInfoEmbed"; import { getUserInfoEmbed } from "./functions/getUserInfoEmbed";
import { allowTimeout } from "../../RegExpRunner"; import { allowTimeout, RegExpRunner } from "../../RegExpRunner";
import { inputPatternToRegExp, InvalidRegexError } from "../../validatorUtils"; import { inputPatternToRegExp, InvalidRegexError } from "../../validatorUtils";
import { asyncFilter } from "../../utils/async"; import { asyncFilter } from "../../utils/async";
import Timeout = NodeJS.Timeout; import Timeout = NodeJS.Timeout;
import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions";
const SEARCH_RESULTS_PER_PAGE = 15; const SEARCH_RESULTS_PER_PAGE = 15;
const SEARCH_ID_RESULTS_PER_PAGE = 50; const SEARCH_ID_RESULTS_PER_PAGE = 50;
@ -29,6 +30,29 @@ class SearchError extends Error {}
type MemberSearchParams = ArgsFromSignatureOrArray<typeof searchCmdSignature>; type MemberSearchParams = ArgsFromSignatureOrArray<typeof searchCmdSignature>;
type BanSearchParams = ArgsFromSignatureOrArray<typeof banSearchSignature>; type BanSearchParams = ArgsFromSignatureOrArray<typeof banSearchSignature>;
type RegexRunner = InstanceType<typeof RegExpRunner>["exec"];
function getOptimizedRegExpRunner(pluginData: GuildPluginData<UtilityPluginType>, 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( export async function displaySearch(
pluginData: GuildPluginData<UtilityPluginType>, pluginData: GuildPluginData<UtilityPluginType>,
args: MemberSearchParams, args: MemberSearchParams,
@ -270,59 +294,51 @@ async function performMemberSearch(
} }
if (args.query) { if (args.query) {
let isSafeRegex = true;
let queryRegex: RegExp; let queryRegex: RegExp;
if (args.regex) { if (args.regex) {
const flags = args["case-sensitive"] ? "" : "i"; const flags = args["case-sensitive"] ? "" : "i";
queryRegex = inputPatternToRegExp(args.query.trimStart()); queryRegex = inputPatternToRegExp(args.query.trimStart());
queryRegex = new RegExp(queryRegex.source, flags); queryRegex = new RegExp(queryRegex.source, flags);
isSafeRegex = false;
} else { } else {
queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i");
} }
const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex);
if (args["status-search"]) { if (args["status-search"]) {
matchingMembers = await asyncFilter(matchingMembers, async member => { matchingMembers = await asyncFilter(matchingMembers, async member => {
if (member.game) { if (member.game) {
if ( if (member.game.name && (await execRegExp(queryRegex, member.game.name).catch(allowTimeout))) {
member.game.name &&
(await pluginData.state.regexRunner.exec(queryRegex, member.game.name).catch(allowTimeout))
) {
return true; return true;
} }
if ( if (member.game.state && (await execRegExp(queryRegex, member.game.state).catch(allowTimeout))) {
member.game.state &&
(await pluginData.state.regexRunner.exec(queryRegex, member.game.state).catch(allowTimeout))
) {
return true; return true;
} }
if ( if (member.game.details && (await execRegExp(queryRegex, member.game.details).catch(allowTimeout))) {
member.game.details &&
(await pluginData.state.regexRunner.exec(queryRegex, member.game.details).catch(allowTimeout))
) {
return true; return true;
} }
if (member.game.assets) { if (member.game.assets) {
if ( if (
member.game.assets.small_text && 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; return true;
} }
if ( if (
member.game.assets.large_text && 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; return true;
} }
} }
if ( if (member.game.emoji && (await execRegExp(queryRegex, member.game.emoji.name).catch(allowTimeout))) {
member.game.emoji &&
(await pluginData.state.regexRunner.exec(queryRegex, member.game.emoji.name).catch(allowTimeout))
) {
return true; return true;
} }
} }
@ -330,12 +346,12 @@ async function performMemberSearch(
}); });
} else { } else {
matchingMembers = await asyncFilter(matchingMembers, async member => { 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; return true;
} }
const fullUsername = `${member.user.username}#${member.user.discriminator}`; 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; return false;
}); });
@ -380,21 +396,29 @@ async function performBanSearch(
page = 1, page = 1,
perPage = SEARCH_RESULTS_PER_PAGE, perPage = SEARCH_RESULTS_PER_PAGE,
): Promise<{ results: User[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { ): 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); let matchingBans = (await pluginData.guild.getBans()).map(x => x.user);
if (args.query) { if (args.query) {
let isSafeRegex = true;
let queryRegex: RegExp; let queryRegex: RegExp;
if (args.regex) { if (args.regex) {
const flags = args["case-sensitive"] ? "" : "i"; const flags = args["case-sensitive"] ? "" : "i";
queryRegex = inputPatternToRegExp(args.query.trimStart()); queryRegex = inputPatternToRegExp(args.query.trimStart());
queryRegex = new RegExp(queryRegex.source, flags); queryRegex = new RegExp(queryRegex.source, flags);
isSafeRegex = false;
} else { } else {
queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i");
} }
const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex);
matchingBans = await asyncFilter(matchingBans, async user => { matchingBans = await asyncFilter(matchingBans, async user => {
const fullUsername = `${user.username}#${user.discriminator}`; 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; return false;
}); });
} }

View file

@ -20,7 +20,7 @@ import {
TextChannel, TextChannel,
User, User,
} from "eris"; } from "eris";
import url from "url"; import { URL } from "url";
import tlds from "tlds"; import tlds from "tlds";
import emojiRegex from "emoji-regex"; import emojiRegex from "emoji-regex";
import * as t from "io-ts"; 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 urlRegex = new RegExp(`(${realLinkRegex.source}|${plainLinkRegex.source})`, "g");
const protocolRegex = /^[a-z]+:\/\//; const protocolRegex = /^[a-z]+:\/\//;
interface MatchedURL extends url.URL { interface MatchedURL extends URL {
input: string; input: string;
} }
@ -496,7 +496,7 @@ export function getUrlsInString(str: string, onlyUnique = false): MatchedURL[] {
let matchUrl: MatchedURL; let matchUrl: MatchedURL;
try { try {
matchUrl = new url.URL(withProtocol) as MatchedURL; matchUrl = new URL(withProtocol) as MatchedURL;
matchUrl.input = match; matchUrl.input = match;
} catch (e) { } catch (e) {
return urls; return urls;
@ -520,9 +520,61 @@ export function parseInviteCodeInput(str: string): string {
return getInviteCodesInString(str)[0]; return getInviteCodesInString(str)[0];
} }
export function isNotNull(value): value is Exclude<typeof value, null> {
return value != null;
}
// discord.com/invite/<code>
// discordapp.com/invite/<code>
// discord.gg/invite/<code>
// discord.gg/<code>
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[] { export function getInviteCodesInString(str: string): string[] {
const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite|discord.com\/invite)\/([a-z0-9\-]+)/gi; const inviteCodes: string[] = [];
return Array.from(str.matchAll(inviteCodeRegex)).map(m => m[1]);
// 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/]<code>
if (url.hostname === "discord.gg") {
const parts = url.pathname.split("/").filter(Boolean);
return parts[parts.length - 1];
}
// discord.com/invite/<code>[/anything]
// discordapp.com/invite/<code>[/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(); export const unicodeEmojiRegex = emojiRegex();