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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  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. if self.are_settings_setup:
  335. return
  336. group: commands.core.Group = None
  337. for member_name in dir(self):
  338. member = getattr(self, member_name)
  339. if isinstance(member, commands.core.Group):
  340. group = member
  341. break
  342. lookup = {}
  343. for setting in self.settings:
  344. # Manually constructing equivalent of:
  345. # @commands.command(
  346. # brief='Posts a test warning in the configured warning channel.'
  347. # )
  348. # @commands.has_permissions(ban_members=True)
  349. # @commands.guild_only()
  350. # async def get/setvar(self, context, ...):
  351. async def _set_setting(self, context, new_value):
  352. s = lookup[context.command.name]
  353. await self.__set_setting(context, new_value, s)
  354. async def _get_setting(self, context):
  355. s = lookup[context.command.name]
  356. await self.__get_setting(context, s)
  357. set_command = commands.Command(
  358. _set_setting,
  359. name=f'set{setting.name}',
  360. brief=f'Sets {setting.brief}',
  361. description=setting.description,
  362. usage=setting.usage,
  363. checks=[
  364. commands.has_permissions(ban_members=True),
  365. commands.guild_only(),
  366. ])
  367. # XXX: Passing `cog` in init gets ignored and set to `None`.
  368. set_command.cog = self
  369. get_command = commands.Command(
  370. _get_setting,
  371. name=f'get{setting.name}',
  372. brief=f'Shows {setting.brief}',
  373. description=setting.description,
  374. checks=[
  375. commands.has_permissions(ban_members=True),
  376. commands.guild_only(),
  377. ])
  378. get_command.cog = self
  379. if group:
  380. group.add_command(get_command)
  381. group.add_command(set_command)
  382. else:
  383. self.bot.add_command(get_command)
  384. self.bot.add_command(set_command)
  385. lookup[set_command.name] = setting
  386. lookup[get_command.name] = setting
  387. self.are_settings_setup = True
  388. async def __set_setting(self, context, new_value, setting) -> None:
  389. setting_name = setting.name
  390. if context.command.parent:
  391. setting_name = f'{context.command.parent.name}.{setting_name}'
  392. if setting.min_value is not None and new_value < setting.min_value:
  393. await context.message.reply(
  394. f'{CONFIG["failure_emoji"]} `{setting_name}` must be >= {setting.min_value}',
  395. mention_author=False)
  396. return
  397. if setting.max_value is not None and new_value > setting.max_value:
  398. await context.message.reply(
  399. f'{CONFIG["failure_emoji"]} `{setting_name}` must be <= {setting.max_value}',
  400. mention_author=False)
  401. return
  402. if setting.enum_values is not None and new_value not in setting.enum_values:
  403. allowed_values = '`' + ('`, `'.join(setting.enum_values)) + '`'
  404. await context.message.reply(
  405. f'{CONFIG["failure_emoji"]} `{setting_name}` must be one of {allowed_values}',
  406. mention_author=False)
  407. return
  408. key = f'{self.__class__.__name__}.{setting.name}'
  409. Storage.set_config_value(context.guild, key, new_value)
  410. await context.message.reply(
  411. f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
  412. mention_author=False)
  413. async def __get_setting(self, context, setting) -> None:
  414. setting_name = setting.name
  415. if context.command.parent:
  416. setting_name = f'{context.command.parent.name}.{setting_name}'
  417. key = f'{self.__class__.__name__}.{setting.name}'
  418. value = Storage.get_config_value(context.guild, key)
  419. if value is None:
  420. value = self.get_cog_default(setting.name)
  421. await context.message.reply(
  422. f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
  423. mention_author=False)
  424. else:
  425. await context.message.reply(
  426. f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
  427. mention_author=False)
  428. # Bot message handling
  429. @classmethod
  430. def __bot_messages(cls, guild: Guild) -> AgeBoundDict:
  431. bm = Storage.get_state_value(guild, 'bot_messages')
  432. if bm is None:
  433. far_future = datetime.utcnow() + timedelta(days=1000)
  434. bm = AgeBoundDict(timedelta(seconds=600),
  435. lambda k, v : v.message_sent_at() or far_future)
  436. Storage.set_state_value(guild, 'bot_messages', bm)
  437. return bm
  438. async def post_message(self, message: BotMessage) -> bool:
  439. message.source_cog = self
  440. await message._update()
  441. guild_messages = self.__bot_messages(message.guild)
  442. if message.is_sent():
  443. guild_messages[message.message_id()] = message
  444. return True
  445. return False
  446. @commands.Cog.listener()
  447. async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
  448. 'Event handler'
  449. if payload.user_id == self.bot.user.id:
  450. # Ignore bot's own reactions
  451. return
  452. member: Member = payload.member
  453. if member is None:
  454. return
  455. guild: Guild = self.bot.get_guild(payload.guild_id)
  456. if guild is None:
  457. # Possibly a DM
  458. return
  459. channel: GuildChannel = guild.get_channel(payload.channel_id)
  460. if channel is None:
  461. # Possibly a DM
  462. return
  463. message: Message = await channel.fetch_message(payload.message_id)
  464. if message is None:
  465. # Message deleted?
  466. return
  467. if message.author.id != self.bot.user.id:
  468. # Bot didn't author this
  469. return
  470. guild_messages = self.__bot_messages(guild)
  471. bot_message = guild_messages.get(message.id)
  472. if bot_message is None:
  473. # Unknown message (expired or was never tracked)
  474. return
  475. if self is not bot_message.source_cog:
  476. # Belongs to a different cog
  477. return
  478. reaction = bot_message.reaction_for_emoji(payload.emoji)
  479. if reaction is None or not reaction.is_enabled:
  480. # Can't use this reaction with this message
  481. return
  482. if not member.permissions_in(channel).ban_members:
  483. # Not a mod (could make permissions configurable per BotMessageReaction some day)
  484. return
  485. await self.on_mod_react(bot_message, reaction, member)
  486. async def on_mod_react(self,
  487. bot_message: BotMessage,
  488. reaction: BotMessageReaction,
  489. reacted_by: Member) -> None:
  490. """
  491. Subclass override point for receiving mod reactions to bot messages sent
  492. via `post_message()`.
  493. """
  494. pass
  495. # Helpers
  496. @classmethod
  497. async def validate_param(cls, context: commands.Context, param_name: str, value,
  498. allowed_types: tuple = None,
  499. min_value = None,
  500. max_value = None) -> bool:
  501. """
  502. Convenience method for validating a command parameter is of the expected
  503. type and in the expected range. Bad values will cause a reply to be sent
  504. to the original message and a False will be returned. If all checks
  505. succeed, True will be returned.
  506. """
  507. # TODO: Rework this to use BotMessage
  508. if allowed_types is not None and not isinstance(value, allowed_types):
  509. if len(allowed_types) == 1:
  510. await context.message.reply(f'⚠️ `{param_name}` must be of type ' +
  511. f'{allowed_types[0]}.', mention_author=False)
  512. else:
  513. await context.message.reply(f'⚠️ `{param_name}` must be of types ' +
  514. f'{allowed_types}.', mention_author=False)
  515. return False
  516. if min_value is not None and value < min_value:
  517. await context.message.reply(f'⚠️ `{param_name}` must be >= {min_value}.',
  518. mention_author=False)
  519. return False
  520. if max_value is not None and value > max_value:
  521. await context.message.reply(f'⚠️ `{param_name}` must be <= {max_value}.',
  522. mention_author=False)
  523. return True
  524. @classmethod
  525. async def warn(cls, guild: Guild, message: str) -> Message:
  526. """
  527. DEPRECATED. Use post_message.
  528. Sends a warning message to the configured warning channel for the
  529. given guild. If no warning channel is configured no action is taken.
  530. Returns the Message if successful or None if not.
  531. """
  532. channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
  533. if channel_id is None:
  534. cls.log(guild, '\u0007No warning channel set! No warning issued.')
  535. return None
  536. channel: TextChannel = guild.get_channel(channel_id)
  537. if channel is None:
  538. cls.log(guild, '\u0007Configured warning channel does not exist!')
  539. return None
  540. mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
  541. text: str = message
  542. if mention is not None:
  543. text = f'{mention} {text}'
  544. msg: Message = await channel.send(text)
  545. return msg
  546. @classmethod
  547. async def update_warn(cls, warn_message: Message, new_text: str) -> None:
  548. """
  549. DEPRECATED. Use post_message.
  550. Updates the text of a previously posted `warn`. Includes configured
  551. mentions if necessary.
  552. """
  553. text: str = new_text
  554. mention: str = Storage.get_config_value(
  555. warn_message.guild,
  556. ConfigKey.WARNING_MENTION)
  557. if mention is not None:
  558. text = f'{mention} {text}'
  559. await warn_message.edit(content=text)
  560. @classmethod
  561. def log(cls, guild: Guild, message) -> None:
  562. now = datetime.now()
  563. print(f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|{cls.__name__}|{guild.name}] {message}')