Experimental Discord bot written in Python
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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