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 32KB

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