Experimental Discord bot written in Python
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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 typing import Any, Optional, Union
  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: list[str] = [] # last text posted, to test for changes
  135. self.__posted_emoji: set[str] = set() # last emoji list posted
  136. self.__messages: list[Message] = [] # 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 len(self.__messages) > 0
  147. def message_ids(self) -> list[int]:
  148. """Returns the ids of all actual Messages sent. One bot message may be
  149. broken into multiple Discord messages."""
  150. return [ m.id for m in self.__messages ]
  151. def message_sent_at(self) -> Optional[datetime]:
  152. """Returns when the message was sent or None if not sent."""
  153. return self.__messages[0].created_at if len(self.__messages) > 0 else None
  154. def has_reactions(self) -> bool:
  155. """Whether this message has any reactions defined."""
  156. return len(self.__reactions) > 0
  157. async def set_text(self, new_text: str) -> None:
  158. """
  159. Replaces the text of this message. If the message has been sent, it will
  160. be updated.
  161. """
  162. self.text = new_text
  163. await self.update_if_sent()
  164. async def set_reactions(self, reactions: list[BotMessageReaction]) -> None:
  165. """
  166. Replaces all BotMessageReactions with a new list. If the message has
  167. been sent, it will be updated.
  168. """
  169. if reactions == self.__reactions:
  170. # No change
  171. return
  172. self.__reactions = reactions.copy() if reactions is not None else []
  173. await self.update_if_sent()
  174. async def add_reaction(self, reaction: BotMessageReaction) -> None:
  175. """
  176. Adds one BotMessageReaction to this message. If a reaction already
  177. exists for the given emoji it is replaced with the new one. If the
  178. message has been sent, it will be updated.
  179. """
  180. # Alias for update. Makes for clearer intent.
  181. await self.update_reaction(reaction)
  182. async def update_reaction(self, reaction: BotMessageReaction) -> None:
  183. """
  184. Updates or adds a BotMessageReaction. If the message has been sent, it
  185. will be updated.
  186. """
  187. found = False
  188. for i, existing in enumerate(self.__reactions):
  189. if existing.emoji == reaction.emoji:
  190. if reaction == self.__reactions[i]:
  191. # No change
  192. return
  193. self.__reactions[i] = reaction
  194. found = True
  195. break
  196. if not found:
  197. self.__reactions.append(reaction)
  198. await self.update_if_sent()
  199. async def remove_reaction(self, reaction_or_emoji: Union[BotMessageReaction, str]) -> None:
  200. """
  201. Removes a reaction. Can pass either a BotMessageReaction or just the
  202. emoji string. If the message has been sent, it will be updated.
  203. """
  204. for i, existing in enumerate(self.__reactions):
  205. if (isinstance(reaction_or_emoji, str) and existing.emoji == reaction_or_emoji) or \
  206. (isinstance(reaction_or_emoji, BotMessageReaction) and
  207. existing.emoji == reaction_or_emoji.emoji):
  208. self.__reactions.pop(i)
  209. await self.update_if_sent()
  210. return
  211. def reaction_for_emoji(self, emoji) -> Optional[BotMessageReaction]:
  212. """
  213. Finds the BotMessageReaction for the given emoji or None if not found.
  214. Accepts either a PartialEmoji or str.
  215. """
  216. for reaction in self.__reactions:
  217. if isinstance(emoji, PartialEmoji) and reaction.emoji == emoji.name:
  218. return reaction
  219. if isinstance(emoji, str) and reaction.emoji == emoji:
  220. return reaction
  221. return None
  222. async def update_if_sent(self) -> None:
  223. """
  224. Updates the text and/or reactions on a message if it was sent to
  225. the guild, otherwise does nothing. Does not need to be called by
  226. BaseCog subclasses.
  227. """
  228. if len(self.__messages) > 0:
  229. await self.update()
  230. async def update(self) -> None:
  231. """
  232. Sends or updates an already sent message based on BotMessage state.
  233. Does not need to be called by BaseCog subclasses.
  234. """
  235. message_bodies: list[str] = self.__formatted_message()
  236. if len(self.__messages) > 0:
  237. if message_bodies != self.__posted_text:
  238. while len(self.__messages) > len(message_bodies):
  239. last_message = self.__messages.pop(-1)
  240. await last_message.delete()
  241. del Storage.get_bot_messages(self.guild)[last_message.id]
  242. for i in range(min(len(message_bodies), len(self.__messages))):
  243. await self.__messages[i].edit(content=message_bodies[i])
  244. while len(self.__messages) < len(message_bodies):
  245. body = message_bodies[len(self.__messages)]
  246. message = await self.__messages[0].channel.send(content=body, suppress_embeds=self.__suppress_embeds)
  247. Storage.get_bot_messages(self.guild)[message.id] = self
  248. self.__messages.append(message)
  249. self.__posted_text = message_bodies
  250. else: # No messages posted yet
  251. channel: Optional[TextChannel] = None
  252. for index, body in enumerate(message_bodies):
  253. if index == 0 and self.__reply_to:
  254. message = await self.__reply_to.reply(content=body, mention_author=False)
  255. Storage.get_bot_messages(self.guild)[message.id] = self
  256. self.__messages.append(message)
  257. channel = self.__reply_to.channel
  258. else:
  259. if channel is None:
  260. channel_id = Storage.get_config_value(self.guild, ConfigKey.WARNING_CHANNEL_ID)
  261. if channel_id is None:
  262. bot_log(self.guild,
  263. type(self.source_cog) if self.source_cog else None,
  264. '\u0007No warning channel set! No warning issued.')
  265. return
  266. channel: TextChannel = self.guild.get_channel(channel_id) or await self.guild.fetch_channel(channel_id)
  267. if channel is None:
  268. bot_log(self.guild,
  269. type(self.source_cog) if self.source_cog else None,
  270. f'\u0007Configured warning channel does not exist for guild {self.guild.name} ({self.guild.id})!')
  271. return
  272. message = await channel.send(content=body, suppress_embeds=self.__suppress_embeds)
  273. Storage.get_bot_messages(self.guild)[message.id] = self
  274. self.__messages.append(message)
  275. self.__posted_text = message_bodies
  276. emoji_to_remove = self.__posted_emoji.copy()
  277. for reaction in self.__reactions:
  278. if reaction.is_enabled:
  279. if reaction.emoji not in self.__posted_emoji:
  280. await self.__messages[-1].add_reaction(reaction.emoji)
  281. self.__posted_emoji.add(reaction.emoji)
  282. if reaction.emoji in emoji_to_remove:
  283. emoji_to_remove.remove(reaction.emoji)
  284. for emoji in emoji_to_remove:
  285. await self.__messages[-1].clear_reaction(emoji)
  286. if emoji in self.__posted_emoji:
  287. self.__posted_emoji.remove(emoji)
  288. def __formatted_message(self) -> list[str]:
  289. """
  290. Composes the entire message Markdown from components. Includes the main
  291. message, quoted text, summary of available reactions, etc. Returned as
  292. array of message bodies small enough to fit in a Discord message.
  293. """
  294. s: str = ''
  295. if self.type == self.TYPE_INFO:
  296. s += CONFIG['info_emoji'] + ' '
  297. elif self.type == self.TYPE_MOD_WARNING:
  298. mention: str = Storage.get_config_value(self.guild, ConfigKey.WARNING_MENTION)
  299. if mention:
  300. s += mention + ' '
  301. s += CONFIG['warning_emoji'] + ' '
  302. elif self.type == self.TYPE_SUCCESS:
  303. s += CONFIG['success_emoji'] + ' '
  304. elif self.type == self.TYPE_FAILURE:
  305. s += CONFIG['failure_emoji'] + ' '
  306. elif self.type == self.TYPE_LOG:
  307. s += CONFIG['log_emoji'] + ' '
  308. s += self.text
  309. if self.quote:
  310. quoted = '\n> '.join(self.quote.splitlines())
  311. s += f'\n\n> {quoted}'
  312. if len(self.__reactions) > 0:
  313. s += '\n\nAvailable actions:'
  314. for reaction in self.__reactions:
  315. if reaction.is_enabled:
  316. s += f'\n {reaction.emoji} {reaction.description}'
  317. else:
  318. s += f'\n {reaction.description}'
  319. # API complains if *request* is >2000. Unsure how much of that payload is overhead, so will define a max length
  320. # conservatively of 85-90% of that.
  321. ideal_message_length = 1700
  322. max_message_length = 1800
  323. # First time cutting up message
  324. bodies = [s]
  325. while len(bodies[-1]) > max_message_length:
  326. # Try to cut at last newline before length limit, otherwise last space, otherwise hard cut at limit
  327. last_body = bodies.pop(-1)
  328. cut_before = ideal_message_length
  329. cut_after = ideal_message_length
  330. last_newline_index = last_body.rfind('\n', max_message_length // 2, max_message_length)
  331. if last_newline_index >= 0:
  332. cut_before = last_newline_index
  333. cut_after = last_newline_index + 1
  334. else:
  335. last_space_index = last_body.rfind(' ', max_message_length // 2, max_message_length)
  336. if last_space_index >= 0:
  337. cut_before = last_space_index
  338. cut_after = last_space_index + 1
  339. body = last_body[:cut_before].strip()
  340. remainder = last_body[cut_after:].strip()
  341. while body.endswith('\n>'):
  342. body = body[:-2]
  343. while remainder.endswith('\n>'):
  344. remainder = remainder[:-2]
  345. bodies.append(body)
  346. bodies.append(remainder)
  347. return bodies