Experimental Discord bot written in Python
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

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