Experimental Discord bot written in Python
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

basecog.py 7.9KB

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