Experimental Discord bot written in Python
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

logcog.py 39KB

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