From c0614f2470a5f659ff47f76f6459ae94dbd40134 Mon Sep 17 00:00:00 2001 From: Dragory Date: Sun, 29 Jul 2018 15:18:26 +0300 Subject: [PATCH] Add ReactionRoles plugin --- ...80729142200_create_reaction_roles_table.js | 18 ++ src/data/GuildReactionRoles.ts | 59 ++++++ src/index.ts | 4 +- src/models/ReactionRole.ts | 9 + src/plugins/ReactionRoles.ts | 178 ++++++++++++++++++ src/utils.ts | 4 + 6 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 migrations/20180729142200_create_reaction_roles_table.js create mode 100644 src/data/GuildReactionRoles.ts create mode 100644 src/models/ReactionRole.ts create mode 100644 src/plugins/ReactionRoles.ts diff --git a/migrations/20180729142200_create_reaction_roles_table.js b/migrations/20180729142200_create_reaction_roles_table.js new file mode 100644 index 00000000..78880a10 --- /dev/null +++ b/migrations/20180729142200_create_reaction_roles_table.js @@ -0,0 +1,18 @@ +exports.up = async function(knex, Promise) { + if (! await knex.schema.hasTable('reaction_roles')) { + await knex.schema.createTable('reaction_roles', table => { + table.string('guild_id', 20).notNullable(); + table.string('channel_id', 20).notNullable(); + table.string('message_id', 20).notNullable(); + table.string('emoji', 20).notNullable(); + table.string('role_id', 20).notNullable(); + + table.primary(['guild_id', 'channel_id', 'message_id', 'emoji']); + table.index(['message_id', 'emoji']); + }); + } +}; + +exports.down = async function(knex, Promise) { + await knex.schema.dropTableIfExists('reaction_roles'); +}; diff --git a/src/data/GuildReactionRoles.ts b/src/data/GuildReactionRoles.ts new file mode 100644 index 00000000..f282f783 --- /dev/null +++ b/src/data/GuildReactionRoles.ts @@ -0,0 +1,59 @@ +import knex from "../knex"; +import ReactionRole from "../models/ReactionRole"; + +export class GuildReactionRoles { + protected guildId: string; + + constructor(guildId) { + this.guildId = guildId; + } + + async all(): Promise { + const results = await knex("reaction_roles") + .where("guild_id", this.guildId) + .select(); + + return results.map(r => new ReactionRole(r)); + } + + async getForMessage(messageId: string): Promise { + const results = await knex("reaction_roles") + .where("guild_id", this.guildId) + .where("message_id", messageId) + .select(); + + return results.map(r => new ReactionRole(r)); + } + + async getByMessageAndEmoji(messageId: string, emoji: string): Promise { + const result = await knex("reaction_roles") + .where("guild_id", this.guildId) + .where("message_id", messageId) + .where("emoji", emoji) + .first(); + + return result ? new ReactionRole(result) : null; + } + + async removeFromMessage(messageId: string, emoji: string = null) { + let query = knex("reaction_roles") + .where("guild_id", this.guildId) + .where("message_id", messageId); + + if (emoji) { + query = query.where("emoji", emoji); + } + + await query.delete(); + } + + async add(channelId: string, messageId: string, emoji: string, roleId: string) { + await knex("reaction_roles").insert({ + guild_id: this.guildId, + channel_id: channelId, + message_id: messageId, + emoji, + role_id: roleId + }); + } +} diff --git a/src/index.ts b/src/index.ts index 79f6761d..9cfc4de7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { ModActionsPlugin } from "./plugins/ModActions"; import { UtilityPlugin } from "./plugins/Utility"; import { LogsPlugin } from "./plugins/Logs"; import { PostPlugin } from "./plugins/Post"; +import { ReactionRolesPlugin } from "./plugins/ReactionRoles"; import knex from "./knex"; // Run latest database migrations @@ -30,7 +31,8 @@ knex.migrate.latest().then(() => { utility: UtilityPlugin, mod_actions: ModActionsPlugin, logs: LogsPlugin, - post: PostPlugin + post: PostPlugin, + reaction_roles: ReactionRolesPlugin }, globalPlugins: { bot_control: BotControlPlugin diff --git a/src/models/ReactionRole.ts b/src/models/ReactionRole.ts new file mode 100644 index 00000000..f24a47e7 --- /dev/null +++ b/src/models/ReactionRole.ts @@ -0,0 +1,9 @@ +import Model from "./Model"; + +export default class ReactionRole extends Model { + public guild_id: string; + public channel_id: string; + public message_id: string; + public emoji: string; + public role_id: string; +} diff --git a/src/plugins/ReactionRoles.ts b/src/plugins/ReactionRoles.ts new file mode 100644 index 00000000..4c0a0634 --- /dev/null +++ b/src/plugins/ReactionRoles.ts @@ -0,0 +1,178 @@ +import { Plugin, decorators as d } from "knub"; +import { errorMessage, isSnowflake } from "../utils"; +import { GuildReactionRoles } from "../data/GuildReactionRoles"; +import { Channel, Emoji, Message, TextChannel } from "eris"; + +type ReactionRolePair = string[]; + +type CustomEmoji = { + id: string; +} & Emoji; + +export class ReactionRolesPlugin extends Plugin { + protected reactionRoles: GuildReactionRoles; + + getDefaultOptions() { + return { + permissions: { + manage: false + }, + + overrides: [ + { + level: ">=100", + permissions: { + manage: true + } + } + ] + }; + } + + async onLoad() { + this.reactionRoles = new GuildReactionRoles(this.guildId); + return; + + // Pre-fetch all messages with reaction roles so we get their events + const reactionRoles = await this.reactionRoles.all(); + + const channelMessages: Map> = reactionRoles.reduce( + (map: Map>, row) => { + if (!map.has(row.channel_id)) map.set(row.channel_id, new Set()); + map.get(row.channel_id).add(row.message_id); + return map; + }, + new Map() + ); + + const msgLoadPromises = []; + + for (const [channelId, messageIdSet] of channelMessages.entries()) { + const messageIds = Array.from(messageIdSet.values()); + const channel = (await this.guild.channels.get(channelId)) as TextChannel; + if (!channel) continue; + + for (const messageId of messageIds) { + msgLoadPromises.push(channel.getMessage(messageId)); + } + } + + await Promise.all(msgLoadPromises); + } + + @d.command("reaction_roles", " ") + @d.permission("manage") + async reactionRolesCmd( + msg: Message, + args: { channel: Channel; messageId: string; reactionRolePairs: string } + ) { + if (!(args.channel instanceof TextChannel)) { + msg.channel.createMessage(errorMessage("Channel must be a text channel!")); + return; + } + + const targetMessage = await args.channel.getMessage(args.messageId); + if (!targetMessage) { + args.channel.createMessage(errorMessage("Unknown message!")); + return; + } + + const guildEmojis = this.guild.emojis as CustomEmoji[]; + const guildEmojiIds = guildEmojis.map(e => e.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 newRolePairs: ReactionRolePair[] = args.reactionRolePairs + .trim() + .split("\n") + .map(v => v.split("=").map(v => v.trim())) + .map(pair => { + const customEmojiMatch = pair[0].match(/^<:(?:.*?):(\d+)>$/); + if (customEmojiMatch) { + return [customEmojiMatch[1], pair[1]]; + } else { + return pair; + } + }); + + // Verify the specified emojis and roles are valid + for (const pair of newRolePairs) { + if (isSnowflake(pair[0]) && !guildEmojiIds.includes(pair[0])) { + msg.channel.createMessage( + errorMessage("I can only use regular emojis and custom emojis from this server") + ); + return; + } + + if (!this.guild.roles.has(pair[1])) { + msg.channel.createMessage(errorMessage(`Unknown role ${pair[1]}`)); + return; + } + } + + const oldReactionRoles = await this.reactionRoles.getForMessage(targetMessage.id); + const oldRolePairs: ReactionRolePair[] = oldReactionRoles.map(r => [r.emoji, r.role_id]); + + // Remove old reaction/role pairs that weren't included in the new pairs or were changed in some way + const toRemove = oldRolePairs.filter( + pair => !newRolePairs.find(oldPair => oldPair[0] === pair[0] && oldPair[1] === pair[1]) + ); + for (const rolePair of toRemove) { + await this.reactionRoles.removeFromMessage(targetMessage.id, rolePair[0]); + + for (const reaction of targetMessage.reactions.values()) { + if (reaction.emoji.id === rolePair[0] || reaction.emoji.name === rolePair[0]) { + reaction.remove(this.bot.user.id); + } + } + } + + // Add new/changed reaction/role pairs + const toAdd = newRolePairs.filter( + pair => !oldRolePairs.find(oldPair => oldPair[0] === pair[0] && oldPair[1] === pair[1]) + ); + for (const rolePair of toAdd) { + let emoji; + + if (isSnowflake(rolePair[0])) { + // Custom emoji + const guildEmoji = guildEmojis.find(e => e.id === emoji.id); + emoji = `${guildEmoji.name}:${guildEmoji.id}`; + } else { + // Unicode emoji + emoji = rolePair[0]; + } + + await targetMessage.addReaction(emoji); + await this.reactionRoles.add(args.channel.id, targetMessage.id, rolePair[0], rolePair[1]); + } + } + + @d.event("messageReactionAdd") + async onAddReaction(msg: Message, emoji: CustomEmoji, userId: string) { + const matchingReactionRole = await this.reactionRoles.getByMessageAndEmoji( + msg.id, + emoji.id || emoji.name + ); + if (!matchingReactionRole) return; + + const member = this.guild.members.get(userId); + if (!member) return; + + member.addRole(matchingReactionRole.role_id); + } + + @d.event("messageReactionRemove") + async onRemoveReaction(msg: Message, emoji: CustomEmoji, userId: string) { + const matchingReactionRole = await this.reactionRoles.getByMessageAndEmoji( + msg.id, + emoji.id || emoji.name + ); + if (!matchingReactionRole) return; + + const member = this.guild.members.get(userId); + if (!member) return; + + member.removeRole(matchingReactionRole.role_id); + } +} diff --git a/src/utils.ts b/src/utils.ts index bbb602e7..fbf5700b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -74,3 +74,7 @@ export function formatTemplateString(str: string, values) { return (at(values, val)[0] as string) || ""; }); } + +export function isSnowflake(v: string): boolean { + return /^\d{17,20}$/.test(v); +}