Experimental Discord bot written in Python
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

botmessage.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  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[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. def __init__(self,
  112. guild: Guild,
  113. text: str,
  114. type: int = TYPE_DEFAULT, # pylint: disable=redefined-builtin
  115. context: Optional[Any] = None,
  116. reply_to: Optional[Message] = None):
  117. """
  118. Creates a bot message.
  119. - guild: The Discord guild to send the message to.
  120. - text: Main text of the message.
  121. - type: One of the TYPE_ constants.
  122. - context: Arbitrary value that will be passed in the callback. Can be
  123. associated data for performing some action. (optional)
  124. - reply_to: Existing message this message is in reply to. (optional)
  125. """
  126. self.guild: Guild = guild
  127. self.text: str = text
  128. self.type: int = type
  129. self.context: Optional[Any] = context
  130. self.quote: Optional[str] = None
  131. self.source_cog = None # Set by `BaseCog.post_message()`
  132. self.__posted_text: str = None # last text posted, to test for changes
  133. self.__posted_emoji: set[str] = set() # last emoji list posted
  134. self.__message: Optional[Message] = None # set once the message has been posted
  135. self.__reply_to: Optional[Message] = reply_to
  136. self.__reactions: list[BotMessageReaction] = []
  137. def is_sent(self) -> bool:
  138. """
  139. Returns whether this message has been sent to the guild. This may
  140. continue returning False even after calling BaseCog.post_message if
  141. the guild has no configured warning channel.
  142. """
  143. return self.__message is not None
  144. def message_id(self) -> Optional[int]:
  145. 'Returns the Message id or None if not sent.'
  146. return self.__message.id if self.__message else None
  147. def message_sent_at(self) -> Optional[datetime]:
  148. 'Returns when the message was sent or None if not sent.'
  149. return self.__message.created_at if self.__message else None
  150. def has_reactions(self) -> bool:
  151. 'Whether this message has any reactions defined.'
  152. return len(self.__reactions) > 0
  153. async def set_text(self, new_text: str) -> None:
  154. """
  155. Replaces the text of this message. If the message has been sent, it will
  156. be updated.
  157. """
  158. self.text = new_text
  159. await self.update_if_sent()
  160. async def set_reactions(self, reactions: list[BotMessageReaction]) -> None:
  161. """
  162. Replaces all BotMessageReactions with a new list. If the message has
  163. been sent, it will be updated.
  164. """
  165. if reactions == self.__reactions:
  166. # No change
  167. return
  168. self.__reactions = reactions.copy() if reactions is not None else []
  169. await self.update_if_sent()
  170. async def add_reaction(self, reaction: BotMessageReaction) -> None:
  171. """
  172. Adds one BotMessageReaction to this message. If a reaction already
  173. exists for the given emoji it is replaced with the new one. If the
  174. message has been sent, it will be updated.
  175. """
  176. # Alias for update. Makes for clearer intent.
  177. await self.update_reaction(reaction)
  178. async def update_reaction(self, reaction: BotMessageReaction) -> None:
  179. """
  180. Updates or adds a BotMessageReaction. If the message has been sent, it
  181. will be updated.
  182. """
  183. found = False
  184. for i, existing in enumerate(self.__reactions):
  185. if existing.emoji == reaction.emoji:
  186. if reaction == self.__reactions[i]:
  187. # No change
  188. return
  189. self.__reactions[i] = reaction
  190. found = True
  191. break
  192. if not found:
  193. self.__reactions.append(reaction)
  194. await self.update_if_sent()
  195. async def remove_reaction(self, reaction_or_emoji: Union[BotMessageReaction, str]) -> None:
  196. """
  197. Removes a reaction. Can pass either a BotMessageReaction or just the
  198. emoji string. If the message has been sent, it will be updated.
  199. """
  200. for i, existing in enumerate(self.__reactions):
  201. if (isinstance(reaction_or_emoji, str) and existing.emoji == reaction_or_emoji) or \
  202. (isinstance(reaction_or_emoji, BotMessageReaction) and \
  203. existing.emoji == reaction_or_emoji.emoji):
  204. self.__reactions.pop(i)
  205. await self.update_if_sent()
  206. return
  207. def reaction_for_emoji(self, emoji) -> Optional[BotMessageReaction]:
  208. """
  209. Finds the BotMessageReaction for the given emoji or None if not found.
  210. Accepts either a PartialEmoji or str.
  211. """
  212. for reaction in self.__reactions:
  213. if isinstance(emoji, PartialEmoji) and reaction.emoji == emoji.name:
  214. return reaction
  215. if isinstance(emoji, str) and reaction.emoji == emoji:
  216. return reaction
  217. return None
  218. async def update_if_sent(self) -> None:
  219. """
  220. Updates the text and/or reactions on a message if it was sent to
  221. the guild, otherwise does nothing. Does not need to be called by
  222. BaseCog subclasses.
  223. """
  224. if self.__message:
  225. await self.update()
  226. async def update(self) -> None:
  227. """
  228. Sends or updates an already sent message based on BotMessage state.
  229. Does not need to be called by BaseCog subclasses.
  230. """
  231. content: str = self.__formatted_message()
  232. if self.__message:
  233. if content != self.__posted_text:
  234. await self.__message.edit(content=content)
  235. self.__posted_text = content
  236. else:
  237. if self.__reply_to:
  238. self.__message = await self.__reply_to.reply(content=content, mention_author=False)
  239. self.__posted_text = content
  240. else:
  241. channel_id = Storage.get_config_value(self.guild, ConfigKey.WARNING_CHANNEL_ID)
  242. if channel_id is None:
  243. bot_log(self.guild, \
  244. type(self.source_cog) if self.source_cog else None, \
  245. '\u0007No warning channel set! No warning issued.')
  246. return
  247. channel: TextChannel = self.guild.get_channel(channel_id)
  248. if channel is None:
  249. bot_log(self.guild, \
  250. type(self.source_cog) if self.source_cog else None, \
  251. '\u0007Configured warning channel does not exist!')
  252. return
  253. self.__message = await channel.send(content=content)
  254. self.__posted_text = content
  255. emoji_to_remove = self.__posted_emoji.copy()
  256. for reaction in self.__reactions:
  257. if reaction.is_enabled:
  258. if reaction.emoji not in self.__posted_emoji:
  259. await self.__message.add_reaction(reaction.emoji)
  260. self.__posted_emoji.add(reaction.emoji)
  261. if reaction.emoji in emoji_to_remove:
  262. emoji_to_remove.remove(reaction.emoji)
  263. for emoji in emoji_to_remove:
  264. await self.__message.clear_reaction(emoji)
  265. if emoji in self.__posted_emoji:
  266. self.__posted_emoji.remove(emoji)
  267. def __formatted_message(self) -> str:
  268. """
  269. Composes the entire message markdown from components. Includes the main
  270. message, quoted text, summary of available reactions, etc.
  271. """
  272. s: str = ''
  273. if self.type == self.TYPE_INFO:
  274. s += CONFIG['info_emoji'] + ' '
  275. elif self.type == self.TYPE_MOD_WARNING:
  276. mention: str = Storage.get_config_value(self.guild, ConfigKey.WARNING_MENTION)
  277. if mention:
  278. s += mention + ' '
  279. s += CONFIG['warning_emoji'] + ' '
  280. elif self.type == self.TYPE_SUCCESS:
  281. s += CONFIG['success_emoji'] + ' '
  282. elif self.type == self.TYPE_FAILURE:
  283. s += CONFIG['failure_emoji'] + ' '
  284. s += self.text
  285. if self.quote:
  286. s += f'\n\n> {self.quote}'
  287. if len(self.__reactions) > 0:
  288. s += '\n\nAvailable actions:'
  289. for reaction in self.__reactions:
  290. if reaction.is_enabled:
  291. s += f'\n {reaction.emoji} {reaction.description}'
  292. else:
  293. s += f'\n {reaction.description}'
  294. return s