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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """
  2. Base cog class and helper classes.
  3. """
  4. from datetime import datetime, timedelta
  5. from discord import Guild, Member, Message, RawReactionActionEvent
  6. from discord.abc import GuildChannel
  7. from discord.ext import commands
  8. from config import CONFIG
  9. from rocketbot.botmessage import BotMessage, BotMessageReaction
  10. from rocketbot.cogsetting import CogSetting
  11. from rocketbot.collections import AgeBoundDict
  12. from rocketbot.storage import Storage
  13. from rocketbot.utils import bot_log
  14. class BaseCog(commands.Cog):
  15. """
  16. Superclass for all Rocketbot cogs. Provides lots of conveniences for
  17. common tasks.
  18. """
  19. def __init__(self, bot):
  20. self.bot = bot
  21. self.are_settings_setup = False
  22. self.settings = []
  23. # Config
  24. @classmethod
  25. def get_cog_default(cls, key: str):
  26. """
  27. Convenience method for getting a cog configuration default from
  28. `CONFIG['cogs'][<cogname>][<key>]`. These values are used for
  29. CogSettings when no guild-specific value is configured yet.
  30. """
  31. cogs: dict = CONFIG['cog_defaults']
  32. cog = cogs.get(cls.__name__)
  33. if cog is None:
  34. return None
  35. return cog.get(key)
  36. def add_setting(self, setting: CogSetting) -> None:
  37. """
  38. Called by a subclass in __init__ to register a mod-configurable
  39. guild setting. A "get" and "set" command will be generated. If the
  40. setting is named "enabled" (exactly) then "enable" and "disable"
  41. commands will be created instead which set the setting to True/False.
  42. If the cog has a command group it will be detected automatically and
  43. the commands added to that. Otherwise the commands will be added at
  44. the top level.
  45. Changes to settings can be detected by overriding `on_setting_updated`.
  46. """
  47. self.settings.append(setting)
  48. @classmethod
  49. def get_guild_setting(cls,
  50. guild: Guild,
  51. setting: CogSetting,
  52. use_cog_default_if_not_set: bool = True):
  53. """
  54. Returns the configured value for a setting for the given guild. If no
  55. setting is configured the default for the cog will be returned,
  56. unless the optional `use_cog_default_if_not_set` is `False`, then
  57. `None` will be returned.
  58. """
  59. key = f'{cls.__name__}.{setting.name}'
  60. value = Storage.get_config_value(guild, key)
  61. if value is None and use_cog_default_if_not_set:
  62. value = cls.get_cog_default(setting.name)
  63. return value
  64. @classmethod
  65. def set_guild_setting(cls,
  66. guild: Guild,
  67. setting: CogSetting,
  68. new_value) -> None:
  69. """
  70. Manually sets a setting for the given guild. BaseCog creates "get" and
  71. "set" commands for guild administrators to configure values themselves,
  72. but this method can be used for hidden settings from code. A ValueError
  73. will be raised if the new value does not pass validation specified in
  74. the CogSetting.
  75. """
  76. setting.validate_value(new_value)
  77. key = f'{cls.__name__}.{setting.name}'
  78. Storage.set_config_value(guild, key, new_value)
  79. @commands.Cog.listener()
  80. async def on_ready(self):
  81. 'Event listener'
  82. if not self.are_settings_setup:
  83. self.are_settings_setup = True
  84. CogSetting.set_up_all(self, self.bot, self.settings)
  85. async def on_setting_updated(self, guild: Guild, setting: CogSetting) -> None:
  86. """
  87. Subclass override point for being notified when a CogSetting is edited.
  88. """
  89. # Bot message handling
  90. @classmethod
  91. def __bot_messages(cls, guild: Guild) -> AgeBoundDict:
  92. bm = Storage.get_state_value(guild, 'bot_messages')
  93. if bm is None:
  94. far_future = datetime.utcnow() + timedelta(days=1000)
  95. bm = AgeBoundDict(timedelta(seconds=600),
  96. lambda k, v : v.message_sent_at() or far_future)
  97. Storage.set_state_value(guild, 'bot_messages', bm)
  98. return bm
  99. async def post_message(self, message: BotMessage) -> bool:
  100. """
  101. Posts a BotMessage to a guild. Returns whether it was successful. If
  102. the caller wants to listen to reactions they should be added before
  103. calling this method. Listen to reactions by overriding `on_mod_react`.
  104. """
  105. message.source_cog = self
  106. await message.update()
  107. if message.has_reactions() and message.is_sent():
  108. guild_messages = self.__bot_messages(message.guild)
  109. guild_messages[message.message_id()] = message
  110. return message.is_sent()
  111. @commands.Cog.listener()
  112. async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
  113. 'Event handler'
  114. if payload.user_id == self.bot.user.id:
  115. # Ignore bot's own reactions
  116. return
  117. member: Member = payload.member
  118. if member is None:
  119. return
  120. guild: Guild = self.bot.get_guild(payload.guild_id)
  121. if guild is None:
  122. # Possibly a DM
  123. return
  124. channel: GuildChannel = guild.get_channel(payload.channel_id)
  125. if channel is None:
  126. # Possibly a DM
  127. return
  128. message: Message = await channel.fetch_message(payload.message_id)
  129. if message is None:
  130. # Message deleted?
  131. return
  132. if message.author.id != self.bot.user.id:
  133. # Bot didn't author this
  134. return
  135. guild_messages = self.__bot_messages(guild)
  136. bot_message = guild_messages.get(message.id)
  137. if bot_message is None:
  138. # Unknown message (expired or was never tracked)
  139. return
  140. if self is not bot_message.source_cog:
  141. # Belongs to a different cog
  142. return
  143. reaction = bot_message.reaction_for_emoji(payload.emoji)
  144. if reaction is None or not reaction.is_enabled:
  145. # Can't use this reaction with this message
  146. return
  147. if not member.permissions_in(channel).ban_members:
  148. # Not a mod (could make permissions configurable per BotMessageReaction some day)
  149. return
  150. await self.on_mod_react(bot_message, reaction, member)
  151. async def on_mod_react(self,
  152. bot_message: BotMessage,
  153. reaction: BotMessageReaction,
  154. reacted_by: Member) -> None:
  155. """
  156. Subclass override point for receiving mod reactions to bot messages sent
  157. via `post_message()`.
  158. """
  159. # Helpers
  160. @classmethod
  161. def log(cls, guild: Guild, message) -> None:
  162. """
  163. Writes a message to the console. Intended for significant events only.
  164. """
  165. bot_log(guild, cls, message)