diff --git a/bot/modules/guild_admin/__init__.py b/bot/modules/guild_admin/__init__.py index 9f66e5c4..37d38183 100644 --- a/bot/modules/guild_admin/__init__.py +++ b/bot/modules/guild_admin/__init__.py @@ -2,3 +2,4 @@ from .module import module from . import guild_config from . import statreset +from . import reaction_roles diff --git a/bot/modules/guild_admin/reaction_roles/__init__.py b/bot/modules/guild_admin/reaction_roles/__init__.py new file mode 100644 index 00000000..1ee86be0 --- /dev/null +++ b/bot/modules/guild_admin/reaction_roles/__init__.py @@ -0,0 +1,4 @@ +from . import data +from . import settings +from . import tracker +from . import command diff --git a/bot/modules/guild_admin/reaction_roles/command.py b/bot/modules/guild_admin/reaction_roles/command.py new file mode 100644 index 00000000..7f8423c6 --- /dev/null +++ b/bot/modules/guild_admin/reaction_roles/command.py @@ -0,0 +1,324 @@ +import asyncio +import discord + +from cmdClient.lib import ResponseTimedOut +from wards import guild_admin +from settings import UserInputError + +from ..module import module + +from .tracker import ReactionRoleMessage +from .data import reaction_role_reactions +from . import settings + + +example_str = "🧮 mathematics, 🫀 biology, 💻 computer science, 🖼️ design, 🩺 medicine" + + +@module.cmd( + "reactionroles", + group="Guild Admin", + desc="Create or configure reaction role messages.", + aliases=('rroles',), + flags=( + 'delete', 'remove', + 'enable', 'disable', + 'required_role==', 'removable=', 'maximum=', 'refunds=', 'log=', 'default_price=', + 'price=', 'timeout==' + ) +) +@guild_admin() +async def cmd_reactionroles(ctx, flags): + """ + Usage``: + {prefix}rroles messageid + {prefix}rroles messageid emoji1 role1, emoji2 role2, emoji3 role3, ... + {prefix}rroles messageid --message_setting value + {prefix}rroles messageid emoji --emoji_setting value + {prefix}rroles messageid --remove emoji1, emoji2, ... + {prefix}rroles messageid --enable + {prefix}rroles messageid --disable + {prefix}rroles messageid --delete + Description: + Reaction roles are message reactions that give members specific roles when used. + This commands allows you to create and configure messages with reaction roles. + Getting started: + To get started, choose the message you want to add reaction roles to, and copy the link to that message. \ + Then run the command `{prefix}rroles link` (replacing `link` with the link you copied), and \ + follow the prompts. + Configuration: + After creation, you can view the message configuration and reaction roles with `{prefix}rroles link` \ + (you can also use the message id instead of the link, or reply to the message). + + You can set one of the configuration options with `{prefix}rroles link --setting value`.\ + For example to make it impossible to remove the reaction roles,\ + run `{prefix}rroles link --removable off`. + + There are also some configurable per-reaction settings, such as the price of a role.\ + To see these, use `{prefix}rroles link emoji` (replacing `emoji` with the reaction emoji) \ + and set them with e.g. `{prefix}rroles link emoji --price 200`. + Message Settings:: + maximum: Maximum number of roles obtainable from this message. + log: Whether to log reaction role usage into the event log. + removable: Whether the reactions roles can be remove by unreacting. + refunds: Whether to refund the role price when removing the role. + default_price: The default price of each role on this message. + required_role: The role required to use these reactions roles. + Reaction Settings:: + price: The price of this reaction role. + timeout: The amount of time the role lasts. (TBD) + Examples: + ... + """ + if not ctx.args and not ctx.msg.reference: + # No target message provided, list the current reaction messages + # Or give a brief guide if there are no current reaction messages + ... + + target_id = None + target_chid = None + remaining = "" + + if ctx.msg.reference: + target_id = ctx.msg.reference.message_id + target_chid = ctx.msg.reference.channel_id + remaining = ctx.args + elif ctx.args: + # Try to parse the target message + # Expect a link or a messageid as the first argument + splits = ctx.args.split(maxsplit=1) + maybe_target = splits[0] + if len(splits) > 1: + remaining = splits[1] + if maybe_target.isdigit(): + # Assume it is a message id + target_id = int(maybe_target) + elif maybe_target.contains('/'): + # Assume it is a link + # Try and parse it + link_splits = maybe_target.rsplit('/', maxsplit=2) + if len(link_splits) > 1 and link_splits[-1].isdigit() and link_splits[-2].isdigit(): + # Definitely a link + target_id = int(link_splits[-1]) + target_chid = int(link_splits[-2]) + + if not target_id: + return await ctx.error_reply( + "Please provide the message link or message id as the first argument." + ) + + # We have a target, fetch the ReactionMessage if it exists + target = ReactionRoleMessage.fetch(target_id) + if target: + # View or edit target + + # Exclusive flags, delete and remove ignore all other flags + if flags['delete']: + # Handle deletion of the ReactionRoleMessage + ... + if flags['remove']: + # Handle emoji removal + ... + + # Check whether we are editing a particular reaction + # TODO: We might be updating roles or adding reactions as well + if remaining: + emojistr = remaining + # TODO.... + + # Lines for edit output + edit_lines = [] # (success_state, string) + # Columns to update + update = {} + + # Message edit flags + # Gets and modifies the settings, but doesn't write + if flags['disable']: + update['enabled'] = False + edit_lines.append((True, "Reaction role message disabled.")) + elif flags['enable']: + update['enabled'] = True + edit_lines.append((True, "Reaction role message enabled.")) + + if flags['required_role']: + try: + setting = await settings.required_role.parse(target.messageid, ctx, flags['required_role']) + except UserInputError as e: + edit_lines.append((False, e.msg)) + else: + edit_lines.append((True, setting.success_response)) + update[setting._data_column] = setting.data + if flags['removable']: + try: + setting = await settings.removable.parse(target.messageid, ctx, flags['removable']) + except UserInputError as e: + edit_lines.append((False, e.msg)) + else: + edit_lines.append((True, setting.success_response)) + update[setting._data_column] = setting.data + if flags['maximum']: + try: + setting = await settings.maximum.parse(target.messageid, ctx, flags['maximum']) + except UserInputError as e: + edit_lines.append((False, e.msg)) + else: + edit_lines.append((True, setting.success_response)) + update[setting._data_column] = setting.data + if flags['refunds']: + try: + setting = await settings.refunds.parse(target.messageid, ctx, flags['refunds']) + except UserInputError as e: + edit_lines.append((False, e.msg)) + else: + edit_lines.append((True, setting.success_response)) + update[setting._data_column] = setting.data + if flags['log']: + try: + setting = await settings.log.parse(target.messageid, ctx, flags['log']) + except UserInputError as e: + edit_lines.append((False, e.msg)) + else: + edit_lines.append((True, setting.success_response)) + update[setting._data_column] = setting.data + if flags['default_price']: + try: + setting = await settings.default_price.parse(target.messageid, ctx, flags['default_price']) + except UserInputError as e: + edit_lines.append((False, e.msg)) + else: + edit_lines.append((True, setting.success_response)) + update[setting._data_column] = setting.data + + # Update the data all at once + target.data.update(**update) + + # Then format and respond with the edit message + + # TODO: Emoji edit flags + ... + else: + # Start creation process + # First find the target message + message = None + if target_chid: + channel = ctx.guild.get_channel(target_chid) + if channel: + message = await channel.fetch_message(target_id) + else: + # We only have a messageid, need to search for it through all the guild channels + message = await ctx.find_message(target_id) + + if message is None: + return await ctx.error_reply( + "Could not find the specified message!" + ) + + # Confirm that they want to create a new reaction role message. + embed = discord.Embed( + colour=discord.Colour.orange(), + description="Do you want to set up new reaction roles for [this message]({}) (`y(es)`/`n(o)`)?".format( + message.jump_url + ) + ) + out_msg = await ctx.reply(embed=embed) + if not await ctx.ask(msg=None, use_msg=out_msg): + return + + # Set up the message as a new ReactionRole message. + # First obtain the initial emojis + if not remaining: + # Prompt for the initial emojis + embed = discord.Embed( + title="What reaction roles would you like to add?", + description=( + "Please now enter the reaction roles you would like to associate, " + "in the form `emoji role`, where `role` is given by id or partial name. For example:" + "```{}```".format(example_str) + ) + ) + out_msg = await ctx.reply(embed=embed) + + # Wait for a response + def check(msg): + return msg.author == ctx.author and msg.channel == ctx.ch and msg.content + + try: + reply = await ctx.client.wait_for('message', check=check, timeout=300) + except asyncio.TimeoutError: + raise ResponseTimedOut("Prompt timed out, no reaction roles were added.") + finally: + try: + await out_msg.delete() + except discord.HTTPException: + pass + + rolestrs = reply.content + + try: + await reply.delete() + except discord.HTTPException: + pass + else: + rolestrs = remaining + + # Attempt to parse the emojis + # First split based on newline and comma + splits = (split.strip() for line in rolestrs.splitlines() for split in line.split(',')) + splits = [split for split in splits if split] + + # Do a quick check to make sure everything is in the correct format + unsplit = next((split for split in splits if ' ' not in split), None) + if unsplit: + return await ctx.error_reply( + "Couldn't parse the reaction role `{}` into the form `emoji role`.".format(unsplit) + ) + + # Now go through and extract the emojis and roles + # TODO: Handle duplicate emojis? + # TODO: Error handling on the roles, make sure we can actually add them + reactions = {} + for split in splits: + emojistr, rolestr = split.split(maxsplit=1) + # Parse emoji + # TODO: Custom emoji handler, probably store in a PartialEmoji + if ':' in emojistr: + return ctx.error_reply( + "Sorry, at this time we only support built-in emojis! Custom emoji support coming soon." + ) + emoji = emojistr + + # Parse role + # TODO: More graceful error handling + role = await ctx.find_role(rolestr, interactive=True, allow_notfound=False) + + reactions[emoji] = role + + # TODO: Parse any provided settings, and pass them to the data constructor + + # Create the ReactionRoleMessage + rmsg = ReactionRoleMessage.create( + message.id, + message.guild.id, + message.channel.id + ) + + # Insert the reaction data directly + # TODO: Again consider emoji setting data here, for common settings? + # TODO: Will need to be changed for custom emojis + reaction_role_reactions.insert_many( + *((message.id, role.id, emoji) for emoji, role in reactions.items()), + insert_keys=('messageid', 'roleid', 'emoji_name') + ) + + # Refresh the ReactionRoleMessage to pick up the new reactions + rmsg.refresh() + + # Ack the creation + await ctx.embed_reply( + ( + "Created `{}` new reaction roles.\n" + "Please add the reactions to [the message]({}) to make them available for use!".format( + len(reactions), rmsg.message_link + ) + ) + ) diff --git a/bot/modules/guild_admin/reaction_roles/data.py b/bot/modules/guild_admin/reaction_roles/data.py new file mode 100644 index 00000000..6a85f042 --- /dev/null +++ b/bot/modules/guild_admin/reaction_roles/data.py @@ -0,0 +1,19 @@ +from data import RowTable + + +reaction_role_messages = RowTable( + 'reaction_role_messages', + ('messageid', 'guildid', 'channelid', + 'enabled', + 'required_role', 'allow_deselction', + 'max_obtainable', 'allow_refunds', + 'event_log'), + 'messageid' +) + + +reaction_role_reactions = RowTable( + 'reaction_role_reactions', + ('reactionid', 'messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated', 'price', 'timeout'), + 'reactionid' +) diff --git a/bot/modules/guild_admin/reaction_roles/settings.py b/bot/modules/guild_admin/reaction_roles/settings.py new file mode 100644 index 00000000..cf4f8346 --- /dev/null +++ b/bot/modules/guild_admin/reaction_roles/settings.py @@ -0,0 +1,248 @@ +from utils.lib import DotDict +from wards import guild_admin +from settings import ObjectSettings, ColumnData, Setting +import settings.setting_types as setting_types + +from .data import reaction_role_messages, reaction_role_reactions + + +class RoleMessageSettings(ObjectSettings): + settings = DotDict() + + +class RoleMessageSetting(ColumnData, Setting): + _table_interface = reaction_role_messages + _id_column = 'messageid' + _create_row = False + + write_ward = guild_admin + + +@RoleMessageSettings.attach_setting +class required_role(setting_types.Role, RoleMessageSetting): + attr_name = 'required_role' + _data_column = 'required_role' + + display_name = "required_role" + desc = "Role required to use the reaction roles." + + long_desc = ( + "Members will be required to have the specified role to use the reactions on this message." + ) + + @property + def success_response(self): + if self.value: + return "Members need {} to use these reaction roles.".format(self.formatted) + else: + return "All members can now use these reaction roles." + + +@RoleMessageSettings.attach_setting +class removable(setting_types.Boolean, RoleMessageSetting): + attr_name = 'removable' + _data_column = 'removable' + + display_name = "removable" + desc = "Whether the role is removable by deselecting the reaction." + + long_desc = ( + "If enabled, the role will be removed when the reaction is deselected." + ) + + _default = True + + @property + def success_response(self): + if self.value: + return "Members will be able to remove roles by unreacting." + else: + return "Members will not be able to remove the reaction roles." + + +@RoleMessageSettings.attach_setting +class maximum(setting_types.Integer, RoleMessageSetting): + attr_name = 'maximum' + _data_column = 'maximum' + + display_name = "maximum" + desc = "The maximum number of roles a member can get from this message." + + long_desc = ( + "The maximum number of roles that a member can get from this message. " + "They will be notified by DM if they attempt to add more.\n" + "The `removable` setting should generally be enabled with this setting." + ) + + accepts = "An integer number of roles, or `None` to remove the maximum." + + _min = 0 + + @classmethod + def _format_data(cls, id, data, **kwargs): + if data is None: + return "No maximum!" + else: + return "`{}`".format(data) + + @property + def success_response(self): + if self.value: + return "Members can get a maximum of `{}` roles from this message." + else: + return "Members can now get all the roles from this mesage." + + +@RoleMessageSettings.attach_setting +class refunds(setting_types.Boolean, RoleMessageSetting): + attr_name = 'refunds' + _data_column = 'refunds' + + display_name = "refunds" + desc = "Whether a user will be refunded when they deselect a role." + + long_desc = ( + "Whether to give the user a refund when they deselect a role by reaction. " + "This has no effect if `removable` is not enabled, or if the role removed has no cost." + ) + + _default = True + + @property + def success_response(self): + if self.value: + return "Members will get a refund when they remove a role." + else: + return "Members will not get a refund when they remove a role." + + +@RoleMessageSettings.attach_setting +class default_price(setting_types.Integer, RoleMessageSetting): + attr_name = 'default_price' + _data_column = 'default_price' + + display_name = "default_price" + desc = "Default price of reaction roles on this message." + + long_desc = ( + "Reaction roles on this message will have this cost if they do not have an individual price set." + ) + + accepts = "An integer number of coins. Use `0` or `None` to make roles free by default." + + _default = 0 + + @classmethod + def _format_data(cls, id, data, **kwargs): + if not data: + return "Free" + else: + return "`{}` coins".format(data) + + @property + def success_response(self): + if self.value: + return "Reaction roles on this message will cost `{}` coins by default.".format(self.value) + else: + return "Reaction roles on this message will be free by default." + + +@RoleMessageSettings.attach_setting +class log(setting_types.Boolean, RoleMessageSetting): + attr_name = 'log' + _data_column = 'event_log' + + display_name = "log" + desc = "Whether to log reaction role usage in the event log." + + long_desc = ( + "When enabled, roles added or removed with reactions will be logged in the configured event log." + ) + + _default = True + + @property + def success_response(self): + if self.value: + return "Role updates will now be logged." + else: + return "Role updates will not be logged." + + +class ReactionSettings(ObjectSettings): + settings = DotDict() + + +class ReactionSetting(ColumnData, Setting): + _table_interface = reaction_role_reactions + _id_column = 'reactionid' + _create_row = False + + write_ward = guild_admin + + +@ReactionSettings.attach_setting +class price(setting_types.Integer, ReactionSetting): + attr_name = 'price' + _data_column = 'price' + + display_name = "price" + desc = "Price of this reaction role." + + long_desc = ( + "The number of coins that will be deducted from the user when this reaction is used.\n" + "The number may be negative, in order to give a reward when the member choses the reaction." + ) + + accepts = "An integer number of coins. Use `0` to make the role free, or `None` to use the message default." + + @property + def default(self): + """ + The default price is given by the ReactionMessage price setting. + """ + return default_price.get(self._table_interface.fetch(self.id).messageid).value + + @classmethod + def _format_data(cls, id, data, **kwargs): + if data is None: + return "Free" + else: + return "`{}` coins".format(data) + + @property + def success_response(self): + if self.value is not None: + return "This role now costs `{}` coins.".format(self.value) + else: + return "This role is free." + + +@ReactionSettings.attach_setting +class timeout(setting_types.Duration, ReactionSetting): + attr_name = 'timeout' + _data_column = 'timeout' + + display_name = "timeout" + desc = "How long this reaction role will last." + + long_desc = ( + "If set, the reaction role will be removed after the configured duration. " + "Note that this does not affect existing members with the role, or existing expiries." + ) + + _default_multiplier = 1 + + @classmethod + def _format_data(cls, id, data, **kwargs): + if data is None: + return "Never" + else: + return super()._format_data(id, data, **kwargs) + + @property + def success_response(self): + if self.value is not None: + return "Roles will timeout `{}` after selection.".format(self.formatted) + else: + return "Roles will never timeout after selection." diff --git a/bot/modules/guild_admin/reaction_roles/tracker.py b/bot/modules/guild_admin/reaction_roles/tracker.py new file mode 100644 index 00000000..6bb3de35 --- /dev/null +++ b/bot/modules/guild_admin/reaction_roles/tracker.py @@ -0,0 +1,579 @@ +import asyncio +import logging +import traceback +from collections import defaultdict +from typing import List, Mapping, Optional +from cachetools import LFUCache + +import discord +from discord import PartialEmoji + +from meta import client +from core import Lion +from data import Row +from settings import GuildSettings + +from ..module import module +from .data import reaction_role_messages, reaction_role_reactions # , reaction_role_expiry +from .settings import RoleMessageSettings, ReactionSettings + + +class ReactionRoleReaction(PartialEmoji): + """ + Light data class representing a reaction role reaction. + Extends PartialEmoji for comparison with discord Emojis. + """ + __slots__ = ('reactionid', '_message', '_role') + + def __init__(self, reactionid, message=None, **kwargs): + self.reactionid = reactionid + self._message: ReactionRoleMessage = None + self._role = None + + @classmethod + def create(cls, messageid, roleid, emoji: PartialEmoji, message=None, **kwargs) -> 'ReactionRoleReaction': + """ + Create a new ReactionRoleReaction with the provided attributes. + `emoji` sould be provided as a PartialEmoji. + `kwargs` are passed transparently to the `insert` method. + """ + row = reaction_role_reactions.create_row( + messageid=messageid, + roleid=roleid, + emoji_name=emoji.name, + emoji_id=emoji.id, + emoji_animated=emoji.animated, + **kwargs + ) + return cls(row.reactionid, message=message) + + @property + def data(self) -> Row: + return reaction_role_reactions.fetch(self.reactionid) + + @property + def settings(self) -> ReactionSettings: + return ReactionSettings(self.reactionid) + + @property + def reaction_message(self): + if self._message is None: + self._message = ReactionRoleMessage.fetch(self.data.messageid) + return self._message + + @property + def role(self): + if self._role is None: + guild = self.reaction_message.guild + if guild: + self._role = guild.get_role(self.data.roleid) + return self._role + + # PartialEmoji properties + @property + def animated(self) -> bool: + return self.data.emoji_animated + + @property + def name(self) -> str: + return self.data.emoji_name + + @name.setter + def name(self, value): + """ + Name setter. + The emoji name may get updated while the emoji itself remains the same. + """ + self.data.emoji_name = value + + @property + def id(self) -> Optional[int]: + return self.data.emoji_id + + @property + def _state(self): + return client._connection + + +class ReactionRoleMessage: + """ + Light data class representing a reaction role message. + Primarily acts as an interface to the corresponding Settings. + """ + __slots__ = ('messageid', '_message') + + # Full live messageid cache for this client. Should always be up to date. + _messages: Mapping[int, 'ReactionRoleMessage'] = {} # messageid -> associated Reaction message + + # Reaction cache for the live messages. Least frequently used, will be fetched on demand. + _reactions: Mapping[int, List[ReactionRoleReaction]] = LFUCache(1000) # messageid -> List of Reactions + + # User-keyed locks so we only handle one reaction per user at a time + _locks: Mapping[int, asyncio.Lock] = defaultdict(asyncio.Lock) # userid -> Lock + + def __init__(self, messageid): + self.messageid = messageid + self._message = None + + @classmethod + def fetch(cls, messageid) -> 'ReactionRoleMessage': + """ + Fetch the ReactionRoleMessage for the provided messageid. + Returns None if the messageid is not registered. + """ + # Since the cache is assumed to be always up to date, just pass to fetch-from-cache. + return cls._messages.get(messageid, None) + + @classmethod + def create(cls, messageid, guildid, channelid, **kwargs) -> 'ReactionRoleMessage': + """ + Create a ReactionRoleMessage with the given `messageid`. + Other `kwargs` are passed transparently to `insert`. + """ + # Insert the data + reaction_role_messages.create_row( + messageid=messageid, + guildid=guildid, + channelid=channelid, + **kwargs + ) + + # Create the ReactionRoleMessage + rmsg = cls(messageid) + + # Add to the global cache + cls._messages[messageid] = rmsg + + # Return the constructed ReactionRoleMessage + return rmsg + + @property + def data(self) -> Row: + """ + Data row associated with this Message. + Passes directly to the RowTable cache. + Should not generally be used directly, use the settings interface instead. + """ + return reaction_role_messages.fetch(self.messageid) + + @property + def settings(self): + """ + RoleMessageSettings associated to this Message. + """ + return RoleMessageSettings(self.messageid) + + def refresh(self): + """ + Refresh the reaction cache for this message. + Returns the generated `ReactionRoleReaction`s for convenience. + """ + # Fetch reactions and pre-populate reaction cache + rows = reaction_role_reactions.fetch_rows_where(messageid=self.messageid) + reactions = [ReactionRoleReaction(row.reactionid) for row in rows] + self._reactions[self.messageid] = reactions + return reactions + + @property + def reactions(self) -> List[ReactionRoleReaction]: + """ + Returns the list of active reactions for this message, as `ReactionRoleReaction`s. + Lazily fetches the reactions from data if they have not been loaded. + """ + reactions = self._reactions.get(self.messageid, None) + if reactions is None: + reactions = self.refresh() + return reactions + + @property + def enabled(self) -> bool: + """ + Whether this Message is enabled. + Passes directly to data for efficiency. + """ + return self.data.enabled + + @enabled.setter + def enabled(self, value: bool): + self.data.enabled = value + + # Discord properties + @property + def guild(self) -> discord.Guild: + return client.get_guild(self.data.guildid) + + @property + def channel(self) -> discord.TextChannel: + return client.get_channel(self.data.channelid) + + async def fetch_message(self) -> discord.Message: + if self._message: + return self._message + + channel = self.channel + if channel: + try: + self._message = await channel.fetch_message(self.messageid) + return self._message + except discord.NotFound: + # The message no longer exists + # TODO: Cache and data cleanup? Or allow moving after death? + pass + + @property + def message(self) -> Optional[discord.Message]: + return self._message + + @property + def message_link(self) -> str: + """ + Jump link tho the reaction message. + """ + return 'https://discord.com/channels/{}/{}/{}'.format( + self.data.guildid, + self.data.channelid, + self.messageid + ) + + # Event handlers + async def process_raw_reaction_add(self, payload): + """ + Process a general reaction add payload. + """ + event_log = GuildSettings(self.guild.id).event_log + async with self._locks[payload.user_id]: + reaction = next((reaction for reaction in self.reactions if reaction == payload.emoji), None) + if reaction: + # User pressed a live reaction. Process! + member = payload.member + lion = Lion.fetch(member.guild.id, member.id) + role = reaction.role + if reaction.role and (role not in member.roles): + # Required role check, make sure the user has the required role, if set. + required_role = self.settings.required_role.value + if required_role and required_role not in member.roles: + # Silently remove their reaction + try: + message = await self.fetch_message() + await message.remove_reaction( + payload.emoji, + member + ) + except discord.HTTPException: + pass + return + + # Maximum check, check whether the user already has too many roles from this message. + maximum = self.settings.maximum.value + if maximum is not None: + # Fetch the number of applicable roles the user has + roleids = set(reaction.data.roleid for reaction in self.reactions) + member_roleids = set(role.id for role in member.roles) + if len(roleids.intersection(member_roleids)) > maximum: + # Notify the user + embed = discord.Embed( + title="Maximum group roles reached!", + description=( + "Couldn't give you **{}**, " + "because you already have `{}` roles from this group!".format( + role.name, + maximum + ) + ) + ) + # Silently try to notify the user + try: + await member.send(embed=embed) + except discord.HTTPException: + pass + # Silently remove the reaction + try: + message = await self.fetch_message() + await message.remove_reaction( + payload.emoji, + member + ) + except discord.HTTPException: + pass + + return + + # Economy hook, check whether the user can pay for the role. + price = reaction.settings.price.value + if price and price > lion.coins: + # They can't pay! + # Build the can't pay embed + embed = discord.Embed( + title="Insufficient funds!", + description="Sorry, **{}** costs `{}` coins, but you only have `{}`.".format( + role.name, + price, + lion.coins + ), + colour=discord.Colour.red() + ).set_footer( + icon_url=self.guild.icon_url, + text=self.guild.name + ).add_field( + name="Jump Back", + value="[Click here]({})".format(self.message_link) + ) + # Try to send them the embed, ignore errors + try: + await member.send( + embed=embed + ) + except discord.HTTPException: + pass + + # Remove their reaction, ignore errors + try: + message = await self.fetch_message() + await message.remove_reaction( + payload.emoji, + member + ) + except discord.HTTPException: + pass + + return + + # Add the role + try: + await member.add_roles( + role, + atomic=True, + reason="Adding reaction role." + ) + except discord.Forbidden: + event_log.log( + "Insufficient permissions to give {} the [reaction role]({}) {}".format( + member.mention, + self.message_link, + role.mention, + ), + title="Failed to add reaction role", + colour=discord.Colour.red() + ) + except discord.HTTPException: + event_log.log( + "Something went wrong while adding the [reaction role]({}) " + "{} to {}.".format( + self.message_link, + role.mention, + member.mention + ), + title="Failed to add reaction role", + colour=discord.Colour.red() + ) + client.log( + "Unexpected HTTPException encountered while adding '{}' (rid:{}) to " + "user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format( + role.name, + role.id, + member, + member.id, + member.guild.name, + member.guild.id, + traceback.format_exc() + ), + context="REACTION_ROLE_ADD", + level=logging.WARNING + ) + else: + # Charge the user and notify them, if the price is set + if price: + lion.addCoins(-price) + # Notify the user of their purchase + embed = discord.Embed( + title="Purchase successful!", + description="You have purchased **{}** for `{}` coins!".format( + role.name, + price + ), + colour=discord.Colour.green() + ).set_footer( + icon_url=self.guild.icon_url, + text=self.guild.name + ).add_field( + name="Jump Back", + value="[Click Here]({})".format(self.message_link) + ) + try: + await member.send(embed=embed) + except discord.HTTPException: + pass + + # Log the role modification if required + if self.settings.log.value: + event_log.log( + "Added [reaction role]({}) {} " + "to {}{}.".format( + self.message_link, + role.mention, + member.mention, + " for `{}` coins.".format(price) if price else '' + ), + title="Reaction Role Added" + ) + + # Start the timeout, if required + # TODO: timeout system + ... + + async def process_raw_reaction_remove(self, payload): + """ + Process a general reaction remove payload. + """ + if self.settings.removable.value: + event_log = GuildSettings(self.guild.id).event_log + async with self._locks[payload.user_id]: + reaction = next((reaction for reaction in self.reactions if reaction == payload.emoji), None) + if reaction: + # User removed a live reaction. Process! + member = self.guild.get_member(payload.user_id) + role = reaction.role + if member and not member.bot and role and (role in member.roles): + # Check whether they have the required role, if set + required_role = self.settings.required_role.value + if required_role and required_role not in member.roles: + # Ignore the reaction removal + return + + try: + await member.remove_roles( + role, + atomic=True, + reason="Removing reaction role." + ) + except discord.Forbidden: + event_log.log( + "Insufficient permissions to remove " + "the [reaction role]({}) {} from {}".format( + self.message_link, + role.mention, + member.mention, + ), + title="Failed to remove reaction role", + colour=discord.Colour.red() + ) + except discord.HTTPException: + event_log.log( + "Something went wrong while removing the [reaction role]({}) " + "{} from {}.".format( + self.message_link, + role.mention, + member.mention + ), + title="Failed to remove reaction role", + colour=discord.Colour.red() + ) + client.log( + "Unexpected HTTPException encountered while removing '{}' (rid:{}) from " + "user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format( + role.name, + role.id, + member, + member.id, + member.guild.name, + member.guild.id, + traceback.format_exc() + ), + context="REACTION_ROLE_RM", + level=logging.WARNING + ) + else: + # Economy hook, handle refund if required + price = reaction.settings.price.value + refund = self.settings.refunds.value + if price and refund: + # Give the user the refund + lion = Lion.fetch(self.guild.id, member.id) + lion.addCoins(price) + + # Notify the user + embed = discord.Embed( + title="Role sold", + description=( + "You sold the role **{}** for `{}` coins.".format( + role.name, + price + ) + ), + colour=discord.Colour.green() + ).set_footer( + icon_url=self.guild.icon_url, + text=self.guild.name + ).add_field( + name="Jump Back", + value="[Click Here]({})".format(self.message_link) + ) + try: + await member.send(embed=embed) + except discord.HTTPException: + pass + + # Log role removal if required + if self.settings.log.value: + event_log.log( + "Removed [reaction role]({}) {} " + "from {}.".format( + self.message_link, + reaction.role.mention, + member.mention + ), + title="Reaction Role Removed" + ) + + # TODO: Cancel timeout + ... + + +# TODO: Make all the embeds a bit nicer, and maybe make a consistent interface for them +# We could use the reaction url as the author url! At least for the non-builtin emojis. +# TODO: Handle RawMessageDelete event +# TODO: Handle permission errors when fetching message in config + +@client.add_after_event('raw_reaction_add') +async def reaction_role_add(client, payload): + reaction_message = ReactionRoleMessage.fetch(payload.message_id) + if not payload.member.bot and reaction_message and reaction_message.enabled: + try: + await reaction_message.process_raw_reaction_add(payload) + except Exception: + # Unknown exception, catch and log it. + client.log( + "Unhandled exception while handling reaction message payload: {}\n{}".format( + payload, + traceback.format_exc() + ), + context="REACTION_ROLE_ADD", + level=logging.ERROR + ) + + +@client.add_after_event('raw_reaction_remove') +async def reaction_role_remove(client, payload): + reaction_message = ReactionRoleMessage.fetch(payload.message_id) + if reaction_message and reaction_message.enabled: + try: + await reaction_message.process_raw_reaction_remove(payload) + except Exception: + # Unknown exception, catch and log it. + client.log( + "Unhandled exception while handling reaction message payload: {}\n{}".format( + payload, + traceback.format_exc() + ), + context="REACTION_ROLE_RM", + level=logging.ERROR + ) + + +@module.init_task +def load_reaction_roles(client): + """ + Load the ReactionRoleMessages. + """ + rows = reaction_role_messages.fetch_rows_where() + ReactionRoleMessage._messages = {row.messageid: ReactionRoleMessage(row.messageid) for row in rows} diff --git a/bot/utils/interactive.py b/bot/utils/interactive.py index 7bdb043f..986361d5 100644 --- a/bot/utils/interactive.py +++ b/bot/utils/interactive.py @@ -437,7 +437,7 @@ async def ask(ctx, msg, timeout=30, use_msg=None, del_on_timeout=False): out = "{} {}".format(msg, "`y(es)`/`n(o)`") offer_msg = use_msg or await ctx.reply(out) - if use_msg: + if use_msg and msg: await use_msg.edit(content=msg) result_msg = await ctx.listen_for(["y", "yes", "n", "no"], timeout=timeout) diff --git a/bot/utils/seekers.py b/bot/utils/seekers.py index 17a308da..69bb1f46 100644 --- a/bot/utils/seekers.py +++ b/bot/utils/seekers.py @@ -1,3 +1,4 @@ +import asyncio import discord from cmdClient import Context @@ -352,3 +353,71 @@ async def find_member(ctx, userstr, interactive=False, collection=None, silent=F await ctx.error_reply("Couldn't find a member matching `{}`!".format(userstr)) return member + + +@Context.util +async def find_message(ctx, msgid, chlist=None, ignore=[]): + """ + Searches for the given message id in the guild channels. + + Parameters + ------- + msgid: int + The `id` of the message to search for. + chlist: Optional[List[discord.TextChannel]] + List of channels to search in. + If `None`, searches all the text channels that the `ctx.author` can read. + ignore: list + A list of channelids to explicitly ignore in the search. + + Returns + ------- + Optional[discord.Message]: + If a message is found, returns the message. + Otherwise, returns `None`. + """ + if not ctx.guild: + raise InvalidContext("Cannot use this seeker outside of a guild!") + + msgid = int(msgid) + + # Build the channel list to search + if chlist is None: + chlist = [ch for ch in ctx.guild.text_channels if ch.permissions_for(ctx.author).read_messages] + + # Remove any channels we are ignoring + chlist = [ch for ch in chlist if ch.id not in ignore] + + tasks = set() + + i = 0 + while True: + done = set((task for task in tasks if task.done())) + tasks = tasks.difference(done) + + results = [task.result() for task in done] + + result = next((result for result in results if result is not None), None) + if result: + [task.cancel() for task in tasks] + return result + + if i < len(chlist): + task = asyncio.create_task(_search_in_channel(chlist[i], msgid)) + tasks.add(task) + i += 1 + elif len(tasks) == 0: + return None + + await asyncio.sleep(0.1) + + +async def _search_in_channel(channel: discord.TextChannel, msgid: int): + if channel.type != discord.ChannelType.text: + return + try: + message = await channel.fetch_message(msgid) + except Exception: + return None + else: + return message diff --git a/data/schema.sql b/data/schema.sql index 0c9c2f64..d8839b45 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -473,4 +473,42 @@ CREATE VIEW accountability_open_slots AS WHERE closed_at IS NULL ORDER BY start_at ASC; -- }}} + +-- Reaction Roles {{{ +CREATE TABLE reaction_role_messages( + messageid BIGINT PRIMARY KEY, + guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE, + channelid BIGINT NOT NULL, + enabled BOOLEAN DEFAULT TRUE, + required_role BIGINT, + removable BOOLEAN, + maximum INTEGER, + refunds BOOLEAN, + event_log BOOLEAN, + default_price INTEGER +); +CREATE INDEX reaction_role_guilds ON reaction_role_messages (guildid); + +CREATE TABLE reaction_role_reactions( + reactionid SERIAL PRIMARY KEY, + messageid BIGINT NOT NULL REFERENCES reaction_role_messages (messageid) ON DELETE CASCADE, + roleid BIGINT NOT NULL, + emoji_name TEXT, + emoji_id BIGINT, + emoji_animated BOOLEAN, + price INTEGER, + timeout INTEGER +); +CREATE INDEX reaction_role_reaction_messages ON reaction_role_reactions (messageid); + +CREATE TABLE reaction_role_expiry( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + roleid BIGINT NOT NULL, + expiry TIMESTAMPTZ NOT NULL, + reactionid INTEGER REFERENCES reaction_role_reactions (reactionid) ON DELETE SET NULL +); +CREATE INDEX reaction_role_expiry_members ON reaction_role_expiry (guildid, userid, roleid); + +-- }}} -- vim: set fdm=marker: