Experimental Discord bot written in Python
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

logcog.py 33KB

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