Experimental Discord bot written in Python
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

basecog.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  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. brief: str = None,
  256. description: str = None,
  257. usage: str = None,
  258. min_value = None,
  259. max_value = None,
  260. enum_values: set = None):
  261. self.name = name
  262. self.brief = brief
  263. self.description = description or '' # XXX: Can't be None
  264. self.usage = usage
  265. self.min_value = min_value
  266. self.max_value = max_value
  267. self.enum_values = enum_values
  268. if self.enum_values or self.min_value is not None or self.max_value is not None:
  269. self.description += '\n'
  270. if self.enum_values:
  271. allowed_values = '`' + ('`, `'.join(enum_values)) + '`'
  272. self.description += f'\nAllowed values: {allowed_values}'
  273. if self.min_value is not None:
  274. self.description += f'\nMin value: {self.min_value}'
  275. if self.max_value is not None:
  276. self.description += f'\nMax value: {self.max_value}'
  277. if self.usage is None:
  278. self.usage = f'<{self.name}>'
  279. class BaseCog(commands.Cog):
  280. def __init__(self, bot):
  281. self.bot = bot
  282. self.are_settings_setup = False
  283. self.settings = []
  284. # Config
  285. @classmethod
  286. def get_cog_default(cls, key: str):
  287. """
  288. Convenience method for getting a cog configuration default from
  289. `CONFIG['cogs'][<cogname>][<key>]`.
  290. """
  291. cogs: dict = CONFIG['cog_defaults']
  292. cog = cogs.get(cls.__name__)
  293. if cog is None:
  294. return None
  295. return cog.get(key)
  296. def add_setting(self, setting: CogSetting) -> None:
  297. """
  298. Called by a subclass in __init__ to register a mod-configurable
  299. guild setting. A "get" and "set" command will be generated. If the cog
  300. has a command group it will be detected automatically and the commands
  301. added to that. Otherwise the commands will be added at the top level.
  302. """
  303. self.settings.append(setting)
  304. @classmethod
  305. def get_guild_setting(cls,
  306. guild: Guild,
  307. setting: CogSetting,
  308. use_cog_default_if_not_set: bool = True):
  309. """
  310. Returns the configured value for a setting for the given guild. If no
  311. setting is configured the default for the cog will be returned,
  312. unless the optional `use_cog_default_if_not_set` is `False`, then
  313. `None` will be returned.
  314. """
  315. key = f'{cls.__name__}.{setting.name}'
  316. value = Storage.get_config_value(guild, key)
  317. if value is None and use_cog_default_if_not_set:
  318. value = cls.get_cog_default(setting.name)
  319. return value
  320. @classmethod
  321. def set_guild_setting(cls,
  322. guild: Guild,
  323. setting: CogSetting,
  324. new_value) -> None:
  325. """
  326. Manually sets a setting for the given guild. BaseCog creates "get" and
  327. "set" commands for guild administrators to configure values themselves,
  328. but this method can be used for hidden settings from code.
  329. """
  330. key = f'{cls.__name__}.{setting.name}'
  331. Storage.set_config_value(guild, key, new_value)
  332. @commands.Cog.listener()
  333. async def on_ready(self):
  334. self.__set_up_setting_commands()
  335. def __set_up_setting_commands(self):
  336. """
  337. Sets up getter and setter commands for all registered cog settings.
  338. Only runs once.
  339. """
  340. if self.are_settings_setup:
  341. return
  342. self.are_settings_setup = True
  343. # See if the cog has a command group. Currently only supporting one max.
  344. group: commands.core.Group = None
  345. for member_name in dir(self):
  346. member = getattr(self, member_name)
  347. if isinstance(member, commands.core.Group):
  348. group = member
  349. break
  350. for setting in self.settings:
  351. self.__make_getter_setter_commands(setting, group)
  352. def __make_getter_setter_commands(self,
  353. setting: CogSetting,
  354. group: commands.core.Group) -> None:
  355. """
  356. Creates a "get..." and "set..." command for the given setting and
  357. either registers them as subcommands under the given command group or
  358. under the bot if `None`.
  359. """
  360. # Manually constructing equivalent of:
  361. # @commands.command(
  362. # brief='Posts a test warning in the configured warning channel.'
  363. # )
  364. # @commands.has_permissions(ban_members=True)
  365. # @commands.guild_only()
  366. # async def getvar(self, context):
  367. async def getter(self, context):
  368. await self.__get_setting_command(context, setting)
  369. async def setter(self, context, new_value):
  370. await self.__set_setting_command(context, new_value, setting)
  371. get_command = commands.Command(
  372. getter,
  373. name=f'get{setting.name}',
  374. brief=f'Shows {setting.brief}',
  375. description=setting.description,
  376. checks=[
  377. commands.has_permissions(ban_members=True),
  378. commands.guild_only(),
  379. ])
  380. set_command = commands.Command(
  381. setter,
  382. name=f'set{setting.name}',
  383. brief=f'Sets {setting.brief}',
  384. description=setting.description,
  385. usage=setting.usage,
  386. checks=[
  387. commands.has_permissions(ban_members=True),
  388. commands.guild_only(),
  389. ])
  390. # XXX: Passing `cog` in init gets ignored and set to `None` so set after.
  391. # This ensures the callback is passed `self`.
  392. get_command.cog = self
  393. set_command.cog = self
  394. if group:
  395. group.add_command(get_command)
  396. group.add_command(set_command)
  397. else:
  398. self.bot.add_command(get_command)
  399. self.bot.add_command(set_command)
  400. async def __set_setting_command(self, context, new_value, setting) -> None:
  401. setting_name = setting.name
  402. if context.command.parent:
  403. setting_name = f'{context.command.parent.name}.{setting_name}'
  404. if setting.min_value is not None and new_value < setting.min_value:
  405. await context.message.reply(
  406. f'{CONFIG["failure_emoji"]} `{setting_name}` must be >= {setting.min_value}',
  407. mention_author=False)
  408. return
  409. if setting.max_value is not None and new_value > setting.max_value:
  410. await context.message.reply(
  411. f'{CONFIG["failure_emoji"]} `{setting_name}` must be <= {setting.max_value}',
  412. mention_author=False)
  413. return
  414. if setting.enum_values is not None and new_value not in setting.enum_values:
  415. allowed_values = '`' + ('`, `'.join(setting.enum_values)) + '`'
  416. await context.message.reply(
  417. f'{CONFIG["failure_emoji"]} `{setting_name}` must be one of {allowed_values}',
  418. mention_author=False)
  419. return
  420. key = f'{self.__class__.__name__}.{setting.name}'
  421. Storage.set_config_value(context.guild, key, new_value)
  422. await context.message.reply(
  423. f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
  424. mention_author=False)
  425. async def __get_setting_command(self, context, setting) -> None:
  426. setting_name = setting.name
  427. if context.command.parent:
  428. setting_name = f'{context.command.parent.name}.{setting_name}'
  429. key = f'{self.__class__.__name__}.{setting.name}'
  430. value = Storage.get_config_value(context.guild, key)
  431. if value is None:
  432. value = self.get_cog_default(setting.name)
  433. await context.message.reply(
  434. f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
  435. mention_author=False)
  436. else:
  437. await context.message.reply(
  438. f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
  439. mention_author=False)
  440. # Bot message handling
  441. @classmethod
  442. def __bot_messages(cls, guild: Guild) -> AgeBoundDict:
  443. bm = Storage.get_state_value(guild, 'bot_messages')
  444. if bm is None:
  445. far_future = datetime.utcnow() + timedelta(days=1000)
  446. bm = AgeBoundDict(timedelta(seconds=600),
  447. lambda k, v : v.message_sent_at() or far_future)
  448. Storage.set_state_value(guild, 'bot_messages', bm)
  449. return bm
  450. async def post_message(self, message: BotMessage) -> bool:
  451. message.source_cog = self
  452. await message._update()
  453. guild_messages = self.__bot_messages(message.guild)
  454. if message.is_sent():
  455. guild_messages[message.message_id()] = message
  456. return True
  457. return False
  458. @commands.Cog.listener()
  459. async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
  460. 'Event handler'
  461. if payload.user_id == self.bot.user.id:
  462. # Ignore bot's own reactions
  463. return
  464. member: Member = payload.member
  465. if member is None:
  466. return
  467. guild: Guild = self.bot.get_guild(payload.guild_id)
  468. if guild is None:
  469. # Possibly a DM
  470. return
  471. channel: GuildChannel = guild.get_channel(payload.channel_id)
  472. if channel is None:
  473. # Possibly a DM
  474. return
  475. message: Message = await channel.fetch_message(payload.message_id)
  476. if message is None:
  477. # Message deleted?
  478. return
  479. if message.author.id != self.bot.user.id:
  480. # Bot didn't author this
  481. return
  482. guild_messages = self.__bot_messages(guild)
  483. bot_message = guild_messages.get(message.id)
  484. if bot_message is None:
  485. # Unknown message (expired or was never tracked)
  486. return
  487. if self is not bot_message.source_cog:
  488. # Belongs to a different cog
  489. return
  490. reaction = bot_message.reaction_for_emoji(payload.emoji)
  491. if reaction is None or not reaction.is_enabled:
  492. # Can't use this reaction with this message
  493. return
  494. if not member.permissions_in(channel).ban_members:
  495. # Not a mod (could make permissions configurable per BotMessageReaction some day)
  496. return
  497. await self.on_mod_react(bot_message, reaction, member)
  498. async def on_mod_react(self,
  499. bot_message: BotMessage,
  500. reaction: BotMessageReaction,
  501. reacted_by: Member) -> None:
  502. """
  503. Subclass override point for receiving mod reactions to bot messages sent
  504. via `post_message()`.
  505. """
  506. pass
  507. # Helpers
  508. @classmethod
  509. async def validate_param(cls, context: commands.Context, param_name: str, value,
  510. allowed_types: tuple = None,
  511. min_value = None,
  512. max_value = None) -> bool:
  513. """
  514. Convenience method for validating a command parameter is of the expected
  515. type and in the expected range. Bad values will cause a reply to be sent
  516. to the original message and a False will be returned. If all checks
  517. succeed, True will be returned.
  518. """
  519. # TODO: Rework this to use BotMessage
  520. if allowed_types is not None and not isinstance(value, allowed_types):
  521. if len(allowed_types) == 1:
  522. await context.message.reply(f'⚠️ `{param_name}` must be of type ' +
  523. f'{allowed_types[0]}.', mention_author=False)
  524. else:
  525. await context.message.reply(f'⚠️ `{param_name}` must be of types ' +
  526. f'{allowed_types}.', mention_author=False)
  527. return False
  528. if min_value is not None and value < min_value:
  529. await context.message.reply(f'⚠️ `{param_name}` must be >= {min_value}.',
  530. mention_author=False)
  531. return False
  532. if max_value is not None and value > max_value:
  533. await context.message.reply(f'⚠️ `{param_name}` must be <= {max_value}.',
  534. mention_author=False)
  535. return True
  536. @classmethod
  537. async def warn(cls, guild: Guild, message: str) -> Message:
  538. """
  539. DEPRECATED. Use post_message.
  540. Sends a warning message to the configured warning channel for the
  541. given guild. If no warning channel is configured no action is taken.
  542. Returns the Message if successful or None if not.
  543. """
  544. channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
  545. if channel_id is None:
  546. cls.log(guild, '\u0007No warning channel set! No warning issued.')
  547. return None
  548. channel: TextChannel = guild.get_channel(channel_id)
  549. if channel is None:
  550. cls.log(guild, '\u0007Configured warning channel does not exist!')
  551. return None
  552. mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
  553. text: str = message
  554. if mention is not None:
  555. text = f'{mention} {text}'
  556. msg: Message = await channel.send(text)
  557. return msg
  558. @classmethod
  559. async def update_warn(cls, warn_message: Message, new_text: str) -> None:
  560. """
  561. DEPRECATED. Use post_message.
  562. Updates the text of a previously posted `warn`. Includes configured
  563. mentions if necessary.
  564. """
  565. text: str = new_text
  566. mention: str = Storage.get_config_value(
  567. warn_message.guild,
  568. ConfigKey.WARNING_MENTION)
  569. if mention is not None:
  570. text = f'{mention} {text}'
  571. await warn_message.edit(content=text)
  572. @classmethod
  573. def log(cls, guild: Guild, message) -> None:
  574. now = datetime.now()
  575. print(f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|{cls.__name__}|{guild.name}] {message}')