diff --git a/backend/src/data/DefaultLogMessages.json b/backend/src/data/DefaultLogMessages.json index e36e5e53..5633c8e3 100644 --- a/backend/src/data/DefaultLogMessages.json +++ b/backend/src/data/DefaultLogMessages.json @@ -10,7 +10,7 @@ "MEMBER_UNBAN": "🔓 User (`{userId}`) was unbanned by {userMention(mod)}", "MEMBER_FORCEBAN": "🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", "MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}", - "MEMBER_JOIN": "📥 {userMention(member)} joined{new} (created {account_age} ago)", + "MEMBER_JOIN": "📥 {new} {userMention(member)} joined (created {account_age} ago)", "MEMBER_LEAVE": "📤 {userMention(member)} left the server", "MEMBER_ROLE_ADD": "🔑 {userMention(member)}: role(s) **{roles}** added by {userMention(mod)}", "MEMBER_ROLE_REMOVE": "🔑 {userMention(member)}: role(s) **{roles}** removed by {userMention(mod)}", diff --git a/backend/src/data/GuildVCAlerts.ts b/backend/src/data/GuildVCAlerts.ts index 9da16dec..6acc35e9 100644 --- a/backend/src/data/GuildVCAlerts.ts +++ b/backend/src/data/GuildVCAlerts.ts @@ -50,7 +50,7 @@ export class GuildVCAlerts extends BaseGuildRepository { }); } - async add(requestorId: string, userId: string, channelId: string, expiresAt: string, body: string) { + async add(requestorId: string, userId: string, channelId: string, expiresAt: string, body: string, active: boolean) { await this.allAlerts.insert({ guild_id: this.guildId, requestor_id: requestorId, @@ -58,6 +58,7 @@ export class GuildVCAlerts extends BaseGuildRepository { channel_id: channelId, expires_at: expiresAt, body, + active, }); } } diff --git a/backend/src/data/entities/VCAlert.ts b/backend/src/data/entities/VCAlert.ts index 4bdf965e..e627afa6 100644 --- a/backend/src/data/entities/VCAlert.ts +++ b/backend/src/data/entities/VCAlert.ts @@ -17,4 +17,6 @@ export class VCAlert { @Column() expires_at: string; @Column() body: string; + + @Column() active: boolean; } diff --git a/backend/src/migrations/1580654617890-AddActiveFollowsToLocateUser.ts b/backend/src/migrations/1580654617890-AddActiveFollowsToLocateUser.ts new file mode 100644 index 00000000..430b10de --- /dev/null +++ b/backend/src/migrations/1580654617890-AddActiveFollowsToLocateUser.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; + +export class AddActiveFollowsToLocateUser1580654617890 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + "vc_alerts", + new TableColumn({ + name: "active", + type: "boolean", + isNullable: false, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("vc_alerts", "active"); + } +} diff --git a/backend/src/plugins/Automod/Automod.ts b/backend/src/plugins/Automod/Automod.ts index 026873e0..3ad6431d 100644 --- a/backend/src/plugins/Automod/Automod.ts +++ b/backend/src/plugins/Automod/Automod.ts @@ -60,10 +60,12 @@ import { TMatchWordsTrigger, TMemberJoinTrigger, TRule, + TMatchAttachmentTypeTrigger, } from "./types"; import { pluginInfo } from "./info"; import { ERRORS, RecoverablePluginError } from "../../RecoverablePluginError"; import Timeout = NodeJS.Timeout; +import { StrictValidationError } from "src/validatorUtils"; const unactioned = (action: TextRecentAction | OtherRecentAction) => !action.actioned; @@ -116,6 +118,19 @@ const defaultMatchLinksTrigger: Partial = { match_custom_status: false, }; +const defaultMatchAttachmentTypeTrigger: Partial = { + filetype_blacklist: [], + blacklist_enabled: false, + filetype_whitelist: [], + whitelist_enabled: false, + match_messages: true, + match_embeds: true, + match_visible_names: false, + match_usernames: false, + match_nicknames: false, + match_custom_status: false, +}; + const defaultTextSpamTrigger: Partial> = { per_channel: true, }; @@ -130,6 +145,7 @@ const defaultTriggers = { match_regex: defaultMatchRegexTrigger, match_invites: defaultMatchInvitesTrigger, match_links: defaultMatchLinksTrigger, + match_attachment_type: defaultMatchAttachmentTypeTrigger, message_spam: defaultTextSpamTrigger, mention_spam: defaultTextSpamTrigger, link_spam: defaultTextSpamTrigger, @@ -249,6 +265,21 @@ export class AutomodPlugin extends ZeppelinPlugin`, + ]); + } else if (!white && !black) { + throw new StrictValidationError([ + `Must have either blacklist or whitelist enabled at rule <${rule.name}/match_attachment_type>`, + ]); + } + } } } @@ -457,6 +488,23 @@ export class AutomodPlugin extends ZeppelinPlugin { + return this.evaluateMatchAttachmentTypeTrigger(trigger.match_attachment_type, msg); + }); + if (match) return { ...match, trigger: "match_attachment_type" } as TextTriggerMatchResult; + } + if (trigger.message_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg); if (match) return { ...match, rule, trigger: "message_spam" }; @@ -1319,6 +1374,8 @@ export class AutomodPlugin extends ZeppelinPlugin; +export const MatchAttachmentTypeTrigger = t.type({ + filetype_blacklist: t.array(t.string), + blacklist_enabled: t.boolean, + filetype_whitelist: t.array(t.string), + whitelist_enabled: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_visible_names: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, + match_custom_status: t.boolean, +}); +export type TMatchAttachmentTypeTrigger = t.TypeOf; + export const BaseSpamTrigger = t.type({ amount: t.number, within: t.string, @@ -280,6 +294,7 @@ export const Rule = t.type({ match_regex: tNullable(MatchRegexTrigger), match_invites: tNullable(MatchInvitesTrigger), match_links: tNullable(MatchLinksTrigger), + match_attachment_type: tNullable(MatchAttachmentTypeTrigger), message_spam: tNullable(MessageSpamTrigger), mention_spam: tNullable(MentionSpamTrigger), link_spam: tNullable(LinkSpamTrigger), diff --git a/backend/src/plugins/Cases.ts b/backend/src/plugins/Cases.ts index b8fa1448..44c17402 100644 --- a/backend/src/plugins/Cases.ts +++ b/backend/src/plugins/Cases.ts @@ -245,6 +245,21 @@ export class CasesPlugin extends ZeppelinPlugin { return { embed }; } + public async getCaseTypeAmountForUserId(userID: string, type: CaseTypes): Promise { + const cases = (await this.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; + } + /** * A helper for posting to the case log channel. * Returns silently if the case log channel isn't specified or is invalid. diff --git a/backend/src/plugins/LocateUser.ts b/backend/src/plugins/LocateUser.ts index c79ff368..156fd494 100644 --- a/backend/src/plugins/LocateUser.ts +++ b/backend/src/plugins/LocateUser.ts @@ -1,10 +1,10 @@ import { decorators as d, IPluginOptions, getInviteLink, logger } from "knub"; -import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; +import { trimPluginDescription, ZeppelinPlugin, CommandInfo } from "./ZeppelinPlugin"; import humanizeDuration from "humanize-duration"; import { Message, Member, Guild, TextableChannel, VoiceChannel, Channel, User } from "eris"; import { GuildVCAlerts } from "../data/GuildVCAlerts"; import moment from "moment-timezone"; -import { resolveMember, sorter, createChunkedMessage, errorMessage, successMessage, MINUTES } from "../utils"; +import { resolveMember, sorter, createChunkedMessage, MINUTES, SECONDS } from "../utils"; import * as t from "io-ts"; const ConfigSchema = t.type({ @@ -13,7 +13,7 @@ const ConfigSchema = t.type({ }); type TConfigSchema = t.TypeOf; -const ALERT_LOOP_TIME = 30 * 1000; +const ALERT_LOOP_TIME = 30 * SECONDS; export class LocatePlugin extends ZeppelinPlugin { public static pluginName = "locate_user"; @@ -29,8 +29,9 @@ export class LocatePlugin extends ZeppelinPlugin { }; private alerts: GuildVCAlerts; - private outdatedAlertsTimeout; + private outdatedAlertsTimeout: NodeJS.Timeout; private usersWithAlerts: string[] = []; + private unloaded = false; public static getStaticDefaultOptions(): IPluginOptions { return { @@ -56,6 +57,11 @@ export class LocatePlugin extends ZeppelinPlugin { this.fillActiveAlertsList(); } + onUnload() { + clearTimeout(this.outdatedAlertsTimeout); + this.unloaded = true; + } + async outdatedAlertsLoop() { const outdatedAlerts = await this.alerts.getOutdatedAlerts(); @@ -64,7 +70,9 @@ export class LocatePlugin extends ZeppelinPlugin { await this.removeUserIdFromActiveAlerts(alert.user_id); } - this.outdatedAlertsTimeout = setTimeout(() => this.outdatedAlertsLoop(), ALERT_LOOP_TIME); + if (!this.unloaded) { + this.outdatedAlertsTimeout = setTimeout(() => this.outdatedAlertsLoop(), ALERT_LOOP_TIME); + } } async fillActiveAlertsList() { @@ -80,45 +88,113 @@ export class LocatePlugin extends ZeppelinPlugin { @d.command("where", "", { aliases: ["w"], extra: { - info: { + info: { description: "Posts an instant invite to the voice channel that `` is in", + basicUsage: "!w 108552944961454080", + parameterDescriptions: { + member: "The member that we want to find", + }, }, }, }) @d.permission("can_where") async whereCmd(msg: Message, args: { member: Member }) { const member = await resolveMember(this.bot, this.guild, args.member.id); - sendWhere(this.guild, member, msg.channel, `${msg.member.mention} |`); + sendWhere(this.guild, member, msg.channel, `${msg.member.mention} | `); } - @d.command("vcalert", " ", { - overloads: [" ", ""], - aliases: ["vca"], + @d.command("follow", " [reminder:string$]", { + aliases: ["f", "vcalert", "vca"], + options: [ + { + name: "duration", + shortcut: "d", + type: "delay", + }, + { + name: "active", + shortcut: "a", + isSwitch: true, + }, + ], extra: { - info: { + info: { description: "Sets up an alert that notifies you any time `` switches or joins voice channels", + basicUsage: "!f 108552944961454080", + examples: trimPluginDescription(` + To get an alert for 1 hour: + \`!f 108552944961454080 -d 1h\` + + To get an alert for 2 hours and 30 minutes with the reminder "Earrape": + \`!f 108552944961454080 -d 2h30m Earrape\` + *Note: The duration must be specified before the reminder, otherwise it will be part of it* + + To get an alert for 3 days and be moved to the channel: + \`!f 108552944961454080 -d 3d -a\` + *Note: As with the duration, active must be specified before the rminder, otherwise it will be part of it* + `), + optionDescriptions: { + duration: "How long the alert shall be active. The alert will be automatically deleted after this time", + active: "A switch that, when true, will move you to the channel the user joined", + }, + parameterDescriptions: { + member: "The server member we want to set as the alerts target", + reminder: "Any text that will be displayed every time the alert triggers", + }, }, }, }) @d.permission("can_alert") - async vcalertCmd(msg: Message, args: { member: Member; duration?: number; reminder?: string }) { + async followCmd(msg: Message, args: { member: Member; reminder?: string; duration?: number; active?: boolean }) { const time = args.duration || 10 * MINUTES; const alertTime = moment().add(time, "millisecond"); const body = args.reminder || "None"; + const active = args.active || false; - this.alerts.add(msg.author.id, args.member.id, msg.channel.id, alertTime.format("YYYY-MM-DD HH:mm:ss"), body); + if (time < 30 * SECONDS) { + this.sendErrorMessage(msg.channel, "Sorry, but the minimum duration for an alert is 30 seconds!"); + return; + } + + await this.alerts.add( + msg.author.id, + args.member.id, + msg.channel.id, + alertTime.format("YYYY-MM-DD HH:mm:ss"), + body, + active, + ); if (!this.usersWithAlerts.includes(args.member.id)) { this.usersWithAlerts.push(args.member.id); } - msg.channel.createMessage( - `If ${args.member.mention} joins or switches VC in the next ${humanizeDuration(time)} i will notify you`, - ); + if (active) { + this.sendSuccessMessage( + msg.channel, + `Every time ${args.member.mention} joins or switches VC in the next ${humanizeDuration( + time, + )} i will notify and move you.\nPlease make sure to be in a voice channel, otherwise i cannot move you!`, + ); + } else { + this.sendSuccessMessage( + msg.channel, + `Every time ${args.member.mention} joins or switches VC in the next ${humanizeDuration( + time, + )} i will notify you`, + ); + } } - @d.command("vcalerts") + @d.command("follows", [], { + aliases: ["fs", "vcalerts", "vca"], + extra: { + info: { + description: "Displays all of your active alerts ordered by expiration time", + }, + }, + }) @d.permission("can_alert") - async listVcalertCmd(msg: Message) { + async listFollowCmd(msg: Message) { const alerts = await this.alerts.getAlertsByRequestorId(msg.member.id); if (alerts.length === 0) { this.sendErrorMessage(msg.channel, "You have no active alerts!"); @@ -130,22 +206,29 @@ export class LocatePlugin extends ZeppelinPlugin { const lines = Array.from(alerts.entries()).map(([i, alert]) => { const num = i + 1; const paddedNum = num.toString().padStart(longestNum, " "); - return `\`${paddedNum}.\` \`${alert.expires_at}\` Member: <@!${alert.user_id}> Reminder: \`${alert.body}\``; + return `\`${paddedNum}.\` \`${alert.expires_at}\` **Target:** <@!${alert.user_id}> **Reminder:** \`${ + alert.body + }\` **Active:** ${alert.active.valueOf()}`; }); - createChunkedMessage(msg.channel, lines.join("\n")); + await createChunkedMessage(msg.channel, lines.join("\n")); } - @d.command("vcalerts delete", "", { - aliases: ["vcalerts d"], + @d.command("follows delete", "", { + aliases: ["fs d", "vcalerts delete", "vcalerts d", "vca d"], + extra: { + info: { + description: + "Deletes the alert at the position .\nThe value needed for can be found using `!follows` (`!fs`)", + }, + }, }) @d.permission("can_alert") - async deleteVcalertCmd(msg: Message, args: { num: number }) { + async deleteFollowCmd(msg: Message, args: { num: number }) { const alerts = await this.alerts.getAlertsByRequestorId(msg.member.id); alerts.sort(sorter("expires_at")); - const lastNum = alerts.length + 1; - if (args.num > lastNum || args.num < 0) { - msg.channel.createMessage(errorMessage("Unknown alert")); + if (args.num > alerts.length || args.num <= 0) { + this.sendErrorMessage(msg.channel, "Unknown alert!"); return; } @@ -159,7 +242,6 @@ export class LocatePlugin extends ZeppelinPlugin { async userJoinedVC(member: Member, channel: Channel) { if (this.usersWithAlerts.includes(member.id)) { this.sendAlerts(member.id); - await this.removeUserIdFromActiveAlerts(member.id); } } @@ -167,10 +249,22 @@ export class LocatePlugin extends ZeppelinPlugin { async userSwitchedVC(member: Member, newChannel: Channel, oldChannel: Channel) { if (this.usersWithAlerts.includes(member.id)) { this.sendAlerts(member.id); - await this.removeUserIdFromActiveAlerts(member.id); } } + @d.event("voiceChannelLeave") + async userLeftVC(member: Member, channel: Channel) { + const triggeredAlerts = await this.alerts.getAlertsByUserId(member.id); + const voiceChannel = channel as VoiceChannel; + + triggeredAlerts.forEach(alert => { + const txtChannel = this.bot.getChannel(alert.channel_id) as TextableChannel; + txtChannel.createMessage( + `🔴 <@!${alert.requestor_id}> the user <@!${alert.user_id}> disconnected out of \`${voiceChannel.name}\``, + ); + }); + } + @d.event("guildBanAdd") async onGuildBanAdd(_, user: User) { const alerts = await this.alerts.getAlertsByUserId(user.id); @@ -185,8 +279,11 @@ export class LocatePlugin extends ZeppelinPlugin { triggeredAlerts.forEach(alert => { const prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${alert.body}\`\n`; - sendWhere(this.guild, member, this.bot.getChannel(alert.channel_id) as TextableChannel, prepend); - this.alerts.delete(alert.id); + const txtChannel = this.bot.getChannel(alert.channel_id) as TextableChannel; + sendWhere(this.guild, member, txtChannel, prepend); + if (alert.active) { + this.moveMember(alert.requestor_id, member, txtChannel); + } }); } @@ -196,6 +293,22 @@ export class LocatePlugin extends ZeppelinPlugin { this.usersWithAlerts.splice(index, 1); } } + + async moveMember(toMoveID: string, target: Member, errorChannel: TextableChannel) { + const modMember: Member = await this.bot.getRESTGuildMember(this.guildId, toMoveID); + if (modMember.voiceState.channelID != null) { + try { + await modMember.edit({ + channelID: target.voiceState.channelID, + }); + } catch (e) { + this.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); + return; + } + } else { + this.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); + } + } } export async function sendWhere(guild: Guild, member: Member, channel: TextableChannel, prepend: string) { @@ -212,7 +325,7 @@ export async function sendWhere(guild: Guild, member: Member, channel: TextableC return; } channel.createMessage( - prepend + ` ${member.mention} is in the following channel: ${voice.name} ${getInviteLink(invite)}`, + prepend + ` ${member.mention} is in the following channel: \`${voice.name}\` ${getInviteLink(invite)}`, ); } } diff --git a/backend/src/plugins/ModActions.ts b/backend/src/plugins/ModActions.ts index ef26c937..09a50784 100644 --- a/backend/src/plugins/ModActions.ts +++ b/backend/src/plugins/ModActions.ts @@ -46,6 +46,8 @@ const ConfigSchema = t.type({ ban_message: tNullable(t.string), alert_on_rejoin: t.boolean, alert_channel: tNullable(t.string), + warn_notify_threshold: t.number, + warn_notify_message: t.string, can_note: t.boolean, can_warn: t.boolean, can_mute: t.boolean, @@ -162,6 +164,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { ban_message: "You have been banned from the {guildName} server. Reason given: {reason}", alert_on_rejoin: false, alert_channel: null, + 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?", can_note: false, can_warn: false, @@ -671,6 +676,21 @@ export class ModActionsPlugin extends ZeppelinPlugin { const config = this.getConfig(); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); + const casesPlugin = this.getPlugin("cases"); + const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn); + if (priorWarnAmount >= config.warn_notify_threshold) { + const tooManyWarningsMsg = await msg.channel.createMessage( + config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`), + ); + + const reply = await waitForReaction(this.bot, tooManyWarningsMsg, ["✅", "❌"]); + tooManyWarningsMsg.delete(); + if (!reply || reply.name === "❌") { + msg.channel.createMessage(errorMessage("Warn cancelled by moderator")); + return; + } + } + let contactMethods; try { contactMethods = this.readContactMethodsFromArgs(args); diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts index e998d756..bbcd7d6e 100644 --- a/backend/src/plugins/Utility.ts +++ b/backend/src/plugins/Utility.ts @@ -387,6 +387,13 @@ export class UtilityPlugin extends ZeppelinPlugin { if (member.game) { if (member.game.name && member.game.name.match(queryRegex)) return true; if (member.game.state && member.game.state.match(queryRegex)) return true; + if (member.game.details && member.game.details.match(queryRegex)) return true; + if ( + member.game.assets && + (member.game.assets.small_text.match(queryRegex) || member.game.assets.large_text.match(queryRegex)) + ) + return true; + if (member.game.emoji && member.game.emoji.name.match(queryRegex)) return true; } return false; }); @@ -1539,6 +1546,7 @@ export class UtilityPlugin extends ZeppelinPlugin { async jumboCmd(msg: Message, args: { emoji: string }) { // Get emoji url const config = this.getConfig(); + const size = config.jumbo_size > 2048 ? 2048 : config.jumbo_size; const emojiRegex = new RegExp(`(<.*:).*:(\\d+)`); const results = emojiRegex.exec(args.emoji); let extention = ".png"; @@ -1551,7 +1559,7 @@ export class UtilityPlugin extends ZeppelinPlugin { } url += `${results[2]}${extention}`; if (extention === ".png") { - const image = await this.resizeBuffer(await this.getBufferFromUrl(url), config.jumbo_size, config.jumbo_size); + const image = await this.resizeBuffer(await this.getBufferFromUrl(url), size, size); file = { name: `emoji${extention}`, file: image, @@ -1567,11 +1575,11 @@ export class UtilityPlugin extends ZeppelinPlugin { let url = CDN_URL + `/${twemoji.convert.toCodePoint(args.emoji)}.svg`; let image; try { - image = await this.resizeBuffer(await this.getBufferFromUrl(url), config.jumbo_size, config.jumbo_size); + image = await this.resizeBuffer(await this.getBufferFromUrl(url), size, size); } catch { if (url.toLocaleLowerCase().endsWith("fe0f.svg")) { url = url.slice(0, url.lastIndexOf("-fe0f")) + ".svg"; - image = await this.resizeBuffer(await this.getBufferFromUrl(url), config.jumbo_size, config.jumbo_size); + image = await this.resizeBuffer(await this.getBufferFromUrl(url), size, size); } } file = { diff --git a/dashboard/src/components/docs/Plugin.vue b/dashboard/src/components/docs/Plugin.vue index dccbb5a5..431f9146 100644 --- a/dashboard/src/components/docs/Plugin.vue +++ b/dashboard/src/components/docs/Plugin.vue @@ -38,7 +38,7 @@ v-bind:ref="getCommandSlug(command.trigger)" v-bind:class="{target: targetCommandId === getCommandSlug(command.trigger)}">

!{{ command.trigger }} - / !{{ alias }} + / !{{ alias }}