From cfa3c90841ee4647a84f337c44c1f2d88641ab96 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 18 Oct 2021 15:28:14 +0300 Subject: [PATCH] feature (reaction-roles): Configuration command. New monolithic `rroles` configuration command. Complete logging and setting implementation in RR tracker. Improve strings in RR settings. Add special `NEWPAGE` manual section parsed by the help command. Add `tabulated` method for `ObjectSettings` for easy display. Add `_parse_create` switch for the `Role` setting type. --- bot/constants.py | 2 +- .../guild_admin/reaction_roles/command.py | 1025 +++++++++++++---- .../guild_admin/reaction_roles/settings.py | 18 +- .../guild_admin/reaction_roles/tracker.py | 60 +- bot/modules/meta/help.py | 58 +- bot/settings/base.py | 10 + bot/settings/setting_types.py | 4 +- 7 files changed, 912 insertions(+), 265 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index ed3a3b3b..35f9ec7c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 3 +DATA_VERSION = 4 diff --git a/bot/modules/guild_admin/reaction_roles/command.py b/bot/modules/guild_admin/reaction_roles/command.py index 7f8423c6..8a88381b 100644 --- a/bot/modules/guild_admin/reaction_roles/command.py +++ b/bot/modules/guild_admin/reaction_roles/command.py @@ -1,27 +1,192 @@ import asyncio import discord +from collections import defaultdict +from discord import PartialEmoji -from cmdClient.lib import ResponseTimedOut +from cmdClient.lib import ResponseTimedOut, UserCancelled from wards import guild_admin from settings import UserInputError +from utils.lib import tick, cross from ..module import module from .tracker import ReactionRoleMessage -from .data import reaction_role_reactions +from .data import reaction_role_reactions, reaction_role_messages from . import settings +example_emoji = "🧮" example_str = "🧮 mathematics, 🫀 biology, 💻 computer science, 🖼️ design, 🩺 medicine" +def _parse_messageref(ctx): + """ + Parse a message reference from the context message and return it. + Removes the parsed string from `ctx.args` if applicable. + Supports the following reference types, in precedence order: + - A Discord message reply reference. + - A message link. + - A message id. + + Returns: (channelid, messageid) + `messageid` will be `None` if a valid reference was not found. + `channelid` will be `None` if the message was provided by pure id. + """ + target_id = None + target_chid = None + + if ctx.msg.reference: + # True message reference extract message and return + target_id = ctx.msg.reference.message_id + target_chid = ctx.msg.reference.channel_id + elif ctx.args: + # Parse the first word of the message arguments + splits = ctx.args.split(maxsplit=1) + maybe_target = splits[0] + + # Expect a message id or message link + if maybe_target.isdigit(): + # Assume it is a message id + target_id = int(maybe_target) + elif '/' in maybe_target: + # Assume it is a link + # Split out the channelid and messageid, if possible + link_splits = maybe_target.rsplit('/', maxsplit=2) + if len(link_splits) > 1 and link_splits[-1].isdigit() and link_splits[-2].isdigit(): + target_id = int(link_splits[-1]) + target_chid = int(link_splits[-2]) + + # If we found a target id, truncate the arguments + if target_id is not None: + if len(splits) > 1: + ctx.args = splits[1].strip() + else: + ctx.args = "" + else: + # Last-ditch attempt, see if the argument could be a stored reaction + maybe_emoji = maybe_target.strip(',') + guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id) + messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows] + emojis = {reaction.emoji: message for message in messages for reaction in message.reactions} + emoji_name_map = {emoji.name.lower(): emoji for emoji in emojis} + emoji_id_map = {emoji.id: emoji for emoji in emojis if emoji.id} + result = _parse_emoji(maybe_emoji, emoji_name_map, emoji_id_map) + if result and result in emojis: + message = emojis[result] + target_id = message.messageid + target_chid = message.data.channelid + + # Return the message reference + return (target_chid, target_id) + + +def _parse_emoji(emoji_str, name_map, id_map): + """ + Extract a PartialEmoji from a user provided emoji string, given the accepted raw names and ids. + """ + emoji = None + if len(emoji_str) < 10 and all(ord(char) >= 256 for char in emoji_str): + # The string is pure unicode, we assume built in emoji + emoji = PartialEmoji(name=emoji_str) + elif emoji_str.lower() in name_map: + emoji = name_map[emoji_str.lower()] + elif emoji_str.isdigit() and int(emoji_str) in id_map: + emoji = id_map[int(emoji_str)] + else: + # Attempt to parse as custom emoji + # Accept custom emoji provided in the full form + emoji_split = emoji_str.strip('<>:').split(':') + if len(emoji_split) in (2, 3) and emoji_split[-1].isdigit(): + emoji_id = int(emoji_split[-1]) + emoji_name = emoji_split[-2] + emoji_animated = emoji_split[0] == 'a' + emoji = PartialEmoji( + name=emoji_name, + id=emoji_id, + animated=emoji_animated + ) + return emoji + + +async def reaction_ask(ctx, question, timeout=120, timeout_msg=None, cancel_msg=None): + """ + Asks the author the provided question in an embed, and provides check/cross reactions for answering. + """ + embed = discord.Embed( + colour=discord.Colour.orange(), + description=question + ) + out_msg = await ctx.reply(embed=embed) + + # Wait for a tick/cross + asyncio.create_task(out_msg.add_reaction(tick)) + asyncio.create_task(out_msg.add_reaction(cross)) + + def check(reaction, user): + result = True + result = result and reaction.message == out_msg + result = result and user == ctx.author + result = result and (reaction.emoji == tick or reaction.emoji == cross) + return result + + try: + reaction, _ = await ctx.client.wait_for( + 'reaction_add', + check=check, + timeout=120 + ) + except asyncio.TimeoutError: + try: + await out_msg.edit( + embed=discord.Embed( + colour=discord.Colour.red(), + description=timeout_msg or "Prompt timed out." + ) + ) + except discord.HTTPException: + pass + raise ResponseTimedOut from None + if reaction.emoji == cross: + try: + await out_msg.edit( + embed=discord.Embed( + colour=discord.Colour.red(), + description=cancel_msg or "Cancelled." + ) + ) + except discord.HTTPException: + pass + raise UserCancelled from None + + try: + await out_msg.delete() + except discord.HTTPException: + pass + + return True + + +_message_setting_flags = { + 'removable': settings.removable, + 'maximum': settings.maximum, + 'required_role': settings.required_role, + 'log': settings.log, + 'refunds': settings.refunds, + 'default_price': settings.default_price, +} +_reaction_setting_flags = { + 'price': settings.price, + 'timeout': settings.timeout +} + + @module.cmd( "reactionroles", group="Guild Admin", desc="Create or configure reaction role messages.", aliases=('rroles',), flags=( - 'delete', 'remove', + 'delete', 'remove==', 'enable', 'disable', 'required_role==', 'removable=', 'maximum=', 'refunds=', 'log=', 'default_price=', 'price=', 'timeout==' @@ -31,32 +196,39 @@ example_str = "🧮 mathematics, 🫀 biology, 💻 computer science, 🖼️ de 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 + {prefix}rroles + {prefix}rroles [enable|disable|delete] msglink + {prefix}rroles msglink [emoji1 role1, emoji2 role2, ...] + {prefix}rroles msglink --remove emoji1, emoji2, ... + {prefix}rroles msglink --message_setting [value] + {prefix}rroles msglink emoji --reaction_setting [value] 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. + Create and configure "reaction roles", i.e. roles obtainable by \ + clicking reactions on a particular message. + `msglink` is the link or message id of the message with reactions. + `emoji` should be given as the emoji itself, or the name or id. + `role` may be given by name, mention, or id. 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). + First choose the message you want to add reaction roles to, \ + and copy the link or message id for that message. \ + Then run the command `{prefix}rroles link`, replacing `link` with the copied link, \ + and follow the prompts. + For faster setup, use `{prefix}rroles link emoji1 role1, emoji2 role2` instead. + Editing reaction roles: + Remove roles with `{prefix}rroles link --remove emoji1, emoji2, ...` + Add/edit roles with `{prefix}rroles link emoji1 role1, emoji2 role2, ...` + Examples``: + {prefix}rroles {ctx.msg.id} 🧮 mathematics, 🫀 biology, 🩺 medicine + {prefix}rroles disable {ctx.msg.id} + PAGEBREAK: + Page 2 + Advanced configuration: + Type `{prefix}rroles link` again to view the advanced setting window, \ + and use `{prefix}rroles link --setting value` to modify the settings. \ + See below for descriptions of each message setting. + For example to disable event logging, run `{prefix}rroles link --log off`. - 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`. + For per-reaction settings, instead use `{prefix}rroles link emoji --setting value`. Message Settings:: maximum: Maximum number of roles obtainable from this message. log: Whether to log reaction role usage into the event log. @@ -67,172 +239,402 @@ async def cmd_reactionroles(ctx, flags): Reaction Settings:: price: The price of this reaction role. timeout: The amount of time the role lasts. (TBD) - Examples: - ... + Configuration Examples``: + {prefix}rroles {ctx.msg.id} --maximum 5 + {prefix}rroles {ctx.msg.id} --default_price 20 + {prefix}rroles {ctx.msg.id} --required_role None + {prefix}rroles {ctx.msg.id} 🧮 --price 1024 """ - if not ctx.args and not ctx.msg.reference: + if not ctx.args: # No target message provided, list the current reaction messages # Or give a brief guide if there are no current reaction messages - ... + guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id) + if guild_message_rows: + # List messages - target_id = None - target_chid = None - remaining = "" + # First get the list of reaction role messages in the guild + messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows] - 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] + # Sort them by channelid and messageid + messages.sort(key=lambda m: (m.data.channelid, m.messageid)) + + # Build the message description strings + message_strings = [] + for message in messages: + header = ( + "`{}` in <#{}> ([Click to jump]({})){}".format( + message.messageid, + message.data.channelid, + message.message_link, + " (disabled)" if not message.enabled else "" + ) + ) + role_strings = [ + "{} <@&{}>".format(reaction.emoji, reaction.data.roleid) + for reaction in message.reactions + ] + role_string = '\n'.join(role_strings) or "No reaction roles!" + + message_strings.append("{}\n{}".format(header, role_string)) + + pages = [] + page = [] + page_len = 0 + page_chars = 0 + i = 0 + while i < len(message_strings): + message_string = message_strings[i] + chars = len(message_string) + lines = len(message_string.splitlines()) + if (page and lines + page_len > 20) or (chars + page_chars > 2000): + pages.append('\n\n'.join(page)) + page = [] + page_len = 0 + page_chars = 0 + else: + page.append(message_string) + page_len += lines + page_chars += chars + i += 1 + if page: + pages.append('\n\n'.join(page)) + + page_count = len(pages) + title = "Reaction Roles in {}".format(ctx.guild.name) + embeds = [ + discord.Embed( + colour=discord.Colour.orange(), + description=page, + title=title + ) + for page in pages + ] + if page_count > 1: + [embed.set_footer(text="Page {} of {}".format(i + 1, page_count)) for i, embed in enumerate(embeds)] + await ctx.pager(embeds) + else: + # Send a setup guide + embed = discord.Embed( + title="No Reaction Roles set up!", + description=( + "To setup reaction roles, first copy the link or message id of the message you want to " + "add the roles to. Then run `{prefix}rroles link`, replacing `link` with the link you copied, " + "and follow the prompts.\n" + "See `{prefix}help rroles` for more information.".format(prefix=ctx.best_prefix) + ), + colour=discord.Colour.orange() + ) + await ctx.reply(embed=embed) + return + + # Extract first word, look for a subcommand + splits = ctx.args.split(maxsplit=1) + subcmd = splits[0].lower() + + if subcmd in ('enable', 'disable', 'delete'): + # Truncate arguments and extract target 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]) + ctx.args = splits[1] + target_chid, target_id = _parse_messageref(ctx) + else: + target_chid = None + target_id = None + ctx.args = '' - if not target_id: + # Handle subcommand special cases + if subcmd == 'enable': + if ctx.args and not target_id: + await ctx.error_reply( + "Couldn't find the message to enable!\n" + "**Usage:** `{}rroles enable [message link or id]`.".format(ctx.best_prefix) + ) + elif not target_id: + # Confirm enabling of all reaction messages + await reaction_ask( + "Are you sure you want to enable all reaction role messages in this server?", + timeout_msg="Prompt timed out, no reaction roles enabled.", + cancel_msg="User cancelled, no reaction roles enabled." + ) + reaction_role_messages.update_where( + {'enabled': True}, + guildid=ctx.guild.id + ) + await ctx.embed_reply( + "All reaction role messages have been enabled.", + colour=discord.Colour.green(), + ) + else: + # Fetch the target + target = ReactionRoleMessage.fetch(target_id) + if target is None: + await ctx.error_reply( + "This message doesn't have any reaction roles!\n" + "Run the command again without `enable` to assign reaction roles." + ) + else: + # We have a valid target + if target.enabled: + await ctx.error_reply( + "This message is already enabled!" + ) + else: + target.enabled = True + await ctx.embed_reply( + "The message has been enabled!" + ) + elif subcmd == 'disable': + if ctx.args and not target_id: + await ctx.error_reply( + "Couldn't find the message to disable!\n" + "**Usage:** `{}rroles disable [message link or id]`.".format(ctx.best_prefix) + ) + elif not target_id: + # Confirm disabling of all reaction messages + await reaction_ask( + "Are you sure you want to disable all reaction role messages in this server?", + timeout_msg="Prompt timed out, no reaction roles disabled.", + cancel_msg="User cancelled, no reaction roles disabled." + ) + reaction_role_messages.update_where( + {'enabled': False}, + guildid=ctx.guild.id + ) + await ctx.embed_reply( + "All reaction role messages have been disabled.", + colour=discord.Colour.green(), + ) + else: + # Fetch the target + target = ReactionRoleMessage.fetch(target_id) + if target is None: + await ctx.error_reply( + "This message doesn't have any reaction roles! Nothing to disable." + ) + else: + # We have a valid target + if not target.enabled: + await ctx.error_reply( + "This message is already disabled!" + ) + else: + target.enabled = False + await ctx.embed_reply( + "The message has been disabled!" + ) + elif subcmd == 'delete': + if ctx.args and not target_id: + await ctx.error_reply( + "Couldn't find the message to remove!\n" + "**Usage:** `{}rroles remove [message link or id]`.".format(ctx.best_prefix) + ) + elif not target_id: + # Confirm disabling of all reaction messages + await reaction_ask( + "Are you sure you want to remove all reaction role messages in this server?", + timeout_msg="Prompt timed out, no messages removed.", + cancel_msg="User cancelled, no messages removed." + ) + reaction_role_messages.delete_where( + guildid=ctx.guild.id + ) + await ctx.embed_reply( + "All reaction role messages have been removed.", + colour=discord.Colour.green(), + ) + else: + # Fetch the target + target = ReactionRoleMessage.fetch(target_id) + if target is None: + await ctx.error_reply( + "This message doesn't have any reaction roles! Nothing to remove." + ) + else: + # We have a valid target + target.delete() + await ctx.embed_reply( + "The message has been removed and is no longer a reaction role message." + ) + return + else: + # Just extract target + target_chid, target_id = _parse_messageref(ctx) + + # Handle target parsing issue + if target_id is None: return await ctx.error_reply( - "Please provide the message link or message id as the first argument." + "Couldn't parse `{}` as a message id or message link!\n" + "See `{}help rroles` for detailed usage information.".format(ctx.args.split()[0], ctx.best_prefix) ) - # We have a target, fetch the ReactionMessage if it exists + # Get the associated ReactionRoleMessage, if it exists target = ReactionRoleMessage.fetch(target_id) + + # Get the target message 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 - ... + message = await target.fetch_message() + if not message: + # TODO: Consider offering some sort of `move` option here. + await ctx.error_reply( + "This reaction role message no longer exists!\n" + "Use `{}rroles delete {}` to remove it from the list.".format(ctx.best_prefix, target.messageid) + ) else: - # Start creation process - # First find the target message message = None if target_chid: channel = ctx.guild.get_channel(target_chid) - if channel: + if not channel: + await ctx.error_reply( + "The provided channel no longer exists!" + ) + elif channel.type != discord.ChannelType.text: + await ctx.error_reply( + "The provided channel is not a text channel!" + ) + else: message = await channel.fetch_message(target_id) + if not message: + await ctx.error_reply( + "Couldn't find the specified message in {}!".format(channel.mention) + ) else: - # We only have a messageid, need to search for it through all the guild channels + out_msg = await ctx.embed_reply("Searching for `{}`".format(target_id)) message = await ctx.find_message(target_id) + try: + await out_msg.delete() + except discord.HTTPException: + pass + if not message: + await ctx.error_reply( + "Couldn't find the message `{}`!".format(target_id) + ) + if not message: + return - if message is None: - return await ctx.error_reply( - "Could not find the specified message!" + # Handle the `remove` flag specially + # In particular, all other flags are ignored + if flags['remove']: + if not target: + await ctx.error_reply( + "The specified message has no reaction roles! Nothing to remove." ) + else: + # Parse emojis and remove from target + target_emojis = {reaction.emoji: reaction for reaction in target.reactions} + emoji_name_map = {emoji.name.lower(): emoji for emoji in target_emojis} + emoji_id_map = {emoji.id: emoji for emoji in target_emojis} + items = [item.strip() for item in flags['remove'].split(',')] + to_remove = [] # List of reactions to remove + for emoji_str in items: + emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map) + if emoji is None: + return await ctx.error_reply( + "Couldn't parse `{}` as an emoji! No reactions were removed.".format(emoji_str) + ) + if emoji not in target_emojis: + return await ctx.error_reply( + "{} is not a reaction role for this message!".format(emoji) + ) + to_remove.append(target_emojis[emoji]) + + # Delete reactions from data + description = '\n'.join("{} <@&{}>".format(reaction.emoji, reaction.data.roleid) for reaction in to_remove) + reaction_role_reactions.delete_where(reactionid=[reaction.reactionid for reaction in to_remove]) + target.refresh() + + # Ack + embed = discord.Embed( + colour=discord.Colour.green(), + title="Reaction Roles deactivated", + description=description + ) + await ctx.reply(embed=embed) + return + + # Any remaining arguments should be emoji specifications with optional role + # Parse these now + given_emojis = {} # Map PartialEmoji -> Optional[Role] + existing_emojis = set() # Set of existing reaction emoji identifiers + + if ctx.args: + # First build the list of custom emojis we can accept by name + # We do this by reverse precedence, so the highest priority emojis are added last + custom_emojis = [] + custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild + if target: + custom_emojis.extend([r.emoji for r in target.reactions]) # Configured reaction roles on the target + custom_emojis.extend([r.emoji for r in message.reactions if r.custom_emoji]) # Actual reactions on the message + + # Filter out the built in emojis and those without a name + custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id) + + # Build the maps to lookup provided custom emojis + emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis} + emoji_id_map = {emoji.id: emoji for emoji in custom_emojis} + + # Now parse the provided emojis + # Assume that all-unicode strings are built-in emojis + # We can't assume much else unless we have a list of such emojis + splits = (split.strip() for line in ctx.args.splitlines() for split in line.split(',') if split) + splits = (split.split(maxsplit=1) for split in splits if split) + arg_emoji_strings = { + split[0]: split[1] if len(split) > 1 else None + for split in splits + } # emoji_str -> Optional[role_str] + + arg_emoji_map = {} + for emoji_str, role_str in arg_emoji_strings.items(): + emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map) + if emoji is None: + return await ctx.error_reply( + "Couldn't parse `{}` as an emoji!".format(emoji_str) + ) + else: + arg_emoji_map[emoji] = role_str + + # Final pass extracts roles + # If any new emojis were provided, their roles should be specified, we enforce this during role parsing + # First collect the existing emoji strings + if target: + for reaction in target.reactions: + emoji_id = reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id + existing_emojis.add(emoji_id) + + # Now parse and assign the roles, building the final map + for emoji, role_str in arg_emoji_map.items(): + emoji_id = emoji.name if emoji.id is None else emoji.id + role = None + if role_str: + role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False) + elif emoji_id not in existing_emojis: + return await ctx.error_reply( + "New emoji {} was given without an associated role!".format(emoji) + ) + given_emojis[emoji] = role + + # Next manage target creation or emoji editing, if required + if target is None: + # Reaction message creation wizard # 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( + await reaction_ask( + ctx, + question="Do you want to set up new reaction roles for [this message]({})?".format( message.jump_url - ) + ), + timeout_msg="Prompt timed out, no reaction roles created.", + cancel_msg="Reaction Role creation cancelled." ) - 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: + # Continue with creation + # Obtain emojis if not already provided + if not given_emojis: # Prompt for the initial emojis embed = discord.Embed( + colour=discord.Colour.orange(), 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:" + "Please now type the reaction roles you would like to add " + "in the form `emoji role`, where `role` is given by partial name or id. For example:" "```{}```".format(example_str) ) ) @@ -245,12 +647,16 @@ async def cmd_reactionroles(ctx, flags): 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() + await out_msg.edit( + embed=discord.Embed( + colour=discord.Colour.red(), + description="Prompt timed out, no reaction roles created." + ) + ) except discord.HTTPException: pass + return rolestrs = reply.content @@ -258,67 +664,274 @@ async def cmd_reactionroles(ctx, flags): 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] + # Attempt to parse the emojis + # First build the list of custom emojis we can accept by name + custom_emojis = [] + custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild + custom_emojis.extend( + r.emoji for r in message.reactions if r.custom_emoji + ) # Actual reactions on the message - # 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) - ) + # Filter out the built in emojis and those without a name + custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id) - # 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 + # Build the maps to lookup provided custom emojis + emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis} + emoji_id_map = {emoji.id: emoji for emoji in custom_emojis} - # Parse role - # TODO: More graceful error handling - role = await ctx.find_role(rolestr, interactive=True, allow_notfound=False) + # Now parse the provided emojis + # Assume that all-unicode strings are built-in emojis + # We can't assume much else unless we have a list of such emojis + splits = (split.strip() for line in rolestrs.splitlines() for split in line.split(',') if split) + splits = (split.split(maxsplit=1) for split in splits if split) + arg_emoji_strings = { + split[0]: split[1] if len(split) > 1 else None + for split in splits + } # emoji_str -> Optional[role_str] - reactions[emoji] = role + # Check all the emojis have roles associated + for emoji_str, role_str in arg_emoji_strings.items(): + if role_str is None: + return await ctx.error_reply( + "No role provided for `{}`! Reaction role creation cancelled.".format(emoji_str) + ) - # TODO: Parse any provided settings, and pass them to the data constructor + # Parse the provided roles and emojis + for emoji_str, role_str in arg_emoji_strings.items(): + emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map) + if emoji is None: + return await ctx.error_reply( + "Couldn't parse `{}` as an emoji!".format(emoji_str) + ) + else: + given_emojis[emoji] = await ctx.find_role( + role_str, + create=True, + interactive=True, + allow_notfound=False + ) + + if len(given_emojis) > 20: + return await ctx.error_reply("A maximum of 20 reactions are possible per message! Cancelling creation.") # Create the ReactionRoleMessage - rmsg = ReactionRoleMessage.create( + target = 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') + *((message.id, role.id, emoji.name, emoji.id, emoji.animated) for emoji, role in given_emojis.items()), + insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated') ) - # Refresh the ReactionRoleMessage to pick up the new reactions - rmsg.refresh() + # Refresh the message to pick up the new reactions + target.refresh() + + # Add the reactions to the message, if possible + existing_reactions = set( + reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id + for reaction in message.reactions + ) + missing = [ + reaction.emoji for reaction in target.reactions + if (reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id) not in existing_reactions + ] + if not any(emoji.id not in set(cemoji.id for cemoji in ctx.guild.emojis) for emoji in missing if emoji.id): + # We can add the missing emojis + for emoji in missing: + try: + await message.add_reaction(emoji) + except discord.HTTPException: + break + else: + missing = [] # Ack the creation + ack_msg = "Created `{}` new reaction roles on [this message]({})!".format( + len(target.reactions), + target.message_link + ) + if missing: + ack_msg += "\nPlease add the missing reactions to the message!" 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 + ack_msg + ) + elif given_emojis: + # Update the target reactions + # Create a map of the emojis that need to be added or updated + needs_update = { + emoji: role for emoji, role in given_emojis.items() if role + } + + # Fetch the existing target emojis to split the roles into inserts and updates + target_emojis = {reaction.emoji: reaction for reaction in target.reactions} + + # Handle the new roles + insert_targets = { + emoji: role for emoji, role in needs_update.items() if emoji not in target_emojis + } + if insert_targets: + if len(insert_targets) + len(target_emojis) > 20: + return await ctx.error_reply("Too many reactions! A maximum of 20 reactions are possible per message!") + reaction_role_reactions.insert_many( + *( + (message.id, role.id, emoji.name, emoji.id, emoji.animated) + for emoji, role in insert_targets.items() + ), + insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated') + ) + # Handle the updated roles + update_targets = { + target_emojis[emoji]: role for emoji, role in needs_update.items() if emoji in target_emojis + } + if update_targets: + reaction_role_reactions.update_many( + *((role.id, reaction.reactionid) for reaction, role in update_targets.items()), + set_keys=('roleid',), + where_keys=('reactionid',), + ) + + # Finally, refresh to load the new reactions + target.refresh() + + # Now that the target is created/updated, all the provided emojis should be reactions + given_reactions = [] + if given_emojis: + # Make a map of the existing reactions + existing_reactions = { + reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id: reaction + for reaction in target.reactions + } + given_reactions = [ + existing_reactions[emoji.name if emoji.id is None else emoji.id] + for emoji in given_emojis + ] + + # Handle message setting updates + update_lines = [] # Setting update lines to display + update_columns = {} # Message data columns to update + for flag in _message_setting_flags: + if flags[flag]: + setting_class = _message_setting_flags[flag] + try: + setting = await setting_class.parse(target.messageid, ctx, flags[flag]) + except UserInputError as e: + return await ctx.error_reply( + title="Couldn't save settings!", + description="{} {}\nNo settings were modified.".format(cross, e.msg) ) + else: + update_lines.append( + "{} {}".format(tick, setting.success_response) + ) + update_columns[setting._data_column] = setting.data + if update_columns: + # First write the data + reaction_role_messages.update_where( + update_columns, + messageid=target.messageid + ) + # Then ack the setting update + if len(update_lines) > 1: + embed = discord.Embed( + colour=discord.Colour.green(), + title="Reaction Role message settings updated!", + description='\n'.join(update_lines) + ) + else: + embed = discord.Embed( + colour=discord.Colour.green(), + description=update_lines[0] + ) + await ctx.reply(embed=embed) + + # Handle reaction setting updates + update_lines = [] # Setting update lines to display + update_columns = {} # Message data columns to update, for all given reactions + reactions = given_reactions or target.reactions + for flag in _reaction_setting_flags: + for reaction in reactions: + if flags[flag]: + setting_class = _reaction_setting_flags[flag] + try: + setting = await setting_class.parse(reaction.reactionid, ctx, flags[flag]) + except UserInputError as e: + return await ctx.error_reply( + title="Couldn't save reaction role settings!", + description="{} {}\nNo reaction roles were modified.".format(cross, e.msg) + ) + else: + update_lines.append( + setting.success_response.format(reaction=reaction) + ) + update_columns[setting._data_column] = setting.data + if update_columns: + # First write the data + reaction_role_reactions.update_where( + update_columns, + reactionid=[reaction.reactionid for reaction in reactions] + ) + # Then ack the setting update + if len(update_lines) > 1: + blocks = ['\n'.join(update_lines[i:i+20]) for i in range(0, len(update_lines), 20)] + embeds = [ + discord.Embed( + colour=discord.Colour.green(), + title="Reaction Role settings updated!", + description=block + ) for block in blocks + ] + await ctx.pager(embeds) + else: + embed = discord.Embed( + colour=discord.Colour.green(), + description=update_lines[0] + ) + await ctx.reply(embed=embed) + + # Show the reaction role message summary + # Build the reaction fields + reaction_fields = [] # List of tuples (name, value) + for reaction in target.reactions: + reaction_fields.append( + ( + "{} {}".format(reaction.emoji.name, reaction.emoji if reaction.emoji.id else ''), + "<@&{}>\n{}".format(reaction.data.roleid, reaction.settings.tabulated()) ) ) + + # Build the final setting pages + description = ( + "{settings_table}\n" + "To update a message setting: `{prefix}rroles messageid --setting value`\n" + "To update an emoji setting: `{prefix}rroles messageid emoji --setting value`\n" + "See more usage information and examples with `{prefix}help rroles`." + ).format( + prefix=ctx.best_prefix, + settings_table=target.settings.tabulated() + ) + + field_blocks = [reaction_fields[i:i+6] for i in range(0, len(reaction_fields), 6)] + page_count = len(field_blocks) + embeds = [] + for i, block in enumerate(field_blocks): + title = "Reaction role settings for message id `{}`".format(target.messageid) + embed = discord.Embed( + title=title, + description=description + ).set_author( + name="Click to jump to message", + url=target.message_link + ) + for name, value in block: + embed.add_field(name=name, value=value) + if page_count > 1: + embed.set_footer(text="Page {} of {}".format(i+1, page_count)) + embeds.append(embed) + + # Finally, send the reaction role information + await ctx.pager(embeds) diff --git a/bot/modules/guild_admin/reaction_roles/settings.py b/bot/modules/guild_admin/reaction_roles/settings.py index cf4f8346..d7a5ab51 100644 --- a/bot/modules/guild_admin/reaction_roles/settings.py +++ b/bot/modules/guild_admin/reaction_roles/settings.py @@ -37,6 +37,10 @@ class required_role(setting_types.Role, RoleMessageSetting): else: return "All members can now use these reaction roles." + @classmethod + def _get_guildid(cls, id: int, **kwargs): + return reaction_role_messages.fetch(id).guildid + @RoleMessageSettings.attach_setting class removable(setting_types.Boolean, RoleMessageSetting): @@ -88,7 +92,7 @@ class maximum(setting_types.Integer, RoleMessageSetting): @property def success_response(self): if self.value: - return "Members can get a maximum of `{}` roles from this message." + return "Members can get a maximum of `{}` roles from this message.".format(self.value) else: return "Members can now get all the roles from this mesage." @@ -205,7 +209,7 @@ class price(setting_types.Integer, ReactionSetting): @classmethod def _format_data(cls, id, data, **kwargs): - if data is None: + if not data: return "Free" else: return "`{}` coins".format(data) @@ -213,9 +217,9 @@ class price(setting_types.Integer, ReactionSetting): @property def success_response(self): if self.value is not None: - return "This role now costs `{}` coins.".format(self.value) + return "{{reaction.emoji}} {{reaction.role.mention}} now costs `{}` coins.".format(self.value) else: - return "This role is free." + return "{reaction.emoji} {reaction.role.mention} is now free." @ReactionSettings.attach_setting @@ -243,6 +247,8 @@ class timeout(setting_types.Duration, ReactionSetting): @property def success_response(self): if self.value is not None: - return "Roles will timeout `{}` after selection.".format(self.formatted) + return "{{reaction.emoji}} {{reaction.role.mention}} will timeout `{}` after selection.".format( + self.formatted + ) else: - return "Roles will never timeout after selection." + return "{reaction.emoji} {reaction.role.mention} 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 index 6bb3de35..c7aec20c 100644 --- a/bot/modules/guild_admin/reaction_roles/tracker.py +++ b/bot/modules/guild_admin/reaction_roles/tracker.py @@ -18,17 +18,17 @@ from .data import reaction_role_messages, reaction_role_reactions # , reaction_ from .settings import RoleMessageSettings, ReactionSettings -class ReactionRoleReaction(PartialEmoji): +class ReactionRoleReaction: """ Light data class representing a reaction role reaction. - Extends PartialEmoji for comparison with discord Emojis. """ - __slots__ = ('reactionid', '_message', '_role') + __slots__ = ('reactionid', '_emoji', '_message', '_role') def __init__(self, reactionid, message=None, **kwargs): self.reactionid = reactionid self._message: ReactionRoleMessage = None self._role = None + self._emoji = None @classmethod def create(cls, messageid, roleid, emoji: PartialEmoji, message=None, **kwargs) -> 'ReactionRoleReaction': @@ -47,6 +47,17 @@ class ReactionRoleReaction(PartialEmoji): ) return cls(row.reactionid, message=message) + @property + def emoji(self) -> PartialEmoji: + if self._emoji is None: + data = self.data + self._emoji = PartialEmoji( + name=data.emoji_name, + animated=data.emoji_animated, + id=data.emoji_id, + ) + return self._emoji + @property def data(self) -> Row: return reaction_role_reactions.fetch(self.reactionid) @@ -69,31 +80,6 @@ class ReactionRoleReaction(PartialEmoji): 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: """ @@ -147,6 +133,20 @@ class ReactionRoleMessage: # Return the constructed ReactionRoleMessage return rmsg + def delete(self): + """ + Delete this ReactionRoleMessage. + """ + # Remove message from cache + self._messages.pop(self.messageid, None) + + # Remove reactions from cache + reactionids = [reaction.reactionid for reaction in self.reactions] + [self._reactions.pop(reactionid, None) for reactionid in reactionids] + + # Remove message from data + reaction_role_messages.delete_where(messageid=self.messageid) + @property def data(self) -> Row: """ @@ -242,7 +242,7 @@ class ReactionRoleMessage: """ 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) + reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None) if reaction: # User pressed a live reaction. Process! member = payload.member @@ -428,7 +428,7 @@ class ReactionRoleMessage: 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) + reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None) if reaction: # User removed a live reaction. Process! member = self.guild.get_member(payload.user_id) diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index ce71efa6..ecb46fb4 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -78,15 +78,17 @@ async def cmd_help(ctx): help_map = {field_name: i for i, (field_name, _) in enumerate(help_fields)} if not help_map: - await ctx.reply("No documentation has been written for this command yet!") + return await ctx.reply("No documentation has been written for this command yet!") + field_pages = [[]] + page_fields = field_pages[0] for name, pos in help_map.items(): if name.endswith("``"): # Handle codeline help fields - help_fields[pos] = ( + page_fields.append(( name.strip("`"), "`{}`".format('`\n`'.join(help_fields[pos][1].splitlines())) - ) + )) elif name.endswith(":"): # Handle property/value help fields lines = help_fields[pos][1].splitlines() @@ -98,10 +100,10 @@ async def cmd_help(ctx): names.append(split[0] if len(split) > 1 else "") values.append(split[-1]) - help_fields[pos] = ( + page_fields.append(( name.strip(':'), prop_tabulate(names, values) - ) + )) elif name == "Related": # Handle the related field names = [cmd_name.strip() for cmd_name in help_fields[pos][1].split(',')] @@ -110,32 +112,46 @@ async def cmd_help(ctx): (getattr(ctx.client.cmd_names.get(cmd_name, None), 'desc', '') or '').format(ctx=ctx) for cmd_name in names ] - help_fields[pos] = ( + page_fields.append(( name, prop_tabulate(names, values) - ) + )) + elif name == "PAGEBREAK": + page_fields = [] + field_pages.append(page_fields) + else: + page_fields.append((name, help_fields[pos][1])) + # Build the aliases aliases = getattr(command, 'aliases', []) alias_str = "(Aliases `{}`.)".format("`, `".join(aliases)) if aliases else "" - # Build the embed - embed = discord.Embed( - title="`{}` command documentation. {}".format(command.name, alias_str), - colour=discord.Colour(0x9b59b6) - ) - for fieldname, fieldvalue in help_fields: - embed.add_field( - name=fieldname, - value=fieldvalue.format(ctx=ctx, prefix=ctx.best_prefix), - inline=False + # Build the embeds + pages = [] + for i, page_fields in enumerate(field_pages): + embed = discord.Embed( + title="`{}` command documentation. {}".format( + command.name, + alias_str + ), + colour=discord.Colour(0x9b59b6) ) + for fieldname, fieldvalue in page_fields: + embed.add_field( + name=fieldname, + value=fieldvalue.format(ctx=ctx, prefix=ctx.best_prefix), + inline=False + ) - embed.set_footer( - text="[optional] and denote optional and required arguments, respectively." - ) + embed.set_footer( + text="{}\n[optional] and denote optional and required arguments, respectively.".format( + "Page {} of {}".format(i + 1, len(field_pages)) if len(field_pages) > 1 else '', + ) + ) + pages.append(embed) # Post the embed - await ctx.reply(embed=embed) + await ctx.pager(pages) else: # Build the command groups cmd_groups = {} diff --git a/bot/settings/base.py b/bot/settings/base.py index 2793ee16..8e86654b 100644 --- a/bot/settings/base.py +++ b/bot/settings/base.py @@ -257,6 +257,16 @@ class ObjectSettings: cls.settings[name] = setting return setting + def tabulated(self): + """ + Convenience method to provide a complete setting property-table. + """ + formatted = { + setting.display_name: setting.get(self.id, **dict(self.params)).formatted + for name, setting in self.settings.items() + } + return prop_tabulate(*zip(*formatted.items())) + class ColumnData: """ diff --git a/bot/settings/setting_types.py b/bot/settings/setting_types.py index 95f04589..ddc47ab5 100644 --- a/bot/settings/setting_types.py +++ b/bot/settings/setting_types.py @@ -327,6 +327,8 @@ class Role(SettingType): # Whether to disallow returning roles which don't exist as `discord.Object`s _strict = True + _parse_create = False + @classmethod def _data_from_value(cls, id: int, value: Optional[discord.Role], **kwargs): """ @@ -370,7 +372,7 @@ class Role(SettingType): if userstr.lower() in ('0', 'none'): return None else: - role = await ctx.find_role(userstr, create=False, interactive=True) + role = await ctx.find_role(userstr, create=cls._parse_create, interactive=True) if role is None: raise SafeCancellation else: