diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts new file mode 100644 index 00000000..413f29da --- /dev/null +++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts @@ -0,0 +1,70 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { ConfigSchema, ReactionRolesPluginType } from "./types"; +import { GuildReactionRoles } from "src/data/GuildReactionRoles"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { Queue } from "src/Queue"; +import { autoRefreshLoop } from "./util/autoRefreshLoop"; +import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd"; +import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd"; +import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd"; +import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt"; + +const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API + +const defaultOptions: PluginOptions = { + config: { + auto_refresh_interval: MIN_AUTO_REFRESH, + + can_manage: false, + }, + + overrides: [ + { + level: ">=100", + config: { + can_manage: true, + }, + }, + ], +}; + +export const ReactionRolesPlugin = zeppelinPlugin()("reaction_roles", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + commands: [ + RefreshReactionRolesCmd, + ClearReactionRolesCmd, + InitReactionRolesCmd, + ], + + // prettier-ignore + events: [ + AddReactionRoleEvt, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id); + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.reactionRemoveQueue = new Queue(); + state.roleChangeQueue = new Queue(); + state.pendingRoleChanges = new Map(); + state.pendingRefreshes = new Set(); + + let autoRefreshInterval = pluginData.config.get().auto_refresh_interval; + if (autoRefreshInterval != null) { + autoRefreshInterval = Math.max(MIN_AUTO_REFRESH, autoRefreshInterval); + autoRefreshLoop(pluginData, autoRefreshInterval); + } + }, + + onUnload(pluginData) { + if (pluginData.state.autoRefreshTimeout) { + clearTimeout(pluginData.state.autoRefreshTimeout); + } + }, +}); diff --git a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts new file mode 100644 index 00000000..f08e07bc --- /dev/null +++ b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts @@ -0,0 +1,35 @@ +import { reactionRolesCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { TextChannel } from "eris"; + +export const ClearReactionRolesCmd = reactionRolesCmd({ + trigger: "reaction_roles clear", + permission: "can_manage", + + signature: { + messageId: ct.string(), + }, + + 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 existingReactionRoles = pluginData.state.reactionRoles.getForMessage(args.messageId); + if (!existingReactionRoles) { + sendErrorMessage(pluginData, msg.channel, "Message doesn't have reaction roles on it"); + return; + } + + pluginData.state.reactionRoles.removeFromMessage(args.messageId); + + const channel = pluginData.guild.channels.get(savedMessage.channel_id) as TextChannel; + const targetMessage = await channel.getMessage(savedMessage.id); + await targetMessage.removeReactions(); + + sendSuccessMessage(pluginData, msg.channel, "Reaction roles cleared"); + }, +}); diff --git a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts new file mode 100644 index 00000000..8f39258d --- /dev/null +++ b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts @@ -0,0 +1,107 @@ +import { reactionRolesCmd, TReactionRolePair } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { TextChannel } from "eris"; +import { RecoverablePluginError, ERRORS } from "src/RecoverablePluginError"; +import { canUseEmoji } from "src/utils"; +import { applyReactionRoleReactionsToMessage } from "../util/applyReactionRoleReactionsToMessage"; + +const CLEAR_ROLES_EMOJI = "❌"; + +export const InitReactionRolesCmd = reactionRolesCmd({ + trigger: "reaction_roles", + permission: "can_manage", + + signature: { + messageId: ct.string(), + reactionRolePairs: ct.string({ catchAll: true }), + + exclusive: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), + }, + + 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 channel = (await pluginData.guild.channels.get(savedMessage.channel_id)) as TextChannel; + if (!channel || !(channel instanceof TextChannel)) { + sendErrorMessage(pluginData, msg.channel, "Channel no longer exists"); + return; + } + + const targetMessage = await channel.getMessage(args.messageId); + if (!targetMessage) { + sendErrorMessage(pluginData, msg.channel, "Unknown message (2)"); + return; + } + + // Clear old reaction roles for the message from the DB + await pluginData.state.reactionRoles.removeFromMessage(targetMessage.id); + + // Turn "emoji = role" pairs into an array of tuples of the form [emoji, roleId] + // Emoji is either a unicode emoji or the snowflake of a custom emoji + const emojiRolePairs: TReactionRolePair[] = args.reactionRolePairs + .trim() + .split("\n") + .map(v => v.split("=").map(v => v.trim())) // tslint:disable-line + .map( + (pair): TReactionRolePair => { + const customEmojiMatch = pair[0].match(/^$/); + if (customEmojiMatch) { + return [customEmojiMatch[2], pair[1], customEmojiMatch[1]]; + } else { + return pair as TReactionRolePair; + } + }, + ); + + // Verify the specified emojis and roles are valid and usable + for (const pair of emojiRolePairs) { + if (pair[0] === CLEAR_ROLES_EMOJI) { + sendErrorMessage( + pluginData, + msg.channel, + `The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`, + ); + return; + } + + try { + if (!canUseEmoji(pluginData.client, pair[0])) { + sendErrorMessage( + pluginData, + msg.channel, + "I can only use regular emojis and custom emojis from servers I'm on", + ); + return; + } + } catch (e) { + if (e instanceof RecoverablePluginError && e.code === ERRORS.INVALID_EMOJI) { + sendErrorMessage(pluginData, msg.channel, `Invalid emoji: ${pair[0]}`); + return; + } + + throw e; + } + + if (!pluginData.guild.roles.has(pair[1])) { + sendErrorMessage(pluginData, msg.channel, `Unknown role ${pair[1]}`); + return; + } + } + + // Save the new reaction roles to the database + for (const pair of emojiRolePairs) { + await pluginData.state.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1], args.exclusive); + } + + // Apply the reactions themselves + const reactionRoles = await pluginData.state.reactionRoles.getForMessage(targetMessage.id); + await applyReactionRoleReactionsToMessage(pluginData, targetMessage.channel.id, targetMessage.id, reactionRoles); + + sendSuccessMessage(pluginData, msg.channel, "Reaction roles added"); + }, +}); diff --git a/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts new file mode 100644 index 00000000..28039503 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts @@ -0,0 +1,31 @@ +import { reactionRolesCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { refreshReactionRoles } from "../util/refreshReactionRoles"; + +export const RefreshReactionRolesCmd = reactionRolesCmd({ + trigger: "reaction_roles refresh", + permission: "can_manage", + + signature: { + messageId: ct.string(), + }, + + async run({ message: msg, args, pluginData }) { + const savedMessage = await pluginData.state.savedMessages.find(args.messageId); + if (!savedMessage) { + console.log("ah"); + sendErrorMessage(pluginData, msg.channel, "Unknown message"); + return; + } + + if (pluginData.state.pendingRefreshes.has(`${savedMessage.channel_id}-${savedMessage.id}`)) { + sendErrorMessage(pluginData, msg.channel, "Another refresh in progress"); + return; + } + + await refreshReactionRoles(pluginData, savedMessage.channel_id, savedMessage.id); + + sendSuccessMessage(pluginData, msg.channel, "Reaction roles refreshed"); + }, +}); diff --git a/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts b/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts new file mode 100644 index 00000000..9b7b045f --- /dev/null +++ b/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts @@ -0,0 +1,63 @@ +import { reactionRolesEvent } from "../types"; +import { resolveMember, noop, sleep } from "src/utils"; +import { addMemberPendingRoleChange } from "../util/addMemberPendingRoleChange"; +import { Message } from "eris"; + +const CLEAR_ROLES_EMOJI = "❌"; + +export const AddReactionRoleEvt = reactionRolesEvent({ + event: "messageReactionAdd", + + async listener(meta) { + const pluginData = meta.pluginData; + const msg = meta.args.message as Message; + const emoji = meta.args.emoji; + const userId = meta.args.userID; + + // Make sure this message has reaction roles on it + const reactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id); + if (reactionRoles.length === 0) return; + + const member = await resolveMember(pluginData.client, pluginData.guild, userId); + if (!member) return; + + if (emoji.name === CLEAR_ROLES_EMOJI) { + // User reacted with "clear roles" emoji -> clear their roles + const reactionRoleRoleIds = reactionRoles.map(rr => rr.role_id); + for (const roleId of reactionRoleRoleIds) { + addMemberPendingRoleChange(pluginData, userId, "-", roleId); + } + + pluginData.state.reactionRemoveQueue.add(async () => { + await msg.channel.removeMessageReaction(msg.id, CLEAR_ROLES_EMOJI, userId); + }); + } else { + // User reacted with a reaction role emoji -> add the role + const matchingReactionRole = await pluginData.state.reactionRoles.getByMessageAndEmoji( + msg.id, + emoji.id || emoji.name, + ); + if (!matchingReactionRole) return; + + // If the reaction role is exclusive, remove any other roles in the message first + if (matchingReactionRole.is_exclusive) { + const messageReactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id); + for (const reactionRole of messageReactionRoles) { + addMemberPendingRoleChange(pluginData, userId, "-", reactionRole.role_id); + } + } + + addMemberPendingRoleChange(pluginData, userId, "+", matchingReactionRole.role_id); + } + + // Remove the reaction after a small delay + setTimeout(() => { + pluginData.state.reactionRemoveQueue.add(async () => { + const reaction = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name; + const wait = sleep(1500); + await msg.channel.removeMessageReaction(msg.id, reaction, userId).catch(noop); + await wait; + }); + }, 1500); + }, +}); diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts new file mode 100644 index 00000000..434a4e7d --- /dev/null +++ b/backend/src/plugins/ReactionRoles/types.ts @@ -0,0 +1,44 @@ +import * as t from "io-ts"; +import { BasePluginType, eventListener, command, PluginData } from "knub"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildReactionRoles } from "src/data/GuildReactionRoles"; +import { Queue } from "src/Queue"; + +export const ConfigSchema = t.type({ + auto_refresh_interval: t.number, + can_manage: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export type RoleChangeMode = "+" | "-"; + +export type PendingMemberRoleChanges = { + timeout: NodeJS.Timeout; + applyFn: () => void; + changes: Array<{ + mode: RoleChangeMode; + roleId: string; + }>; +}; + +const ReactionRolePair = t.union([t.tuple([t.string, t.string, t.string]), t.tuple([t.string, t.string])]); +export type TReactionRolePair = t.TypeOf; +type ReactionRolePair = [string, string, string?]; + +export interface ReactionRolesPluginType extends BasePluginType { + config: TConfigSchema; + state: { + reactionRoles: GuildReactionRoles; + savedMessages: GuildSavedMessages; + + reactionRemoveQueue: Queue; + roleChangeQueue: Queue; + pendingRoleChanges: Map; + pendingRefreshes: Set; + + autoRefreshTimeout: NodeJS.Timeout; + }; +} + +export const reactionRolesCmd = command(); +export const reactionRolesEvent = eventListener(); diff --git a/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts new file mode 100644 index 00000000..a6043377 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts @@ -0,0 +1,59 @@ +import { PluginData } from "knub"; +import { ReactionRolesPluginType, RoleChangeMode, PendingMemberRoleChanges } from "../types"; +import { resolveMember } from "src/utils"; +import { logger } from "src/logger"; + +const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500; + +export async function addMemberPendingRoleChange( + pluginData: PluginData, + memberId: string, + mode: RoleChangeMode, + roleId: string, +) { + if (!pluginData.state.pendingRoleChanges.has(memberId)) { + const newPendingRoleChangeObj: PendingMemberRoleChanges = { + timeout: null, + changes: [], + applyFn: async () => { + pluginData.state.pendingRoleChanges.delete(memberId); + + const lock = await pluginData.locks.acquire(`member-roles-${memberId}`); + + const member = await resolveMember(pluginData.client, pluginData.guild, memberId); + if (member) { + const newRoleIds = new Set(member.roles); + for (const change of newPendingRoleChangeObj.changes) { + if (change.mode === "+") newRoleIds.add(change.roleId); + else newRoleIds.delete(change.roleId); + } + + try { + await member.edit( + { + roles: Array.from(newRoleIds.values()), + }, + "Reaction roles", + ); + } catch (e) { + logger.warn( + `Failed to apply role changes to ${member.username}#${member.discriminator} (${member.id}): ${e.message}`, + ); + } + } + lock.unlock(); + }, + }; + + pluginData.state.pendingRoleChanges.set(memberId, newPendingRoleChangeObj); + } + + const pendingRoleChangeObj = pluginData.state.pendingRoleChanges.get(memberId); + pendingRoleChangeObj.changes.push({ mode, roleId }); + + if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout); + pendingRoleChangeObj.timeout = setTimeout( + () => pluginData.state.roleChangeQueue.add(pendingRoleChangeObj.applyFn), + ROLE_CHANGE_BATCH_DEBOUNCE_TIME, + ); +} diff --git a/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts b/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts new file mode 100644 index 00000000..396c5adf --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts @@ -0,0 +1,58 @@ +import { PluginData } from "knub"; +import { ReactionRolesPluginType } from "../types"; +import { ReactionRole } from "src/data/entities/ReactionRole"; +import { TextChannel } from "eris"; +import { isDiscordRESTError, sleep, isSnowflake } from "src/utils"; +import { logger } from "src/logger"; + +const CLEAR_ROLES_EMOJI = "❌"; + +export async function applyReactionRoleReactionsToMessage( + pluginData: PluginData, + channelId: string, + messageId: string, + reactionRoles: ReactionRole[], +) { + const channel = pluginData.guild.channels.get(channelId) as TextChannel; + if (!channel) return; + + let targetMessage; + try { + targetMessage = await channel.getMessage(messageId); + } catch (e) { + if (isDiscordRESTError(e)) { + if (e.code === 10008) { + // Unknown message, remove reaction roles from the message + logger.warn( + `Removed reaction roles from unknown message ${channelId}/${messageId} in guild ${pluginData.guild.name} (${pluginData.guild.id})`, + ); + await pluginData.state.reactionRoles.removeFromMessage(messageId); + } else { + logger.warn( + `Error when applying reaction roles to message ${channelId}/${messageId} in guild ${pluginData.guild.name} (${pluginData.guild.id}), error code ${e.code}`, + ); + } + + return; + } else { + throw e; + } + } + + // Remove old reactions, if any + const removeSleep = sleep(1250); + await targetMessage.removeReactions(); + await removeSleep; + + // Add reaction role reactions + for (const rr of reactionRoles) { + const emoji = isSnowflake(rr.emoji) ? `foo:${rr.emoji}` : rr.emoji; + + const sleepTime = sleep(1250); // Make sure we only add 1 reaction per ~second so as not to hit rate limits + await targetMessage.addReaction(emoji); + await sleepTime; + } + + // Add the "clear reactions" button + await targetMessage.addReaction(CLEAR_ROLES_EMOJI); +} diff --git a/backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts b/backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts new file mode 100644 index 00000000..e29e3dfa --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts @@ -0,0 +1,10 @@ +import { PluginData } from "knub"; +import { ReactionRolesPluginType } from "../types"; +import { runAutoRefresh } from "./runAutoRefresh"; + +export async function autoRefreshLoop(pluginData: PluginData, interval: number) { + pluginData.state.autoRefreshTimeout = setTimeout(async () => { + await runAutoRefresh(pluginData); + autoRefreshLoop(pluginData, interval); + }, interval); +} diff --git a/backend/src/plugins/ReactionRoles/util/refreshReactionRoles.ts b/backend/src/plugins/ReactionRoles/util/refreshReactionRoles.ts new file mode 100644 index 00000000..4ea195f7 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/refreshReactionRoles.ts @@ -0,0 +1,20 @@ +import { ReactionRolesPluginType } from "../types"; +import { PluginData } from "knub"; +import { applyReactionRoleReactionsToMessage } from "./applyReactionRoleReactionsToMessage"; + +export async function refreshReactionRoles( + pluginData: PluginData, + channelId: string, + messageId: string, +) { + const pendingKey = `${channelId}-${messageId}`; + if (pluginData.state.pendingRefreshes.has(pendingKey)) return; + pluginData.state.pendingRefreshes.add(pendingKey); + + try { + const reactionRoles = await pluginData.state.reactionRoles.getForMessage(messageId); + await applyReactionRoleReactionsToMessage(pluginData, channelId, messageId, reactionRoles); + } finally { + pluginData.state.pendingRefreshes.delete(pendingKey); + } +} diff --git a/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts b/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts new file mode 100644 index 00000000..666df965 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts @@ -0,0 +1,13 @@ +import { PluginData } from "knub"; +import { ReactionRolesPluginType } from "../types"; +import { refreshReactionRoles } from "./refreshReactionRoles"; + +export async function runAutoRefresh(pluginData: PluginData) { + // Refresh reaction roles on all reaction role messages + const reactionRoles = await pluginData.state.reactionRoles.all(); + const idPairs = new Set(reactionRoles.map(r => `${r.channel_id}-${r.message_id}`)); + for (const pair of idPairs) { + const [channelId, messageId] = pair.split("-"); + await refreshReactionRoles(pluginData, channelId, messageId); + } +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 6416517e..9461f549 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -22,6 +22,7 @@ import { RolesPlugin } from "./Roles/RolesPlugin"; import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin"; import { StarboardPlugin } from "./Starboard/StarboardPlugin"; import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin"; +import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -34,6 +35,7 @@ export const guildPlugins: Array> = [ PersistPlugin, PingableRolesPlugin, PostPlugin, + ReactionRolesPlugin, MessageSaverPlugin, ModActionsPlugin, NameHistoryPlugin,