Experimental Discord bot written in Python
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

botmessage.py 9.7KB

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