Experimental Discord bot written in Python
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

botmessage.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. """
  2. Classes for crafting messages from the bot. Content can change as information
  3. changes, and mods can perform actions on the message via emoji reactions.
  4. """
  5. from typing import Any, Optional, Union
  6. from datetime import datetime
  7. from discord import Guild, Message, PartialEmoji, TextChannel
  8. from config import CONFIG
  9. from rocketbot.storage import ConfigKey, Storage
  10. from rocketbot.utils import bot_log
  11. class BotMessageReaction:
  12. """
  13. A possible reaction to a bot message that will trigger an action. The list
  14. of available reactions will be listed at the end of a BotMessage. When a
  15. mod reacts to the message with the emote, something can happen.
  16. If the reaction is disabled, reactions will not register. The description
  17. will still show up in the message, but no emoji is shown. This can be used
  18. to explain why an action is no longer available.
  19. """
  20. def __init__(self, emoji: str, is_enabled: bool, description: str):
  21. """
  22. Creates a bot message reaction.
  23. - emoji: The emoji string
  24. - is_enabled: Whether the emoji can be used
  25. - description: What reacting with this emoji does (or an explanation of
  26. why it's disabled)
  27. """
  28. self.emoji: str = emoji
  29. self.is_enabled: bool = is_enabled
  30. self.description: str = description
  31. def __eq__(self, other):
  32. return other is not None and \
  33. other.emoji == self.emoji and \
  34. other.is_enabled == self.is_enabled and \
  35. other.description == self.description
  36. @classmethod
  37. def standard_set(cls,
  38. did_delete: bool = None,
  39. message_count: int = 1,
  40. did_kick: bool = None,
  41. did_ban: bool = None,
  42. user_count: int = 1) -> list: # list[BotMessageReaction]:
  43. """
  44. Convenience factory for generating any of the three most common
  45. commands: delete message(s), kick user(s), and ban user(s). All
  46. arguments are optional. Resulting list can be passed directly to
  47. `BotMessage.set_reactions()`.
  48. Params
  49. - did_delete Whether the message(s) have been deleted. Pass True or
  50. False if this applies, omit to leave out delete action.
  51. - message_count How many messages there are. Used for pluralizing
  52. description. Defaults to 1. Omit if n/a.
  53. - did_kick Whether the user(s) have been kicked. Pass True or
  54. False if this applies, omit to leave out kick action.
  55. - did_ban Whether the user(s) have been banned. Pass True or
  56. False if this applies, omit to leave out ban action.
  57. - user_count How many users there are. Used for pluralizing
  58. description. Defaults to 1. Omit if n/a.
  59. """
  60. reactions: list[BotMessageReaction] = []
  61. if did_delete is not None:
  62. if did_delete:
  63. reactions.append(BotMessageReaction(
  64. CONFIG['trash_emoji'],
  65. False,
  66. 'Message deleted' if message_count == 1 else 'Messages deleted'))
  67. else:
  68. reactions.append(BotMessageReaction(
  69. CONFIG['trash_emoji'],
  70. True,
  71. 'Delete message' if message_count == 1 else 'Delete messages'))
  72. if did_kick is not None:
  73. if did_ban is not None and did_ban:
  74. # Don't show kick option at all if we also banned
  75. pass
  76. elif did_kick:
  77. reactions.append(BotMessageReaction(
  78. CONFIG['kick_emoji'],
  79. False,
  80. 'User kicked' if user_count == 1 else 'Users kicked'))
  81. else:
  82. reactions.append(BotMessageReaction(
  83. CONFIG['kick_emoji'],
  84. True,
  85. 'Kick user' if user_count == 1 else 'Kick users'))
  86. if did_ban is not None:
  87. if did_ban:
  88. reactions.append(BotMessageReaction(
  89. CONFIG['ban_emoji'],
  90. False,
  91. 'User banned' if user_count == 1 else 'Users banned'))
  92. else:
  93. reactions.append(BotMessageReaction(
  94. CONFIG['ban_emoji'],
  95. True,
  96. 'Ban user' if user_count == 1 else 'Ban users'))
  97. return reactions
  98. class BotMessage:
  99. """
  100. Holds state for a bot-generated message. A message is composed, sent via
  101. `BaseCog.post_message()`, and can later be updated.
  102. A message consists of a type (e.g. info, warning), text, optional quoted
  103. text (such as the content of a flagged message), and an optional list of
  104. actions that can be taken via a mod reacting to the message.
  105. """
  106. TYPE_DEFAULT: int = 0
  107. TYPE_INFO: int = 1
  108. TYPE_MOD_WARNING: int = 2
  109. TYPE_SUCCESS: int = 3
  110. TYPE_FAILURE: int = 4
  111. TYPE_LOG: int = 5
  112. def __init__(self,
  113. guild: Guild,
  114. text: str,
  115. type: int = TYPE_DEFAULT, # pylint: disable=redefined-builtin
  116. context: Optional[Any] = None,
  117. reply_to: Optional[Message] = None,
  118. suppress_embeds: bool = False):
  119. """
  120. Creates a bot message.
  121. - guild: The Discord guild to send the message to.
  122. - text: Main text of the message.
  123. - type: One of the TYPE_ constants.
  124. - context: Arbitrary value that will be passed in the callback. Can be
  125. associated data for performing some action. (optional)
  126. - reply_to: Existing message this message is in reply to. (optional)
  127. """
  128. self.guild: Guild = guild
  129. self.text: str = text
  130. self.type: int = type
  131. self.context: Optional[Any] = context
  132. self.quote: Optional[str] = None
  133. self.source_cog = None # Set by `BaseCog.post_message()`
  134. self.__posted_text: str = None # last text posted, to test for changes
  135. self.__posted_emoji: set[str] = set() # last emoji list posted
  136. self.__message: Optional[Message] = None # set once the message has been posted
  137. self.__reply_to: Optional[Message] = reply_to
  138. self.__suppress_embeds = suppress_embeds
  139. self.__reactions: list[BotMessageReaction] = []
  140. def is_sent(self) -> bool:
  141. """
  142. Returns whether this message has been sent to the guild. This may
  143. continue returning False even after calling BaseCog.post_message if
  144. the guild has no configured warning channel.
  145. """
  146. return self.__message is not None
  147. def message_id(self) -> Optional[int]:
  148. 'Returns the Message id or None if not sent.'
  149. return self.__message.id if self.__message else None
  150. def message_sent_at(self) -> Optional[datetime]:
  151. 'Returns when the message was sent or None if not sent.'
  152. return self.__message.created_at if self.__message else None
  153. def has_reactions(self) -> bool:
  154. 'Whether this message has any reactions defined.'
  155. return len(self.__reactions) > 0
  156. async def set_text(self, new_text: str) -> None:
  157. """
  158. Replaces the text of this message. If the message has been sent, it will
  159. be updated.
  160. """
  161. self.text = new_text
  162. await self.update_if_sent()
  163. async def set_reactions(self, reactions: list[BotMessageReaction]) -> None:
  164. """
  165. Replaces all BotMessageReactions with a new list. If the message has
  166. been sent, it will be updated.
  167. """
  168. if reactions == self.__reactions:
  169. # No change
  170. return
  171. self.__reactions = reactions.copy() if reactions is not None else []
  172. await self.update_if_sent()
  173. async def add_reaction(self, reaction: BotMessageReaction) -> None:
  174. """
  175. Adds one BotMessageReaction to this message. If a reaction already
  176. exists for the given emoji it is replaced with the new one. If the
  177. message has been sent, it will be updated.
  178. """
  179. # Alias for update. Makes for clearer intent.
  180. await self.update_reaction(reaction)
  181. async def update_reaction(self, reaction: BotMessageReaction) -> None:
  182. """
  183. Updates or adds a BotMessageReaction. If the message has been sent, it
  184. will be updated.
  185. """
  186. found = False
  187. for i, existing in enumerate(self.__reactions):
  188. if existing.emoji == reaction.emoji:
  189. if reaction == self.__reactions[i]:
  190. # No change
  191. return
  192. self.__reactions[i] = reaction
  193. found = True
  194. break
  195. if not found:
  196. self.__reactions.append(reaction)
  197. await self.update_if_sent()
  198. async def remove_reaction(self, reaction_or_emoji: Union[BotMessageReaction, str]) -> None:
  199. """
  200. Removes a reaction. Can pass either a BotMessageReaction or just the
  201. emoji string. If the message has been sent, it will be updated.
  202. """
  203. for i, existing in enumerate(self.__reactions):
  204. if (isinstance(reaction_or_emoji, str) and existing.emoji == reaction_or_emoji) or \
  205. (isinstance(reaction_or_emoji, BotMessageReaction) and \
  206. existing.emoji == reaction_or_emoji.emoji):
  207. self.__reactions.pop(i)
  208. await self.update_if_sent()
  209. return
  210. def reaction_for_emoji(self, emoji) -> Optional[BotMessageReaction]:
  211. """
  212. Finds the BotMessageReaction for the given emoji or None if not found.
  213. Accepts either a PartialEmoji or str.
  214. """
  215. for reaction in self.__reactions:
  216. if isinstance(emoji, PartialEmoji) and reaction.emoji == emoji.name:
  217. return reaction
  218. if isinstance(emoji, str) and reaction.emoji == emoji:
  219. return reaction
  220. return None
  221. async def update_if_sent(self) -> None:
  222. """
  223. Updates the text and/or reactions on a message if it was sent to
  224. the guild, otherwise does nothing. Does not need to be called by
  225. BaseCog subclasses.
  226. """
  227. if self.__message:
  228. await self.update()
  229. async def update(self) -> None:
  230. """
  231. Sends or updates an already sent message based on BotMessage state.
  232. Does not need to be called by BaseCog subclasses.
  233. """
  234. content: str = self.__formatted_message()
  235. if self.__message:
  236. if content != self.__posted_text:
  237. await self.__message.edit(content=content)
  238. self.__posted_text = content
  239. else:
  240. if self.__reply_to:
  241. self.__message = await self.__reply_to.reply(content=content, mention_author=False)
  242. self.__posted_text = content
  243. else:
  244. channel_id = Storage.get_config_value(self.guild, ConfigKey.WARNING_CHANNEL_ID)
  245. if channel_id is None:
  246. bot_log(self.guild, \
  247. type(self.source_cog) if self.source_cog else None, \
  248. '\u0007No warning channel set! No warning issued.')
  249. return
  250. channel: TextChannel = self.guild.get_channel(channel_id)
  251. if channel is None:
  252. channel = await self.guild.fetch_channel(channel_id)
  253. if channel is None:
  254. bot_log(self.guild, \
  255. type(self.source_cog) if self.source_cog else None, \
  256. f'\u0007Configured warning channel does not exist for guild {self.guild.name} ({self.guild.id})!')
  257. return
  258. self.__message = await channel.send(content=content, suppress_embeds=self.__suppress_embeds)
  259. self.__posted_text = content
  260. emoji_to_remove = self.__posted_emoji.copy()
  261. for reaction in self.__reactions:
  262. if reaction.is_enabled:
  263. if reaction.emoji not in self.__posted_emoji:
  264. await self.__message.add_reaction(reaction.emoji)
  265. self.__posted_emoji.add(reaction.emoji)
  266. if reaction.emoji in emoji_to_remove:
  267. emoji_to_remove.remove(reaction.emoji)
  268. for emoji in emoji_to_remove:
  269. await self.__message.clear_reaction(emoji)
  270. if emoji in self.__posted_emoji:
  271. self.__posted_emoji.remove(emoji)
  272. def __formatted_message(self) -> str:
  273. """
  274. Composes the entire message markdown from components. Includes the main
  275. message, quoted text, summary of available reactions, etc.
  276. """
  277. s: str = ''
  278. if self.type == self.TYPE_INFO:
  279. s += CONFIG['info_emoji'] + ' '
  280. elif self.type == self.TYPE_MOD_WARNING:
  281. mention: str = Storage.get_config_value(self.guild, ConfigKey.WARNING_MENTION)
  282. if mention:
  283. s += mention + ' '
  284. s += CONFIG['warning_emoji'] + ' '
  285. elif self.type == self.TYPE_SUCCESS:
  286. s += CONFIG['success_emoji'] + ' '
  287. elif self.type == self.TYPE_FAILURE:
  288. s += CONFIG['failure_emoji'] + ' '
  289. elif self.type == self.TYPE_LOG:
  290. s += CONFIG['log_emoji'] + ' '
  291. s += self.text
  292. if self.quote:
  293. s += f'\n\n> {self.quote}'
  294. if len(self.__reactions) > 0:
  295. s += '\n\nAvailable actions:'
  296. for reaction in self.__reactions:
  297. if reaction.is_enabled:
  298. s += f'\n {reaction.emoji} {reaction.description}'
  299. else:
  300. s += f'\n {reaction.description}'
  301. return s