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 9.6KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """
  2. Base cog class and helper classes.
  3. """
  4. from datetime import datetime, timedelta, timezone
  5. from typing import Optional
  6. import discord
  7. from discord import Guild, Interaction, Member, Message, RawReactionActionEvent, TextChannel
  8. from discord.abc import GuildChannel
  9. from discord.app_commands import AppCommandError
  10. from discord.app_commands.errors import CommandInvokeError
  11. from discord.ext.commands import Cog
  12. from config import CONFIG
  13. from rocketbot.bot import Rocketbot
  14. from rocketbot.botmessage import BotMessage, BotMessageReaction
  15. from rocketbot.cogsetting import CogSetting
  16. from rocketbot.collections import AgeBoundDict
  17. from rocketbot.storage import Storage
  18. from rocketbot.utils import bot_log, dump_stacktrace
  19. class WarningContext:
  20. def __init__(self, member: Member, warn_time: datetime):
  21. self.member = member
  22. self.last_warned = warn_time
  23. class BaseCog(Cog):
  24. STATE_KEY_RECENT_WARNINGS = "BaseCog.recent_warnings"
  25. """
  26. Superclass for all Rocketbot cogs. Provides lots of conveniences for
  27. common tasks.
  28. """
  29. def __init__(
  30. self,
  31. bot: Rocketbot,
  32. config_prefix: Optional[str],
  33. short_description: str,
  34. long_description: Optional[str] = None,
  35. ):
  36. """
  37. Parameters
  38. ----------
  39. bot: Rocketbot
  40. config_prefix: str
  41. Prefix to show on variables in /set and /get commands to namespace
  42. configuration variables. E.g. if config_prefix is "foo", a config
  43. variable named "bar" in that cog will show as "foo.bar". If None,
  44. config variable acts as a top-level variable with no prefix.
  45. """
  46. self.bot: Rocketbot = bot
  47. self.are_settings_setup: bool = False
  48. self.settings: list[CogSetting] = []
  49. self.config_prefix: Optional[str] = config_prefix
  50. self.short_description: str = short_description
  51. self.long_description: str = long_description
  52. async def cog_app_command_error(self, interaction: Interaction, error: AppCommandError) -> None:
  53. if isinstance(error, CommandInvokeError):
  54. error = error.original
  55. dump_stacktrace(error)
  56. message = f"\nException: {error.__class__.__name__}, "\
  57. f"Command: {interaction.command.qualified_name if interaction.command else None}, "\
  58. f"User: {interaction.user}, "\
  59. f"Time: {discord.utils.format_dt(interaction.created_at, style='F')}"
  60. try:
  61. await interaction.response.send_message(f"An error occurred: {message}", ephemeral=True)
  62. except discord.InteractionResponded:
  63. await interaction.followup.send(f"An error occurred: {message}", ephemeral=True)
  64. # Config
  65. @classmethod
  66. def get_cog_default(cls, key: str):
  67. """
  68. Convenience method for getting a cog configuration default from
  69. `CONFIG['cogs'][<cog_name>][<key>]`. These values are used for
  70. CogSettings when no guild-specific value is configured yet.
  71. """
  72. cogs: dict = CONFIG['cog_defaults']
  73. cog = cogs.get(cls.__name__)
  74. if cog is None:
  75. return None
  76. return cog.get(key)
  77. def add_setting(self, setting: CogSetting) -> None:
  78. """
  79. Called by a subclass in __init__ to register a mod-configurable
  80. guild setting. A "get" and "set" command will be generated. If the
  81. setting is named "enabled" (exactly) then "enable" and "disable"
  82. commands will be created instead which set the setting to True/False.
  83. If the cog has a command group it will be detected automatically and
  84. the commands added to that. Otherwise, the commands will be added at
  85. the top level.
  86. Changes to settings can be detected by overriding `on_setting_updated`.
  87. """
  88. self.settings.append(setting)
  89. @classmethod
  90. def get_guild_setting(cls,
  91. guild: Guild,
  92. setting: CogSetting,
  93. use_cog_default_if_not_set: bool = True):
  94. """
  95. Returns the configured value for a setting for the given guild. If no
  96. setting is configured the default for the cog will be returned,
  97. unless the optional `use_cog_default_if_not_set` is `False`, then
  98. `None` will be returned.
  99. """
  100. key = f'{cls.__name__}.{setting.name}'
  101. value = Storage.get_config_value(guild, key)
  102. if value is None and use_cog_default_if_not_set:
  103. value = cls.get_cog_default(setting.name)
  104. return value
  105. @classmethod
  106. def set_guild_setting(cls,
  107. guild: Guild,
  108. setting: CogSetting,
  109. new_value) -> None:
  110. """
  111. Manually sets a setting for the given guild. BaseCog creates "get" and
  112. "set" commands for guild administrators to configure values themselves,
  113. but this method can be used for hidden settings from code. A ValueError
  114. will be raised if the new value does not pass validation specified in
  115. the CogSetting.
  116. """
  117. setting.validate_value(new_value)
  118. key = f'{cls.__name__}.{setting.name}'
  119. Storage.set_config_value(guild, key, new_value)
  120. # @commands.Cog.listener()
  121. async def __on_ready(self):
  122. """Event listener"""
  123. if not self.are_settings_setup:
  124. self.are_settings_setup = True
  125. CogSetting.set_up_all(self, self.bot, self.settings)
  126. async def on_setting_updated(self, guild: Guild, setting: CogSetting) -> None:
  127. """
  128. Subclass override point for being notified when a CogSetting is edited.
  129. """
  130. # Warning squelch
  131. def was_warned_recently(self, member: Member) -> bool:
  132. """
  133. Tests if a given member was included in a mod warning message recently.
  134. Used to suppress redundant messages. Should be checked before pinging
  135. mods for relatively minor warnings about single users, but warnings
  136. about larger threats involving several members (e.g. join raids) should
  137. issue warnings regardless. Call record_warning or record_warnings after
  138. triggering a mod warning.
  139. """
  140. recent_warns: AgeBoundDict[int, WarningContext, datetime, timedelta] = Storage.get_state_value(member.guild,
  141. BaseCog.STATE_KEY_RECENT_WARNINGS)
  142. if recent_warns is None:
  143. return False
  144. context: WarningContext = recent_warns.get(member.id)
  145. if context is None:
  146. return False
  147. squelch_warning_seconds: int = CONFIG['squelch_warning_seconds']
  148. return datetime.now() - context.last_warned < timedelta(seconds=squelch_warning_seconds)
  149. def record_warning(self, member: Member):
  150. """
  151. Records that mods have been warned about a member and do not need to be
  152. warned about them again for a short while.
  153. """
  154. recent_warns: AgeBoundDict[int, WarningContext, datetime, timedelta] = Storage.get_state_value(member.guild,
  155. BaseCog.STATE_KEY_RECENT_WARNINGS)
  156. if recent_warns is None:
  157. recent_warns = AgeBoundDict(timedelta(seconds=CONFIG['squelch_warning_seconds']),
  158. lambda i, context0 : context0.last_warned)
  159. Storage.set_state_value(member.guild, BaseCog.STATE_KEY_RECENT_WARNINGS, recent_warns)
  160. context: WarningContext = recent_warns.get(member.id)
  161. if context is None:
  162. context = WarningContext(member, datetime.now())
  163. recent_warns[member.id] = context
  164. else:
  165. context.last_warned = datetime.now()
  166. def record_warnings(self, members: list):
  167. """
  168. Records that mods have been warned about some members and do not need to
  169. be warned about them again for a short while.
  170. """
  171. for member in members:
  172. self.record_warning(member)
  173. # Bot message handling
  174. @classmethod
  175. def __bot_messages(cls, guild: Guild) -> AgeBoundDict[int, BotMessage, datetime, timedelta]:
  176. bm: AgeBoundDict[int, BotMessage, datetime, timedelta] = Storage.get_state_value(guild, 'bot_messages')
  177. if bm is None:
  178. far_future = datetime.now(timezone.utc) + timedelta(days=1000)
  179. bm = AgeBoundDict(timedelta(seconds=600),
  180. lambda k, v : v.message_sent_at() or far_future)
  181. Storage.set_state_value(guild, 'bot_messages', bm)
  182. return bm
  183. async def post_message(self, message: BotMessage) -> bool:
  184. """
  185. Posts a BotMessage to a guild. Returns whether it was successful. If
  186. the caller wants to listen to reactions they should be added before
  187. calling this method. Listen to reactions by overriding `on_mod_react`.
  188. """
  189. message.source_cog = self
  190. await message.update()
  191. return message.is_sent()
  192. @Cog.listener()
  193. async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
  194. """Event handler"""
  195. # Avoid any unnecessary requests. Gets called for every reaction
  196. # multiplied by every active cog.
  197. if payload.user_id == self.bot.user.id:
  198. # Ignore bot's own reactions
  199. return
  200. guild: Guild = self.bot.get_guild(payload.guild_id) or await self.bot.fetch_guild(payload.guild_id)
  201. if guild is None:
  202. # Possibly a DM
  203. return
  204. guild_messages: dict[int, BotMessage] = Storage.get_bot_messages(guild)
  205. bot_message = guild_messages.get(payload.message_id)
  206. if bot_message is None:
  207. # Unknown message (expired or was never tracked)
  208. return
  209. if self is not bot_message.source_cog:
  210. # Belongs to a different cog
  211. return
  212. reaction = bot_message.reaction_for_emoji(payload.emoji)
  213. if reaction is None or not reaction.is_enabled:
  214. # Can't use this reaction with this message
  215. return
  216. g_channel: GuildChannel = guild.get_channel(payload.channel_id) or await guild.fetch_channel(payload.channel_id)
  217. if g_channel is None:
  218. # Possibly a DM
  219. return
  220. if not isinstance(g_channel, TextChannel):
  221. return
  222. channel: TextChannel = g_channel
  223. member: Member = payload.member
  224. if member is None:
  225. return
  226. if not channel.permissions_for(member).ban_members:
  227. # Not a mod (could make permissions configurable per BotMessageReaction some day)
  228. return
  229. message: Message = await channel.fetch_message(payload.message_id)
  230. if message is None:
  231. # Message deleted?
  232. return
  233. if message.author.id != self.bot.user.id:
  234. # Bot didn't author this
  235. return
  236. await self.on_mod_react(bot_message, reaction, member)
  237. async def on_mod_react(self,
  238. bot_message: BotMessage,
  239. reaction: BotMessageReaction,
  240. reacted_by: Member) -> None:
  241. """
  242. Subclass override point for receiving mod reactions to bot messages sent
  243. via `post_message()`.
  244. """
  245. # Helpers
  246. @classmethod
  247. def log(cls, guild: Optional[Guild], message) -> None:
  248. """
  249. Writes a message to the console. Intended for significant events only.
  250. """
  251. bot_log(guild, cls, message)