From 90ee4ad9095e87a0cea3b93e5d2ffccc1570c826 Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Wed, 22 Jul 2020 22:33:10 +0200 Subject: [PATCH 1/8] Migrate ChannelArchiver to new Plugin structure, isOwner -> pluginUtils --- backend/src/pluginUtils.ts | 10 ++ .../ChannelArchiver/ChannelArchiverPlugin.ts | 16 +++ .../commands/ArchiveChannelCmd.ts | 110 ++++++++++++++++++ .../ChannelArchiver/rehostAttachment.ts | 29 +++++ backend/src/plugins/ChannelArchiver/types.ts | 7 ++ backend/src/plugins/availablePlugins.ts | 2 + 6 files changed, 174 insertions(+) create mode 100644 backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts create mode 100644 backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts create mode 100644 backend/src/plugins/ChannelArchiver/rehostAttachment.ts create mode 100644 backend/src/plugins/ChannelArchiver/types.ts diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 89184e7e..d23f3d6d 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -66,3 +66,13 @@ export function getBaseUrl(pluginData: PluginData) { const knub = pluginData.getKnubInstance() as TZeppelinKnub; return knub.getGlobalConfig().url; } + +export function isOwner(pluginData: PluginData, userId: string) { + const knub = pluginData.getKnubInstance() as TZeppelinKnub; + const owners = knub.getGlobalConfig().owners; + if (!owners) { + return false; + } + + return owners.includes(userId); +} diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts new file mode 100644 index 00000000..599977db --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts @@ -0,0 +1,16 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { ChannelArchiverPluginType } from "./types"; +import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd"; + +export const ChannelArchiverPlugin = zeppelinPlugin()("channel_archiver", { + showInDocs: false, + + // prettier-ignore + commands: [ + ArchiveChannelCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + }, +}); diff --git a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts new file mode 100644 index 00000000..61eff881 --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts @@ -0,0 +1,110 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { channelArchiverCmd } from "../types"; +import { isOwner, sendErrorMessage } from "src/pluginUtils"; +import { confirm, SECONDS, noop } from "src/utils"; +import moment from "moment-timezone"; +import { rehostAttachment } from "../rehostAttachment"; + +const MAX_ARCHIVED_MESSAGES = 5000; +const MAX_MESSAGES_PER_FETCH = 100; +const PROGRESS_UPDATE_INTERVAL = 5 * SECONDS; + +export const ArchiveChannelCmd = channelArchiverCmd({ + trigger: "archive_channel", + permission: null, + + config: { + preFilters: [ + (command, context) => { + return isOwner(context.pluginData, context.message.author.id); + }, + ], + }, + + signature: { + channel: ct.textChannel(), + + "attachment-channel": ct.textChannel({ option: true }), + messages: ct.number({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + if (!args["attachment-channel"]) { + const confirmed = await confirm( + pluginData.client, + msg.channel, + msg.author.id, + "No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.", + ); + if (!confirmed) { + sendErrorMessage(pluginData, msg.channel, "Canceled"); + return; + } + } + + const maxMessagesToArchive = args.messages ? Math.min(args.messages, MAX_ARCHIVED_MESSAGES) : MAX_ARCHIVED_MESSAGES; + if (maxMessagesToArchive <= 0) return; + + const archiveLines = []; + let archivedMessages = 0; + let previousId; + + const startTime = Date.now(); + const progressMsg = await msg.channel.createMessage("Creating archive..."); + const progressUpdateInterval = setInterval(() => { + const secondsSinceStart = Math.round((Date.now() - startTime) / 1000); + progressMsg + .edit(`Creating archive...\n**Status:** ${archivedMessages} messages archived in ${secondsSinceStart} seconds`) + .catch(() => clearInterval(progressUpdateInterval)); + }, PROGRESS_UPDATE_INTERVAL); + + while (archivedMessages < maxMessagesToArchive) { + const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages); + const messages = await args.channel.getMessages(messagesToFetch, previousId); + if (messages.length === 0) break; + + for (const message of messages) { + const ts = moment.utc(message.timestamp).format("YYYY-MM-DD HH:mm:ss"); + let content = `[${ts}] [${message.author.id}] [${message.author.username}#${ + message.author.discriminator + }]: ${message.content || ""}`; + + if (message.attachments.length) { + if (args["attachment-channel"]) { + const rehostedAttachmentUrl = await rehostAttachment(message.attachments[0], args["attachment-channel"]); + content += `\n-- Attachment: ${rehostedAttachmentUrl}`; + } else { + content += `\n-- Attachment: ${message.attachments[0].url}`; + } + } + + if (message.reactions && Object.keys(message.reactions).length > 0) { + const reactionCounts = []; + for (const [emoji, info] of Object.entries(message.reactions)) { + reactionCounts.push(`${info.count}x ${emoji}`); + } + content += `\n-- Reactions: ${reactionCounts.join(", ")}`; + } + + archiveLines.push(content); + previousId = message.id; + archivedMessages++; + } + } + + clearInterval(progressUpdateInterval); + + archiveLines.reverse(); + + const nowTs = moment().format("YYYY-MM-DD HH:mm:ss"); + + let result = `Archived ${archiveLines.length} messages from #${args.channel.name} at ${nowTs}`; + result += `\n\n${archiveLines.join("\n")}\n`; + + progressMsg.delete().catch(noop); + msg.channel.createMessage("Archive created!", { + file: Buffer.from(result), + name: `archive-${args.channel.name}-${moment().format("YYYY-MM-DD-HH-mm-ss")}.txt`, + }); + }, +}); diff --git a/backend/src/plugins/ChannelArchiver/rehostAttachment.ts b/backend/src/plugins/ChannelArchiver/rehostAttachment.ts new file mode 100644 index 00000000..0b12e360 --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/rehostAttachment.ts @@ -0,0 +1,29 @@ +import { Attachment, TextChannel } from "eris"; +import { downloadFile } from "src/utils"; +import fs from "fs"; +const fsp = fs.promises; + +const MAX_ATTACHMENT_REHOST_SIZE = 1024 * 1024 * 8; + +export async function rehostAttachment(attachment: Attachment, targetChannel: TextChannel): Promise { + if (attachment.size > MAX_ATTACHMENT_REHOST_SIZE) { + return "Attachment too big to rehost"; + } + + let downloaded; + try { + downloaded = await downloadFile(attachment.url, 3); + } catch (e) { + return "Failed to download attachment after 3 tries"; + } + + try { + const rehostMessage = await targetChannel.createMessage(`Rehost of attachment ${attachment.id}`, { + name: attachment.filename, + file: await fsp.readFile(downloaded.path), + }); + return rehostMessage.attachments[0].url; + } catch (e) { + return "Failed to rehost attachment"; + } +} diff --git a/backend/src/plugins/ChannelArchiver/types.ts b/backend/src/plugins/ChannelArchiver/types.ts new file mode 100644 index 00000000..cbed3e52 --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/types.ts @@ -0,0 +1,7 @@ +import { BasePluginType, command } from "knub"; + +export interface ChannelArchiverPluginType extends BasePluginType { + state: {}; +} + +export const channelArchiverCmd = command(); diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..e09b2b32 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,10 +13,12 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin"; // prettier-ignore export const guildPlugins: Array> = [ AutoReactionsPlugin, + ChannelArchiverPlugin, LocateUserPlugin, PersistPlugin, PingableRolesPlugin, From 599a504b1752138ff90ff12e38e9b5720b23d48e Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Wed, 22 Jul 2020 23:15:40 +0200 Subject: [PATCH 2/8] Migrate Starboard to new Plugin structure --- .../src/plugins/Starboard/StarboardPlugin.ts | 118 ++++++++++++++++++ .../Starboard/commands/MigratePinsCmd.ts | 52 ++++++++ .../events/StarboardReactionAddEvt.ts | 74 +++++++++++ .../events/StarboardReactionRemoveEvts.ts | 17 +++ backend/src/plugins/Starboard/types.ts | 43 +++++++ .../getStarboardOptsForStarboardChannel.ts | 19 +++ .../plugins/Starboard/util/onMessageDelete.ts | 27 ++++ .../Starboard/util/preprocessStaticConfig.ts | 12 ++ .../util/removeMessageFromStarboard.ts | 6 + .../removeMessageFromStarboardMessages.ts | 3 + .../Starboard/util/saveMessageToStarboard.ts | 70 +++++++++++ backend/src/plugins/availablePlugins.ts | 2 + 12 files changed, 443 insertions(+) create mode 100644 backend/src/plugins/Starboard/StarboardPlugin.ts create mode 100644 backend/src/plugins/Starboard/commands/MigratePinsCmd.ts create mode 100644 backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts create mode 100644 backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts create mode 100644 backend/src/plugins/Starboard/types.ts create mode 100644 backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts create mode 100644 backend/src/plugins/Starboard/util/onMessageDelete.ts create mode 100644 backend/src/plugins/Starboard/util/preprocessStaticConfig.ts create mode 100644 backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts create mode 100644 backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts create mode 100644 backend/src/plugins/Starboard/util/saveMessageToStarboard.ts diff --git a/backend/src/plugins/Starboard/StarboardPlugin.ts b/backend/src/plugins/Starboard/StarboardPlugin.ts new file mode 100644 index 00000000..5d251077 --- /dev/null +++ b/backend/src/plugins/Starboard/StarboardPlugin.ts @@ -0,0 +1,118 @@ +import { PluginOptions } from "knub"; +import { ConfigSchema, StarboardPluginType } from "./types"; +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { trimPluginDescription } from "src/utils"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildStarboardMessages } from "src/data/GuildStarboardMessages"; +import { GuildStarboardReactions } from "src/data/GuildStarboardReactions"; +import { onMessageDelete } from "./util/onMessageDelete"; +import { MigratePinsCmd } from "./commands/MigratePinsCmd"; +import { StarboardReactionAddEvt } from "./events/StarboardReactionAddEvt"; +import { StarboardReactionRemoveEvt, StarboardReactionRemoveAllEvt } from "./events/StarboardReactionRemoveEvts"; + +const defaultOptions: PluginOptions = { + config: { + can_migrate: false, + boards: {}, + }, + + overrides: [ + { + level: ">=100", + config: { + can_migrate: true, + }, + }, + ], +}; + +export const StarboardPlugin = zeppelinPlugin()("starboard", { + configSchema: ConfigSchema, + defaultOptions, + + info: { + prettyName: "Starboard", + description: trimPluginDescription(` + This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel. + `), + configurationGuide: trimPluginDescription(` + ### Note on emojis + To specify emoji in the config, you need to use the emoji's "raw form". + To obtain this, post the emoji with a backslash in front of it. + + - Example with a default emoji: "\:star:" => "⭐" + - Example with a custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>" + + ### Basic starboard + Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226). + + ~~~yml + starboard: + config: + boards: + basic: + channel_id: "604342689038729226" + stars_required: 5 + ~~~ + + ### Custom star emoji + This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji + + ~~~yml + starboard: + config: + boards: + basic: + channel_id: "604342689038729226" + star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"] + stars_required: 5 + ~~~ + + ### Limit starboard to a specific channel + This is identical to the basic starboard above, but only works from a specific channel (473087035574321152). + + ~~~yml + starboard: + config: + boards: + basic: + enabled: false # The starboard starts disabled and is then enabled in a channel override below + channel_id: "604342689038729226" + stars_required: 5 + overrides: + - channel: "473087035574321152" + config: + boards: + basic: + enabled: true + ~~~ + `), + }, + + // prettier-ignore + commands: [ + MigratePinsCmd, + ], + + // prettier-ignore + events: [ + StarboardReactionAddEvt, + StarboardReactionRemoveEvt, + StarboardReactionRemoveAllEvt, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.starboardMessages = GuildStarboardMessages.getGuildInstance(guild.id); + state.starboardReactions = GuildStarboardReactions.getGuildInstance(guild.id); + + state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); + state.savedMessages.events.on("delete", state.onMessageDeleteFn); + }, + + onUnload(pluginData) { + pluginData.state.savedMessages.events.off("delete", pluginData.state.onMessageDeleteFn); + }, +}); diff --git a/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts new file mode 100644 index 00000000..e37155c3 --- /dev/null +++ b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts @@ -0,0 +1,52 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { starboardCmd } from "../types"; +import { sendSuccessMessage, sendErrorMessage } from "src/pluginUtils"; +import { TextChannel } from "eris"; +import { saveMessageToStarboard } from "../util/saveMessageToStarboard"; + +export const MigratePinsCmd = starboardCmd({ + trigger: "starboard migrate_pins", + permission: "can_migrate", + + description: "Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.", + + signature: { + pinChannel: ct.textChannel(), + starboardName: ct.string(), + }, + + async run({ message: msg, args, pluginData }) { + const config = await pluginData.config.get(); + const starboard = config.boards[args.starboardName]; + if (!starboard) { + sendErrorMessage(pluginData, msg.channel, "Unknown starboard specified"); + return; + } + + const starboardChannel = pluginData.guild.channels.get(starboard.channel_id); + if (!starboardChannel || !(starboardChannel instanceof TextChannel)) { + sendErrorMessage(pluginData, msg.channel, "Starboard has an unknown/invalid channel id"); + return; + } + + msg.channel.createMessage(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`); + + const pins = await args.pinChannel.getPins(); + pins.reverse(); // Migrate pins starting from the oldest message + + for (const pin of pins) { + const existingStarboardMessage = await pluginData.state.starboardMessages.getMatchingStarboardMessages( + starboardChannel.id, + pin.id, + ); + if (existingStarboardMessage.length > 0) continue; + await saveMessageToStarboard(pluginData, pin, starboard); + } + + sendSuccessMessage( + pluginData, + msg.channel, + `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`, + ); + }, +}); diff --git a/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts new file mode 100644 index 00000000..1f8aef28 --- /dev/null +++ b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts @@ -0,0 +1,74 @@ +import { starboardEvt } from "../types"; +import { Message } from "eris"; +import { UnknownUser, resolveMember, noop } from "src/utils"; +import { saveMessageToStarboard } from "../util/saveMessageToStarboard"; + +export const StarboardReactionAddEvt = starboardEvt({ + event: "messageReactionAdd", + + async listener(meta) { + const pluginData = meta.pluginData; + + let msg = meta.args.message as Message; + const userId = meta.args.userID; + const emoji = meta.args.emoji; + + if (!msg.author) { + // Message is not cached, fetch it + try { + msg = await msg.channel.getMessage(msg.id); + } catch (e) { + // Sometimes we get this event for messages we can't fetch with getMessage; ignore silently + return; + } + } + + // No self-votes! + if (msg.author.id === userId) return; + + const user = await resolveMember(pluginData.client, pluginData.guild, userId); + if (user instanceof UnknownUser) return; + if (user.bot) return; + + const config = pluginData.config.getMatchingConfig({ member: user, channelId: msg.channel.id }); + const applicableStarboards = Object.values(config.boards) + .filter(board => board.enabled) + // Can't star messages in the starboard channel itself + .filter(board => board.channel_id !== msg.channel.id) + // Matching emoji + .filter(board => { + return board.star_emoji.some((boardEmoji: string) => { + if (emoji.id) { + // Custom emoji + const customEmojiMatch = boardEmoji.match(/^?$/); + if (customEmojiMatch) { + return customEmojiMatch[1] === emoji.id; + } + + return boardEmoji === emoji.id; + } else { + // Unicode emoji + return emoji.name === boardEmoji; + } + }); + }); + + for (const starboard of applicableStarboards) { + // Save reaction into the database + await pluginData.state.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop); + + // If the message has already been posted to this starboard, we don't need to do anything else + const starboardMessages = await pluginData.state.starboardMessages.getMatchingStarboardMessages( + starboard.channel_id, + msg.id, + ); + if (starboardMessages.length > 0) continue; + + const reactions = await pluginData.state.starboardReactions.getAllReactionsForMessageId(msg.id); + const reactionsCount = reactions.length; + if (reactionsCount >= starboard.stars_required) { + await saveMessageToStarboard(pluginData, msg, starboard); + } + } + }, +}); diff --git a/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts b/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts new file mode 100644 index 00000000..5e15ab90 --- /dev/null +++ b/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts @@ -0,0 +1,17 @@ +import { starboardEvt } from "../types"; + +export const StarboardReactionRemoveEvt = starboardEvt({ + event: "messageReactionRemove", + + async listener(meta) { + await meta.pluginData.state.starboardReactions.deleteStarboardReaction(meta.args.message.id, meta.args.userID); + }, +}); + +export const StarboardReactionRemoveAllEvt = starboardEvt({ + event: "messageReactionRemoveAll", + + async listener(meta) { + await meta.pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(meta.args.message.id); + }, +}); diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts new file mode 100644 index 00000000..4f382ab8 --- /dev/null +++ b/backend/src/plugins/Starboard/types.ts @@ -0,0 +1,43 @@ +import * as t from "io-ts"; +import { BasePluginType, command, eventListener } from "knub"; +import { tNullable, tDeepPartial } from "src/utils"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildStarboardMessages } from "src/data/GuildStarboardMessages"; +import { GuildStarboardReactions } from "src/data/GuildStarboardReactions"; + +const StarboardOpts = t.type({ + channel_id: t.string, + stars_required: t.number, + star_emoji: tNullable(t.array(t.string)), + copy_full_embed: tNullable(t.boolean), + enabled: tNullable(t.boolean), +}); +export type TStarboardOpts = t.TypeOf; + +export const ConfigSchema = t.type({ + boards: t.record(t.string, StarboardOpts), + can_migrate: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export const PartialConfigSchema = tDeepPartial(ConfigSchema); + +export const defaultStarboardOpts: Partial = { + star_emoji: ["⭐"], + enabled: true, +}; + +export interface StarboardPluginType extends BasePluginType { + config: TConfigSchema; + + state: { + savedMessages: GuildSavedMessages; + starboardMessages: GuildStarboardMessages; + starboardReactions: GuildStarboardReactions; + + onMessageDeleteFn; + }; +} + +export const starboardCmd = command(); +export const starboardEvt = eventListener(); diff --git a/backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts b/backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts new file mode 100644 index 00000000..1c32b42e --- /dev/null +++ b/backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts @@ -0,0 +1,19 @@ +import { TStarboardOpts, StarboardPluginType, defaultStarboardOpts } from "../types"; +import { PluginData } from "knub"; + +export function getStarboardOptsForStarboardChannel( + pluginData: PluginData, + starboardChannel, +): TStarboardOpts[] { + const config = pluginData.config.getForChannel(starboardChannel); + + const configs = Object.values(config.boards).filter(opts => opts.channel_id === starboardChannel.id); + configs.forEach(cfg => { + if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled; + if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji; + if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required; + if (cfg.copy_full_embed == null) cfg.copy_full_embed = false; + }); + + return configs; +} diff --git a/backend/src/plugins/Starboard/util/onMessageDelete.ts b/backend/src/plugins/Starboard/util/onMessageDelete.ts new file mode 100644 index 00000000..c79e3da8 --- /dev/null +++ b/backend/src/plugins/Starboard/util/onMessageDelete.ts @@ -0,0 +1,27 @@ +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { PluginData } from "knub"; +import { StarboardPluginType } from "../types"; +import { removeMessageFromStarboard } from "./removeMessageFromStarboard"; +import { removeMessageFromStarboardMessages } from "./removeMessageFromStarboardMessages"; + +export async function onMessageDelete(pluginData: PluginData, msg: SavedMessage) { + // Deleted source message + const starboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForMessageId(msg.id); + for (const starboardMessage of starboardMessages) { + removeMessageFromStarboard(pluginData, starboardMessage); + } + + // Deleted message from the starboard + const deletedStarboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForStarboardMessageId( + msg.id, + ); + if (deletedStarboardMessages.length === 0) return; + + for (const starboardMessage of deletedStarboardMessages) { + removeMessageFromStarboardMessages( + pluginData, + starboardMessage.starboard_message_id, + starboardMessage.starboard_channel_id, + ); + } +} diff --git a/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts b/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts new file mode 100644 index 00000000..b690fbf8 --- /dev/null +++ b/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts @@ -0,0 +1,12 @@ +import { PartialConfigSchema, defaultStarboardOpts } from "../types"; +import * as t from "io-ts"; + +export function preprocessStaticConfig(config: t.TypeOf) { + if (config.boards) { + for (const [name, opts] of Object.entries(config.boards)) { + config.boards[name] = Object.assign({}, defaultStarboardOpts, config.boards[name]); + } + } + + return config; +} diff --git a/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts b/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts new file mode 100644 index 00000000..6c790b5d --- /dev/null +++ b/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts @@ -0,0 +1,6 @@ +import { StarboardMessage } from "src/data/entities/StarboardMessage"; +import { noop } from "src/utils"; + +export async function removeMessageFromStarboard(pluginData, msg: StarboardMessage) { + await pluginData.client.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop); +} diff --git a/backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts b/backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts new file mode 100644 index 00000000..1d00177d --- /dev/null +++ b/backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts @@ -0,0 +1,3 @@ +export async function removeMessageFromStarboardMessages(pluginData, starboard_message_id: string, channel_id: string) { + await pluginData.state.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id); +} diff --git a/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts b/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts new file mode 100644 index 00000000..c060f8b5 --- /dev/null +++ b/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts @@ -0,0 +1,70 @@ +import { PluginData } from "knub"; +import { StarboardPluginType, TStarboardOpts } from "../types"; +import { Message, GuildChannel, TextChannel, Embed } from "eris"; +import moment from "moment-timezone"; +import { EMPTY_CHAR, messageLink } from "src/utils"; +import path from "path"; + +export async function saveMessageToStarboard( + pluginData: PluginData, + msg: Message, + starboard: TStarboardOpts, +) { + const channel = pluginData.guild.channels.get(starboard.channel_id); + if (!channel) return; + + const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]"); + + const embed: Embed = { + footer: { + text: `#${(msg.channel as GuildChannel).name}`, + }, + author: { + name: `${msg.author.username}#${msg.author.discriminator}`, + }, + fields: [], + timestamp: new Date(msg.timestamp).toISOString(), + type: "rich", + }; + + if (msg.author.avatarURL) { + embed.author.icon_url = msg.author.avatarURL; + } + + if (msg.content) { + embed.description = msg.content; + } + + // Merge media and - if copy_full_embed is enabled - fields and title from the first embed in the original message + if (msg.embeds.length > 0) { + if (msg.embeds[0].image) embed.image = msg.embeds[0].image; + + if (starboard.copy_full_embed) { + if (msg.embeds[0].title) { + const titleText = msg.embeds[0].url ? `[${msg.embeds[0].title}](${msg.embeds[0].url})` : msg.embeds[0].title; + embed.fields.push({ name: EMPTY_CHAR, value: titleText }); + } + + if (msg.embeds[0].fields) embed.fields.push(...msg.embeds[0].fields); + } + } + + // If there are no embeds, add the first image attachment explicitly + else if (msg.attachments.length) { + for (const attachment of msg.attachments) { + const ext = path + .extname(attachment.filename) + .slice(1) + .toLowerCase(); + if (!["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) continue; + + embed.image = { url: attachment.url }; + break; + } + } + + embed.fields.push({ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` }); + + const starboardMessage = await (channel as TextChannel).createMessage({ embed }); + await pluginData.state.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..06042b89 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,6 +13,7 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { StarboardPlugin } from "./Starboard/StarboardPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -23,6 +24,7 @@ export const guildPlugins: Array> = [ MessageSaverPlugin, NameHistoryPlugin, RemindersPlugin, + StarboardPlugin, TagsPlugin, UsernameSaverPlugin, UtilityPlugin, From b4b8680431cee59a0be08cd347bc2010069d40f4 Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Thu, 23 Jul 2020 00:25:40 +0200 Subject: [PATCH 3/8] Migrate Slowmode to new Plugin structure --- .../src/plugins/Slowmode/SlowmodePlugin.ts | 65 +++++++++++++ .../Slowmode/commands/SlowmodeClearCmd.ts | 40 ++++++++ .../Slowmode/commands/SlowmodeDisableCmd.ts | 20 ++++ .../commands/SlowmodeGetChannelCmd.ts | 37 ++++++++ .../Slowmode/commands/SlowmodeListCmd.ts | 46 +++++++++ .../commands/SlowmodeSetChannelCmd.ts | 93 +++++++++++++++++++ backend/src/plugins/Slowmode/types.ts | 28 ++++++ .../Slowmode/util/actualDisableSlowmodeCmd.ts | 39 ++++++++ .../Slowmode/util/applyBotSlowmodeToUserId.ts | 43 +++++++++ .../util/clearBotSlowmodeFromUserId.ts | 24 +++++ .../Slowmode/util/clearExpiredSlowmodes.ts | 31 +++++++ .../util/disableBotSlowmodeForChannel.ts | 28 ++++++ .../plugins/Slowmode/util/onMessageCreate.ts | 42 +++++++++ backend/src/plugins/availablePlugins.ts | 2 + 14 files changed, 538 insertions(+) create mode 100644 backend/src/plugins/Slowmode/SlowmodePlugin.ts create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts create mode 100644 backend/src/plugins/Slowmode/types.ts create mode 100644 backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts create mode 100644 backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts create mode 100644 backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts create mode 100644 backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts create mode 100644 backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts create mode 100644 backend/src/plugins/Slowmode/util/onMessageCreate.ts diff --git a/backend/src/plugins/Slowmode/SlowmodePlugin.ts b/backend/src/plugins/Slowmode/SlowmodePlugin.ts new file mode 100644 index 00000000..91154385 --- /dev/null +++ b/backend/src/plugins/Slowmode/SlowmodePlugin.ts @@ -0,0 +1,65 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { SlowmodePluginType, ConfigSchema } from "./types"; +import { GuildSlowmodes } from "src/data/GuildSlowmodes"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildLogs } from "src/data/GuildLogs"; +import { SECONDS } from "src/utils"; +import { onMessageCreate } from "./util/onMessageCreate"; +import { clearExpiredSlowmodes } from "./util/clearExpiredSlowmodes"; +import { SlowmodeDisableCmd } from "./commands/SlowmodeDisableCmd"; +import { SlowmodeClearCmd } from "./commands/SlowmodeClearCmd"; +import { SlowmodeListCmd } from "./commands/SlowmodeListCmd"; +import { SlowmodeGetChannelCmd } from "./commands/SlowmodeGetChannelCmd"; +import { SlowmodeSetChannelCmd } from "./commands/SlowmodeSetChannelCmd"; + +const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * SECONDS; + +const defaultOptions: PluginOptions = { + config: { + use_native_slowmode: true, + + can_manage: false, + is_affected: true, + }, + + overrides: [ + { + level: ">=50", + config: { + can_manage: true, + is_affected: false, + }, + }, + ], +}; + +export const SlowmodePlugin = zeppelinPlugin()("slowmode", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + commands: [ + SlowmodeDisableCmd, + SlowmodeClearCmd, + SlowmodeListCmd, + SlowmodeGetChannelCmd, + SlowmodeSetChannelCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.slowmodes = GuildSlowmodes.getGuildInstance(guild.id); + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.logs = new GuildLogs(guild.id); + state.clearInterval = setInterval(() => clearExpiredSlowmodes(pluginData), BOT_SLOWMODE_CLEAR_INTERVAL); + + state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.savedMessages.events.on("create", state.onMessageCreateFn); + }, + + onUnload(pluginData) { + pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn); + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts new file mode 100644 index 00000000..a5806b41 --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts @@ -0,0 +1,40 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { slowmodeCmd } from "../types"; +import { clearBotSlowmodeFromUserId } from "../util/clearBotSlowmodeFromUserId"; + +export const SlowmodeClearCmd = slowmodeCmd({ + trigger: ["slowmode clear", "slowmode c"], + permission: "can_manage", + + signature: { + channel: ct.textChannel(), + user: ct.resolvedUserLoose(), + + force: ct.bool({ option: true, isSwitch: true }), + }, + + async run({ message: msg, args, pluginData }) { + const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id); + if (!channelSlowmode) { + sendErrorMessage(pluginData, msg.channel, "Channel doesn't have slowmode!"); + return; + } + + try { + await clearBotSlowmodeFromUserId(pluginData, args.channel, args.user.id, args.force); + } catch (e) { + return sendErrorMessage( + pluginData, + msg.channel, + `Failed to clear slowmode from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`, + ); + } + + sendSuccessMessage( + pluginData, + msg.channel, + `Slowmode cleared from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`, + ); + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts new file mode 100644 index 00000000..20294f2d --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts @@ -0,0 +1,20 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { slowmodeCmd } from "../types"; +import { disableBotSlowmodeForChannel } from "../util/disableBotSlowmodeForChannel"; +import { noop } from "src/utils"; +import { actualDisableSlowmodeCmd } from "../util/actualDisableSlowmodeCmd"; + +export const SlowmodeDisableCmd = slowmodeCmd({ + trigger: ["slowmode disable", "slowmode d"], + permission: "can_manage", + + signature: { + channel: ct.textChannel(), + }, + + async run({ message: msg, args, pluginData }) { + // Workaround until you can call this cmd from SlowmodeSetChannelCmd + actualDisableSlowmodeCmd(msg, args, pluginData); + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts new file mode 100644 index 00000000..589f1373 --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts @@ -0,0 +1,37 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { slowmodeCmd } from "../types"; +import { TextChannel } from "eris"; +import humanizeDuration from "humanize-duration"; + +export const SlowmodeGetChannelCmd = slowmodeCmd({ + trigger: "slowmode", + permission: "can_manage", + source: "guild", + + signature: { + channel: ct.textChannel({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + const channel = args.channel || (msg.channel as TextChannel); + + let currentSlowmode = channel.rateLimitPerUser; + let isNative = true; + + if (!currentSlowmode) { + const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); + if (botSlowmode) { + currentSlowmode = botSlowmode.slowmode_seconds; + isNative = false; + } + } + + if (currentSlowmode) { + const humanized = humanizeDuration(channel.rateLimitPerUser * 1000); + const slowmodeType = isNative ? "native" : "bot-maintained"; + msg.channel.createMessage(`The current slowmode of <#${channel.id}> is **${humanized}** (${slowmodeType})`); + } else { + msg.channel.createMessage("Channel is not on slowmode"); + } + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts new file mode 100644 index 00000000..422cb503 --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts @@ -0,0 +1,46 @@ +import { slowmodeCmd } from "../types"; +import { GuildChannel, TextChannel } from "eris"; +import { createChunkedMessage } from "knub/dist/helpers"; +import { errorMessage } from "src/utils"; +import humanizeDuration from "humanize-duration"; + +export const SlowmodeListCmd = slowmodeCmd({ + trigger: ["slowmode list", "slowmode l", "slowmodes"], + permission: "can_manage", + + async run({ message: msg, pluginData }) { + const channels = pluginData.guild.channels; + const slowmodes: Array<{ channel: GuildChannel; seconds: number; native: boolean }> = []; + + for (const channel of channels.values()) { + if (!(channel instanceof TextChannel)) continue; + + // Bot slowmode + const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); + if (botSlowmode) { + slowmodes.push({ channel, seconds: botSlowmode.slowmode_seconds, native: false }); + continue; + } + + // Native slowmode + if (channel.rateLimitPerUser) { + slowmodes.push({ channel, seconds: channel.rateLimitPerUser, native: true }); + continue; + } + } + + if (slowmodes.length) { + const lines = slowmodes.map(slowmode => { + const humanized = humanizeDuration(slowmode.seconds * 1000); + + const type = slowmode.native ? "native slowmode" : "bot slowmode"; + + return `<#${slowmode.channel.id}> **${humanized}** ${type}`; + }); + + createChunkedMessage(msg.channel, lines.join("\n")); + } else { + msg.channel.createMessage(errorMessage("No active slowmodes!")); + } + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts new file mode 100644 index 00000000..ab59ce26 --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts @@ -0,0 +1,93 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { slowmodeCmd } from "../types"; +import { TextChannel } from "eris"; +import humanizeDuration from "humanize-duration"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { convertDelayStringToMS, HOURS, DAYS } from "src/utils"; +import { disableBotSlowmodeForChannel } from "../util/disableBotSlowmodeForChannel"; +import { actualDisableSlowmodeCmd } from "../util/actualDisableSlowmodeCmd"; + +const NATIVE_SLOWMODE_LIMIT = 6 * HOURS; // 6 hours +const MAX_SLOWMODE = DAYS * 365 * 100; // 100 years + +export const SlowmodeSetChannelCmd = slowmodeCmd({ + trigger: "slowmode", + permission: "can_manage", + source: "guild", + + // prettier-ignore + signature: [ + { + time: ct.string(), + }, + { + channel: ct.textChannel(), + time: ct.string(), + } + ], + + async run({ message: msg, args, pluginData }) { + const channel = args.channel || msg.channel; + + if (channel == null || !(channel instanceof TextChannel)) { + sendErrorMessage(pluginData, msg.channel, "Channel must be a text channel"); + return; + } + + const seconds = Math.ceil(convertDelayStringToMS(args.time, "s") / 1000); + const useNativeSlowmode = + pluginData.config.getForChannel(channel).use_native_slowmode && seconds <= NATIVE_SLOWMODE_LIMIT; + + if (seconds === 0) { + // Workaround until we can call SlowmodeDisableCmd from here + return actualDisableSlowmodeCmd(msg, { channel }, pluginData); + } + + if (seconds > MAX_SLOWMODE) { + sendErrorMessage( + pluginData, + msg.channel, + `Sorry, slowmodes can be at most 100 years long. Maybe 99 would be enough?`, + ); + return; + } + + if (useNativeSlowmode) { + // Native slowmode + + // If there is an existing bot-maintained slowmode, disable that first + const existingBotSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); + if (existingBotSlowmode) { + await disableBotSlowmodeForChannel(pluginData, channel); + } + + // Set slowmode + try { + await channel.edit({ + rateLimitPerUser: seconds, + }); + } catch (e) { + return sendErrorMessage(pluginData, msg.channel, "Failed to set native slowmode (check permissions)"); + } + } else { + // Bot-maintained slowmode + + // If there is an existing native slowmode, disable that first + if (channel.rateLimitPerUser) { + await channel.edit({ + rateLimitPerUser: 0, + }); + } + + await pluginData.state.slowmodes.setChannelSlowmode(channel.id, seconds); + } + + const humanizedSlowmodeTime = humanizeDuration(seconds * 1000); + const slowmodeType = useNativeSlowmode ? "native slowmode" : "bot-maintained slowmode"; + sendSuccessMessage( + pluginData, + msg.channel, + `Set ${humanizedSlowmodeTime} slowmode for <#${channel.id}> (${slowmodeType})`, + ); + }, +}); diff --git a/backend/src/plugins/Slowmode/types.ts b/backend/src/plugins/Slowmode/types.ts new file mode 100644 index 00000000..0f956644 --- /dev/null +++ b/backend/src/plugins/Slowmode/types.ts @@ -0,0 +1,28 @@ +import * as t from "io-ts"; +import { BasePluginType, eventListener, command } from "knub"; +import { GuildSlowmodes } from "src/data/GuildSlowmodes"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildLogs } from "src/data/GuildLogs"; + +export const ConfigSchema = t.type({ + use_native_slowmode: t.boolean, + + can_manage: t.boolean, + is_affected: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export interface SlowmodePluginType extends BasePluginType { + config: TConfigSchema; + state: { + slowmodes: GuildSlowmodes; + savedMessages: GuildSavedMessages; + logs: GuildLogs; + clearInterval: NodeJS.Timeout; + + onMessageCreateFn; + }; +} + +export const slowmodeCmd = command(); +export const slowmodeEvt = eventListener(); diff --git a/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts new file mode 100644 index 00000000..948c474a --- /dev/null +++ b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts @@ -0,0 +1,39 @@ +import { Message } from "eris"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { disableBotSlowmodeForChannel } from "./disableBotSlowmodeForChannel"; +import { noop } from "src/utils"; + +export async function actualDisableSlowmodeCmd(msg: Message, args, pluginData) { + const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id); + const hasNativeSlowmode = args.channel.rateLimitPerUser; + + if (!botSlowmode && hasNativeSlowmode === 0) { + sendErrorMessage(pluginData, msg.channel, "Channel is not on slowmode!"); + return; + } + + const initMsg = await msg.channel.createMessage("Disabling slowmode..."); + + // Disable bot-maintained slowmode + let failedUsers = []; + if (botSlowmode) { + const result = await disableBotSlowmodeForChannel(pluginData, args.channel); + failedUsers = result.failedUsers; + } + + // Disable native slowmode + if (hasNativeSlowmode) { + await args.channel.edit({ rateLimitPerUser: 0 }); + } + + if (failedUsers.length) { + sendSuccessMessage( + pluginData, + msg.channel, + `Slowmode disabled! Failed to clear slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>`, + ); + } else { + sendSuccessMessage(pluginData, msg.channel, "Slowmode disabled!"); + initMsg.delete().catch(noop); + } +} diff --git a/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts b/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts new file mode 100644 index 00000000..0b22a738 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts @@ -0,0 +1,43 @@ +import { SlowmodePluginType } from "../types"; +import { PluginData } from "knub"; +import { GuildChannel, TextChannel, Constants } from "eris"; +import { UnknownUser, isDiscordRESTError, stripObjectToScalars } from "src/utils"; +import { LogType } from "src/data/LogType"; +import { logger } from "src/logger"; + +export async function applyBotSlowmodeToUserId( + pluginData: PluginData, + channel: GuildChannel & TextChannel, + userId: string, +) { + // Deny sendMessage permission from the user. If there are existing permission overwrites, take those into account. + const existingOverride = channel.permissionOverwrites.get(userId); + const newDeniedPermissions = (existingOverride ? existingOverride.deny : 0) | Constants.Permissions.sendMessages; + const newAllowedPermissions = (existingOverride ? existingOverride.allow : 0) & ~Constants.Permissions.sendMessages; + + try { + await channel.editPermission(userId, newAllowedPermissions, newDeniedPermissions, "member"); + } catch (e) { + const user = pluginData.client.users.get(userId) || new UnknownUser({ id: userId }); + + if (isDiscordRESTError(e) && e.code === 50013) { + logger.warn( + `Missing permissions to apply bot slowmode to user ${userId} on channel ${channel.name} (${channel.id}) on server ${pluginData.guild.name} (${pluginData.guild.id})`, + ); + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Missing permissions to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`, + user: stripObjectToScalars(user), + channel: stripObjectToScalars(channel), + }); + } else { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Failed to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`, + user: stripObjectToScalars(user), + channel: stripObjectToScalars(channel), + }); + throw e; + } + } + + await pluginData.state.slowmodes.addSlowmodeUser(channel.id, userId); +} diff --git a/backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts b/backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts new file mode 100644 index 00000000..dcde2322 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts @@ -0,0 +1,24 @@ +import { PluginData } from "knub"; +import { SlowmodePluginType } from "../types"; +import { GuildChannel, TextChannel } from "eris"; + +export async function clearBotSlowmodeFromUserId( + pluginData: PluginData, + channel: GuildChannel & TextChannel, + userId: string, + force = false, +) { + try { + // Remove permission overrides from the channel for this user + // Previously we diffed the overrides so we could clear the "send messages" override without touching other + // overrides. Unfortunately, it seems that was a bit buggy - we didn't always receive the event for the changed + // overrides and then we also couldn't diff against them. For consistency's sake, we just delete the override now. + await channel.deletePermission(userId); + } catch (e) { + if (!force) { + throw e; + } + } + + await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, userId); +} diff --git a/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts b/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts new file mode 100644 index 00000000..1f0889a9 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts @@ -0,0 +1,31 @@ +import { PluginData } from "knub"; +import { SlowmodePluginType } from "../types"; +import { LogType } from "src/data/LogType"; +import { logger } from "src/logger"; +import { GuildChannel, TextChannel } from "eris"; +import { UnknownUser, stripObjectToScalars } from "src/utils"; +import { clearBotSlowmodeFromUserId } from "./clearBotSlowmodeFromUserId"; + +export async function clearExpiredSlowmodes(pluginData: PluginData) { + const expiredSlowmodeUsers = await pluginData.state.slowmodes.getExpiredSlowmodeUsers(); + for (const user of expiredSlowmodeUsers) { + const channel = pluginData.guild.channels.get(user.channel_id); + if (!channel) { + await pluginData.state.slowmodes.clearSlowmodeUser(user.channel_id, user.user_id); + continue; + } + + try { + await clearBotSlowmodeFromUserId(pluginData, channel as GuildChannel & TextChannel, user.user_id); + } catch (e) { + logger.error(e); + + const realUser = pluginData.client.users.get(user.user_id) || new UnknownUser({ id: user.user_id }); + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Failed to clear slowmode permissions from {userMention(user)} in {channelMention(channel)}`, + user: stripObjectToScalars(realUser), + channel: stripObjectToScalars(channel), + }); + } + } +} diff --git a/backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts b/backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts new file mode 100644 index 00000000..f505a9c1 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts @@ -0,0 +1,28 @@ +import { GuildChannel, TextChannel } from "eris"; +import { PluginData } from "knub"; +import { SlowmodePluginType } from "../types"; +import { clearBotSlowmodeFromUserId } from "./clearBotSlowmodeFromUserId"; + +export async function disableBotSlowmodeForChannel( + pluginData: PluginData, + channel: GuildChannel & TextChannel, +) { + // Disable channel slowmode + await pluginData.state.slowmodes.deleteChannelSlowmode(channel.id); + + // Remove currently applied slowmodes + const users = await pluginData.state.slowmodes.getChannelSlowmodeUsers(channel.id); + const failedUsers = []; + + for (const slowmodeUser of users) { + try { + await clearBotSlowmodeFromUserId(pluginData, channel, slowmodeUser.user_id); + } catch (e) { + // Removing the slowmode failed. Record this so the permissions can be changed manually, and remove the database entry. + failedUsers.push(slowmodeUser.user_id); + await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, slowmodeUser.user_id); + } + } + + return { failedUsers }; +} diff --git a/backend/src/plugins/Slowmode/util/onMessageCreate.ts b/backend/src/plugins/Slowmode/util/onMessageCreate.ts new file mode 100644 index 00000000..13bebcd3 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/onMessageCreate.ts @@ -0,0 +1,42 @@ +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { GuildChannel, TextChannel } from "eris"; +import { PluginData } from "knub"; +import { SlowmodePluginType } from "../types"; +import { resolveMember } from "src/utils"; +import { applyBotSlowmodeToUserId } from "./applyBotSlowmodeToUserId"; +import { hasPermission } from "src/pluginUtils"; + +export async function onMessageCreate(pluginData: PluginData, msg: SavedMessage) { + if (msg.is_bot) return; + + const channel = pluginData.guild.channels.get(msg.channel_id) as GuildChannel & TextChannel; + if (!channel) return; + + // Don't apply slowmode if the lock was interrupted earlier (e.g. the message was caught by word filters) + const thisMsgLock = await pluginData.locks.acquire(`message-${msg.id}`); + if (thisMsgLock.interrupted) return; + + // Check if this channel even *has* a bot-maintained slowmode + const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); + if (!channelSlowmode) return thisMsgLock.unlock(); + + // Make sure this user is affected by the slowmode + const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); + const isAffected = hasPermission(pluginData, "is_affected", { channelId: channel.id, userId: msg.user_id, member }); + if (!isAffected) return thisMsgLock.unlock(); + + // Delete any extra messages sent after a slowmode was already applied + const userHasSlowmode = await pluginData.state.slowmodes.userHasSlowmode(channel.id, msg.user_id); + if (userHasSlowmode) { + const message = await channel.getMessage(msg.id); + if (message) { + message.delete(); + return thisMsgLock.interrupt(); + } + + return thisMsgLock.unlock(); + } + + await applyBotSlowmodeToUserId(pluginData, channel, msg.user_id); + thisMsgLock.unlock(); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..f5b1acb3 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,6 +13,7 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -23,6 +24,7 @@ export const guildPlugins: Array> = [ MessageSaverPlugin, NameHistoryPlugin, RemindersPlugin, + SlowmodePlugin, TagsPlugin, UsernameSaverPlugin, UtilityPlugin, From 3685cc4df866ecf295659a92dcf22707485ba14b Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Thu, 23 Jul 2020 00:55:12 +0200 Subject: [PATCH 4/8] Migrate Roles to new Plugin structure --- backend/src/plugins/Roles/RolesPlugin.ts | 49 ++++++++++ .../src/plugins/Roles/commands/AddRoleCmd.ts | 61 ++++++++++++ .../plugins/Roles/commands/MassAddRoleCmd.ts | 98 +++++++++++++++++++ .../Roles/commands/MassRemoveRoleCmd.ts | 98 +++++++++++++++++++ .../plugins/Roles/commands/RemoveRoleCmd.ts | 61 ++++++++++++ backend/src/plugins/Roles/types.ts | 19 ++++ backend/src/plugins/availablePlugins.ts | 2 + 7 files changed, 388 insertions(+) create mode 100644 backend/src/plugins/Roles/RolesPlugin.ts create mode 100644 backend/src/plugins/Roles/commands/AddRoleCmd.ts create mode 100644 backend/src/plugins/Roles/commands/MassAddRoleCmd.ts create mode 100644 backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts create mode 100644 backend/src/plugins/Roles/commands/RemoveRoleCmd.ts create mode 100644 backend/src/plugins/Roles/types.ts diff --git a/backend/src/plugins/Roles/RolesPlugin.ts b/backend/src/plugins/Roles/RolesPlugin.ts new file mode 100644 index 00000000..565e34c6 --- /dev/null +++ b/backend/src/plugins/Roles/RolesPlugin.ts @@ -0,0 +1,49 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { ConfigSchema, RolesPluginType } from "./types"; +import { GuildLogs } from "src/data/GuildLogs"; +import { AddRoleCmd } from "./commands/AddRoleCmd"; +import { RemoveRoleCmd } from "./commands/RemoveRoleCmd"; +import { MassAddRoleCmd } from "./commands/MassAddRoleCmd"; +import { MassRemoveRoleCmd } from "./commands/MassRemoveRoleCmd"; + +const defaultOptions: PluginOptions = { + config: { + can_assign: false, + can_mass_assign: false, + assignable_roles: ["558037973581430785"], + }, + overrides: [ + { + level: ">=50", + config: { + can_assign: true, + }, + }, + { + level: ">=100", + config: { + can_mass_assign: true, + }, + }, + ], +}; + +export const RolesPlugin = zeppelinPlugin()("roles", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + commands: [ + AddRoleCmd, + RemoveRoleCmd, + MassAddRoleCmd, + MassRemoveRoleCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.logs = new GuildLogs(guild.id); + }, +}); diff --git a/backend/src/plugins/Roles/commands/AddRoleCmd.ts b/backend/src/plugins/Roles/commands/AddRoleCmd.ts new file mode 100644 index 00000000..c9969a89 --- /dev/null +++ b/backend/src/plugins/Roles/commands/AddRoleCmd.ts @@ -0,0 +1,61 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage, canActOn } from "src/pluginUtils"; +import { rolesCmd } from "../types"; +import { resolveRoleId, stripObjectToScalars, verboseUserMention } from "src/utils"; +import { LogType } from "src/data/LogType"; +import { GuildChannel } from "eris"; + +export const AddRoleCmd = rolesCmd({ + trigger: "addrole", + permission: "can_assign", + + signature: { + member: ct.resolvedMember(), + role: ct.string({ catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + if (!canActOn(pluginData, msg.member, args.member, true)) { + return sendErrorMessage(pluginData, msg.channel, "Cannot add roles to this user: insufficient permissions"); + } + + const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); + if (!roleId) { + return sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + } + + const config = pluginData.config.getForMessage(msg); + if (!config.assignable_roles.includes(roleId)) { + return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + } + + // Sanity check: make sure the role is configured properly + const role = (msg.channel as GuildChannel).guild.roles.get(roleId); + if (!role) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Unknown role configured for 'roles' plugin: ${roleId}`, + }); + return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + } + + if (args.member.roles.includes(roleId)) { + return sendErrorMessage(pluginData, msg.channel, "Member already has that role"); + } + + pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); + + await args.member.addRole(roleId); + + pluginData.state.logs.log(LogType.MEMBER_ROLE_ADD, { + member: stripObjectToScalars(args.member, ["user", "roles"]), + roles: role.name, + mod: stripObjectToScalars(msg.author), + }); + + sendSuccessMessage( + pluginData, + msg.channel, + `Added role **${role.name}** to ${verboseUserMention(args.member.user)}!`, + ); + }, +}); diff --git a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts new file mode 100644 index 00000000..d916ea10 --- /dev/null +++ b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts @@ -0,0 +1,98 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, canActOn } from "src/pluginUtils"; +import { rolesCmd } from "../types"; +import { resolveMember, resolveRoleId, stripObjectToScalars, successMessage } from "src/utils"; +import { LogType } from "src/data/LogType"; +import { logger } from "src/logger"; + +export const MassAddRoleCmd = rolesCmd({ + trigger: "massaddrole", + permission: "can_mass_assign", + + signature: { + role: ct.string(), + members: ct.string({ rest: true }), + }, + + async run({ message: msg, args, pluginData }) { + msg.channel.createMessage(`Resolving members...`); + + const members = []; + const unknownMembers = []; + for (const memberId of args.members) { + const member = await resolveMember(pluginData.client, pluginData.guild, memberId); + if (member) members.push(member); + else unknownMembers.push(memberId); + } + + for (const member of members) { + if (!canActOn(pluginData, msg.member, member, true)) { + return sendErrorMessage( + pluginData, + msg.channel, + "Cannot add roles to 1 or more specified members: insufficient permissions", + ); + } + } + + const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); + if (!roleId) { + return sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + } + + const config = pluginData.config.getForMessage(msg); + if (!config.assignable_roles.includes(roleId)) { + return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + } + + const role = pluginData.guild.roles.get(roleId); + if (!role) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Unknown role configured for 'roles' plugin: ${roleId}`, + }); + return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + } + + const membersWithoutTheRole = members.filter(m => !m.roles.includes(roleId)); + let assigned = 0; + const failed = []; + const alreadyHadRole = members.length - membersWithoutTheRole.length; + + msg.channel.createMessage( + `Adding role **${role.name}** to ${membersWithoutTheRole.length} ${ + membersWithoutTheRole.length === 1 ? "member" : "members" + }...`, + ); + + for (const member of membersWithoutTheRole) { + try { + pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, member.id); + await member.addRole(roleId); + pluginData.state.logs.log(LogType.MEMBER_ROLE_ADD, { + member: stripObjectToScalars(member, ["user", "roles"]), + roles: role.name, + mod: stripObjectToScalars(msg.author), + }); + assigned++; + } catch (e) { + logger.warn(`Error when adding role via !massaddrole: ${e.message}`); + failed.push(member.id); + } + } + + let resultMessage = `Added role **${role.name}** to ${assigned} ${assigned === 1 ? "member" : "members"}!`; + if (alreadyHadRole) { + resultMessage += ` ${alreadyHadRole} ${alreadyHadRole === 1 ? "member" : "members"} already had the role.`; + } + + if (failed.length) { + resultMessage += `\nFailed to add the role to the following members: ${failed.join(", ")}`; + } + + if (unknownMembers.length) { + resultMessage += `\nUnknown members: ${unknownMembers.join(", ")}`; + } + + msg.channel.createMessage(successMessage(resultMessage)); + }, +}); diff --git a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts new file mode 100644 index 00000000..e88115ae --- /dev/null +++ b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts @@ -0,0 +1,98 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, canActOn } from "src/pluginUtils"; +import { rolesCmd } from "../types"; +import { resolveMember, stripObjectToScalars, successMessage, resolveRoleId } from "src/utils"; +import { LogType } from "src/data/LogType"; +import { logger } from "src/logger"; + +export const MassRemoveRoleCmd = rolesCmd({ + trigger: "massremoverole", + permission: "can_mass_assign", + + signature: { + role: ct.string(), + members: ct.string({ rest: true }), + }, + + async run({ message: msg, args, pluginData }) { + msg.channel.createMessage(`Resolving members...`); + + const members = []; + const unknownMembers = []; + for (const memberId of args.members) { + const member = await resolveMember(pluginData.client, pluginData.guild, memberId); + if (member) members.push(member); + else unknownMembers.push(memberId); + } + + for (const member of members) { + if (!canActOn(pluginData, msg.member, member, true)) { + return sendErrorMessage( + pluginData, + msg.channel, + "Cannot add roles to 1 or more specified members: insufficient permissions", + ); + } + } + + const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); + if (!roleId) { + return sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + } + + const config = pluginData.config.getForMessage(msg); + if (!config.assignable_roles.includes(roleId)) { + return sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + } + + const role = pluginData.guild.roles.get(roleId); + if (!role) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Unknown role configured for 'roles' plugin: ${roleId}`, + }); + return sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + } + + const membersWithTheRole = members.filter(m => m.roles.includes(roleId)); + let assigned = 0; + const failed = []; + const didNotHaveRole = members.length - membersWithTheRole.length; + + msg.channel.createMessage( + `Removing role **${role.name}** from ${membersWithTheRole.length} ${ + membersWithTheRole.length === 1 ? "member" : "members" + }...`, + ); + + for (const member of membersWithTheRole) { + try { + pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id); + await member.removeRole(roleId); + pluginData.state.logs.log(LogType.MEMBER_ROLE_REMOVE, { + member: stripObjectToScalars(member, ["user", "roles"]), + roles: role.name, + mod: stripObjectToScalars(msg.author), + }); + assigned++; + } catch (e) { + logger.warn(`Error when removing role via !massremoverole: ${e.message}`); + failed.push(member.id); + } + } + + let resultMessage = `Removed role **${role.name}** from ${assigned} ${assigned === 1 ? "member" : "members"}!`; + if (didNotHaveRole) { + resultMessage += ` ${didNotHaveRole} ${didNotHaveRole === 1 ? "member" : "members"} didn't have the role.`; + } + + if (failed.length) { + resultMessage += `\nFailed to remove the role from the following members: ${failed.join(", ")}`; + } + + if (unknownMembers.length) { + resultMessage += `\nUnknown members: ${unknownMembers.join(", ")}`; + } + + msg.channel.createMessage(successMessage(resultMessage)); + }, +}); diff --git a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts new file mode 100644 index 00000000..c02b1a2d --- /dev/null +++ b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts @@ -0,0 +1,61 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage, canActOn } from "src/pluginUtils"; +import { rolesCmd } from "../types"; +import { GuildChannel } from "eris"; +import { LogType } from "src/data/LogType"; +import { stripObjectToScalars, verboseUserMention, resolveRoleId } from "src/utils"; + +export const RemoveRoleCmd = rolesCmd({ + trigger: "removerole", + permission: "can_assign", + + signature: { + member: ct.resolvedMember(), + role: ct.string({ catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + if (!canActOn(pluginData, msg.member, args.member, true)) { + return sendErrorMessage(pluginData, msg.channel, "Cannot remove roles from this user: insufficient permissions"); + } + + const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); + if (!roleId) { + return sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + } + + const config = pluginData.config.getForMessage(msg); + if (!config.assignable_roles.includes(roleId)) { + return sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + } + + // Sanity check: make sure the role is configured properly + const role = (msg.channel as GuildChannel).guild.roles.get(roleId); + if (!role) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Unknown role configured for 'roles' plugin: ${roleId}`, + }); + return sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + } + + if (!args.member.roles.includes(roleId)) { + return sendErrorMessage(pluginData, msg.channel, "Member doesn't have that role"); + } + + pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id); + + await args.member.removeRole(roleId); + + pluginData.state.logs.log(LogType.MEMBER_ROLE_REMOVE, { + member: stripObjectToScalars(args.member, ["user", "roles"]), + roles: role.name, + mod: stripObjectToScalars(msg.author), + }); + + sendSuccessMessage( + pluginData, + msg.channel, + `Removed role **${role.name}** removed from ${verboseUserMention(args.member.user)}!`, + ); + }, +}); diff --git a/backend/src/plugins/Roles/types.ts b/backend/src/plugins/Roles/types.ts new file mode 100644 index 00000000..fa86518f --- /dev/null +++ b/backend/src/plugins/Roles/types.ts @@ -0,0 +1,19 @@ +import * as t from "io-ts"; +import { BasePluginType, eventListener, command } from "knub"; +import { GuildLogs } from "src/data/GuildLogs"; + +export const ConfigSchema = t.type({ + can_assign: t.boolean, + can_mass_assign: t.boolean, + assignable_roles: t.array(t.string), +}); +export type TConfigSchema = t.TypeOf; + +export interface RolesPluginType extends BasePluginType { + config: TConfigSchema; + state: { + logs: GuildLogs; + }; +} + +export const rolesCmd = command(); diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..aad4e1d6 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,6 +13,7 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { RolesPlugin } from "./Roles/RolesPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -23,6 +24,7 @@ export const guildPlugins: Array> = [ MessageSaverPlugin, NameHistoryPlugin, RemindersPlugin, + RolesPlugin, TagsPlugin, UsernameSaverPlugin, UtilityPlugin, From d8a52c4619929542924ab004e53b94dfc69acc0f Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Thu, 23 Jul 2020 01:23:36 +0200 Subject: [PATCH 5/8] Migrate Censor to new Plugin structure --- backend/src/plugins/Censor/CensorPlugin.ts | 64 +++++++ backend/src/plugins/Censor/types.ts | 36 ++++ .../plugins/Censor/util/applyFiltersToMsg.ts | 157 ++++++++++++++++++ .../src/plugins/Censor/util/censorMessage.ts | 31 ++++ .../plugins/Censor/util/onMessageCreate.ts | 17 ++ .../plugins/Censor/util/onMessageUpdate.ts | 17 ++ backend/src/plugins/availablePlugins.ts | 2 + 7 files changed, 324 insertions(+) create mode 100644 backend/src/plugins/Censor/CensorPlugin.ts create mode 100644 backend/src/plugins/Censor/types.ts create mode 100644 backend/src/plugins/Censor/util/applyFiltersToMsg.ts create mode 100644 backend/src/plugins/Censor/util/censorMessage.ts create mode 100644 backend/src/plugins/Censor/util/onMessageCreate.ts create mode 100644 backend/src/plugins/Censor/util/onMessageUpdate.ts diff --git a/backend/src/plugins/Censor/CensorPlugin.ts b/backend/src/plugins/Censor/CensorPlugin.ts new file mode 100644 index 00000000..e18be28c --- /dev/null +++ b/backend/src/plugins/Censor/CensorPlugin.ts @@ -0,0 +1,64 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { ConfigSchema, CensorPluginType } from "./types"; +import { GuildLogs } from "src/data/GuildLogs"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { onMessageCreate } from "./util/onMessageCreate"; +import { onMessageUpdate } from "./util/onMessageUpdate"; + +const defaultOptions: PluginOptions = { + config: { + filter_zalgo: false, + filter_invites: true, + invite_guild_whitelist: null, + invite_guild_blacklist: null, + invite_code_whitelist: null, + invite_code_blacklist: null, + allow_group_dm_invites: false, + + filter_domains: false, + domain_whitelist: null, + domain_blacklist: null, + + blocked_tokens: null, + blocked_words: null, + blocked_regex: null, + }, + + overrides: [ + { + level: ">=50", + config: { + filter_zalgo: false, + filter_invites: false, + filter_domains: false, + blocked_tokens: null, + blocked_words: null, + blocked_regex: null, + }, + }, + ], +}; + +export const CensorPlugin = zeppelinPlugin()("censor", { + configSchema: ConfigSchema, + defaultOptions, + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.serverLogs = new GuildLogs(guild.id); + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + + state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.savedMessages.events.on("create", state.onMessageCreateFn); + + state.onMessageUpdateFn = msg => onMessageUpdate(pluginData, msg); + state.savedMessages.events.on("update", state.onMessageUpdateFn); + }, + + onUnload(pluginData) { + pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn); + pluginData.state.savedMessages.events.off("update", pluginData.state.onMessageUpdateFn); + }, +}); diff --git a/backend/src/plugins/Censor/types.ts b/backend/src/plugins/Censor/types.ts new file mode 100644 index 00000000..ac03af15 --- /dev/null +++ b/backend/src/plugins/Censor/types.ts @@ -0,0 +1,36 @@ +import * as t from "io-ts"; +import { BasePluginType, eventListener } from "knub"; +import { tNullable } from "src/utils"; +import { TSafeRegex } from "src/validatorUtils"; +import { GuildLogs } from "src/data/GuildLogs"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; + +export const ConfigSchema = t.type({ + filter_zalgo: t.boolean, + filter_invites: t.boolean, + invite_guild_whitelist: tNullable(t.array(t.string)), + invite_guild_blacklist: tNullable(t.array(t.string)), + invite_code_whitelist: tNullable(t.array(t.string)), + invite_code_blacklist: tNullable(t.array(t.string)), + allow_group_dm_invites: t.boolean, + filter_domains: t.boolean, + domain_whitelist: tNullable(t.array(t.string)), + domain_blacklist: tNullable(t.array(t.string)), + blocked_tokens: tNullable(t.array(t.string)), + blocked_words: tNullable(t.array(t.string)), + blocked_regex: tNullable(t.array(TSafeRegex)), +}); +export type TConfigSchema = t.TypeOf; + +export interface CensorPluginType extends BasePluginType { + config: TConfigSchema; + state: { + serverLogs: GuildLogs; + savedMessages: GuildSavedMessages; + + onMessageCreateFn; + onMessageUpdateFn; + }; +} + +export const censorEvent = eventListener(); diff --git a/backend/src/plugins/Censor/util/applyFiltersToMsg.ts b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts new file mode 100644 index 00000000..7e388c96 --- /dev/null +++ b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts @@ -0,0 +1,157 @@ +import { PluginData } from "knub"; +import { CensorPluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { Embed, GuildInvite } from "eris"; +import { ZalgoRegex } from "src/data/Zalgo"; +import { getInviteCodesInString, getUrlsInString, resolveMember, resolveInvite } from "src/utils"; +import cloneDeep from "lodash.clonedeep"; +import { censorMessage } from "./censorMessage"; +import escapeStringRegexp from "escape-string-regexp"; +import { logger } from "src/logger"; + +export async function applyFiltersToMsg( + pluginData: PluginData, + savedMessage: SavedMessage, +): Promise { + const member = await resolveMember(pluginData.client, pluginData.guild, savedMessage.user_id); + const config = pluginData.config.getMatchingConfig({ member, channelId: savedMessage.channel_id }); + + let messageContent = savedMessage.data.content || ""; + if (savedMessage.data.attachments) messageContent += " " + JSON.stringify(savedMessage.data.attachments); + if (savedMessage.data.embeds) { + const embeds = (savedMessage.data.embeds as Embed[]).map(e => cloneDeep(e)); + for (const embed of embeds) { + if (embed.type === "video") { + // Ignore video descriptions as they're not actually shown on the embed + delete embed.description; + } + } + + messageContent += " " + JSON.stringify(embeds); + } + + // Filter zalgo + const filterZalgo = config.filter_zalgo; + if (filterZalgo) { + const result = ZalgoRegex.exec(messageContent); + if (result) { + censorMessage(pluginData, savedMessage, "zalgo detected"); + return true; + } + } + + // Filter invites + const filterInvites = config.filter_invites; + if (filterInvites) { + const inviteGuildWhitelist = config.invite_guild_whitelist; + const inviteGuildBlacklist = config.invite_guild_blacklist; + const inviteCodeWhitelist = config.invite_code_whitelist; + const inviteCodeBlacklist = config.invite_code_blacklist; + const allowGroupDMInvites = config.allow_group_dm_invites; + + const inviteCodes = getInviteCodesInString(messageContent); + + const invites: Array = await Promise.all( + inviteCodes.map(code => resolveInvite(pluginData.client, code)), + ); + + for (const invite of invites) { + // Always filter unknown invites if invite filtering is enabled + if (invite == null) { + censorMessage(pluginData, savedMessage, `unknown invite not found in whitelist`); + return true; + } + + if (!invite.guild && !allowGroupDMInvites) { + censorMessage(pluginData, savedMessage, `group dm invites are not allowed`); + return true; + } + + if (inviteGuildWhitelist && !inviteGuildWhitelist.includes(invite.guild.id)) { + censorMessage( + pluginData, + savedMessage, + `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) not found in whitelist`, + ); + return true; + } + + if (inviteGuildBlacklist && inviteGuildBlacklist.includes(invite.guild.id)) { + censorMessage( + pluginData, + savedMessage, + `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) found in blacklist`, + ); + return true; + } + + if (inviteCodeWhitelist && !inviteCodeWhitelist.includes(invite.code)) { + censorMessage(pluginData, savedMessage, `invite code (\`${invite.code}\`) not found in whitelist`); + return true; + } + + if (inviteCodeBlacklist && inviteCodeBlacklist.includes(invite.code)) { + censorMessage(pluginData, savedMessage, `invite code (\`${invite.code}\`) found in blacklist`); + return true; + } + } + } + + // Filter domains + const filterDomains = config.filter_domains; + if (filterDomains) { + const domainWhitelist = config.domain_whitelist; + const domainBlacklist = config.domain_blacklist; + + const urls = getUrlsInString(messageContent); + for (const thisUrl of urls) { + if (domainWhitelist && !domainWhitelist.includes(thisUrl.hostname)) { + censorMessage(pluginData, savedMessage, `domain (\`${thisUrl.hostname}\`) not found in whitelist`); + return true; + } + + if (domainBlacklist && domainBlacklist.includes(thisUrl.hostname)) { + censorMessage(pluginData, savedMessage, `domain (\`${thisUrl.hostname}\`) found in blacklist`); + return true; + } + } + } + + // Filter tokens + const blockedTokens = config.blocked_tokens || []; + for (const token of blockedTokens) { + if (messageContent.toLowerCase().includes(token.toLowerCase())) { + censorMessage(pluginData, savedMessage, `blocked token (\`${token}\`) found`); + return true; + } + } + + // Filter words + const blockedWords = config.blocked_words || []; + for (const word of blockedWords) { + const regex = new RegExp(`\\b${escapeStringRegexp(word)}\\b`, "i"); + if (regex.test(messageContent)) { + censorMessage(pluginData, savedMessage, `blocked word (\`${word}\`) found`); + return true; + } + } + + // Filter regex + const blockedRegex: RegExp[] = config.blocked_regex || []; + for (const [i, regex] of blockedRegex.entries()) { + if (typeof regex.test !== "function") { + logger.info( + `[DEBUG] Regex <${regex}> was not a regex; index ${i} of censor.blocked_regex for guild ${pluginData.guild.name} (${pluginData.guild.id})`, + ); + continue; + } + + // We're testing both the original content and content + attachments/embeds here so regexes that use ^ and $ still match the regular content properly + if (regex.test(savedMessage.data.content) || regex.test(messageContent)) { + censorMessage(pluginData, savedMessage, `blocked regex (\`${regex.source}\`) found`); + return true; + } + } + + return false; +} diff --git a/backend/src/plugins/Censor/util/censorMessage.ts b/backend/src/plugins/Censor/util/censorMessage.ts new file mode 100644 index 00000000..27b41160 --- /dev/null +++ b/backend/src/plugins/Censor/util/censorMessage.ts @@ -0,0 +1,31 @@ +import { PluginData } from "knub"; +import { CensorPluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { LogType } from "src/data/LogType"; +import { stripObjectToScalars, resolveUser } from "src/utils"; +import { disableCodeBlocks, deactivateMentions } from "knub/dist/helpers"; + +export async function censorMessage( + pluginData: PluginData, + savedMessage: SavedMessage, + reason: string, +) { + pluginData.state.serverLogs.ignoreLog(LogType.MESSAGE_DELETE, savedMessage.id); + + try { + await pluginData.client.deleteMessage(savedMessage.channel_id, savedMessage.id, "Censored"); + } catch (e) { + return; + } + + const user = await resolveUser(pluginData.client, savedMessage.user_id); + const channel = pluginData.guild.channels.get(savedMessage.channel_id); + + pluginData.state.serverLogs.log(LogType.CENSOR, { + user: stripObjectToScalars(user), + channel: stripObjectToScalars(channel), + reason, + message: savedMessage, + messageText: disableCodeBlocks(deactivateMentions(savedMessage.data.content)), + }); +} diff --git a/backend/src/plugins/Censor/util/onMessageCreate.ts b/backend/src/plugins/Censor/util/onMessageCreate.ts new file mode 100644 index 00000000..dbbb11e6 --- /dev/null +++ b/backend/src/plugins/Censor/util/onMessageCreate.ts @@ -0,0 +1,17 @@ +import { PluginData } from "knub"; +import { CensorPluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { applyFiltersToMsg } from "./applyFiltersToMsg"; + +export async function onMessageCreate(pluginData: PluginData, savedMessage: SavedMessage) { + if (savedMessage.is_bot) return; + const lock = await pluginData.locks.acquire(`message-${savedMessage.id}`); + + const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage); + + if (wasDeleted) { + lock.interrupt(); + } else { + lock.unlock(); + } +} diff --git a/backend/src/plugins/Censor/util/onMessageUpdate.ts b/backend/src/plugins/Censor/util/onMessageUpdate.ts new file mode 100644 index 00000000..f427cfef --- /dev/null +++ b/backend/src/plugins/Censor/util/onMessageUpdate.ts @@ -0,0 +1,17 @@ +import { PluginData } from "knub"; +import { CensorPluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { applyFiltersToMsg } from "./applyFiltersToMsg"; + +export async function onMessageUpdate(pluginData: PluginData, savedMessage: SavedMessage) { + if (savedMessage.is_bot) return; + const lock = await pluginData.locks.acquire(`message-${savedMessage.id}`); + + const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage); + + if (wasDeleted) { + lock.interrupt(); + } else { + lock.unlock(); + } +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..d1b04e8b 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,10 +13,12 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { CensorPlugin } from "./Censor/CensorPlugin"; // prettier-ignore export const guildPlugins: Array> = [ AutoReactionsPlugin, + CensorPlugin, LocateUserPlugin, PersistPlugin, PingableRolesPlugin, From dc6e86c877c8e9575ae02838e32a1a71f5db94f9 Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Thu, 23 Jul 2020 01:33:01 +0200 Subject: [PATCH 6/8] Migrate GuildInfoSaver to new Plugin structure --- .../GuildInfoSaver/GuildInfoSaverPlugin.ts | 27 +++++++++++++++++++ backend/src/plugins/GuildInfoSaver/types.ts | 9 +++++++ backend/src/plugins/availablePlugins.ts | 2 ++ 3 files changed, 38 insertions(+) create mode 100644 backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts create mode 100644 backend/src/plugins/GuildInfoSaver/types.ts diff --git a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts new file mode 100644 index 00000000..29649dab --- /dev/null +++ b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts @@ -0,0 +1,27 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginData } from "knub"; +import { AllowedGuilds } from "src/data/AllowedGuilds"; +import { GuildInfoSaverPluginType } from "./types"; +import { MINUTES } from "src/utils"; + +export const GuildInfoSaverPlugin = zeppelinPlugin()("guild_info_saver", { + showInDocs: false, + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.allowedGuilds = new AllowedGuilds(); + + updateGuildInfo(pluginData); + state.updateInterval = setInterval(() => updateGuildInfo(pluginData), 60 * MINUTES); + }, +}); + +function updateGuildInfo(pluginData: PluginData) { + pluginData.state.allowedGuilds.updateInfo( + pluginData.guild.id, + pluginData.guild.name, + pluginData.guild.iconURL, + pluginData.guild.ownerID, + ); +} diff --git a/backend/src/plugins/GuildInfoSaver/types.ts b/backend/src/plugins/GuildInfoSaver/types.ts new file mode 100644 index 00000000..33e913c9 --- /dev/null +++ b/backend/src/plugins/GuildInfoSaver/types.ts @@ -0,0 +1,9 @@ +import { BasePluginType } from "knub"; +import { AllowedGuilds } from "src/data/AllowedGuilds"; + +export interface GuildInfoSaverPluginType extends BasePluginType { + state: { + allowedGuilds: AllowedGuilds; + updateInterval: NodeJS.Timeout; + }; +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..44aa2914 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,10 +13,12 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { GuildInfoSaverPlugin } from "./GuildInfoSaver/GuildInfoSaverPlugin"; // prettier-ignore export const guildPlugins: Array> = [ AutoReactionsPlugin, + GuildInfoSaverPlugin, LocateUserPlugin, PersistPlugin, PingableRolesPlugin, From e682e9b8c554a50974f4242f06ccce581470dd15 Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Thu, 23 Jul 2020 02:06:50 +0200 Subject: [PATCH 7/8] Migrate AutoDelete to new Plugin structure --- .../plugins/AutoDelete/AutoDeletePlugin.ts | 48 +++++++++++++++++++ backend/src/plugins/AutoDelete/types.ts | 37 ++++++++++++++ .../util/addMessageToDeletionQueue.ts | 17 +++++++ .../plugins/AutoDelete/util/deleteNextItem.ts | 28 +++++++++++ .../AutoDelete/util/onMessageCreate.ts | 26 ++++++++++ .../AutoDelete/util/onMessageDelete.ts | 12 +++++ .../AutoDelete/util/onMessageDeleteBulk.ts | 10 ++++ .../AutoDelete/util/scheduleNextDeletion.ts | 14 ++++++ backend/src/plugins/availablePlugins.ts | 2 + 9 files changed, 194 insertions(+) create mode 100644 backend/src/plugins/AutoDelete/AutoDeletePlugin.ts create mode 100644 backend/src/plugins/AutoDelete/types.ts create mode 100644 backend/src/plugins/AutoDelete/util/addMessageToDeletionQueue.ts create mode 100644 backend/src/plugins/AutoDelete/util/deleteNextItem.ts create mode 100644 backend/src/plugins/AutoDelete/util/onMessageCreate.ts create mode 100644 backend/src/plugins/AutoDelete/util/onMessageDelete.ts create mode 100644 backend/src/plugins/AutoDelete/util/onMessageDeleteBulk.ts create mode 100644 backend/src/plugins/AutoDelete/util/scheduleNextDeletion.ts diff --git a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts new file mode 100644 index 00000000..ce55d90f --- /dev/null +++ b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts @@ -0,0 +1,48 @@ +import { PluginOptions } from "knub"; +import { AutoDeletePluginType, ConfigSchema } from "./types"; +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildLogs } from "src/data/GuildLogs"; +import { onMessageCreate } from "./util/onMessageCreate"; +import { onMessageDelete } from "./util/onMessageDelete"; +import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk"; + +const defaultOptions: PluginOptions = { + config: { + enabled: false, + delay: "5s", + }, +}; + +export const AutoDeletePlugin = zeppelinPlugin()("auto_delete", { + configSchema: ConfigSchema, + defaultOptions, + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.guildSavedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.guildLogs = new GuildLogs(guild.id); + + state.deletionQueue = []; + state.nextDeletion = null; + state.nextDeletionTimeout = null; + + state.maxDelayWarningSent = false; + + state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.guildSavedMessages.events.on("create", state.onMessageCreateFn); + + state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); + state.guildSavedMessages.events.on("delete", state.onMessageDeleteFn); + + state.onMessageDeleteBulkFn = msgs => onMessageDeleteBulk(pluginData, msgs); + state.guildSavedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn); + }, + + onUnload(pluginData) { + pluginData.state.guildSavedMessages.events.off("create", pluginData.state.onMessageCreateFn); + pluginData.state.guildSavedMessages.events.off("delete", pluginData.state.onMessageDeleteFn); + pluginData.state.guildSavedMessages.events.off("deleteBulk", pluginData.state.onMessageDeleteBulkFn); + }, +}); diff --git a/backend/src/plugins/AutoDelete/types.ts b/backend/src/plugins/AutoDelete/types.ts new file mode 100644 index 00000000..1eb9342d --- /dev/null +++ b/backend/src/plugins/AutoDelete/types.ts @@ -0,0 +1,37 @@ +import * as t from "io-ts"; +import { BasePluginType } from "knub"; +import { tDelayString, MINUTES } from "src/utils"; +import { GuildLogs } from "src/data/GuildLogs"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { SavedMessage } from "src/data/entities/SavedMessage"; + +export const MAX_DELAY = 5 * MINUTES; + +export interface IDeletionQueueItem { + deleteAt: number; + message: SavedMessage; +} + +export const ConfigSchema = t.type({ + enabled: t.boolean, + delay: tDelayString, +}); +export type TConfigSchema = t.TypeOf; + +export interface AutoDeletePluginType extends BasePluginType { + config: TConfigSchema; + state: { + guildSavedMessages: GuildSavedMessages; + guildLogs: GuildLogs; + + deletionQueue: IDeletionQueueItem[]; + nextDeletion: number; + nextDeletionTimeout; + + maxDelayWarningSent: boolean; + + onMessageCreateFn; + onMessageDeleteFn; + onMessageDeleteBulkFn; + }; +} diff --git a/backend/src/plugins/AutoDelete/util/addMessageToDeletionQueue.ts b/backend/src/plugins/AutoDelete/util/addMessageToDeletionQueue.ts new file mode 100644 index 00000000..b727a3bc --- /dev/null +++ b/backend/src/plugins/AutoDelete/util/addMessageToDeletionQueue.ts @@ -0,0 +1,17 @@ +import { PluginData } from "knub"; +import { AutoDeletePluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { scheduleNextDeletion } from "./scheduleNextDeletion"; +import { sorter } from "src/utils"; + +export function addMessageToDeletionQueue( + pluginData: PluginData, + msg: SavedMessage, + delay: number, +) { + const deleteAt = Date.now() + delay; + pluginData.state.deletionQueue.push({ deleteAt, message: msg }); + pluginData.state.deletionQueue.sort(sorter("deleteAt")); + + scheduleNextDeletion(pluginData); +} diff --git a/backend/src/plugins/AutoDelete/util/deleteNextItem.ts b/backend/src/plugins/AutoDelete/util/deleteNextItem.ts new file mode 100644 index 00000000..593914df --- /dev/null +++ b/backend/src/plugins/AutoDelete/util/deleteNextItem.ts @@ -0,0 +1,28 @@ +import { PluginData } from "knub"; +import { AutoDeletePluginType } from "../types"; +import moment from "moment-timezone"; +import { LogType } from "src/data/LogType"; +import { stripObjectToScalars, resolveUser } from "src/utils"; +import { logger } from "src/logger"; +import { scheduleNextDeletion } from "./scheduleNextDeletion"; + +export async function deleteNextItem(pluginData: PluginData) { + const [itemToDelete] = pluginData.state.deletionQueue.splice(0, 1); + if (!itemToDelete) return; + + pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id); + pluginData.client.deleteMessage(itemToDelete.message.channel_id, itemToDelete.message.id).catch(logger.warn); + + scheduleNextDeletion(pluginData); + + const user = await resolveUser(pluginData.client, itemToDelete.message.user_id); + const channel = pluginData.guild.channels.get(itemToDelete.message.channel_id); + const messageDate = moment(itemToDelete.message.data.timestamp, "x").format("YYYY-MM-DD HH:mm:ss"); + + pluginData.state.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, { + message: itemToDelete.message, + user: stripObjectToScalars(user), + channel: stripObjectToScalars(channel), + messageDate, + }); +} diff --git a/backend/src/plugins/AutoDelete/util/onMessageCreate.ts b/backend/src/plugins/AutoDelete/util/onMessageCreate.ts new file mode 100644 index 00000000..11dad95d --- /dev/null +++ b/backend/src/plugins/AutoDelete/util/onMessageCreate.ts @@ -0,0 +1,26 @@ +import { AutoDeletePluginType, MAX_DELAY } from "../types"; +import { PluginData } from "knub"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { convertDelayStringToMS, resolveMember } from "src/utils"; +import { LogType } from "src/data/LogType"; +import { addMessageToDeletionQueue } from "./addMessageToDeletionQueue"; + +export async function onMessageCreate(pluginData: PluginData, msg: SavedMessage) { + const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); + const config = pluginData.config.getMatchingConfig({ member, channelId: msg.channel_id }); + if (config.enabled) { + let delay = convertDelayStringToMS(config.delay); + + if (delay > MAX_DELAY) { + delay = MAX_DELAY; + if (!pluginData.state.maxDelayWarningSent) { + pluginData.state.guildLogs.log(LogType.BOT_ALERT, { + body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`, + }); + pluginData.state.maxDelayWarningSent = true; + } + } + + addMessageToDeletionQueue(pluginData, msg, delay); + } +} diff --git a/backend/src/plugins/AutoDelete/util/onMessageDelete.ts b/backend/src/plugins/AutoDelete/util/onMessageDelete.ts new file mode 100644 index 00000000..e0e96382 --- /dev/null +++ b/backend/src/plugins/AutoDelete/util/onMessageDelete.ts @@ -0,0 +1,12 @@ +import { PluginData } from "knub"; +import { AutoDeletePluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { scheduleNextDeletion } from "./scheduleNextDeletion"; + +export function onMessageDelete(pluginData: PluginData, msg: SavedMessage) { + const indexToDelete = pluginData.state.deletionQueue.findIndex(item => item.message.id === msg.id); + if (indexToDelete > -1) { + pluginData.state.deletionQueue.splice(indexToDelete, 1); + scheduleNextDeletion(pluginData); + } +} diff --git a/backend/src/plugins/AutoDelete/util/onMessageDeleteBulk.ts b/backend/src/plugins/AutoDelete/util/onMessageDeleteBulk.ts new file mode 100644 index 00000000..9993254c --- /dev/null +++ b/backend/src/plugins/AutoDelete/util/onMessageDeleteBulk.ts @@ -0,0 +1,10 @@ +import { AutoDeletePluginType } from "../types"; +import { PluginData } from "knub"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { onMessageDelete } from "./onMessageDelete"; + +export function onMessageDeleteBulk(pluginData: PluginData, messages: SavedMessage[]) { + for (const msg of messages) { + onMessageDelete(pluginData, msg); + } +} diff --git a/backend/src/plugins/AutoDelete/util/scheduleNextDeletion.ts b/backend/src/plugins/AutoDelete/util/scheduleNextDeletion.ts new file mode 100644 index 00000000..5a9f2797 --- /dev/null +++ b/backend/src/plugins/AutoDelete/util/scheduleNextDeletion.ts @@ -0,0 +1,14 @@ +import { PluginData } from "knub"; +import { AutoDeletePluginType } from "../types"; +import { deleteNextItem } from "./deleteNextItem"; + +export function scheduleNextDeletion(pluginData: PluginData) { + if (pluginData.state.deletionQueue.length === 0) { + clearTimeout(pluginData.state.nextDeletionTimeout); + return; + } + + const firstDeleteAt = pluginData.state.deletionQueue[0].deleteAt; + clearTimeout(pluginData.state.nextDeletionTimeout); + pluginData.state.nextDeletionTimeout = setTimeout(() => deleteNextItem(pluginData), firstDeleteAt - Date.now()); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..2583acc5 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,9 +13,11 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { AutoDeletePlugin } from "./AutoDelete/AutoDeletePlugin"; // prettier-ignore export const guildPlugins: Array> = [ + AutoDeletePlugin, AutoReactionsPlugin, LocateUserPlugin, PersistPlugin, From 5c070643a3647116246a18cbc5a25859d2a4e6bc Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Thu, 23 Jul 2020 21:26:22 +0200 Subject: [PATCH 8/8] Migrate Post to new Plugin structure --- backend/src/plugins/Post/PostPlugin.ts | 58 ++++++ backend/src/plugins/Post/commands/EditCmd.ts | 30 +++ .../src/plugins/Post/commands/EditEmbedCmd.ts | 63 ++++++ backend/src/plugins/Post/commands/PostCmd.ts | 23 +++ .../src/plugins/Post/commands/PostEmbedCmd.ts | 76 +++++++ .../Post/commands/SchedluedPostsDeleteCmd.ts | 25 +++ .../Post/commands/ScheduledPostsListCmd.ts | 57 ++++++ .../Post/commands/ScheduledPostsShowCmd.ts | 26 +++ backend/src/plugins/Post/types.ts | 23 +++ .../src/plugins/Post/util/actualPostCmd.ts | 185 ++++++++++++++++++ .../src/plugins/Post/util/formatContent.ts | 3 + .../plugins/Post/util/parseScheduleTime.ts | 32 +++ backend/src/plugins/Post/util/postMessage.ts | 67 +++++++ .../plugins/Post/util/scheduledPostLoop.ts | 82 ++++++++ backend/src/plugins/availablePlugins.ts | 2 + 15 files changed, 752 insertions(+) create mode 100644 backend/src/plugins/Post/PostPlugin.ts create mode 100644 backend/src/plugins/Post/commands/EditCmd.ts create mode 100644 backend/src/plugins/Post/commands/EditEmbedCmd.ts create mode 100644 backend/src/plugins/Post/commands/PostCmd.ts create mode 100644 backend/src/plugins/Post/commands/PostEmbedCmd.ts create mode 100644 backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts create mode 100644 backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts create mode 100644 backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts create mode 100644 backend/src/plugins/Post/types.ts create mode 100644 backend/src/plugins/Post/util/actualPostCmd.ts create mode 100644 backend/src/plugins/Post/util/formatContent.ts create mode 100644 backend/src/plugins/Post/util/parseScheduleTime.ts create mode 100644 backend/src/plugins/Post/util/postMessage.ts create mode 100644 backend/src/plugins/Post/util/scheduledPostLoop.ts diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts new file mode 100644 index 00000000..5308d0ba --- /dev/null +++ b/backend/src/plugins/Post/PostPlugin.ts @@ -0,0 +1,58 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { ConfigSchema, PostPluginType } from "./types"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildScheduledPosts } from "src/data/GuildScheduledPosts"; +import { GuildLogs } from "src/data/GuildLogs"; +import { PostCmd } from "./commands/PostCmd"; +import { PostEmbedCmd } from "./commands/PostEmbedCmd"; +import { EditCmd } from "./commands/EditCmd"; +import { EditEmbedCmd } from "./commands/EditEmbedCmd"; +import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd"; +import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd"; +import { ScheduledPostsDeleteCmd } from "./commands/SchedluedPostsDeleteCmd"; +import { scheduledPostLoop } from "./util/scheduledPostLoop"; + +const defaultOptions: PluginOptions = { + config: { + can_post: false, + }, + overrides: [ + { + level: ">=100", + config: { + can_post: true, + }, + }, + ], +}; + +export const PostPlugin = zeppelinPlugin()("post", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + commands: [ + PostCmd, + PostEmbedCmd, + EditCmd, + EditEmbedCmd, + ScheduledPostsShowCmd, + ScheduledPostsListCmd, + ScheduledPostsDeleteCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.scheduledPosts = GuildScheduledPosts.getGuildInstance(guild.id); + state.logs = new GuildLogs(guild.id); + + scheduledPostLoop(pluginData); + }, + + onUnload(pluginData) { + clearTimeout(pluginData.state.scheduledPostLoopTimeout); + }, +}); diff --git a/backend/src/plugins/Post/commands/EditCmd.ts b/backend/src/plugins/Post/commands/EditCmd.ts new file mode 100644 index 00000000..21594e13 --- /dev/null +++ b/backend/src/plugins/Post/commands/EditCmd.ts @@ -0,0 +1,30 @@ +import { postCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { formatContent } from "../util/formatContent"; + +export const EditCmd = postCmd({ + trigger: "edit", + permission: "can_post", + + signature: { + messageId: ct.string(), + content: ct.string({ catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + const savedMessage = await pluginData.state.savedMessages.find(args.messageId); + if (!savedMessage) { + sendErrorMessage(pluginData, msg.channel, "Unknown message"); + return; + } + + if (savedMessage.user_id !== pluginData.client.user.id) { + sendErrorMessage(pluginData, msg.channel, "Message wasn't posted by me"); + return; + } + + await pluginData.client.editMessage(savedMessage.channel_id, savedMessage.id, formatContent(args.content)); + sendSuccessMessage(pluginData, msg.channel, "Message edited"); + }, +}); diff --git a/backend/src/plugins/Post/commands/EditEmbedCmd.ts b/backend/src/plugins/Post/commands/EditEmbedCmd.ts new file mode 100644 index 00000000..4ccb4d98 --- /dev/null +++ b/backend/src/plugins/Post/commands/EditEmbedCmd.ts @@ -0,0 +1,63 @@ +import { postCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { Embed } from "eris"; +import { trimLines } from "src/utils"; +import { formatContent } from "../util/formatContent"; + +const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/; + +export const EditEmbedCmd = postCmd({ + trigger: "edit_embed", + permission: "can_post", + + signature: { + messageId: ct.string(), + maincontent: ct.string({ catchAll: true }), + + title: ct.string({ option: true }), + content: ct.string({ option: true }), + color: ct.string({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + const savedMessage = await pluginData.state.savedMessages.find(args.messageId); + if (!savedMessage) { + sendErrorMessage(pluginData, msg.channel, "Unknown message"); + return; + } + + const content = args.content || args.maincontent; + + let color = null; + if (args.color) { + const colorMatch = args.color.match(COLOR_MATCH_REGEX); + if (!colorMatch) { + sendErrorMessage(pluginData, msg.channel, "Invalid color specified, use hex colors"); + return; + } + + color = parseInt(colorMatch[1], 16); + } + + const embed: Embed = savedMessage.data.embeds[0] as Embed; + embed.type = "rich"; + if (args.title) embed.title = args.title; + if (content) embed.description = formatContent(content); + if (color) embed.color = color; + + await pluginData.client.editMessage(savedMessage.channel_id, savedMessage.id, { embed }); + await sendSuccessMessage(pluginData, msg.channel, "Embed edited"); + + if (args.content) { + const prefix = pluginData.guildConfig.prefix || "!"; + msg.channel.createMessage( + trimLines(` + <@!${msg.author.id}> You can now specify an embed's content directly at the end of the command: + \`${prefix}edit_embed -title "Some title" content goes here\` + The \`-content\` option will soon be removed in favor of this. + `), + ); + } + }, +}); diff --git a/backend/src/plugins/Post/commands/PostCmd.ts b/backend/src/plugins/Post/commands/PostCmd.ts new file mode 100644 index 00000000..25ae5a6b --- /dev/null +++ b/backend/src/plugins/Post/commands/PostCmd.ts @@ -0,0 +1,23 @@ +import { postCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { actualPostCmd } from "../util/actualPostCmd"; + +export const PostCmd = postCmd({ + trigger: "post", + permission: "can_post", + + signature: { + channel: ct.textChannel(), + content: ct.string({ catchAll: true }), + + "enable-mentions": ct.bool({ option: true, isSwitch: true }), + schedule: ct.string({ option: true }), + repeat: ct.delay({ option: true }), + "repeat-until": ct.string({ option: true }), + "repeat-times": ct.number({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + actualPostCmd(pluginData, msg, args.channel, { content: args.content }, args); + }, +}); diff --git a/backend/src/plugins/Post/commands/PostEmbedCmd.ts b/backend/src/plugins/Post/commands/PostEmbedCmd.ts new file mode 100644 index 00000000..299aef7c --- /dev/null +++ b/backend/src/plugins/Post/commands/PostEmbedCmd.ts @@ -0,0 +1,76 @@ +import { postCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { actualPostCmd } from "../util/actualPostCmd"; +import { sendErrorMessage } from "src/pluginUtils"; +import { Embed } from "eris"; +import { isValidEmbed } from "src/utils"; +import { formatContent } from "../util/formatContent"; + +const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/; + +export const PostEmbedCmd = postCmd({ + trigger: "post_embed", + permission: "can_post", + + signature: { + channel: ct.textChannel(), + maincontent: ct.string({ catchAll: true }), + + title: ct.string({ option: true }), + content: ct.string({ option: true }), + color: ct.string({ option: true }), + raw: ct.bool({ option: true, isSwitch: true, shortcut: "r" }), + + schedule: ct.string({ option: true }), + repeat: ct.delay({ option: true }), + "repeat-until": ct.string({ option: true }), + "repeat-times": ct.number({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + const content = args.content || args.maincontent; + + if (!args.title && !content) { + sendErrorMessage(pluginData, msg.channel, "Title or content required"); + return; + } + + let color = null; + if (args.color) { + const colorMatch = args.color.toLowerCase().match(COLOR_MATCH_REGEX); + if (!colorMatch) { + sendErrorMessage(pluginData, msg.channel, "Invalid color specified, use hex colors"); + return; + } + + color = parseInt(colorMatch[1], 16); + } + + let embed: Embed = { type: "rich" }; + if (args.title) embed.title = args.title; + if (color) embed.color = color; + + if (content) { + if (args.raw) { + let parsed; + try { + parsed = JSON.parse(content); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, "Syntax error in embed JSON"); + return; + } + + if (!isValidEmbed(parsed)) { + sendErrorMessage(pluginData, msg.channel, "Embed is not valid"); + return; + } + + embed = Object.assign({}, embed, parsed); + } else { + embed.description = formatContent(content); + } + } + + actualPostCmd(pluginData, msg, args.channel, { embed }, args); + }, +}); diff --git a/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts b/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts new file mode 100644 index 00000000..f78e7006 --- /dev/null +++ b/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts @@ -0,0 +1,25 @@ +import { postCmd } from "../types"; +import { sorter } from "src/utils"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; + +export const ScheduledPostsDeleteCmd = postCmd({ + trigger: ["scheduled_posts delete", "scheduled_posts d"], + permission: "can_post", + + signature: { + num: ct.number(), + }, + + async run({ message: msg, args, pluginData }) { + const scheduledPosts = await pluginData.state.scheduledPosts.all(); + scheduledPosts.sort(sorter("post_at")); + const post = scheduledPosts[args.num - 1]; + if (!post) { + return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + } + + await pluginData.state.scheduledPosts.delete(post.id); + sendSuccessMessage(pluginData, msg.channel, "Scheduled post deleted!"); + }, +}); diff --git a/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts new file mode 100644 index 00000000..85d3f950 --- /dev/null +++ b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts @@ -0,0 +1,57 @@ +import { postCmd } from "../types"; +import { trimLines, sorter, disableCodeBlocks, deactivateMentions, createChunkedMessage } from "src/utils"; +import humanizeDuration from "humanize-duration"; + +const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50; + +export const ScheduledPostsListCmd = postCmd({ + trigger: ["scheduled_posts", "scheduled_posts list"], + permission: "can_post", + + async run({ message: msg, pluginData }) { + const scheduledPosts = await pluginData.state.scheduledPosts.all(); + if (scheduledPosts.length === 0) { + msg.channel.createMessage("No scheduled posts"); + return; + } + + scheduledPosts.sort(sorter("post_at")); + + let i = 1; + const postLines = scheduledPosts.map(p => { + let previewText = + p.content.content || (p.content.embed && (p.content.embed.description || p.content.embed.title)) || ""; + + const isTruncated = previewText.length > SCHEDULED_POST_PREVIEW_TEXT_LENGTH; + + previewText = disableCodeBlocks(deactivateMentions(previewText)) + .replace(/\s+/g, " ") + .slice(0, SCHEDULED_POST_PREVIEW_TEXT_LENGTH); + + const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`]; + if (p.attachments.length) parts.push("*(with attachment)*"); + if (p.content.embed) parts.push("*(embed)*"); + if (p.repeat_until) { + parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`); + } + if (p.repeat_times) { + parts.push( + `*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${ + p.repeat_times === 1 ? "time" : "times" + })*`, + ); + } + parts.push(`*(${p.author_name})*`); + + return parts.join(" "); + }); + + const finalMessage = trimLines(` + ${postLines.join("\n")} + + Use \`scheduled_posts \` to view a scheduled post in full + Use \`scheduled_posts delete \` to delete a scheduled post + `); + createChunkedMessage(msg.channel, finalMessage); + }, +}); diff --git a/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts new file mode 100644 index 00000000..cc0e3a42 --- /dev/null +++ b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts @@ -0,0 +1,26 @@ +import { postCmd } from "../types"; +import { sorter } from "src/utils"; +import { sendErrorMessage } from "src/pluginUtils"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { postMessage } from "../util/postMessage"; +import { TextChannel } from "eris"; + +export const ScheduledPostsShowCmd = postCmd({ + trigger: ["scheduled_posts", "scheduled_posts show"], + permission: "can_post", + + signature: { + num: ct.number(), + }, + + async run({ message: msg, args, pluginData }) { + const scheduledPosts = await pluginData.state.scheduledPosts.all(); + scheduledPosts.sort(sorter("post_at")); + const post = scheduledPosts[args.num - 1]; + if (!post) { + return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + } + + postMessage(pluginData, msg.channel as TextChannel, post.content, post.attachments, post.enable_mentions); + }, +}); diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts new file mode 100644 index 00000000..f1d697bf --- /dev/null +++ b/backend/src/plugins/Post/types.ts @@ -0,0 +1,23 @@ +import * as t from "io-ts"; +import { BasePluginType, command } from "knub"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildScheduledPosts } from "src/data/GuildScheduledPosts"; +import { GuildLogs } from "src/data/GuildLogs"; + +export const ConfigSchema = t.type({ + can_post: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export interface PostPluginType extends BasePluginType { + config: TConfigSchema; + state: { + savedMessages: GuildSavedMessages; + scheduledPosts: GuildScheduledPosts; + logs: GuildLogs; + + scheduledPostLoopTimeout: NodeJS.Timeout; + }; +} + +export const postCmd = command(); diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts new file mode 100644 index 00000000..556d05a0 --- /dev/null +++ b/backend/src/plugins/Post/util/actualPostCmd.ts @@ -0,0 +1,185 @@ +import { Message, Channel, TextChannel } from "eris"; +import { StrictMessageContent, errorMessage, DBDateFormat, stripObjectToScalars, MINUTES } from "src/utils"; +import moment from "moment-timezone"; +import { LogType } from "src/data/LogType"; +import humanizeDuration from "humanize-duration"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { PluginData } from "knub"; +import { PostPluginType } from "../types"; +import { parseScheduleTime } from "./parseScheduleTime"; +import { postMessage } from "./postMessage"; + +const MIN_REPEAT_TIME = 5 * MINUTES; +const MAX_REPEAT_TIME = Math.pow(2, 32); +const MAX_REPEAT_UNTIL = moment().add(100, "years"); + +export async function actualPostCmd( + pluginData: PluginData, + msg: Message, + targetChannel: Channel, + content: StrictMessageContent, + opts?: { + "enable-mentions"?: boolean; + schedule?: string; + repeat?: number; + "repeat-until"?: string; + "repeat-times"?: number; + }, +) { + if (!(targetChannel instanceof TextChannel)) { + msg.channel.createMessage(errorMessage("Channel is not a text channel")); + return; + } + + if (content == null && msg.attachments.length === 0) { + msg.channel.createMessage(errorMessage("Message content or attachment required")); + return; + } + + if (opts.repeat) { + if (opts.repeat < MIN_REPEAT_TIME) { + return sendErrorMessage( + pluginData, + msg.channel, + `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`, + ); + } + if (opts.repeat > MAX_REPEAT_TIME) { + return sendErrorMessage(pluginData, msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`); + } + } + + // If this is a scheduled or repeated post, figure out the next post date + let postAt; + if (opts.schedule) { + // Schedule the post to be posted later + postAt = parseScheduleTime(opts.schedule); + if (!postAt) { + return sendErrorMessage(pluginData, msg.channel, "Invalid schedule time"); + } + } else if (opts.repeat) { + postAt = moment().add(opts.repeat, "ms"); + } + + // For repeated posts, make sure repeat-until or repeat-times is specified + let repeatUntil: moment.Moment = null; + let repeatTimes: number = null; + let repeatDetailsStr: string = null; + + if (opts["repeat-until"]) { + repeatUntil = parseScheduleTime(opts["repeat-until"]); + + // Invalid time + if (!repeatUntil) { + return sendErrorMessage(pluginData, msg.channel, "Invalid time specified for -repeat-until"); + } + if (repeatUntil.isBefore(moment())) { + return sendErrorMessage(pluginData, msg.channel, "You can't set -repeat-until in the past"); + } + if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) { + return sendErrorMessage( + pluginData, + msg.channel, + "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?", + ); + } + } else if (opts["repeat-times"]) { + repeatTimes = opts["repeat-times"]; + if (repeatTimes <= 0) { + return sendErrorMessage(pluginData, msg.channel, "-repeat-times must be 1 or more"); + } + } + + if (repeatUntil && repeatTimes) { + return sendErrorMessage(pluginData, msg.channel, "You can only use one of -repeat-until or -repeat-times at once"); + } + + if (opts.repeat && !repeatUntil && !repeatTimes) { + return sendErrorMessage( + pluginData, + msg.channel, + "You must specify -repeat-until or -repeat-times for repeated messages", + ); + } + + if (opts.repeat) { + repeatDetailsStr = repeatUntil + ? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}` + : `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`; + } + + // Save schedule/repeat information in DB + if (postAt) { + if (postAt < moment()) { + return sendErrorMessage(pluginData, msg.channel, "Post can't be scheduled to be posted in the past"); + } + + await pluginData.state.scheduledPosts.create({ + author_id: msg.author.id, + author_name: `${msg.author.username}#${msg.author.discriminator}`, + channel_id: targetChannel.id, + content, + attachments: msg.attachments, + post_at: postAt.format(DBDateFormat), + enable_mentions: opts["enable-mentions"], + repeat_interval: opts.repeat, + repeat_until: repeatUntil ? repeatUntil.format(DBDateFormat) : null, + repeat_times: repeatTimes ?? null, + }); + + if (opts.repeat) { + pluginData.state.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(targetChannel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + repeatInterval: humanizeDuration(opts.repeat), + repeatDetails: repeatDetailsStr, + }); + } else { + pluginData.state.logs.log(LogType.SCHEDULED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(targetChannel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + }); + } + } + + // When the message isn't scheduled for later, post it immediately + if (!opts.schedule) { + await postMessage(pluginData, targetChannel, content, msg.attachments, opts["enable-mentions"]); + } + + if (opts.repeat) { + pluginData.state.logs.log(LogType.REPEATED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(targetChannel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + repeatInterval: humanizeDuration(opts.repeat), + repeatDetails: repeatDetailsStr, + }); + } + + // Bot reply schenanigans + let successMessage = opts.schedule + ? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)` + : `Message posted in <#${targetChannel.id}>`; + + if (opts.repeat) { + successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`; + + if (repeatUntil) { + successMessage += ` until ${repeatUntil.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`; + } else if (repeatTimes) { + successMessage += `, ${repeatTimes} times in total`; + } + + successMessage += "."; + } + + if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) { + sendSuccessMessage(pluginData, msg.channel, successMessage); + } +} diff --git a/backend/src/plugins/Post/util/formatContent.ts b/backend/src/plugins/Post/util/formatContent.ts new file mode 100644 index 00000000..e8d6ba93 --- /dev/null +++ b/backend/src/plugins/Post/util/formatContent.ts @@ -0,0 +1,3 @@ +export function formatContent(str) { + return str.replace(/\\n/g, "\n"); +} diff --git a/backend/src/plugins/Post/util/parseScheduleTime.ts b/backend/src/plugins/Post/util/parseScheduleTime.ts new file mode 100644 index 00000000..c4d57231 --- /dev/null +++ b/backend/src/plugins/Post/util/parseScheduleTime.ts @@ -0,0 +1,32 @@ +import moment, { Moment } from "moment-timezone"; +import { convertDelayStringToMS } from "src/utils"; + +export function parseScheduleTime(str): Moment { + const dt1 = moment(str, "YYYY-MM-DD HH:mm:ss"); + if (dt1 && dt1.isValid()) return dt1; + + const dt2 = moment(str, "YYYY-MM-DD HH:mm"); + if (dt2 && dt2.isValid()) return dt2; + + const date = moment(str, "YYYY-MM-DD"); + if (date && date.isValid()) return date; + + const t1 = moment(str, "HH:mm:ss"); + if (t1 && t1.isValid()) { + if (t1.isBefore(moment())) t1.add(1, "day"); + return t1; + } + + const t2 = moment(str, "HH:mm"); + if (t2 && t2.isValid()) { + if (t2.isBefore(moment())) t2.add(1, "day"); + return t2; + } + + const delayStringMS = convertDelayStringToMS(str, "m"); + if (delayStringMS) { + return moment().add(delayStringMS, "ms"); + } + + return null; +} diff --git a/backend/src/plugins/Post/util/postMessage.ts b/backend/src/plugins/Post/util/postMessage.ts new file mode 100644 index 00000000..e45bfb24 --- /dev/null +++ b/backend/src/plugins/Post/util/postMessage.ts @@ -0,0 +1,67 @@ +import { PluginData } from "knub"; +import { PostPluginType } from "../types"; +import { TextChannel, MessageContent, Attachment, Message, Role } from "eris"; +import { downloadFile, getRoleMentions } from "src/utils"; +import fs from "fs"; +import { formatContent } from "./formatContent"; + +const fsp = fs.promises; + +export async function postMessage( + pluginData: PluginData, + channel: TextChannel, + content: MessageContent, + attachments: Attachment[] = [], + enableMentions: boolean = false, +): Promise { + if (typeof content === "string") { + content = { content }; + } + + if (content && content.content) { + content.content = formatContent(content.content); + } + + let downloadedAttachment; + let file; + if (attachments.length) { + downloadedAttachment = await downloadFile(attachments[0].url); + file = { + name: attachments[0].filename, + file: await fsp.readFile(downloadedAttachment.path), + }; + } + + const rolesMadeMentionable: Role[] = []; + if (enableMentions && content.content) { + const mentionedRoleIds = getRoleMentions(content.content); + if (mentionedRoleIds != null) { + for (const roleId of mentionedRoleIds) { + const role = pluginData.guild.roles.get(roleId); + if (role && !role.mentionable) { + await role.edit({ + mentionable: true, + }); + rolesMadeMentionable.push(role); + } + } + } + + content.allowedMentions.everyone = false; + } + + const createdMsg = await channel.createMessage(content, file); + pluginData.state.savedMessages.setPermanent(createdMsg.id); + + for (const role of rolesMadeMentionable) { + role.edit({ + mentionable: false, + }); + } + + if (downloadedAttachment) { + downloadedAttachment.deleteFn(); + } + + return createdMsg; +} diff --git a/backend/src/plugins/Post/util/scheduledPostLoop.ts b/backend/src/plugins/Post/util/scheduledPostLoop.ts new file mode 100644 index 00000000..124af05b --- /dev/null +++ b/backend/src/plugins/Post/util/scheduledPostLoop.ts @@ -0,0 +1,82 @@ +import { PluginData } from "knub"; +import { PostPluginType } from "../types"; +import { logger } from "src/logger"; +import { stripObjectToScalars, DBDateFormat, SECONDS } from "src/utils"; +import { LogType } from "src/data/LogType"; +import moment from "moment-timezone"; +import { TextChannel, User } from "eris"; +import { postMessage } from "./postMessage"; + +const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS; + +export async function scheduledPostLoop(pluginData: PluginData) { + const duePosts = await pluginData.state.scheduledPosts.getDueScheduledPosts(); + for (const post of duePosts) { + const channel = pluginData.guild.channels.get(post.channel_id); + if (channel instanceof TextChannel) { + const [username, discriminator] = post.author_name.split("#"); + const author: Partial = pluginData.client.users.get(post.author_id) || { + id: post.author_id, + username, + discriminator, + }; + + try { + const postedMessage = await postMessage( + pluginData, + channel, + post.content, + post.attachments, + post.enable_mentions, + ); + pluginData.state.logs.log(LogType.POSTED_SCHEDULED_MESSAGE, { + author: stripObjectToScalars(author), + channel: stripObjectToScalars(channel), + messageId: postedMessage.id, + }); + } catch (e) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Failed to post scheduled message by {userMention(author)} to {channelMention(channel)}`, + channel: stripObjectToScalars(channel), + author: stripObjectToScalars(author), + }); + logger.warn( + `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${pluginData.guild.name} (${pluginData.guild.id})`, + ); + } + } + + let shouldClear = true; + + if (post.repeat_interval) { + const nextPostAt = moment().add(post.repeat_interval, "ms"); + + if (post.repeat_until) { + const repeatUntil = moment(post.repeat_until, DBDateFormat); + if (nextPostAt.isSameOrBefore(repeatUntil)) { + await pluginData.state.scheduledPosts.update(post.id, { + post_at: nextPostAt.format(DBDateFormat), + }); + shouldClear = false; + } + } else if (post.repeat_times) { + if (post.repeat_times > 1) { + await pluginData.state.scheduledPosts.update(post.id, { + post_at: nextPostAt.format(DBDateFormat), + repeat_times: post.repeat_times - 1, + }); + shouldClear = false; + } + } + } + + if (shouldClear) { + await pluginData.state.scheduledPosts.delete(post.id); + } + } + + pluginData.state.scheduledPostLoopTimeout = setTimeout( + () => scheduledPostLoop(pluginData), + SCHEDULED_POST_CHECK_INTERVAL, + ); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..10f343f9 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,6 +13,7 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { PostPlugin } from "./Post/PostPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -20,6 +21,7 @@ export const guildPlugins: Array> = [ LocateUserPlugin, PersistPlugin, PingableRolesPlugin, + PostPlugin, MessageSaverPlugin, NameHistoryPlugin, RemindersPlugin,