Experimental Discord bot written in Python
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

basecog.py 24KB

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