""" Classes for crafting messages from the bot. Content can change as information changes, and mods can perform actions on the message via emoji reactions. """ from datetime import datetime from discord import Guild, Message, PartialEmoji, TextChannel from config import CONFIG from rocketbot.storage import ConfigKey, Storage from rocketbot.utils import bot_log class BotMessageReaction: """ A possible reaction to a bot message that will trigger an action. The list of available reactions will be listed at the end of a BotMessage. When a mod reacts to the message with the emote, something can happen. If the reaction is disabled, reactions will not register. The description will still show up in the message, but no emoji is shown. This can be used to explain why an action is no longer available. """ def __init__(self, emoji: str, is_enabled: bool, description: str): self.emoji = emoji self.is_enabled = is_enabled self.description = description def __eq__(self, other): return other is not None and \ other.emoji == self.emoji and \ other.is_enabled == self.is_enabled and \ other.description == self.description @classmethod def standard_set(cls, did_delete: bool = None, message_count: int = 1, did_kick: bool = None, did_ban: bool = None, user_count: int = 1) -> list: """ Convenience factory for generating any of the three most common commands: delete message(s), kick user(s), and ban user(s). All arguments are optional. Resulting list can be passed directly to `BotMessage.set_reactions()`. Params - did_delete Whether the message(s) have been deleted. Pass True or False if this applies, omit to leave out delete action. - message_count How many messages there are. Used for pluralizing description. Defaults to 1. Omit if n/a. - did_kick Whether the user(s) have been kicked. Pass True or False if this applies, omit to leave out kick action. - did_ban Whether the user(s) have been banned. Pass True or False if this applies, omit to leave out ban action. - user_count How many users there are. Used for pluralizing description. Defaults to 1. Omit if n/a. """ reactions = [] if did_delete is not None: if did_delete: reactions.append(BotMessageReaction( CONFIG['trash_emoji'], False, 'Message deleted' if message_count == 1 else 'Messages deleted')) else: reactions.append(BotMessageReaction( CONFIG['trash_emoji'], True, 'Delete message' if message_count == 1 else 'Delete messages')) if did_kick is not None: if did_ban is not None and did_ban: # Don't show kick option at all if we also banned pass elif did_kick: reactions.append(BotMessageReaction( CONFIG['kick_emoji'], False, 'User kicked' if user_count == 1 else 'Users kicked')) else: reactions.append(BotMessageReaction( CONFIG['kick_emoji'], True, 'Kick user' if user_count == 1 else 'Kick users')) if did_ban is not None: if did_ban: reactions.append(BotMessageReaction( CONFIG['ban_emoji'], False, 'User banned' if user_count == 1 else 'Users banned')) else: reactions.append(BotMessageReaction( CONFIG['ban_emoji'], True, 'Ban user' if user_count == 1 else 'Ban users')) return reactions class BotMessage: """ Holds state for a bot-generated message. A message is composed, sent via `BaseCog.post_message()`, and can later be updated. A message consists of a type (e.g. info, warning), text, optional quoted text (such as the content of a flagged message), and an optional list of actions that can be taken via a mod reacting to the message. """ TYPE_DEFAULT = 0 TYPE_INFO = 1 TYPE_MOD_WARNING = 2 TYPE_SUCCESS = 3 TYPE_FAILURE = 4 def __init__(self, guild: Guild, text: str, type: int = TYPE_DEFAULT, # pylint: disable=redefined-builtin context = None, reply_to: Message = None): self.guild = guild self.text = text self.type = type self.context = context self.quote = None self.source_cog = None # Set by `BaseCog.post_message()` self.__posted_text = None # last text posted, to test for changes self.__posted_emoji = set() self.__message = None # Message self.__reply_to = reply_to self.__reactions = [] # BotMessageReaction[] def is_sent(self) -> bool: """ Returns whether this message has been sent to the guild. This may continue returning False even after calling BaseCog.post_message if the guild has no configured warning channel. """ return self.__message is not None def message_id(self): 'Returns the Message id or None if not sent.' return self.__message.id if self.__message else None def message_sent_at(self) -> datetime: 'Returns when the message was sent or None if not sent.' return self.__message.created_at if self.__message else None def has_reactions(self) -> bool: 'Whether this message has any reactions defined.' return len(self.__reactions) > 0 async def set_text(self, new_text: str) -> None: """ Replaces the text of this message. If the message has been sent, it will be updated. """ self.text = new_text await self.update_if_sent() async def set_reactions(self, reactions: list) -> None: """ Replaces all BotMessageReactions with a new list. If the message has been sent, it will be updated. """ if reactions == self.__reactions: # No change return self.__reactions = reactions.copy() if reactions is not None else [] await self.update_if_sent() async def add_reaction(self, reaction: BotMessageReaction) -> None: """ Adds one BotMessageReaction to this message. If a reaction already exists for the given emoji it is replaced with the new one. If the message has been sent, it will be updated. """ # Alias for update. Makes for clearer intent. await self.update_reaction(reaction) async def update_reaction(self, reaction: BotMessageReaction) -> None: """ Updates or adds a BotMessageReaction. If the message has been sent, it will be updated. """ found = False for i, existing in enumerate(self.__reactions): if existing.emoji == reaction.emoji: if reaction == self.__reactions[i]: # No change return self.__reactions[i] = reaction found = True break if not found: self.__reactions.append(reaction) await self.update_if_sent() async def remove_reaction(self, reaction_or_emoji) -> None: """ Removes a reaction. Can pass either a BotMessageReaction or just the emoji string. If the message has been sent, it will be updated. """ for i, existing in enumerate(self.__reactions): if (isinstance(reaction_or_emoji, str) and existing.emoji == reaction_or_emoji) or \ (isinstance(reaction_or_emoji, BotMessageReaction) and \ existing.emoji == reaction_or_emoji.emoji): self.__reactions.pop(i) await self.update_if_sent() return def reaction_for_emoji(self, emoji) -> BotMessageReaction: """ Finds the BotMessageReaction for the given emoji or None if not found. Accepts either a PartialEmoji or str. """ for reaction in self.__reactions: if isinstance(emoji, PartialEmoji) and reaction.emoji == emoji.name: return reaction if isinstance(emoji, str) and reaction.emoji == emoji: return reaction return None async def update_if_sent(self) -> None: """ Updates the text and/or reactions on a message if it was sent to the guild, otherwise does nothing. Does not need to be called by BaseCog subclasses. """ if self.__message: await self.update() async def update(self) -> None: """ Sends or updates an already sent message based on BotMessage state. Does not need to be called by BaseCog subclasses. """ content: str = self.__formatted_message() if self.__message: if content != self.__posted_text: await self.__message.edit(content=content) self.__posted_text = content else: if self.__reply_to: self.__message = await self.__reply_to.reply(content=content, mention_author=False) self.__posted_text = content else: channel_id = Storage.get_config_value(self.guild, ConfigKey.WARNING_CHANNEL_ID) if channel_id is None: bot_log(self.guild, None, '\u0007No warning channel set! No warning issued.') return channel: TextChannel = self.guild.get_channel(channel_id) if channel is None: bot_log(self.guild, None, '\u0007Configured warning channel does not exist!') return self.__message = await channel.send(content=content) self.__posted_text = content emoji_to_remove = self.__posted_emoji.copy() for reaction in self.__reactions: if reaction.is_enabled: if reaction.emoji not in self.__posted_emoji: await self.__message.add_reaction(reaction.emoji) self.__posted_emoji.add(reaction.emoji) if reaction.emoji in emoji_to_remove: emoji_to_remove.remove(reaction.emoji) for emoji in emoji_to_remove: await self.__message.clear_reaction(emoji) if emoji in self.__posted_emoji: self.__posted_emoji.remove(emoji) def __formatted_message(self) -> str: s: str = '' if self.type == self.TYPE_INFO: s += CONFIG['info_emoji'] + ' ' elif self.type == self.TYPE_MOD_WARNING: mention: str = Storage.get_config_value(self.guild, ConfigKey.WARNING_MENTION) if mention: s += mention + ' ' s += CONFIG['warning_emoji'] + ' ' elif self.type == self.TYPE_SUCCESS: s += CONFIG['success_emoji'] + ' ' elif self.type == self.TYPE_FAILURE: s += CONFIG['failure_emoji'] + ' ' s += self.text if self.quote: s += f'\n\n> {self.quote}' if len(self.__reactions) > 0: s += '\n\nAvailable actions:' for reaction in self.__reactions: if reaction.is_enabled: s += f'\n {reaction.emoji} {reaction.description}' else: s += f'\n {reaction.description}' return s