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

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