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] 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,