| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- """
- 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 typing import Any, Optional, Union
-
- 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):
- """
- Creates a bot message reaction.
- - emoji: The emoji string
- - is_enabled: Whether the emoji can be used
- - description: What reacting with this emoji does (or an explanation of
- why it's disabled)
- """
- self.emoji: str = emoji
- self.is_enabled: bool = is_enabled
- self.description: str = 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[BotMessageReaction]:
- """
- 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: list[BotMessageReaction] = []
- 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: int = 0
- TYPE_INFO: int = 1
- TYPE_MOD_WARNING: int = 2
- TYPE_SUCCESS: int = 3
- TYPE_FAILURE: int = 4
-
- def __init__(self,
- guild: Guild,
- text: str,
- type: int = TYPE_DEFAULT, # pylint: disable=redefined-builtin
- context: Optional[Any] = None,
- reply_to: Optional[Message] = None):
- """
- Creates a bot message.
- - guild: The Discord guild to send the message to.
- - text: Main text of the message.
- - type: One of the TYPE_ constants.
- - context: Arbitrary value that will be passed in the callback. Can be
- associated data for performing some action. (optional)
- - reply_to: Existing message this message is in reply to. (optional)
- """
- self.guild: Guild = guild
- self.text: str = text
- self.type: int = type
- self.context: Optional[Any] = context
- self.quote: Optional[str] = None
- self.source_cog = None # Set by `BaseCog.post_message()`
- self.__posted_text: str = None # last text posted, to test for changes
- self.__posted_emoji: set[str] = set() # last emoji list posted
- self.__message: Optional[Message] = None # set once the message has been posted
- self.__reply_to: Optional[Message] = reply_to
- self.__reactions: list[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) -> Optional[int]:
- 'Returns the Message id or None if not sent.'
- return self.__message.id if self.__message else None
-
- def message_sent_at(self) -> Optional[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[BotMessageReaction]) -> 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: Union[BotMessageReaction, str]) -> 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) -> Optional[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, \
- type(self.source_cog) if self.source_cog else 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, \
- type(self.source_cog) if self.source_cog else 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:
- """
- Composes the entire message markdown from components. Includes the main
- message, quoted text, summary of available reactions, etc.
- """
- 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
|