Experimental Discord bot written in Python
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. """
  2. Base cog class and helper classes.
  3. """
  4. from datetime import datetime, timedelta
  5. from discord import Guild, Member, Message, PartialEmoji, RawReactionActionEvent, TextChannel
  6. from discord.abc import GuildChannel
  7. from discord.ext import commands
  8. from config import CONFIG
  9. from rocketbot.collections import AgeBoundDict
  10. from rocketbot.storage import ConfigKey, Storage
  11. class BotMessageReaction:
  12. """
  13. A possible reaction to a bot message that will trigger an action. The list
  14. of available reactions will be listed at the end of a BotMessage. When a
  15. mod reacts to the message with the emote, something can happen.
  16. If the reaction is disabled, reactions will not register. The description
  17. will still show up in the message, but no emoji is shown. This can be used
  18. to explain why an action is no longer available.
  19. """
  20. def __init__(self, emoji: str, is_enabled: bool, description: str):
  21. self.emoji = emoji
  22. self.is_enabled = is_enabled
  23. self.description = description
  24. def __eq__(self, other):
  25. return other is not None and \
  26. other.emoji == self.emoji and \
  27. other.is_enabled == self.is_enabled and \
  28. other.description == self.description
  29. @classmethod
  30. def standard_set(cls,
  31. did_delete: bool = None,
  32. message_count: int = 1,
  33. did_kick: bool = None,
  34. did_ban: bool = None,
  35. user_count: int = 1) -> list:
  36. """
  37. Convenience factory for generating any of the three most common
  38. commands: delete message(s), kick user(s), and ban user(s). All
  39. arguments are optional. Resulting list can be passed directly to
  40. `BotMessage.set_reactions()`.
  41. Params
  42. - did_delete Whether the message(s) have been deleted. Pass True or
  43. False if this applies, omit to leave out delete action.
  44. - message_count How many messages there are. Used for pluralizing
  45. description. Defaults to 1. Omit if n/a.
  46. - did_kick Whether the user(s) have been kicked. Pass True or
  47. False if this applies, omit to leave out kick action.
  48. - did_ban Whether the user(s) have been banned. Pass True or
  49. False if this applies, omit to leave out ban action.
  50. - user_count How many users there are. Used for pluralizing
  51. description. Defaults to 1. Omit if n/a.
  52. """
  53. reactions = []
  54. if did_delete is not None:
  55. if did_delete:
  56. reactions.append(BotMessageReaction(
  57. CONFIG['trash_emoji'],
  58. False,
  59. 'Message deleted' if message_count == 1 else 'Messages deleted'))
  60. else:
  61. reactions.append(BotMessageReaction(
  62. CONFIG['trash_emoji'],
  63. True,
  64. 'Delete message' if message_count == 1 else 'Delete messages'))
  65. if did_kick is not None:
  66. if did_ban is not None and did_ban:
  67. # Don't show kick option at all if we also banned
  68. pass
  69. elif did_kick:
  70. reactions.append(BotMessageReaction(
  71. CONFIG['kick_emoji'],
  72. False,
  73. 'User kicked' if user_count == 1 else 'Users kicked'))
  74. else:
  75. reactions.append(BotMessageReaction(
  76. CONFIG['kick_emoji'],
  77. True,
  78. 'Kick user' if user_count == 1 else 'Kick users'))
  79. if did_ban is not None:
  80. if did_ban:
  81. reactions.append(BotMessageReaction(
  82. CONFIG['ban_emoji'],
  83. False,
  84. 'User banned' if user_count == 1 else 'Users banned'))
  85. else:
  86. reactions.append(BotMessageReaction(
  87. CONFIG['ban_emoji'],
  88. True,
  89. 'Ban user' if user_count == 1 else 'Ban users'))
  90. return reactions
  91. class BotMessage:
  92. """
  93. Holds state for a bot-generated message. A message is composed, sent via
  94. `BaseCog.post_message()`, and can later be updated.
  95. A message consists of a type (e.g. info, warning), text, optional quoted
  96. text (such as the content of a flagged message), and an optional list of
  97. actions that can be taken via a mod reacting to the message.
  98. """
  99. TYPE_DEFAULT = 0
  100. TYPE_INFO = 1
  101. TYPE_MOD_WARNING = 2
  102. TYPE_SUCCESS = 3
  103. TYPE_FAILURE = 4
  104. def __init__(self,
  105. guild: Guild,
  106. text: str,
  107. type: int = TYPE_DEFAULT,
  108. context = None,
  109. reply_to: Message = None):
  110. self.guild = guild
  111. self.text = text
  112. self.type = type
  113. self.context = context
  114. self.quote = None
  115. self.source_cog = None # Set by `BaseCog.post_message()`
  116. self.__posted_text = None # last text posted, to test for changes
  117. self.__posted_emoji = set()
  118. self.__message = None # Message
  119. self.__reply_to = reply_to
  120. self.__reactions = [] # BotMessageReaction[]
  121. def is_sent(self) -> bool:
  122. """
  123. Returns whether this message has been sent to the guild. This may
  124. continue returning False even after calling BaseCog.post_message if
  125. the guild has no configured warning channel.
  126. """
  127. return self.__message is not None
  128. def message_id(self):
  129. 'Returns the Message id or None if not sent.'
  130. return self.__message.id if self.__message else None
  131. def message_sent_at(self) -> datetime:
  132. 'Returns when the message was sent or None if not sent.'
  133. return self.__message.created_at if self.__message else None
  134. def has_reactions(self) -> bool:
  135. 'Whether this message has any reactions defined.'
  136. return len(self.__reactions) > 0
  137. async def set_text(self, new_text: str) -> None:
  138. """
  139. Replaces the text of this message. If the message has been sent, it will
  140. be updated.
  141. """
  142. self.text = new_text
  143. await self.update_if_sent()
  144. async def set_reactions(self, reactions: list) -> None:
  145. """
  146. Replaces all BotMessageReactions with a new list. If the message has
  147. been sent, it will be updated.
  148. """
  149. if reactions == self.__reactions:
  150. # No change
  151. return
  152. self.__reactions = reactions.copy() if reactions is not None else []
  153. await self.update_if_sent()
  154. async def add_reaction(self, reaction: BotMessageReaction) -> None:
  155. """
  156. Adds one BotMessageReaction to this message. If a reaction already
  157. exists for the given emoji it is replaced with the new one. If the
  158. message has been sent, it will be updated.
  159. """
  160. # Alias for update. Makes for clearer intent.
  161. await self.update_reaction(reaction)
  162. async def update_reaction(self, reaction: BotMessageReaction) -> None:
  163. """
  164. Updates or adds a BotMessageReaction. If the message has been sent, it
  165. will be updated.
  166. """
  167. found = False
  168. for i, existing in enumerate(self.__reactions):
  169. if existing.emoji == reaction.emoji:
  170. if reaction == self.__reactions[i]:
  171. # No change
  172. return
  173. self.__reactions[i] = reaction
  174. found = True
  175. break
  176. if not found:
  177. self.__reactions.append(reaction)
  178. await self.update_if_sent()
  179. async def remove_reaction(self, reaction_or_emoji) -> None:
  180. """
  181. Removes a reaction. Can pass either a BotMessageReaction or just the
  182. emoji string. If the message has been sent, it will be updated.
  183. """
  184. for i, existing in enumerate(self.__reactions):
  185. if (isinstance(reaction_or_emoji, str) and existing.emoji == reaction_or_emoji) or \
  186. (isinstance(reaction_or_emoji, BotMessageReaction) and \
  187. existing.emoji == reaction_or_emoji.emoji):
  188. self.__reactions.pop(i)
  189. await self.update_if_sent()
  190. return
  191. def reaction_for_emoji(self, emoji) -> BotMessageReaction:
  192. """
  193. Finds the BotMessageReaction for the given emoji or None if not found.
  194. Accepts either a PartialEmoji or str.
  195. """
  196. for reaction in self.__reactions:
  197. if isinstance(emoji, PartialEmoji) and reaction.emoji == emoji.name:
  198. return reaction
  199. if isinstance(emoji, str) and reaction.emoji == emoji:
  200. return reaction
  201. return None
  202. async def update_if_sent(self) -> None:
  203. """
  204. Updates the text and/or reactions on a message if it was sent to
  205. the guild, otherwise does nothing. Does not need to be called by
  206. BaseCog subclasses.
  207. """
  208. if self.__message:
  209. await self.update()
  210. async def update(self) -> None:
  211. """
  212. Sends or updates an already sent message based on BotMessage state.
  213. Does not need to be called by BaseCog subclasses.
  214. """
  215. content: str = self.__formatted_message()
  216. if self.__message:
  217. if content != self.__posted_text:
  218. await self.__message.edit(content=content)
  219. self.__posted_text = content
  220. else:
  221. if self.__reply_to:
  222. self.__message = await self.__reply_to.reply(content=content, mention_author=False)
  223. self.__posted_text = content
  224. else:
  225. channel_id = Storage.get_config_value(self.guild, ConfigKey.WARNING_CHANNEL_ID)
  226. if channel_id is None:
  227. BaseCog.log(self.guild, '\u0007No warning channel set! No warning issued.')
  228. return
  229. channel: TextChannel = self.guild.get_channel(channel_id)
  230. if channel is None:
  231. BaseCog.log(self.guild, '\u0007Configured warning channel does not exist!')
  232. return
  233. self.__message = await channel.send(content=content)
  234. self.__posted_text = content
  235. emoji_to_remove = self.__posted_emoji.copy()
  236. for reaction in self.__reactions:
  237. if reaction.is_enabled:
  238. if reaction.emoji not in self.__posted_emoji:
  239. await self.__message.add_reaction(reaction.emoji)
  240. self.__posted_emoji.add(reaction.emoji)
  241. if reaction.emoji in emoji_to_remove:
  242. emoji_to_remove.remove(reaction.emoji)
  243. for emoji in emoji_to_remove:
  244. await self.__message.clear_reaction(emoji)
  245. if emoji in self.__posted_emoji:
  246. self.__posted_emoji.remove(emoji)
  247. def __formatted_message(self) -> str:
  248. s: str = ''
  249. if self.type == self.TYPE_INFO:
  250. s += CONFIG['info_emoji'] + ' '
  251. elif self.type == self.TYPE_MOD_WARNING:
  252. mention: str = Storage.get_config_value(self.guild, ConfigKey.WARNING_MENTION)
  253. if mention:
  254. s += mention + ' '
  255. s += CONFIG['warning_emoji'] + ' '
  256. elif self.type == self.TYPE_SUCCESS:
  257. s += CONFIG['success_emoji'] + ' '
  258. elif self.type == self.TYPE_FAILURE:
  259. s += CONFIG['failure_emoji'] + ' '
  260. s += self.text
  261. if self.quote:
  262. s += f'\n\n> {self.quote}'
  263. if len(self.__reactions) > 0:
  264. s += '\n\nAvailable actions:'
  265. for reaction in self.__reactions:
  266. if reaction.is_enabled:
  267. s += f'\n {reaction.emoji} {reaction.description}'
  268. else:
  269. s += f'\n {reaction.description}'
  270. return s
  271. class CogSetting:
  272. """
  273. Describes a configuration setting for a guild that can be edited by the
  274. mods of those guilds. BaseCog can generate "get" and "set" commands
  275. automatically, reducing the boilerplate of generating commands manually.
  276. Offers simple validation rules.
  277. """
  278. def __init__(self,
  279. name: str,
  280. datatype,
  281. brief: str = None,
  282. description: str = None,
  283. usage: str = None,
  284. min_value = None,
  285. max_value = None,
  286. enum_values: set = None):
  287. """
  288. Params:
  289. - name Setting identifier. Must follow variable naming
  290. conventions.
  291. - datatype Datatype of the setting. E.g. int, float, str
  292. - brief Description of the setting, starting with lower case.
  293. Will be inserted into phrases like "Sets <brief>" and
  294. "Gets <brief".
  295. - description Long-form description. Min, max, and enum values will be
  296. appended to the end, so does not need to include these.
  297. - usage Description of the value argument in a set command, e.g.
  298. "<maxcount:int>"
  299. - min_value Smallest allowable value. Must be of the same datatype as
  300. the value. None for no minimum.
  301. - max_value Largest allowable value. None for no maximum.
  302. - enum_values Set of allowed values. None if unconstrained.
  303. """
  304. self.name = name
  305. self.datatype = datatype
  306. self.brief = brief
  307. self.description = description or '' # Can't be None
  308. self.usage = usage
  309. self.min_value = min_value
  310. self.max_value = max_value
  311. self.enum_values = enum_values
  312. if self.enum_values or self.min_value is not None or self.max_value is not None:
  313. self.description += '\n'
  314. if self.enum_values:
  315. allowed_values = '`' + ('`, `'.join(enum_values)) + '`'
  316. self.description += f'\nAllowed values: {allowed_values}'
  317. if self.min_value is not None:
  318. self.description += f'\nMin value: {self.min_value}'
  319. if self.max_value is not None:
  320. self.description += f'\nMax value: {self.max_value}'
  321. if self.usage is None:
  322. self.usage = f'<{self.name}>'
  323. class BaseCog(commands.Cog):
  324. """
  325. Superclass for all Rocketbot cogs. Provides lots of conveniences for
  326. common tasks.
  327. """
  328. def __init__(self, bot):
  329. self.bot = bot
  330. self.are_settings_setup = False
  331. self.settings = []
  332. # Config
  333. @classmethod
  334. def get_cog_default(cls, key: str):
  335. """
  336. Convenience method for getting a cog configuration default from
  337. `CONFIG['cogs'][<cogname>][<key>]`. These values are used for
  338. CogSettings when no guild-specific value is configured yet.
  339. """
  340. cogs: dict = CONFIG['cog_defaults']
  341. cog = cogs.get(cls.__name__)
  342. if cog is None:
  343. return None
  344. return cog.get(key)
  345. def add_setting(self, setting: CogSetting) -> None:
  346. """
  347. Called by a subclass in __init__ to register a mod-configurable
  348. guild setting. A "get" and "set" command will be generated. If the
  349. setting is named "enabled" (exactly) then "enable" and "disable"
  350. commands will be created instead which set the setting to True/False.
  351. If the cog has a command group it will be detected automatically and
  352. the commands added to that. Otherwise the commands will be added at
  353. the top level.
  354. Changes to settings can be detected by overriding `on_setting_updated`.
  355. """
  356. self.settings.append(setting)
  357. @classmethod
  358. def get_guild_setting(cls,
  359. guild: Guild,
  360. setting: CogSetting,
  361. use_cog_default_if_not_set: bool = True):
  362. """
  363. Returns the configured value for a setting for the given guild. If no
  364. setting is configured the default for the cog will be returned,
  365. unless the optional `use_cog_default_if_not_set` is `False`, then
  366. `None` will be returned.
  367. """
  368. key = f'{cls.__name__}.{setting.name}'
  369. value = Storage.get_config_value(guild, key)
  370. if value is None and use_cog_default_if_not_set:
  371. value = cls.get_cog_default(setting.name)
  372. return value
  373. @classmethod
  374. def set_guild_setting(cls,
  375. guild: Guild,
  376. setting: CogSetting,
  377. new_value) -> None:
  378. """
  379. Manually sets a setting for the given guild. BaseCog creates "get" and
  380. "set" commands for guild administrators to configure values themselves,
  381. but this method can be used for hidden settings from code. A ValueError
  382. will be raised if the new value does not pass validation specified in
  383. the CogSetting.
  384. """
  385. if setting.min_value is not None and new_value < setting.min_value:
  386. raise ValueError(f'{setting.name} must be at least {setting.min_value}')
  387. if setting.max_value is not None and new_value > setting.max_value:
  388. raise ValueError(f'{setting.name} must be no more than {setting.max_value}')
  389. if setting.enum_values and new_value not in setting.enum_values:
  390. raise ValueError(f'{setting.name} must be one of {setting.enum_values}')
  391. key = f'{cls.__name__}.{setting.name}'
  392. Storage.set_config_value(guild, key, new_value)
  393. @commands.Cog.listener()
  394. async def on_ready(self):
  395. 'Event listener'
  396. self.__set_up_setting_commands()
  397. def __set_up_setting_commands(self):
  398. """
  399. Sets up commands for editing all registered cog settings. This method
  400. only runs once.
  401. """
  402. if self.are_settings_setup:
  403. return
  404. self.are_settings_setup = True
  405. # See if the cog has a command group. Currently only supporting one max.
  406. group: commands.core.Group = None
  407. for member_name in dir(self):
  408. member = getattr(self, member_name)
  409. if isinstance(member, commands.core.Group):
  410. group = member
  411. break
  412. for setting in self.settings:
  413. if setting.name == 'enabled' or setting.name == 'is_enabled':
  414. self.__make_enable_disable_commands(setting, group)
  415. else:
  416. self.__make_getter_setter_commands(setting, group)
  417. def __make_getter_setter_commands(self,
  418. setting: CogSetting,
  419. group: commands.core.Group) -> None:
  420. """
  421. Creates a "get..." and "set..." command for the given setting and
  422. either registers them as subcommands under the given command group or
  423. under the bot if `None`.
  424. """
  425. # Manually constructing equivalent of:
  426. # @commands.command()
  427. # @commands.has_permissions(ban_members=True)
  428. # @commands.guild_only()
  429. # async def getvar(self, context):
  430. async def getter(self, context):
  431. await self.__get_setting_command(context, setting)
  432. async def setter_int(self, context, new_value: int):
  433. await self.__set_setting_command(context, new_value, setting)
  434. async def setter_float(self, context, new_value: float):
  435. await self.__set_setting_command(context, new_value, setting)
  436. async def setter_str(self, context, new_value: str):
  437. await self.__set_setting_command(context, new_value, setting)
  438. async def setter_bool(self, context, new_value: bool):
  439. await self.__set_setting_command(context, new_value, setting)
  440. setter = None
  441. if setting.datatype == int:
  442. setter = setter_int
  443. elif setting.datatype == float:
  444. setter = setter_float
  445. elif setting.datatype == str:
  446. setter = setter_str
  447. elif setting.datatype == bool:
  448. setter = setter_bool
  449. else:
  450. raise RuntimeError(f'Datatype {setting.datatype} unsupported')
  451. get_command = commands.Command(
  452. getter,
  453. name=f'get{setting.name}',
  454. brief=f'Shows {setting.brief}',
  455. description=setting.description,
  456. checks=[
  457. commands.has_permissions(ban_members=True),
  458. commands.guild_only(),
  459. ])
  460. set_command = commands.Command(
  461. setter,
  462. name=f'set{setting.name}',
  463. brief=f'Sets {setting.brief}',
  464. description=setting.description,
  465. usage=setting.usage,
  466. checks=[
  467. commands.has_permissions(ban_members=True),
  468. commands.guild_only(),
  469. ])
  470. # Passing `cog` in init gets ignored and set to `None` so set after.
  471. # This ensures the callback is passed `self`.
  472. get_command.cog = self
  473. set_command.cog = self
  474. if group:
  475. group.add_command(get_command)
  476. group.add_command(set_command)
  477. else:
  478. self.bot.add_command(get_command)
  479. self.bot.add_command(set_command)
  480. def __make_enable_disable_commands(self,
  481. setting: CogSetting,
  482. group: commands.core.Group) -> None:
  483. """
  484. Creates "enable" and "disable" commands.
  485. """
  486. async def enabler(self, context):
  487. await self.__enable_command(context, setting)
  488. async def disabler(self, context):
  489. await self.__disable_command(context, setting)
  490. enable_command = commands.Command(
  491. enabler,
  492. name='enable',
  493. brief=f'Enables {setting.brief}',
  494. description=setting.description,
  495. checks=[
  496. commands.has_permissions(ban_members=True),
  497. commands.guild_only(),
  498. ])
  499. disable_command = commands.Command(
  500. disabler,
  501. name='disable',
  502. brief=f'Disables {setting.brief}',
  503. description=setting.description,
  504. checks=[
  505. commands.has_permissions(ban_members=True),
  506. commands.guild_only(),
  507. ])
  508. enable_command.cog = self
  509. disable_command.cog = self
  510. if group:
  511. group.add_command(enable_command)
  512. group.add_command(disable_command)
  513. else:
  514. self.bot.add_command(enable_command)
  515. self.bot.add_command(disable_command)
  516. async def __set_setting_command(self, context, new_value, setting) -> None:
  517. setting_name = setting.name
  518. if context.command.parent:
  519. setting_name = f'{context.command.parent.name}.{setting_name}'
  520. if setting.min_value is not None and new_value < setting.min_value:
  521. await context.message.reply(
  522. f'{CONFIG["failure_emoji"]} `{setting_name}` must be >= {setting.min_value}',
  523. mention_author=False)
  524. return
  525. if setting.max_value is not None and new_value > setting.max_value:
  526. await context.message.reply(
  527. f'{CONFIG["failure_emoji"]} `{setting_name}` must be <= {setting.max_value}',
  528. mention_author=False)
  529. return
  530. if setting.enum_values is not None and new_value not in setting.enum_values:
  531. allowed_values = '`' + ('`, `'.join(setting.enum_values)) + '`'
  532. await context.message.reply(
  533. f'{CONFIG["failure_emoji"]} `{setting_name}` must be one of {allowed_values}',
  534. mention_author=False)
  535. return
  536. key = f'{self.__class__.__name__}.{setting.name}'
  537. Storage.set_config_value(context.guild, key, new_value)
  538. await context.message.reply(
  539. f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
  540. mention_author=False)
  541. await self.on_setting_updated(context.guild, setting)
  542. self.log(context.guild, f'{context.author.name} set {key} to {new_value}')
  543. async def __get_setting_command(self, context, setting) -> None:
  544. setting_name = setting.name
  545. if context.command.parent:
  546. setting_name = f'{context.command.parent.name}.{setting_name}'
  547. key = f'{self.__class__.__name__}.{setting.name}'
  548. value = Storage.get_config_value(context.guild, key)
  549. if value is None:
  550. value = self.get_cog_default(setting.name)
  551. await context.message.reply(
  552. f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
  553. mention_author=False)
  554. else:
  555. await context.message.reply(
  556. f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
  557. mention_author=False)
  558. async def __enable_command(self, context, setting) -> None:
  559. key = f'{self.__class__.__name__}.{setting.name}'
  560. Storage.set_config_value(context.guild, key, True)
  561. await context.message.reply(
  562. f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
  563. mention_author=False)
  564. await self.on_setting_updated(context.guild, setting)
  565. self.log(context.guild, f'{context.author.name} enabled {self.__class__.__name__}')
  566. async def __disable_command(self, context, setting) -> None:
  567. key = f'{self.__class__.__name__}.{setting.name}'
  568. Storage.set_config_value(context.guild, key, False)
  569. await context.message.reply(
  570. f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
  571. mention_author=False)
  572. await self.on_setting_updated(context.guild, setting)
  573. self.log(context.guild, f'{context.author.name} disabled {self.__class__.__name__}')
  574. async def on_setting_updated(self, guild: Guild, setting: CogSetting) -> None:
  575. """
  576. Subclass override point for being notified when a CogSetting is edited.
  577. """
  578. # Bot message handling
  579. @classmethod
  580. def __bot_messages(cls, guild: Guild) -> AgeBoundDict:
  581. bm = Storage.get_state_value(guild, 'bot_messages')
  582. if bm is None:
  583. far_future = datetime.utcnow() + timedelta(days=1000)
  584. bm = AgeBoundDict(timedelta(seconds=600),
  585. lambda k, v : v.message_sent_at() or far_future)
  586. Storage.set_state_value(guild, 'bot_messages', bm)
  587. return bm
  588. async def post_message(self, message: BotMessage) -> bool:
  589. """
  590. Posts a BotMessage to a guild. Returns whether it was successful. If
  591. the caller wants to listen to reactions they should be added before
  592. calling this method. Listen to reactions by overriding `on_mod_react`.
  593. """
  594. message.source_cog = self
  595. await message.update()
  596. if message.has_reactions() and message.is_sent():
  597. guild_messages = self.__bot_messages(message.guild)
  598. guild_messages[message.message_id()] = message
  599. return message.is_sent()
  600. @commands.Cog.listener()
  601. async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
  602. 'Event handler'
  603. if payload.user_id == self.bot.user.id:
  604. # Ignore bot's own reactions
  605. return
  606. member: Member = payload.member
  607. if member is None:
  608. return
  609. guild: Guild = self.bot.get_guild(payload.guild_id)
  610. if guild is None:
  611. # Possibly a DM
  612. return
  613. channel: GuildChannel = guild.get_channel(payload.channel_id)
  614. if channel is None:
  615. # Possibly a DM
  616. return
  617. message: Message = await channel.fetch_message(payload.message_id)
  618. if message is None:
  619. # Message deleted?
  620. return
  621. if message.author.id != self.bot.user.id:
  622. # Bot didn't author this
  623. return
  624. guild_messages = self.__bot_messages(guild)
  625. bot_message = guild_messages.get(message.id)
  626. if bot_message is None:
  627. # Unknown message (expired or was never tracked)
  628. return
  629. if self is not bot_message.source_cog:
  630. # Belongs to a different cog
  631. return
  632. reaction = bot_message.reaction_for_emoji(payload.emoji)
  633. if reaction is None or not reaction.is_enabled:
  634. # Can't use this reaction with this message
  635. return
  636. if not member.permissions_in(channel).ban_members:
  637. # Not a mod (could make permissions configurable per BotMessageReaction some day)
  638. return
  639. await self.on_mod_react(bot_message, reaction, member)
  640. async def on_mod_react(self,
  641. bot_message: BotMessage,
  642. reaction: BotMessageReaction,
  643. reacted_by: Member) -> None:
  644. """
  645. Subclass override point for receiving mod reactions to bot messages sent
  646. via `post_message()`.
  647. """
  648. # Helpers
  649. @classmethod
  650. def log(cls, guild: Guild, message) -> None:
  651. """
  652. Writes a message to the console. Intended for significant events only.
  653. """
  654. now = datetime.now()
  655. print(f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|{cls.__name__}|{guild.name}] {message}')