|
|
@@ -4,47 +4,36 @@ Cog for detecting large numbers of guild joins in a short period of time.
|
|
4
|
4
|
import weakref
|
|
5
|
5
|
from collections.abc import Sequence
|
|
6
|
6
|
from datetime import datetime, timedelta
|
|
7
|
|
-from discord import Emoji, Guild, GuildSticker, Invite, Member, Message, RawBulkMessageDeleteEvent, RawMessageDeleteEvent, RawMessageUpdateEvent, Role, Thread, User
|
|
|
7
|
+from discord import AuditLogAction, AuditLogEntry, Emoji, Guild, GuildSticker, Invite, Member, Message, RawBulkMessageDeleteEvent, RawMessageDeleteEvent, RawMessageUpdateEvent, Role, Thread, User
|
|
8
|
8
|
from discord.abc import GuildChannel
|
|
9
|
9
|
from discord.ext import commands
|
|
10
|
10
|
from discord.utils import escape_markdown
|
|
11
|
|
-from typing import List, Union
|
|
|
11
|
+from typing import List, Optional, Union
|
|
|
12
|
+import traceback
|
|
12
|
13
|
|
|
13
|
14
|
from config import CONFIG
|
|
14
|
15
|
from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
|
|
15
|
16
|
from rocketbot.collections import AgeBoundList
|
|
16
|
17
|
from rocketbot.storage import Storage
|
|
17
|
18
|
|
|
18
|
|
-class LogCog(BaseCog, name='Logging'):
|
|
|
19
|
+class LoggingCog(BaseCog, name='Logging'):
|
|
19
|
20
|
"""
|
|
20
|
21
|
Cog for logging notable events to a designated logging channel.
|
|
21
|
22
|
"""
|
|
22
|
23
|
SETTING_ENABLED = CogSetting('enabled', bool,
|
|
23
|
24
|
brief='logging',
|
|
24
|
25
|
description='Whether this cog is enabled for a guild.')
|
|
25
|
|
- SETTING_EDITS_ENABLED = CogSetting('edits_enabled', bool,
|
|
26
|
|
- brief='post edits',
|
|
27
|
|
- description='Whether to log when users edit their posts.')
|
|
28
|
|
- SETTING_JOINS_ENABLED = CogSetting('joins_enabled', bool,
|
|
29
|
|
- brief='joins',
|
|
30
|
|
- description='Whether to log when new users join the server.')
|
|
31
|
|
- SETTING_LEAVES_ENABLED = CogSetting('leaves_enabled', bool,
|
|
32
|
|
- brief='leaves',
|
|
33
|
|
- description='Whether to log when users leave the server.')
|
|
34
|
26
|
|
|
35
|
27
|
def __init__(self, bot):
|
|
36
|
28
|
super().__init__(bot)
|
|
37
|
|
- self.add_setting(LogCog.SETTING_ENABLED)
|
|
38
|
|
- self.add_setting(LogCog.SETTING_EDITS_ENABLED)
|
|
39
|
|
- self.add_setting(LogCog.SETTING_JOINS_ENABLED)
|
|
40
|
|
- self.add_setting(LogCog.SETTING_LEAVES_ENABLED)
|
|
|
29
|
+ self.add_setting(LoggingCog.SETTING_ENABLED)
|
|
41
|
30
|
|
|
42
|
31
|
@commands.group(
|
|
43
|
32
|
brief='Manages event logging',
|
|
44
|
33
|
)
|
|
45
|
34
|
@commands.has_permissions(ban_members=True)
|
|
46
|
35
|
@commands.guild_only()
|
|
47
|
|
- async def log(self, context: commands.Context):
|
|
|
36
|
+ async def logging(self, context: commands.Context):
|
|
48
|
37
|
'Logging command group'
|
|
49
|
38
|
if context.invoked_subcommand is None:
|
|
50
|
39
|
await context.send_help()
|
|
|
@@ -53,15 +42,66 @@ class LogCog(BaseCog, name='Logging'):
|
|
53
|
42
|
|
|
54
|
43
|
@commands.Cog.listener()
|
|
55
|
44
|
async def on_guild_channel_delete(self, channel: GuildChannel) -> None:
|
|
56
|
|
- pass
|
|
|
45
|
+ """
|
|
|
46
|
+ Called whenever a guild channel is deleted or created.
|
|
|
47
|
+
|
|
|
48
|
+ Note that you can get the guild from guild.
|
|
|
49
|
+
|
|
|
50
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_channel_delete
|
|
|
51
|
+ """
|
|
|
52
|
+ guild = channel.guild
|
|
|
53
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
54
|
+ return
|
|
|
55
|
+ text = f'Channel **{channel.name}** deleted.'
|
|
|
56
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
57
|
+ await bot_message.update()
|
|
57
|
58
|
|
|
58
|
59
|
@commands.Cog.listener()
|
|
59
|
60
|
async def on_guild_channel_create(self, channel: GuildChannel) -> None:
|
|
60
|
|
- pass
|
|
|
61
|
+ """
|
|
|
62
|
+ Called whenever a guild channel is deleted or created.
|
|
|
63
|
+
|
|
|
64
|
+ Note that you can get the guild from guild.
|
|
|
65
|
+
|
|
|
66
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_channel_create
|
|
|
67
|
+ """
|
|
|
68
|
+ guild = channel.guild
|
|
|
69
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
70
|
+ return
|
|
|
71
|
+ text = f'Channel **{channel.name}** created. {channel.mention}'
|
|
|
72
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
73
|
+ await bot_message.update()
|
|
61
|
74
|
|
|
62
|
75
|
@commands.Cog.listener()
|
|
63
|
76
|
async def on_guild_channel_update(self, before: GuildChannel, after: GuildChannel) -> None:
|
|
64
|
|
- pass
|
|
|
77
|
+ """
|
|
|
78
|
+ Called whenever a guild channel is updated. e.g. changed name, topic,
|
|
|
79
|
+ permissions.
|
|
|
80
|
+
|
|
|
81
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_channel_update
|
|
|
82
|
+ """
|
|
|
83
|
+ guild = after.guild
|
|
|
84
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
85
|
+ return
|
|
|
86
|
+ changes = []
|
|
|
87
|
+ if after.name != before.name:
|
|
|
88
|
+ changes.append(f'Name: `{before.name}` -> `{after.name}`')
|
|
|
89
|
+ if after.category != before.category:
|
|
|
90
|
+ changes.append(f'Category: {before.category.name if before.category else None}' + \
|
|
|
91
|
+ f' -> {after.category.name if after.category else None}')
|
|
|
92
|
+ if after.changed_roles != before.changed_roles:
|
|
|
93
|
+ changes.append('Roles changed')
|
|
|
94
|
+ if after.overwrites != before.overwrites:
|
|
|
95
|
+ changes.append('Permission overwrites changed')
|
|
|
96
|
+ if after.position != before.position:
|
|
|
97
|
+ changes.append(f'Position: {before.position} -> {after.position}')
|
|
|
98
|
+
|
|
|
99
|
+ if len(changes) == 0:
|
|
|
100
|
+ return
|
|
|
101
|
+ text = f'Channel **{before.name}** updated. Changes:\n'
|
|
|
102
|
+ text += '* ' + '\n* '.join(changes)
|
|
|
103
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
104
|
+ await bot_message.update()
|
|
65
|
105
|
|
|
66
|
106
|
# Events - Guilds
|
|
67
|
107
|
|
|
|
@@ -87,87 +127,410 @@ class LogCog(BaseCog, name='Logging'):
|
|
87
|
127
|
|
|
88
|
128
|
@commands.Cog.listener()
|
|
89
|
129
|
async def on_invite_create(self, invite: Invite) -> None:
|
|
90
|
|
- pass
|
|
|
130
|
+ """
|
|
|
131
|
+ Called when an Invite is created. You must have manage_channels to receive this.
|
|
|
132
|
+
|
|
|
133
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_invite_create
|
|
|
134
|
+ """
|
|
|
135
|
+ guild = invite.guild
|
|
|
136
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
137
|
+ return
|
|
|
138
|
+ text = f'Invite code `{invite.code}` created by {self.__describe_user(invite.inviter)}. '
|
|
|
139
|
+ if invite.max_age == 0:
|
|
|
140
|
+ text += "Doesn't expire."
|
|
|
141
|
+ else:
|
|
|
142
|
+ text += f'Expires in {invite.max_age} seconds.'
|
|
|
143
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
144
|
+ await bot_message.update()
|
|
91
|
145
|
|
|
92
|
146
|
@commands.Cog.listener()
|
|
93
|
147
|
async def on_invite_delete(self, invite: Invite) -> None:
|
|
94
|
|
- pass
|
|
|
148
|
+ """
|
|
|
149
|
+ Called when an Invite is deleted. You must have manage_channels to receive this.
|
|
|
150
|
+
|
|
|
151
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_invite_delete
|
|
|
152
|
+ """
|
|
|
153
|
+ guild = invite.guild
|
|
|
154
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
155
|
+ return
|
|
|
156
|
+ if invite.inviter:
|
|
|
157
|
+ text = f'Invite code `{invite.code}` deleted. Originally created by {self.__describe_user(invite.inviter)}.'
|
|
|
158
|
+ else:
|
|
|
159
|
+ text = f'Invite code `{invite.code}` deleted.'
|
|
|
160
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
161
|
+ await bot_message.update()
|
|
95
|
162
|
|
|
96
|
163
|
# Events - Members
|
|
97
|
164
|
|
|
98
|
165
|
@commands.Cog.listener()
|
|
99
|
166
|
async def on_member_join(self, member: Member) -> None:
|
|
100
|
|
- pass
|
|
|
167
|
+ """
|
|
|
168
|
+ Called when a Member joins a Guild.
|
|
|
169
|
+
|
|
|
170
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_join
|
|
|
171
|
+ """
|
|
|
172
|
+ guild = member.guild
|
|
|
173
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
174
|
+ return
|
|
|
175
|
+ text = f'Member joined server: {self.__describe_user(member)}.'
|
|
|
176
|
+ flags = []
|
|
|
177
|
+ noteworthy = False
|
|
|
178
|
+
|
|
|
179
|
+ if member.flags.did_rejoin:
|
|
|
180
|
+ flags.append('Rejoined this server')
|
|
|
181
|
+
|
|
|
182
|
+ if member.public_flags.active_developer:
|
|
|
183
|
+ flags.append('Is an active developer')
|
|
|
184
|
+ if member.public_flags.hypesquad:
|
|
|
185
|
+ flags.append('Is a HypeSquad Events member')
|
|
|
186
|
+ if member.public_flags.hypesquad_bravery:
|
|
|
187
|
+ flags.append('Is a HypeSquad Bravery member')
|
|
|
188
|
+ if member.public_flags.hypesquad_brilliance:
|
|
|
189
|
+ flags.append('Is a HypeSquad Brilliance member')
|
|
|
190
|
+ if member.public_flags.hypesquad_balance:
|
|
|
191
|
+ flags.append('Is a HypeSquad Balance member')
|
|
|
192
|
+ if member.public_flags.early_supporter:
|
|
|
193
|
+ flags.append('Is an early supporter')
|
|
|
194
|
+
|
|
|
195
|
+ if member.public_flags.spammer:
|
|
|
196
|
+ flags.append('**Is flagged as a spammer**')
|
|
|
197
|
+ noteworthy = True
|
|
|
198
|
+ if member.public_flags.discord_certified_moderator:
|
|
|
199
|
+ flags.append('**Is a Discord Certified Moderator**')
|
|
|
200
|
+ noteworthy = True
|
|
|
201
|
+ if member.public_flags.early_verified_bot_developer:
|
|
|
202
|
+ flags.append('**Is a verified bot developer**')
|
|
|
203
|
+ noteworthy = True
|
|
|
204
|
+ if member.public_flags.verified_bot:
|
|
|
205
|
+ flags.append('**Is a verified bot**')
|
|
|
206
|
+ noteworthy = True
|
|
|
207
|
+ if member.public_flags.bug_hunter or member.public_flags.bug_hunter_level_2:
|
|
|
208
|
+ flags.append('**Is a bug hunter**')
|
|
|
209
|
+ noteworthy = True
|
|
|
210
|
+ if member.public_flags.system:
|
|
|
211
|
+ flags.append('**Is a Discord system user**')
|
|
|
212
|
+ noteworthy = True
|
|
|
213
|
+ if member.public_flags.staff:
|
|
|
214
|
+ flags.append('**Is Discord staff**')
|
|
|
215
|
+ noteworthy = True
|
|
|
216
|
+ if member.public_flags.partner:
|
|
|
217
|
+ flags.append('**Is a Discord partner**')
|
|
|
218
|
+ noteworthy = True
|
|
|
219
|
+
|
|
|
220
|
+ if len(flags) > 0:
|
|
|
221
|
+ text += '\n* ' + '\n* '.join(flags)
|
|
|
222
|
+ if noteworthy:
|
|
|
223
|
+ text += f'\n\nLink: {member.mention}'
|
|
|
224
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_MOD_WARNING)
|
|
|
225
|
+ else:
|
|
|
226
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
227
|
+ await bot_message.update()
|
|
101
|
228
|
|
|
102
|
229
|
@commands.Cog.listener()
|
|
103
|
230
|
async def on_member_remove(self, member: Member) -> None:
|
|
104
|
|
- pass
|
|
|
231
|
+ """
|
|
|
232
|
+ Called when a Member leaves a Guild.
|
|
|
233
|
+
|
|
|
234
|
+ If the guild or member could not be found in the internal cache this event
|
|
|
235
|
+ will not be called, you may use on_raw_member_remove() instead.
|
|
|
236
|
+
|
|
|
237
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_remove
|
|
|
238
|
+ """
|
|
|
239
|
+ guild = member.guild
|
|
|
240
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
241
|
+ return
|
|
|
242
|
+ is_kick = False
|
|
|
243
|
+ kicker = None
|
|
|
244
|
+ kick_reason = None
|
|
|
245
|
+ entry = await self.__find_audit_entry(member, AuditLogAction.kick)
|
|
|
246
|
+ if entry:
|
|
|
247
|
+ is_kick = True
|
|
|
248
|
+ kicker = entry.user
|
|
|
249
|
+ kick_reason = entry.reason
|
|
|
250
|
+
|
|
|
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()
|
|
105
|
262
|
|
|
106
|
263
|
@commands.Cog.listener()
|
|
107
|
264
|
async def on_member_update(self, before: Member, after: Member) -> None:
|
|
108
|
|
- pass
|
|
|
265
|
+ """
|
|
|
266
|
+ Called when a Member updates their profile.
|
|
|
267
|
+
|
|
|
268
|
+ This is called when one or more of the following things change:
|
|
|
269
|
+ * nickname
|
|
|
270
|
+ * roles
|
|
|
271
|
+ * pending
|
|
|
272
|
+ * timeout
|
|
|
273
|
+ * guild avatar
|
|
|
274
|
+ * flags
|
|
|
275
|
+
|
|
|
276
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_update
|
|
|
277
|
+ """
|
|
|
278
|
+ guild = after.guild
|
|
|
279
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
280
|
+ return
|
|
|
281
|
+ changes = []
|
|
|
282
|
+ if after.nick != before.nick:
|
|
|
283
|
+ changes.append(f'Nick: `{before.nick}` -> `{after.nick}`')
|
|
|
284
|
+ if after.roles != before.roles:
|
|
|
285
|
+ added_role_names = []
|
|
|
286
|
+ removed_role_names = []
|
|
|
287
|
+ for role in before.roles:
|
|
|
288
|
+ if role not in after.roles:
|
|
|
289
|
+ removed_role_names.append(role.name)
|
|
|
290
|
+ for role in after.roles:
|
|
|
291
|
+ if role not in before.roles:
|
|
|
292
|
+ added_role_names.append(role.name)
|
|
|
293
|
+ if len(removed_role_names) > 0:
|
|
|
294
|
+ changes.append(f'Removed roles: ~~**{"**~~, ~~**".joined(removed_role_names)}**~~')
|
|
|
295
|
+ if len(added_role_names) > 0:
|
|
|
296
|
+ changes.append(f'Added roles: **{"**, **".joined(added_role_names)}**')
|
|
|
297
|
+ if after.pending != before.pending:
|
|
|
298
|
+ pass # not that interesting and probably noisy
|
|
|
299
|
+ if after.timed_out_until != before.timed_out_until:
|
|
|
300
|
+ if after.timed_out_until:
|
|
|
301
|
+ delta = after.timed_out_until - datetime.now()
|
|
|
302
|
+ changes.append(f'Timed out for `{delta}`')
|
|
|
303
|
+ elif before.timed_out_until:
|
|
|
304
|
+ changes.append('Timeout cleared')
|
|
|
305
|
+ before_guild_avatar = before.guild_avatar.url if before.guild_avatar else None
|
|
|
306
|
+ after_guild_avatar = after.guild_avatar.url if after.guild_avatar else None
|
|
|
307
|
+ if after_guild_avatar != before_guild_avatar:
|
|
|
308
|
+ changes.append(f'Guild avatar: <{before_guild_avatar}> -> <{after_guild_avatar}>')
|
|
|
309
|
+ if after.flags != before.flags:
|
|
|
310
|
+ flag_changes = []
|
|
|
311
|
+ for (name, after_value) in after.iter():
|
|
|
312
|
+ before_value = getattr(before, name)
|
|
|
313
|
+ if after_value != before_value:
|
|
|
314
|
+ flag_changes.append(f'`{name}` = `{before_value}` -> `{after_value}`')
|
|
|
315
|
+ if len(flag_changes) > 0:
|
|
|
316
|
+ changes.append(f'Flag changes: {", ".join(flag_changes)}')
|
|
|
317
|
+
|
|
|
318
|
+ if len(changes) == 0:
|
|
|
319
|
+ return
|
|
|
320
|
+ text = f'Details for member {self.__describe_user(before)} changed:\n'
|
|
|
321
|
+ text += '* ' + '\n* '.join(changes)
|
|
|
322
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
323
|
+ await bot_message.update()
|
|
109
|
324
|
|
|
110
|
325
|
@commands.Cog.listener()
|
|
111
|
326
|
async def on_user_update(self, before: User, after: User) -> None:
|
|
112
|
|
- pass
|
|
|
327
|
+ """
|
|
|
328
|
+ Called when a User updates their profile.
|
|
|
329
|
+
|
|
|
330
|
+ This is called when one or more of the following things change:
|
|
|
331
|
+ * avatar
|
|
|
332
|
+ * username
|
|
|
333
|
+ * discriminator
|
|
|
334
|
+
|
|
|
335
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_user_update
|
|
|
336
|
+ """
|
|
|
337
|
+ if hasattr(after, 'guild'):
|
|
|
338
|
+ guild = after.guild
|
|
|
339
|
+ else:
|
|
|
340
|
+ return
|
|
|
341
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
342
|
+ return
|
|
|
343
|
+ changes = []
|
|
|
344
|
+ before_avatar_url = before.avatar.url if before.avatar else None
|
|
|
345
|
+ after_avatar_url = after.avatar.url if after.avatar else None
|
|
|
346
|
+ if after_avatar_url != before_avatar_url:
|
|
|
347
|
+ changes.append(f'Avatar URL: <{before_avatar_url}> -> <{after_avatar_url}>')
|
|
|
348
|
+ if after.name != before.name:
|
|
|
349
|
+ changes.append(f'Username: `{before.name}` -> `{after.name}`')
|
|
|
350
|
+ if len(changes) == 0:
|
|
|
351
|
+ return
|
|
|
352
|
+ text = f'Details for user {self.__describe_user(before)} changed:\n'
|
|
|
353
|
+ text += '* ' + '\n* '.join(changes)
|
|
|
354
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
355
|
+ await bot_message.update()
|
|
113
|
356
|
|
|
114
|
357
|
@commands.Cog.listener()
|
|
115
|
358
|
async def on_member_ban(self, user: Union[User, Member]) -> None:
|
|
116
|
|
- pass
|
|
|
359
|
+ """
|
|
|
360
|
+ Called when user gets banned from a Guild.
|
|
|
361
|
+
|
|
|
362
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_ban
|
|
|
363
|
+ """
|
|
|
364
|
+ if hasattr(user, 'guild'):
|
|
|
365
|
+ guild = user
|
|
|
366
|
+ else:
|
|
|
367
|
+ return
|
|
|
368
|
+ guild = user.guild
|
|
|
369
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
370
|
+ return
|
|
|
371
|
+ banner = None
|
|
|
372
|
+ ban_reason = None
|
|
|
373
|
+ entry = await self.__find_audit_entry(user, AuditLogAction.ban)
|
|
|
374
|
+ if entry:
|
|
|
375
|
+ banner = entry.user
|
|
|
376
|
+ ban_reason = entry.reason
|
|
|
377
|
+ if banner:
|
|
|
378
|
+ text = f'Member {self.__describe_user(user)} banned by **{banner.name}**.'
|
|
|
379
|
+ if ban_reason:
|
|
|
380
|
+ text += f'\nReason: "{ban_reason}"'
|
|
|
381
|
+ else:
|
|
|
382
|
+ text = f'Member {self.__describe_user(user)} banned.'
|
|
|
383
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
384
|
+ await bot_message.update()
|
|
|
385
|
+
|
|
|
386
|
+ async def __find_audit_entry(self, user: Union[User, Member], action: AuditLogAction, max_age: int = 10) -> Optional[AuditLogEntry]:
|
|
|
387
|
+ """
|
|
|
388
|
+ Searches the audit log for the most recent entry of a given type for a
|
|
|
389
|
+ given user. Intended for finding the relevant entry for a ban/kick that
|
|
|
390
|
+ just occurred.
|
|
|
391
|
+ """
|
|
|
392
|
+ if hasattr(user, 'guild') and user.guild:
|
|
|
393
|
+ guild = user.guild
|
|
|
394
|
+ else:
|
|
|
395
|
+ return None
|
|
|
396
|
+ now = datetime.now()
|
|
|
397
|
+ async for entry in guild.audit_logs():
|
|
|
398
|
+ age_seconds = now.timestamp() - entry.created_at.timestamp()
|
|
|
399
|
+ if entry.action == action and entry.target == user and age_seconds <= max_age:
|
|
|
400
|
+ return entry
|
|
|
401
|
+ return None
|
|
117
|
402
|
|
|
118
|
403
|
@commands.Cog.listener()
|
|
119
|
404
|
async def on_member_unban(self, guild: Guild, user: Union[User, Member]) -> None:
|
|
120
|
|
- pass
|
|
|
405
|
+ """
|
|
|
406
|
+ Called when a User gets unbanned from a Guild.
|
|
|
407
|
+
|
|
|
408
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_member_unban
|
|
|
409
|
+ """
|
|
|
410
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
411
|
+ return
|
|
|
412
|
+ text = f'Member {self.__describe_user(user)} unbanned'
|
|
|
413
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
414
|
+ await bot_message.update()
|
|
121
|
415
|
|
|
122
|
416
|
# Events - Messages
|
|
123
|
417
|
|
|
124
|
418
|
@commands.Cog.listener()
|
|
125
|
|
- async def on_message(self, message: Message):
|
|
|
419
|
+ async def on_message(self, message: Message) -> None:
|
|
|
420
|
+ """
|
|
|
421
|
+ Called when a Message is created and sent.
|
|
|
422
|
+
|
|
|
423
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_message
|
|
|
424
|
+ """
|
|
126
|
425
|
# print(f"Saw message {message.id} \"{message.content}\"")
|
|
127
|
|
- pass
|
|
128
|
426
|
|
|
129
|
427
|
@commands.Cog.listener()
|
|
130
|
428
|
async def on_message_edit(self, before: Message, after: Message) -> None:
|
|
131
|
|
- text = f'Message {after.jump_url} edited by **{after.author.name}** ({after.author.display_name} {after.author.id}).\n\n' + \
|
|
132
|
|
- f'Original markdown:\n> {escape_markdown(before.content)}\n\n' + \
|
|
133
|
|
- f'Updated markdown:\n> {escape_markdown(after.content)}'
|
|
134
|
|
- bot_message = BotMessage(after.guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
|
|
|
429
|
+ """
|
|
|
430
|
+ Called when a Message receives an update event. If the message is not
|
|
|
431
|
+ found in the internal message cache, then these events will not be
|
|
|
432
|
+ called. Messages might not be in cache if the message is too old or the
|
|
|
433
|
+ client is participating in high traffic guilds.
|
|
|
434
|
+
|
|
|
435
|
+ If this occurs increase the max_messages parameter or use the
|
|
|
436
|
+ on_raw_message_edit() event instead.
|
|
|
437
|
+
|
|
|
438
|
+ The following non-exhaustive cases trigger this event:
|
|
|
439
|
+ * A message has been pinned or unpinned.
|
|
|
440
|
+ * The message content has been changed.
|
|
|
441
|
+ * The message has received an embed.
|
|
|
442
|
+ * For performance reasons, the embed server does not do this in a
|
|
|
443
|
+ “consistent” manner.
|
|
|
444
|
+ * The message’s embeds were suppressed or unsuppressed.
|
|
|
445
|
+ * A call message has received an update to its participants or ending time.
|
|
|
446
|
+
|
|
|
447
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_message_edit
|
|
|
448
|
+ """
|
|
|
449
|
+ guild = after.guild
|
|
|
450
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
451
|
+ return
|
|
|
452
|
+ if after.author.id == self.bot.user.id:
|
|
|
453
|
+ return
|
|
|
454
|
+ if after.content == before.content:
|
|
|
455
|
+ # Most likely an embed being updated
|
|
|
456
|
+ return
|
|
|
457
|
+ text = f'Message {after.jump_url} edited by {self.__describe_user(after.author)}.\n' + \
|
|
|
458
|
+ f'Original markdown:\n{self.__quote_markdown(before.content)}\n' + \
|
|
|
459
|
+ f'Updated markdown:\n{self.__quote_markdown(after.content)}'
|
|
|
460
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
|
|
135
|
461
|
await bot_message.update()
|
|
136
|
462
|
|
|
137
|
463
|
@commands.Cog.listener()
|
|
138
|
464
|
async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
|
|
|
465
|
+ """
|
|
|
466
|
+ Called when a message is edited. Unlike on_message_edit(), this is called
|
|
|
467
|
+ regardless of the state of the internal message cache.
|
|
|
468
|
+
|
|
|
469
|
+ If the message is found in the message cache, it can be accessed via
|
|
|
470
|
+ RawMessageUpdateEvent.cached_message. The cached message represents the
|
|
|
471
|
+ message before it has been edited. For example, if the content of a
|
|
|
472
|
+ message is modified and triggers the on_raw_message_edit() coroutine,
|
|
|
473
|
+ the RawMessageUpdateEvent.cached_message will return a Message object
|
|
|
474
|
+ that represents the message before the content was modified.
|
|
|
475
|
+
|
|
|
476
|
+ Due to the inherently raw nature of this event, the data parameter
|
|
|
477
|
+ coincides with the raw data given by the gateway.
|
|
|
478
|
+
|
|
|
479
|
+ Since the data payload can be partial, care must be taken when accessing
|
|
|
480
|
+ stuff in the dictionary. One example of a common case of partial data is
|
|
|
481
|
+ when the 'content' key is inaccessible. This denotes an “embed” only
|
|
|
482
|
+ edit, which is an edit in which only the embeds are updated by the
|
|
|
483
|
+ Discord embed server.
|
|
|
484
|
+
|
|
|
485
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_message_edit
|
|
|
486
|
+ """
|
|
139
|
487
|
if payload.cached_message:
|
|
140
|
488
|
return # already handled by on_message_edit
|
|
141
|
489
|
guild = await self.bot.fetch_guild(payload.guild_id)
|
|
142
|
490
|
if not guild:
|
|
143
|
491
|
return
|
|
|
492
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
493
|
+ return
|
|
144
|
494
|
channel = await guild.fetch_channel(payload.channel_id)
|
|
145
|
495
|
if not channel:
|
|
146
|
496
|
return
|
|
147
|
497
|
message = await channel.fetch_message(payload.message_id)
|
|
148
|
498
|
if not message:
|
|
149
|
499
|
return
|
|
150
|
|
- text = f'Message {message.jump_url} edited by **{message.author.name}** ({message.author.display_name} {message.author.id}).\n\n' + \
|
|
151
|
|
- 'Original markdown unavailable in cache.\n\n' + \
|
|
152
|
|
- f'Updated markdown:\n> {escape_markdown(message.content)}'
|
|
|
500
|
+ text = f'Message {message.jump_url} edited by {self.__describe_user(message.author)}.\n' + \
|
|
|
501
|
+ 'Original markdown unavailable in cache.\n' + \
|
|
|
502
|
+ f'Updated markdown:\n{self.__quote_markdown(message.content)}'
|
|
153
|
503
|
bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
|
|
154
|
504
|
await bot_message.update()
|
|
155
|
505
|
|
|
156
|
506
|
@commands.Cog.listener()
|
|
157
|
507
|
async def on_raw_message_delete(self, payload: RawMessageDeleteEvent) -> None:
|
|
158
|
|
- print('Raw message deleted')
|
|
|
508
|
+ """
|
|
|
509
|
+ Called when a message is deleted. Unlike on_message_delete(), this is
|
|
|
510
|
+ called regardless of the message being in the internal message cache or not.
|
|
|
511
|
+
|
|
|
512
|
+ If the message is found in the message cache, it can be accessed via
|
|
|
513
|
+ RawMessageDeleteEvent.cached_message
|
|
|
514
|
+
|
|
|
515
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_message_delete
|
|
|
516
|
+ """
|
|
159
|
517
|
if payload.cached_message:
|
|
160
|
518
|
message = payload.cached_message
|
|
161
|
|
- text = f'Message by **{message.author.name}** ({message.author.display_name} {message.author.id}) deleted from {message.channel.mention}\n\n' + \
|
|
162
|
|
- f'Markdown:\n> {escape_markdown(message.content)}'
|
|
|
519
|
+ guild = message.guild
|
|
|
520
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
521
|
+ return
|
|
|
522
|
+ if message.author.id == self.bot.user.id:
|
|
|
523
|
+ return
|
|
|
524
|
+ text = f'Message by {self.__describe_user(message.author)} deleted from {message.channel.mention}. ' + \
|
|
|
525
|
+ f'Markdown:\n{self.__quote_markdown(message.content)}'
|
|
163
|
526
|
bot_message = BotMessage(message.guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
|
|
164
|
527
|
await bot_message.update()
|
|
165
|
528
|
else:
|
|
166
|
|
- print(f'Looking up guild {payload.guild_id}')
|
|
167
|
529
|
guild = await self.bot.fetch_guild(payload.guild_id)
|
|
168
|
530
|
if not guild:
|
|
169
|
531
|
return
|
|
170
|
|
- print(f'Looking up channel {payload.channel_id}')
|
|
|
532
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
533
|
+ return
|
|
171
|
534
|
channel = await guild.fetch_channel(payload.channel_id)
|
|
172
|
535
|
if not channel:
|
|
173
|
536
|
return
|
|
|
@@ -177,9 +540,21 @@ class LogCog(BaseCog, name='Logging'):
|
|
177
|
540
|
|
|
178
|
541
|
@commands.Cog.listener()
|
|
179
|
542
|
async def on_raw_bulk_message_delete(self, payload: RawBulkMessageDeleteEvent) -> None:
|
|
|
543
|
+ """
|
|
|
544
|
+ Called when a bulk delete is triggered. Unlike on_bulk_message_delete(),
|
|
|
545
|
+ this is called regardless of the messages being in the internal message
|
|
|
546
|
+ cache or not.
|
|
|
547
|
+
|
|
|
548
|
+ If the messages are found in the message cache, they can be accessed via
|
|
|
549
|
+ RawBulkMessageDeleteEvent.cached_messages
|
|
|
550
|
+
|
|
|
551
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_raw_bulk_message_delete
|
|
|
552
|
+ """
|
|
180
|
553
|
guild = await self.bot.fetch_guild(payload.guild_id)
|
|
181
|
554
|
if not guild:
|
|
182
|
555
|
return
|
|
|
556
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
557
|
+ return
|
|
183
|
558
|
channel = await guild.fetch_channel(payload.channel_id)
|
|
184
|
559
|
count = len(payload.message_ids)
|
|
185
|
560
|
cached_count = len(payload.cached_messages)
|
|
|
@@ -193,8 +568,8 @@ class LogCog(BaseCog, name='Logging'):
|
|
193
|
568
|
await bot_message.update()
|
|
194
|
569
|
|
|
195
|
570
|
for message in payload.cached_messages:
|
|
196
|
|
- text = f'Message by **{message.author.name}** ({message.author.display_name} {message.author.id}) bulk deleted from {message.channel.mention}\n\n' + \
|
|
197
|
|
- f'Markdown:\n> {escape_markdown(message.content)}'
|
|
|
571
|
+ text = f'Message by {self.__describe_user(message.author)} bulk deleted from {message.channel.mention}. ' + \
|
|
|
572
|
+ f'Markdown:\n{self.__quote_markdown(message.content)}'
|
|
198
|
573
|
bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
199
|
574
|
await bot_message.update()
|
|
200
|
575
|
|
|
|
@@ -202,15 +577,76 @@ class LogCog(BaseCog, name='Logging'):
|
|
202
|
577
|
|
|
203
|
578
|
@commands.Cog.listener()
|
|
204
|
579
|
async def on_guild_role_create(self, role: Role) -> None:
|
|
205
|
|
- pass
|
|
|
580
|
+ """
|
|
|
581
|
+ Called when a Guild creates or deletes a new Role.
|
|
|
582
|
+
|
|
|
583
|
+ To get the guild it belongs to, use Role.guild.
|
|
|
584
|
+
|
|
|
585
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_role_create
|
|
|
586
|
+ """
|
|
|
587
|
+ guild = role.guild
|
|
|
588
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
589
|
+ return
|
|
|
590
|
+ text = f'Role created: **{role.name}**'
|
|
|
591
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
592
|
+ await bot_message.update()
|
|
206
|
593
|
|
|
207
|
594
|
@commands.Cog.listener()
|
|
208
|
595
|
async def on_guild_role_delete(self, role: Role) -> None:
|
|
209
|
|
- pass
|
|
|
596
|
+ """
|
|
|
597
|
+ Called when a Guild creates or deletes a new Role.
|
|
|
598
|
+
|
|
|
599
|
+ To get the guild it belongs to, use Role.guild.
|
|
|
600
|
+
|
|
|
601
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_role_delete
|
|
|
602
|
+ """
|
|
|
603
|
+ guild = role.guild
|
|
|
604
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
605
|
+ return
|
|
|
606
|
+ text = f'Role removed: **{role.name}**'
|
|
|
607
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
608
|
+ await bot_message.update()
|
|
210
|
609
|
|
|
211
|
610
|
@commands.Cog.listener()
|
|
212
|
611
|
async def on_guild_role_update(self, before: Role, after: Role) -> None:
|
|
213
|
|
- pass
|
|
|
612
|
+ """
|
|
|
613
|
+ Called when a Role is changed guild-wide.
|
|
|
614
|
+
|
|
|
615
|
+ https://discordpy.readthedocs.io/en/stable/api.html#discord.on_guild_role_update
|
|
|
616
|
+ """
|
|
|
617
|
+ guild = after.guild
|
|
|
618
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
619
|
+ return
|
|
|
620
|
+ changes = []
|
|
|
621
|
+ if after.name != before.name:
|
|
|
622
|
+ changes.append(f'Name: `{before.name}` -> `{after.name}`')
|
|
|
623
|
+ if after.hoist != before.hoist:
|
|
|
624
|
+ changes.append(f'Hoisted: `{before.hoist}` -> `{after.hoist}`')
|
|
|
625
|
+ if after.position != before.position:
|
|
|
626
|
+ changes.append(f'Position: `{before.position}` -> `{after.position}`')
|
|
|
627
|
+ if after.unicode_emoji != before.unicode_emoji:
|
|
|
628
|
+ changes.append(f'Emoji: {before.unicode_emoji} -> {after.unicode_emoji}')
|
|
|
629
|
+ if after.mentionable != before.mentionable:
|
|
|
630
|
+ changes.append(f'Mentionable: `{before.mentionable}` -> `{after.mentionable}`')
|
|
|
631
|
+ if after.permissions != before.permissions:
|
|
|
632
|
+ changes.append('Permissions edited')
|
|
|
633
|
+ if after.color != before.color:
|
|
|
634
|
+ changes.append('Color edited')
|
|
|
635
|
+ before_icon_url = before.icon.url if before.icon else None
|
|
|
636
|
+ after_icon_url = after.icon.url if after.icon else None
|
|
|
637
|
+ if after_icon_url != before_icon_url:
|
|
|
638
|
+ changes.append(f'Icon: <{before_icon_url}> -> <{after_icon_url}>')
|
|
|
639
|
+ before_icon_url = before.display_icon.url if before.display_icon else None
|
|
|
640
|
+ after_icon_url = after.display_icon.url if after.display_icon else None
|
|
|
641
|
+ if after_icon_url != before_icon_url:
|
|
|
642
|
+ changes.append(f'Display icon: <{before_icon_url}> -> <{after_icon_url}>')
|
|
|
643
|
+
|
|
|
644
|
+ if len(changes) == 0:
|
|
|
645
|
+ return
|
|
|
646
|
+ text = 'Role updated. Changes:\n'
|
|
|
647
|
+ text += '* ' + '\n* '.join(changes)
|
|
|
648
|
+ bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
|
|
|
649
|
+ await bot_message.update()
|
|
214
|
650
|
|
|
215
|
651
|
# Events - Threads
|
|
216
|
652
|
|
|
|
@@ -228,59 +664,11 @@ class LogCog(BaseCog, name='Logging'):
|
|
228
|
664
|
|
|
229
|
665
|
|
|
230
|
666
|
# ------------------------------------------------------------------------
|
|
231
|
|
- def remove_me():
|
|
232
|
|
- pass
|
|
233
|
|
-
|
|
234
|
|
-
|
|
235
|
|
- # @commands.Cog.listener()
|
|
236
|
|
- # async def on_member_join(self, member: Member) -> None:
|
|
237
|
|
- # 'Event handler'
|
|
238
|
|
- # guild: Guild = member.guild
|
|
239
|
|
- # if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
240
|
|
- # return
|
|
241
|
|
- # min_count = self.get_guild_setting(guild, self.SETTING_JOIN_COUNT)
|
|
242
|
|
- # seconds = self.get_guild_setting(guild, self.SETTING_JOIN_TIME)
|
|
243
|
|
- # timespan: timedelta = timedelta(seconds=seconds)
|
|
244
|
|
-
|
|
245
|
|
- # last_raid: JoinRaidContext = Storage.get_state_value(guild, self.STATE_KEY_LAST_RAID)
|
|
246
|
|
- # recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
|
|
247
|
|
- # if recent_joins is None:
|
|
248
|
|
- # recent_joins = AgeBoundList(timespan, lambda i, member : member.joined_at)
|
|
249
|
|
- # Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
|
|
250
|
|
- # if last_raid:
|
|
251
|
|
- # if member.joined_at - last_raid.last_join_time() > timespan:
|
|
252
|
|
- # # Last raid is over
|
|
253
|
|
- # Storage.set_state_value(guild, self.STATE_KEY_LAST_RAID, None)
|
|
254
|
|
- # recent_joins.append(member)
|
|
255
|
|
- # return
|
|
256
|
|
- # # Add join to existing raid
|
|
257
|
|
- # last_raid.join_members.append(member)
|
|
258
|
|
- # self.record_warning(member)
|
|
259
|
|
- # if len(last_raid.banned_members) > 0:
|
|
260
|
|
- # self.log(guild, f'Banning as part of last join raid: {member.name}')
|
|
261
|
|
- # await member.ban(
|
|
262
|
|
- # reason='Rocketbot: Part of join raid.',
|
|
263
|
|
- # delete_message_days=0)
|
|
264
|
|
- # last_raid.banned_members.add(member)
|
|
265
|
|
- # elif len(last_raid.kicked_members) > 0:
|
|
266
|
|
- # self.log(guild, f'Kicking as part of last join raid: {member.name}')
|
|
267
|
|
- # await member.kick(
|
|
268
|
|
- # reason='Rocketbot: Part of join raid.')
|
|
269
|
|
- # last_raid.kicked_members.add(member)
|
|
270
|
|
- # await self.__update_warning_message(last_raid)
|
|
271
|
|
- # else:
|
|
272
|
|
- # # Add join to the general, non-raid recent join list
|
|
273
|
|
- # recent_joins.append(member)
|
|
274
|
|
- # if len(recent_joins) >= min_count:
|
|
275
|
|
- # self.log(guild, '\u0007Join raid detected')
|
|
276
|
|
- # last_raid = JoinRaidContext(recent_joins)
|
|
277
|
|
- # Storage.set_state_value(guild, self.STATE_KEY_LAST_RAID, last_raid)
|
|
278
|
|
- # recent_joins.clear()
|
|
279
|
|
- # msg = BotMessage(guild,
|
|
280
|
|
- # text='',
|
|
281
|
|
- # type=BotMessage.TYPE_MOD_WARNING,
|
|
282
|
|
- # context=last_raid)
|
|
283
|
|
- # self.record_warnings(recent_joins)
|
|
284
|
|
- # last_raid.warning_message_ref = weakref.ref(msg)
|
|
285
|
|
- # await self.__update_warning_message(last_raid)
|
|
286
|
|
- # await self.post_message(msg)
|
|
|
667
|
+ def __quote_markdown(self, s: str) -> str:
|
|
|
668
|
+ return '> ' + escape_markdown(s).replace('\n', '\n> ')
|
|
|
669
|
+
|
|
|
670
|
+ def __describe_user(self, user: Union[User, Member]) -> str:
|
|
|
671
|
+ """
|
|
|
672
|
+ Standardized markdown describing a user or member.
|
|
|
673
|
+ """
|
|
|
674
|
+ return f'**{user.name}** ({user.display_name} {user.id})'
|