Experimental Discord bot written in Python
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

logcog.py 27KB

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