Experimental Discord bot written in Python
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

logcog.py 28KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. """
  2. Cog for detecting large numbers of guild joins in a short period of time.
  3. """
  4. from collections.abc import Sequence
  5. from datetime import datetime
  6. from discord import AuditLogAction, AuditLogEntry, Emoji, Guild, GuildSticker, Invite, Member, Message, RawBulkMessageDeleteEvent, RawMessageDeleteEvent, RawMessageUpdateEvent, Role, Thread, User
  7. from discord.abc import GuildChannel
  8. from discord.ext import commands
  9. from discord.utils import escape_markdown
  10. from typing import Optional, Tuple, Union
  11. import difflib
  12. import re
  13. from rocketbot.cogs.basecog import BaseCog, BotMessage, CogSetting
  14. class LoggingCog(BaseCog, name='Logging'):
  15. """
  16. Cog for logging notable events to a designated logging channel.
  17. """
  18. SETTING_ENABLED = CogSetting('enabled', bool,
  19. brief='logging',
  20. description='Whether this cog is enabled for a guild.')
  21. def __init__(self, bot):
  22. super().__init__(bot)
  23. self.add_setting(LoggingCog.SETTING_ENABLED)
  24. @commands.group(
  25. brief='Manages event logging',
  26. )
  27. @commands.has_permissions(ban_members=True)
  28. @commands.guild_only()
  29. async def logging(self, context: commands.Context):
  30. """Logging command group"""
  31. if context.invoked_subcommand is None:
  32. await context.send_help()
  33. # Events - Channels
  34. @commands.Cog.listener()
  35. async def on_guild_channel_delete(self, channel: GuildChannel) -> None:
  36. """
  37. Called whenever a guild channel is deleted or created.
  38. Note that you can get the guild from guild.
  39. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_channel_delete
  40. """
  41. guild = channel.guild
  42. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  43. return
  44. text = f'Channel **{channel.name}** deleted.'
  45. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  46. await bot_message.update()
  47. @commands.Cog.listener()
  48. async def on_guild_channel_create(self, channel: GuildChannel) -> None:
  49. """
  50. Called whenever a guild channel is deleted or created.
  51. Note that you can get the guild from guild.
  52. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_channel_create
  53. """
  54. guild = channel.guild
  55. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  56. return
  57. text = f'Channel **{channel.name}** created. {channel.mention}'
  58. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  59. await bot_message.update()
  60. @commands.Cog.listener()
  61. async def on_guild_channel_update(self, before: GuildChannel, after: GuildChannel) -> None:
  62. """
  63. Called whenever a guild channel is updated. e.g. changed name, topic,
  64. permissions.
  65. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_channel_update
  66. """
  67. guild = after.guild
  68. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  69. return
  70. changes = []
  71. if after.name != before.name:
  72. changes.append(f'Name: `{before.name}` -> `{after.name}`')
  73. if after.category != before.category:
  74. changes.append(f'Category: {before.category.name if before.category else None}' + \
  75. f' -> {after.category.name if after.category else None}')
  76. if after.changed_roles != before.changed_roles:
  77. changes.append('Roles changed')
  78. if after.overwrites != before.overwrites:
  79. changes.append('Permission overwrites changed')
  80. if after.position != before.position:
  81. changes.append(f'Position: {before.position} -> {after.position}')
  82. if len(changes) == 0:
  83. return
  84. text = f'Channel **{before.name}** updated. Changes:\n'
  85. text += '* ' + '\n* '.join(changes)
  86. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  87. await bot_message.update()
  88. # Events - Guilds
  89. @commands.Cog.listener()
  90. async def on_guild_available(self, guild: Guild) -> None:
  91. pass
  92. @commands.Cog.listener()
  93. async def on_guild_unavailable(self, guild: Guild) -> None:
  94. pass
  95. @commands.Cog.listener()
  96. async def on_guild_update(self, before: Guild, after: Guild) -> None:
  97. pass
  98. @commands.Cog.listener()
  99. async def on_guild_emojis_update(self, guild: Guild, before: Sequence[Emoji], after: Sequence[Emoji]) -> None:
  100. pass
  101. @commands.Cog.listener()
  102. async def on_guild_stickers_update(self, guild: Guild, before: Sequence[GuildSticker], after: Sequence[GuildSticker]) -> None:
  103. pass
  104. @commands.Cog.listener()
  105. async def on_invite_create(self, invite: Invite) -> None:
  106. """
  107. Called when an `Invite` is created. You must have manage_channels to receive this.
  108. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_invite_create
  109. """
  110. guild = invite.guild
  111. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  112. return
  113. text = f'Invite code `{invite.code}` created by {self.__describe_user(invite.inviter)}. '
  114. if invite.max_age == 0:
  115. text += "Doesn't expire."
  116. else:
  117. text += f'Expires in {invite.max_age} seconds.'
  118. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  119. await bot_message.update()
  120. @commands.Cog.listener()
  121. async def on_invite_delete(self, invite: Invite) -> None:
  122. """
  123. Called when an `Invite` is deleted. You must have manage_channels to receive this.
  124. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_invite_delete
  125. """
  126. guild = invite.guild
  127. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  128. return
  129. if invite.inviter:
  130. text = f'Invite code `{invite.code}` deleted. Originally created by {self.__describe_user(invite.inviter)}.'
  131. else:
  132. text = f'Invite code `{invite.code}` deleted.'
  133. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  134. await bot_message.update()
  135. # Events - Members
  136. @commands.Cog.listener()
  137. async def on_member_join(self, member: Member) -> None:
  138. """
  139. Called when a Member joins a Guild.
  140. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_join
  141. """
  142. guild = member.guild
  143. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  144. return
  145. text = f'Member joined server: {self.__describe_user(member)}.'
  146. flags = []
  147. noteworthy = False
  148. if member.flags.did_rejoin:
  149. flags.append('Rejoined this server')
  150. if member.public_flags.active_developer:
  151. flags.append('Is an active developer')
  152. if member.public_flags.hypesquad:
  153. flags.append('Is a HypeSquad Events member')
  154. if member.public_flags.hypesquad_bravery:
  155. flags.append('Is a HypeSquad Bravery member')
  156. if member.public_flags.hypesquad_brilliance:
  157. flags.append('Is a HypeSquad Brilliance member')
  158. if member.public_flags.hypesquad_balance:
  159. flags.append('Is a HypeSquad Balance member')
  160. if member.public_flags.early_supporter:
  161. flags.append('Is an early supporter')
  162. if member.public_flags.spammer:
  163. flags.append('**Is flagged as a spammer**')
  164. noteworthy = True
  165. if member.public_flags.discord_certified_moderator:
  166. flags.append('**Is a Discord Certified Moderator**')
  167. noteworthy = True
  168. if member.public_flags.early_verified_bot_developer:
  169. flags.append('**Is a verified bot developer**')
  170. noteworthy = True
  171. if member.public_flags.verified_bot:
  172. flags.append('**Is a verified bot**')
  173. noteworthy = True
  174. if member.public_flags.bug_hunter or member.public_flags.bug_hunter_level_2:
  175. flags.append('**Is a bug hunter**')
  176. noteworthy = True
  177. if member.public_flags.system:
  178. flags.append('**Is a Discord system user**')
  179. noteworthy = True
  180. if member.public_flags.staff:
  181. flags.append('**Is Discord staff**')
  182. noteworthy = True
  183. if member.public_flags.partner:
  184. flags.append('**Is a Discord partner**')
  185. noteworthy = True
  186. if len(flags) > 0:
  187. text += '\n* ' + '\n* '.join(flags)
  188. if noteworthy:
  189. text += f'\n\nLink: {member.mention}'
  190. bot_message = BotMessage(guild, text, BotMessage.TYPE_MOD_WARNING)
  191. else:
  192. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  193. await bot_message.update()
  194. @commands.Cog.listener()
  195. async def on_member_remove(self, member: Member) -> None:
  196. """
  197. Called when a Member leaves a Guild.
  198. If the guild or member could not be found in the internal cache this event
  199. will not be called, you may use on_raw_member_remove() instead.
  200. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_remove
  201. """
  202. guild = member.guild
  203. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  204. return
  205. is_kick = False
  206. kicker = None
  207. kick_reason = None
  208. entry = await self.__find_audit_entry(member, AuditLogAction.kick)
  209. if entry:
  210. is_kick = True
  211. kicker = entry.user
  212. kick_reason = entry.reason
  213. if is_kick:
  214. if kicker and kicker != member:
  215. text = f'Member kicked from the server: {self.__describe_user(member)} by **{kicker.name}**'
  216. else:
  217. text = f'Member kicked from the server: {self.__describe_user(member)}'
  218. else:
  219. text = f'Member left server: {self.__describe_user(member)}'
  220. if kick_reason:
  221. text += f'\nReason: "{kick_reason}"'
  222. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  223. await bot_message.update()
  224. @commands.Cog.listener()
  225. async def on_member_update(self, before: Member, after: Member) -> None:
  226. """
  227. Called when a Member updates their profile.
  228. This is called when one or more of the following things change:
  229. * nickname
  230. * roles
  231. * pending
  232. * timeout
  233. * guild avatar
  234. * flags
  235. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_update
  236. """
  237. guild = after.guild
  238. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  239. return
  240. changes = []
  241. if after.nick != before.nick:
  242. changes.append(f'Nick: `{before.nick}` -> `{after.nick}`')
  243. if after.roles != before.roles:
  244. added_role_names = []
  245. removed_role_names = []
  246. for role in before.roles:
  247. if role not in after.roles:
  248. removed_role_names.append(role.name)
  249. for role in after.roles:
  250. if role not in before.roles:
  251. added_role_names.append(role.name)
  252. if len(removed_role_names) > 0:
  253. changes.append(f'Removed roles: ~~**{"**~~, ~~**".join(removed_role_names)}**~~')
  254. if len(added_role_names) > 0:
  255. changes.append(f'Added roles: **{"**, **".join(added_role_names)}**')
  256. if after.pending != before.pending:
  257. pass # not that interesting and probably noisy
  258. if after.timed_out_until != before.timed_out_until:
  259. if after.timed_out_until:
  260. delta = after.timed_out_until - datetime.now()
  261. changes.append(f'Timed out for `{delta}`')
  262. elif before.timed_out_until:
  263. changes.append('Timeout cleared')
  264. before_guild_avatar = before.guild_avatar.url if before.guild_avatar else None
  265. after_guild_avatar = after.guild_avatar.url if after.guild_avatar else None
  266. if after_guild_avatar != before_guild_avatar:
  267. changes.append(f'Guild avatar: <{before_guild_avatar}> -> <{after_guild_avatar}>')
  268. if after.flags != before.flags:
  269. flag_changes = []
  270. for (name, after_value) in iter(after.flags):
  271. before_value = getattr(before.flags, name)
  272. if after_value != before_value:
  273. flag_changes.append(f'`{name}` = `{before_value}` -> `{after_value}`')
  274. if len(flag_changes) > 0:
  275. changes.append(f'Flag changes: {", ".join(flag_changes)}')
  276. if len(changes) == 0:
  277. return
  278. text = f'Details for member {self.__describe_user(before)} changed:\n'
  279. text += '* ' + ('\n* '.join(changes))
  280. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  281. await bot_message.update()
  282. @commands.Cog.listener()
  283. async def on_user_update(self, before: User, after: User) -> None:
  284. """
  285. Called when a User updates their profile.
  286. This is called when one or more of the following things change:
  287. * avatar
  288. * username
  289. * discriminator
  290. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_user_update
  291. """
  292. if hasattr(after, 'guild'):
  293. guild = after.guild
  294. else:
  295. return
  296. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  297. return
  298. changes = []
  299. before_avatar_url = before.avatar.url if before.avatar else None
  300. after_avatar_url = after.avatar.url if after.avatar else None
  301. if after_avatar_url != before_avatar_url:
  302. changes.append(f'Avatar URL: <{before_avatar_url}> -> <{after_avatar_url}>')
  303. if after.name != before.name:
  304. changes.append(f'Username: `{before.name}` -> `{after.name}`')
  305. if len(changes) == 0:
  306. return
  307. text = f'Details for user {self.__describe_user(before)} changed:\n'
  308. text += '* ' + '\n* '.join(changes)
  309. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  310. await bot_message.update()
  311. @commands.Cog.listener()
  312. async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
  313. """
  314. Called when user gets banned from a Guild.
  315. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_ban
  316. """
  317. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  318. return
  319. banner = None
  320. ban_reason = None
  321. entry = await self.__find_audit_entry(user, AuditLogAction.ban)
  322. if entry:
  323. banner = entry.user
  324. ban_reason = entry.reason
  325. if banner:
  326. text = f'Member {self.__describe_user(user)} banned by **{banner.name}**.'
  327. if ban_reason:
  328. text += f'\nReason: "{ban_reason}"'
  329. else:
  330. text = f'Member {self.__describe_user(user)} banned.'
  331. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  332. await bot_message.update()
  333. async def __find_audit_entry(self, user: Union[User, Member], action: AuditLogAction, max_age: int = 10) -> Optional[AuditLogEntry]:
  334. """
  335. Searches the audit log for the most recent entry of a given type for a
  336. given user. Intended for finding the relevant entry for a ban/kick that
  337. just occurred.
  338. """
  339. if hasattr(user, 'guild') and user.guild:
  340. guild = user.guild
  341. else:
  342. return None
  343. now = datetime.now()
  344. async for entry in guild.audit_logs():
  345. age_seconds = now.timestamp() - entry.created_at.timestamp()
  346. if entry.action == action and entry.target == user and age_seconds <= max_age:
  347. return entry
  348. return None
  349. @commands.Cog.listener()
  350. async def on_member_unban(self, guild: Guild, user: Union[User, Member]) -> None:
  351. """
  352. Called when a User gets unbanned from a Guild.
  353. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_unban
  354. """
  355. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  356. return
  357. text = f'Member {self.__describe_user(user)} unbanned'
  358. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  359. await bot_message.update()
  360. # Events - Messages
  361. @commands.Cog.listener()
  362. async def on_message(self, message: Message) -> None:
  363. """
  364. Called when a Message is created and sent.
  365. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_message
  366. """
  367. # print(f"Saw message {message.id} \"{message.content}\"")
  368. @commands.Cog.listener()
  369. async def on_message_edit(self, before: Message, after: Message) -> None:
  370. """
  371. Called when a Message receives an update event. If the message is not
  372. found in the internal message cache, then these events will not be
  373. called. Messages might not be in cache if the message is too old or the
  374. client is participating in high traffic guilds.
  375. If this occurs increase the max_messages parameter or use the
  376. on_raw_message_edit() event instead.
  377. The following non-exhaustive cases trigger this event:
  378. * A message has been pinned or unpinned.
  379. * The message content has been changed.
  380. * The message has received an embed.
  381. * For performance reasons, the embed server does not do this in a
  382. “consistent” manner.
  383. * The message’s embeds were suppressed or unsuppressed.
  384. * A call message has received an update to its participants or ending time.
  385. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_message_edit
  386. """
  387. guild = after.guild
  388. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  389. return
  390. if after.author.id == self.bot.user.id:
  391. return
  392. content_changed = (after.content != before.content)
  393. attachments_changed = (after.attachments != before.attachments)
  394. embeds_changed = (after.embeds != before.embeds)
  395. embeds_add_only = len(before.embeds or []) == 0 and len(after.embeds or []) > 0
  396. if not content_changed and not attachments_changed and (not embeds_changed or embeds_add_only):
  397. # Most likely an embed being asynchronously populated by server
  398. return
  399. if content_changed:
  400. (before_markdown, after_markdown) = self.__diff(self.__quote_markdown(before.content),
  401. self.__quote_markdown(after.content))
  402. else:
  403. before_markdown = self.__quote_markdown(before.content)
  404. after_markdown = before_markdown if len(before.content.strip()) == 0 else '> _<content unchanged>_'
  405. if attachments_changed:
  406. if len(before.attachments or []) > 0:
  407. for attachment in before.attachments:
  408. before_markdown += f'\n> * 📎 {attachment.url}'
  409. if attachment not in after.attachments or []:
  410. before_markdown += ' (removed)'
  411. else:
  412. before_markdown += '\n> * _<no attachments>_'
  413. if len(after.attachments or []) > 0:
  414. for attachment in after.attachments:
  415. after_markdown += f'\n> * 📎 {attachment.url}'
  416. if attachment not in before.attachments or []:
  417. after_markdown += ' (added)'
  418. else:
  419. after_markdown += '\n> * _<no attachments>_'
  420. if embeds_changed:
  421. if len(before.embeds or []) > 0:
  422. for embed in before.embeds:
  423. before_markdown += f'\n> * 🔗 {embed.url}'
  424. if embed not in after.embeds or []:
  425. before_markdown += ' (removed)'
  426. else:
  427. before_markdown += '\n> * _<no embeds>_'
  428. if len(after.embeds or []) > 0:
  429. for embed in after.embeds:
  430. after_markdown += f'\n> * 🔗 {embed.url}'
  431. if embed not in before.embeds or []:
  432. after_markdown += ' (added)'
  433. else:
  434. after_markdown += '\n> * _<no embeds>_'
  435. text = f'Message {after.jump_url} edited by {self.__describe_user(after.author)}.\n' + \
  436. f'Original:\n{before_markdown}\n' + \
  437. f'Updated:\n{after_markdown}'
  438. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
  439. await bot_message.update()
  440. @commands.Cog.listener()
  441. async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
  442. """
  443. Called when a message is edited. Unlike on_message_edit(), this is called
  444. regardless of the state of the internal message cache.
  445. If the message is found in the message cache, it can be accessed via
  446. RawMessageUpdateEvent.cached_message. The cached message represents the
  447. message before it has been edited. For example, if the content of a
  448. message is modified and triggers the on_raw_message_edit() coroutine,
  449. the RawMessageUpdateEvent.cached_message will return a Message object
  450. that represents the message before the content was modified.
  451. Due to the inherently raw nature of this event, the data parameter
  452. coincides with the raw data given by the gateway.
  453. Since the data payload can be partial, care must be taken when accessing
  454. stuff in the dictionary. One example of a common case of partial data is
  455. when the 'content' key is inaccessible. This denotes an “embed” only
  456. edit, which is an edit in which only the embeds are updated by the
  457. Discord embed server.
  458. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_message_edit
  459. """
  460. if payload.cached_message:
  461. return # already handled by on_message_edit
  462. guild = self.bot.get_guild(payload.guild_id) or await self.bot.fetch_guild(payload.guild_id)
  463. if not guild:
  464. return
  465. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  466. return
  467. channel = guild.get_channel(payload.channel_id) or await guild.fetch_channel(payload.channel_id)
  468. if not channel:
  469. return
  470. message = await channel.fetch_message(payload.message_id)
  471. if not message:
  472. return
  473. text = f'Message {message.jump_url} edited by {self.__describe_user(message.author)}.\n' + \
  474. 'Original markdown unavailable in cache.\n' + \
  475. f'Updated markdown:\n{self.__quote_markdown(message.content)}'
  476. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
  477. await bot_message.update()
  478. @commands.Cog.listener()
  479. async def on_raw_message_delete(self, payload: RawMessageDeleteEvent) -> None:
  480. """
  481. Called when a message is deleted. Unlike on_message_delete(), this is
  482. called regardless of the message being in the internal message cache or not.
  483. If the message is found in the message cache, it can be accessed via
  484. RawMessageDeleteEvent.cached_message
  485. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_message_delete
  486. """
  487. if payload.cached_message:
  488. message = payload.cached_message
  489. guild = message.guild
  490. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  491. return
  492. if message.author.id == self.bot.user.id:
  493. return
  494. text = f'Message by {self.__describe_user(message.author)} deleted from {message.channel.mention}. ' + \
  495. f'Markdown:\n{self.__quote_markdown(message.content)}'
  496. for attachment in message.attachments or []:
  497. text += f'\n> * 📎 {attachment.url}'
  498. for embed in message.embeds or []:
  499. text += f'\n> * 🔗 {embed.url}'
  500. bot_message = BotMessage(message.guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
  501. await bot_message.update()
  502. else:
  503. guild = self.bot.get_guild(payload.guild_id) or await self.bot.fetch_guild(payload.guild_id)
  504. if not guild:
  505. return
  506. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  507. return
  508. channel = guild.get_channel(payload.channel_id) or await guild.fetch_channel(payload.channel_id)
  509. if not channel:
  510. return
  511. text = f'Message {payload.message_id} deleted in ' + channel.mention + ' but content and author not available in cache.'
  512. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
  513. await bot_message.update()
  514. @commands.Cog.listener()
  515. async def on_raw_bulk_message_delete(self, payload: RawBulkMessageDeleteEvent) -> None:
  516. """
  517. Called when a bulk delete is triggered. Unlike on_bulk_message_delete(),
  518. this is called regardless of the messages being in the internal message
  519. cache or not.
  520. If the messages are found in the message cache, they can be accessed via
  521. RawBulkMessageDeleteEvent.cached_messages
  522. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_bulk_message_delete
  523. """
  524. guild = self.bot.get_guild(payload.guild_id) or await self.bot.fetch_guild(payload.guild_id)
  525. if not guild:
  526. return
  527. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  528. return
  529. channel = guild.get_channel(payload.channel_id) or await guild.fetch_channel(payload.channel_id)
  530. count = len(payload.message_ids)
  531. cached_count = len(payload.cached_messages)
  532. uncached_count = count - cached_count
  533. text = f'Bulk deletion of {count} message(s) from {channel.mention}.'
  534. if uncached_count == count:
  535. text += f' No cached content available for any of them.'
  536. elif uncached_count > 0:
  537. text += f' No cached content available for {uncached_count} of them.'
  538. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
  539. await bot_message.update()
  540. for message in payload.cached_messages:
  541. text = f'Message by {self.__describe_user(message.author)} bulk deleted from {message.channel.mention}. ' + \
  542. f'Markdown:\n{self.__quote_markdown(message.content)}'
  543. for attachment in message.attachments or []:
  544. text += f'\n> * 📎 {attachment.url}'
  545. for embed in message.embeds or []:
  546. text += f'\n> * 🔗 {embed.url}'
  547. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
  548. await bot_message.update()
  549. # Events - Roles
  550. @commands.Cog.listener()
  551. async def on_guild_role_create(self, role: Role) -> None:
  552. """
  553. Called when a Guild creates or deletes a new Role.
  554. To get the guild it belongs to, use Role.guild.
  555. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_role_create
  556. """
  557. guild = role.guild
  558. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  559. return
  560. text = f'Role created: **{role.name}**'
  561. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  562. await bot_message.update()
  563. @commands.Cog.listener()
  564. async def on_guild_role_delete(self, role: Role) -> None:
  565. """
  566. Called when a Guild creates or deletes a new Role.
  567. To get the guild it belongs to, use Role.guild.
  568. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_role_delete
  569. """
  570. guild = role.guild
  571. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  572. return
  573. text = f'Role removed: **{role.name}**'
  574. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  575. await bot_message.update()
  576. @commands.Cog.listener()
  577. async def on_guild_role_update(self, before: Role, after: Role) -> None:
  578. """
  579. Called when a Role is changed guild-wide.
  580. https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_role_update
  581. """
  582. guild = after.guild
  583. if not self.get_guild_setting(guild, self.SETTING_ENABLED):
  584. return
  585. changes = []
  586. if after.name != before.name:
  587. changes.append(f'Name: `{before.name}` -> `{after.name}`')
  588. if after.hoist != before.hoist:
  589. changes.append(f'Hoisted: `{before.hoist}` -> `{after.hoist}`')
  590. if after.position != before.position:
  591. changes.append(f'Position: `{before.position}` -> `{after.position}`')
  592. if after.unicode_emoji != before.unicode_emoji:
  593. changes.append(f'Emoji: {before.unicode_emoji} -> {after.unicode_emoji}')
  594. if after.mentionable != before.mentionable:
  595. changes.append(f'Mentionable: `{before.mentionable}` -> `{after.mentionable}`')
  596. if after.permissions != before.permissions:
  597. changes.append('Permissions edited')
  598. if after.color != before.color:
  599. changes.append('Color edited')
  600. before_icon_url = before.icon.url if before.icon else None
  601. after_icon_url = after.icon.url if after.icon else None
  602. if after_icon_url != before_icon_url:
  603. changes.append(f'Icon: <{before_icon_url}> -> <{after_icon_url}>')
  604. before_icon_url = before.display_icon.url if before.display_icon else None
  605. after_icon_url = after.display_icon.url if after.display_icon else None
  606. if after_icon_url != before_icon_url:
  607. changes.append(f'Display icon: <{before_icon_url}> -> <{after_icon_url}>')
  608. if len(changes) == 0:
  609. return
  610. text = f'Role **{after.name}** updated. Changes:\n'
  611. text += '* ' + '\n* '.join(changes)
  612. bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
  613. await bot_message.update()
  614. # Events - Threads
  615. @commands.Cog.listener()
  616. async def on_thread_create(self, thread: Thread) -> None:
  617. pass
  618. @commands.Cog.listener()
  619. async def on_thread_update(self, before: Thread, after: Thread) -> None:
  620. pass
  621. @commands.Cog.listener()
  622. async def on_thread_delete(self, thread: Thread) -> None:
  623. pass
  624. # ------------------------------------------------------------------------
  625. def __quote_markdown(self, s: str) -> str:
  626. if len(s.strip()) == 0:
  627. return '> _<no content>_'
  628. return '> ' + escape_markdown(s).replace('\n', '\n> ')
  629. def __describe_user(self, user: Union[User, Member]) -> str:
  630. """
  631. Standardized Markdown describing a user or member.
  632. """
  633. return f'**{user.name}** ({user.display_name} {user.id})'
  634. def __diff(self, a: str, b: str) -> Tuple[str, str]:
  635. # URLs don't work well in the diffs. Replace them with private use characters, one per unique URL.
  636. preserved_sequences = []
  637. def sub_token(match: re.Match) -> str:
  638. seq = match.group(0)
  639. sequence_index = len(preserved_sequences)
  640. if seq in preserved_sequences:
  641. sequence_index = preserved_sequences.index(seq)
  642. else:
  643. preserved_sequences.append(seq)
  644. return chr(0xe000 + sequence_index)
  645. url_regex = r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
  646. a = re.sub(url_regex, sub_token, a)
  647. b = re.sub(url_regex, sub_token, b)
  648. deletion_start = '~~'
  649. deletion_end = '~~'
  650. addition_start = '**'
  651. addition_end = '**'
  652. markdown_a = ''
  653. markdown_b = ''
  654. a_open = False
  655. b_open = False
  656. # FIXME: Handle URLs better. They get mangled.
  657. # URL regex: http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+
  658. for i, s in enumerate(difflib.ndiff(a, b)):
  659. operation = s[0]
  660. content = s[2:]
  661. if operation != '-' and a_open:
  662. markdown_a += deletion_end
  663. a_open = False
  664. if operation != '+' and b_open:
  665. markdown_b += addition_end
  666. b_open = False
  667. if operation == ' ':
  668. markdown_a += content
  669. markdown_b += content
  670. elif operation == '-':
  671. if not a_open:
  672. markdown_a += deletion_start
  673. a_open = True
  674. markdown_a += content
  675. elif operation == '+':
  676. if not b_open:
  677. markdown_b += addition_start
  678. b_open = True
  679. markdown_b += content
  680. if a_open:
  681. markdown_a += deletion_end
  682. if b_open:
  683. markdown_b += addition_end
  684. # Sub URLs back in
  685. def unsub_token(match: re.Match) -> str:
  686. char = match.group(0)
  687. index = ord(char) - 0xe000
  688. if 0 <= index < len(preserved_sequences):
  689. return preserved_sequences[index]
  690. return char
  691. markdown_a = re.sub(r'[\ue000-\uefff]', unsub_token, markdown_a)
  692. markdown_b = re.sub(r'[\ue000-\uefff]', unsub_token, markdown_b)
  693. return markdown_a, markdown_b