Experimental Discord bot written in Python
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

logcog.py 36KB

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