Experimental Discord bot written in Python
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

basecog.py 14KB

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