Experimental Discord bot written in Python
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. from discord import Guild, Member, Message, PartialEmoji, RawReactionActionEvent, TextChannel
  2. from discord.ext import commands
  3. from datetime import datetime, timedelta
  4. from config import CONFIG
  5. from rscollections import AgeBoundDict
  6. from storage import ConfigKey, Storage
  7. import json
  8. class BotMessageReaction:
  9. """
  10. A possible reaction to a bot message that will trigger an action. The list
  11. of available reactions will be listed at the end of a BotMessage. When a
  12. mod reacts to the message with the emote, something can happen.
  13. If the reaction is disabled, reactions will not register. The description
  14. will still show up in the message, but no emoji is shown. This can be used
  15. to explain why an action is no longer available.
  16. """
  17. def __init__(self, emoji: str, is_enabled: bool, description: str):
  18. self.emoji = emoji
  19. self.is_enabled = is_enabled
  20. self.description = description
  21. def __eq__(self, other):
  22. return other is not None and \
  23. other.emoji == self.emoji and \
  24. other.is_enabled == self.is_enabled and \
  25. other.description == self.description
  26. class BotMessage:
  27. """
  28. Holds state for a bot-generated message. A message is composed, sent via
  29. `BaseCog.post_message()`, and can later be updated.
  30. A message consists of a type (e.g. info, warning), text, optional quoted
  31. text (such as the content of a flagged message), and an optional list of
  32. actions that can be taken via a mod reacting to the message.
  33. """
  34. TYPE_DEFAULT = 0
  35. TYPE_INFO = 1
  36. TYPE_MOD_WARNING = 2
  37. TYPE_SUCCESS = 3
  38. TYPE_FAILURE = 4
  39. def __init__(self,
  40. guild: Guild,
  41. text: str,
  42. type: int = 0, # TYPE_DEFAULT
  43. context = None,
  44. reply_to: Message = None):
  45. self.guild = guild
  46. self.text = text
  47. self.type = type
  48. self.context = context
  49. self.quote = None
  50. self.__posted_text = None # last text posted, to test for changes
  51. self.__posted_emoji = set()
  52. self.__message = None # Message
  53. self.__reply_to = reply_to
  54. self.__reactions = [] # BotMessageReaction[]
  55. def is_sent(self) -> bool:
  56. """
  57. Returns whether this message has been sent to the guild. This may
  58. continue returning False even after calling BaseCog.post_message if
  59. the guild has no configured warning channel.
  60. """
  61. return self.__message is not None
  62. def message_id(self):
  63. return self.__message.id if self.__message else None
  64. def message_sent_at(self):
  65. return self.__message.created_at if self.__message else None
  66. async def set_text(self, new_text: str) -> None:
  67. """
  68. Replaces the text of this message. If the message has been sent, it will
  69. be updated.
  70. """
  71. self.text = new_text
  72. await self.__update_if_sent()
  73. async def set_reactions(self, reactions: list) -> None:
  74. """
  75. Replaces all BotMessageReactions with a new list. If the message has
  76. been sent, it will be updated.
  77. """
  78. if reactions == self.__reactions:
  79. # No change
  80. return
  81. self.__reactions = reactions.copy() if reactions is not None else []
  82. await self.__update_if_sent()
  83. async def add_reaction(self, reaction: BotMessageReaction) -> None:
  84. """
  85. Adds one BotMessageReaction to this message. If a reaction already
  86. exists for the given emoji it is replaced with the new one. If the
  87. message has been sent, it will be updated.
  88. """
  89. # Alias for update. Makes for clearer intent.
  90. await self.update_reaction(reaction)
  91. async def update_reaction(self, reaction: BotMessageReaction) -> None:
  92. """
  93. Updates or adds a BotMessageReaction. If the message has been sent, it
  94. will be updated.
  95. """
  96. found = False
  97. for i in range(len(self.__reactions)):
  98. existing = self.__reactions[i]
  99. if existing.emoji == reaction.emoji:
  100. if reaction == self.__reactions[i]:
  101. # No change
  102. return
  103. self.__reactions[i] = reaction
  104. found = True
  105. break
  106. if not found:
  107. self.__reactions.append(reaction)
  108. await self.__update_if_sent()
  109. async def remove_reaction(self, reaction_or_emoji) -> None:
  110. """
  111. Removes a reaction. Can pass either a BotMessageReaction or just the
  112. emoji string. If the message has been sent, it will be updated.
  113. """
  114. for i in range(len(self.__reactions)):
  115. existing = self.__reactions[i]
  116. if (isinstance(reaction_or_emoji, str) and existing.emoji == reaction_or_emoji) or \
  117. (isinstance(reaction_or_emoji, BotMessageReaction) and existing.emoji == reaction_or_emoji.emoji):
  118. self.__reactions.pop(i)
  119. await self.__update_if_sent()
  120. return
  121. def reaction_for_emoji(self, emoji) -> BotMessageReaction:
  122. for reaction in self.__reactions:
  123. if isinstance(emoji, PartialEmoji) and reaction.emoji == emoji.name:
  124. return reaction
  125. elif isinstance(emoji, str) and reaction.emoji == emoji:
  126. return reaction
  127. return None
  128. async def __update_if_sent(self) -> None:
  129. if self.__message:
  130. await self._update()
  131. async def _update(self) -> None:
  132. content: str = self.__formatted_message()
  133. if self.__message:
  134. if content != self.__posted_text:
  135. await self.__message.edit(content=content)
  136. self.__posted_text = content
  137. else:
  138. if self.__reply_to:
  139. self.__message = await self.__reply_to.reply(content=content, mention_author=False)
  140. self.__posted_text = content
  141. else:
  142. channel_id = Storage.get_config_value(self.guild, ConfigKey.WARNING_CHANNEL_ID)
  143. if channel_id is None:
  144. BaseCog.guild_trace(self.guild, 'No warning channel set! No warning issued.')
  145. return
  146. channel: TextChannel = self.guild.get_channel(channel_id)
  147. if channel is None:
  148. BaseCog.guild_trace(self.guild, 'Configured warning channel does not exist!')
  149. return
  150. self.__message = await channel.send(content=content)
  151. self.__posted_text = content
  152. emoji_to_remove = self.__posted_emoji.copy()
  153. for reaction in self.__reactions:
  154. if reaction.is_enabled:
  155. if reaction.emoji not in self.__posted_emoji:
  156. await self.__message.add_reaction(reaction.emoji)
  157. self.__posted_emoji.add(reaction.emoji)
  158. if reaction.emoji in emoji_to_remove:
  159. emoji_to_remove.remove(reaction.emoji)
  160. for emoji in emoji_to_remove:
  161. await self.__message.clear_reaction(emoji)
  162. if emoji in self.__posted_emoji:
  163. self.__posted_emoji.remove(emoji)
  164. def __formatted_message(self) -> str:
  165. s: str = ''
  166. if self.type == self.TYPE_INFO:
  167. s += CONFIG['info_emoji'] + ' '
  168. elif self.type == self.TYPE_MOD_WARNING:
  169. mention: str = Storage.get_config_value(self.guild, ConfigKey.WARNING_MENTION)
  170. if mention:
  171. s += mention + ' '
  172. s += CONFIG['warning_emoji'] + ' '
  173. elif self.type == self.TYPE_SUCCESS:
  174. s += CONFIG['success_emoji'] + ' '
  175. elif self.type == self.TYPE_FAILURE:
  176. s += CONFIG['failure_emoji'] + ' '
  177. s += self.text
  178. if self.quote:
  179. s += f'\n\n> {self.quote}'
  180. if len(self.__reactions) > 0:
  181. s += '\n\nAvailable actions:'
  182. for reaction in self.__reactions:
  183. if reaction.is_enabled:
  184. s += f'\n {reaction.emoji} {reaction.description}'
  185. else:
  186. s += f'\n {reaction.description}'
  187. return s
  188. class BaseCog(commands.Cog):
  189. def __init__(self, bot):
  190. self.bot = bot
  191. # Config
  192. @classmethod
  193. def get_cog_default(cls, key: str):
  194. """
  195. Convenience method for getting a cog configuration default from
  196. `CONFIG['cogs'][<cogname>][<key>]`.
  197. """
  198. cogs: dict = CONFIG['cog_defaults']
  199. cog = cogs.get(cls.__name__)
  200. if cog is None:
  201. return None
  202. return cog.get(key)
  203. # Bot message handling
  204. @classmethod
  205. def __bot_messages(cls, guild: Guild) -> AgeBoundDict:
  206. bm = Storage.get_state_value(guild, 'bot_messages')
  207. if bm is None:
  208. far_future = datetime.utcnow() + timedelta(days=1000)
  209. bm = AgeBoundDict(timedelta(seconds=600),
  210. lambda k, v : v.message_sent_at() or far_future)
  211. Storage.set_state_value(guild, 'bot_messages', bm)
  212. return bm
  213. @classmethod
  214. async def post_message(cls, message: BotMessage) -> bool:
  215. await message._update()
  216. guild_messages = cls.__bot_messages(message.guild)
  217. if message.is_sent():
  218. guild_messages[message.message_id()] = message
  219. return True
  220. return False
  221. @commands.Cog.listener()
  222. async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
  223. 'Event handler'
  224. if payload.user_id == self.bot.user.id:
  225. # Ignore bot's own reactions
  226. return
  227. member: Member = payload.member
  228. if member is None:
  229. return
  230. guild: Guild = self.bot.get_guild(payload.guild_id)
  231. if guild is None:
  232. # Possibly a DM
  233. return
  234. channel: GuildChannel = guild.get_channel(payload.channel_id)
  235. if channel is None:
  236. # Possibly a DM
  237. return
  238. message: Message = await channel.fetch_message(payload.message_id)
  239. if message is None:
  240. # Message deleted?
  241. return
  242. if message.author.id != self.bot.user.id:
  243. # Bot didn't author this
  244. return
  245. guild_messages = self.__bot_messages(guild)
  246. bot_message = guild_messages.get(message.id)
  247. if bot_message is None:
  248. # Unknown message (expired or was never tracked)
  249. return
  250. reaction = bot_message.reaction_for_emoji(payload.emoji)
  251. if reaction is None or not reaction.is_enabled:
  252. # Can't use this reaction with this message
  253. return
  254. if not member.permissions_in(channel).ban_members:
  255. # Not a mod (could make permissions configurable per BotMessageReaction some day)
  256. return
  257. await self.on_mod_react(bot_message, reaction, member)
  258. async def on_mod_react(self,
  259. bot_message: BotMessage,
  260. reaction: BotMessageReaction,
  261. reacted_by: Member) -> None:
  262. """
  263. Subclass override point for receiving mod reactions to bot messages sent
  264. via `post_message()`.
  265. """
  266. pass
  267. @classmethod
  268. async def validate_param(cls, context: commands.Context, param_name: str, value,
  269. allowed_types: tuple = None,
  270. min_value = None,
  271. max_value = None) -> bool:
  272. """
  273. Convenience method for validating a command parameter is of the expected
  274. type and in the expected range. Bad values will cause a reply to be sent
  275. to the original message and a False will be returned. If all checks
  276. succeed, True will be returned.
  277. """
  278. # TODO: Rework this to use BotMessage
  279. if allowed_types is not None and not isinstance(value, allowed_types):
  280. if len(allowed_types) == 1:
  281. await context.message.reply(f'⚠️ `{param_name}` must be of type ' +
  282. f'{allowed_types[0]}.', mention_author=False)
  283. else:
  284. await context.message.reply(f'⚠️ `{param_name}` must be of types ' +
  285. f'{allowed_types}.', mention_author=False)
  286. return False
  287. if min_value is not None and value < min_value:
  288. await context.message.reply(f'⚠️ `{param_name}` must be >= {min_value}.',
  289. mention_author=False)
  290. return False
  291. if max_value is not None and value > max_value:
  292. await context.message.reply(f'⚠️ `{param_name}` must be <= {max_value}.',
  293. mention_author=False)
  294. return True
  295. @classmethod
  296. async def warn(cls, guild: Guild, message: str) -> Message:
  297. """
  298. Sends a warning message to the configured warning channel for the
  299. given guild. If no warning channel is configured no action is taken.
  300. Returns the Message if successful or None if not.
  301. """
  302. channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
  303. if channel_id is None:
  304. cls.guild_trace(guild, 'No warning channel set! No warning issued.')
  305. return None
  306. channel: TextChannel = guild.get_channel(channel_id)
  307. if channel is None:
  308. cls.guild_trace(guild, 'Configured warning channel does not exist!')
  309. return None
  310. mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
  311. text: str = message
  312. if mention is not None:
  313. text = f'{mention} {text}'
  314. msg: Message = await channel.send(text)
  315. return msg
  316. @classmethod
  317. async def update_warn(cls, warn_message: Message, new_text: str) -> None:
  318. """
  319. Updates the text of a previously posted `warn`. Includes configured
  320. mentions if necessary.
  321. """
  322. text: str = new_text
  323. mention: str = Storage.get_config_value(
  324. warn_message.guild,
  325. ConfigKey.WARNING_MENTION)
  326. if mention is not None:
  327. text = f'{mention} {text}'
  328. await warn_message.edit(content=text)
  329. @classmethod
  330. def guild_trace(cls, guild: Guild, message: str) -> None:
  331. print(f'[guild {guild.id}|{guild.name}] {message}')