Просмотр исходного кода

Lots more logging. Many discord.py upgrade fixes.

master
Rocketsoup 1 год назад
Родитель
Сommit
d243d596a4
7 измененных файлов: 536 добавлений и 113 удалений
  1. 17
    5
      bot.py
  2. 1
    1
      rocketbot/cogs/basecog.py
  3. 1
    1
      rocketbot/cogs/crosspostcog.py
  4. 10
    0
      rocketbot/cogs/generalcog.py
  5. 491
    103
      rocketbot/cogs/logcog.py
  6. 2
    2
      rocketbot/cogs/patterncog.py
  7. 14
    1
      rocketbot/cogsetting.py

+ 17
- 5
bot.py Просмотреть файл

18
 from rocketbot.cogs.generalcog import GeneralCog
18
 from rocketbot.cogs.generalcog import GeneralCog
19
 from rocketbot.cogs.joinagecog import JoinAgeCog
19
 from rocketbot.cogs.joinagecog import JoinAgeCog
20
 from rocketbot.cogs.joinraidcog import JoinRaidCog
20
 from rocketbot.cogs.joinraidcog import JoinRaidCog
21
-from rocketbot.cogs.logcog import LogCog
21
+from rocketbot.cogs.logcog import LoggingCog
22
 from rocketbot.cogs.patterncog import PatternCog
22
 from rocketbot.cogs.patterncog import PatternCog
23
 from rocketbot.cogs.urlspamcog import URLSpamCog
23
 from rocketbot.cogs.urlspamcog import URLSpamCog
24
 from rocketbot.cogs.usernamecog import UsernamePatternCog
24
 from rocketbot.cogs.usernamecog import UsernamePatternCog
41
 		super().__init__(command_prefix, **kwargs)
41
 		super().__init__(command_prefix, **kwargs)
42
 
42
 
43
 	async def on_command_error(self, context: commands.Context, exception):
43
 	async def on_command_error(self, context: commands.Context, exception):
44
+		for line in traceback.format_stack():
45
+			print(line.strip())
44
 		if context.guild is None or \
46
 		if context.guild is None or \
45
 				context.message.channel is None or \
47
 				context.message.channel is None or \
46
 				context.message.author.bot:
48
 				context.message.author.bot:
47
 			return
49
 			return
48
-		if not context.message.author.permissions_in(context.message.channel).ban_members:
50
+		if not context.message.channel.permissions_for(context.message.author).ban_members:
49
 			# Don't tell non-mods about errors
51
 			# Don't tell non-mods about errors
50
 			return
52
 			return
51
 		if isinstance(exception, (commands.errors.CommandError, )):
53
 		if isinstance(exception, (commands.errors.CommandError, )):
55
 				mention_author=False)
57
 				mention_author=False)
56
 			return
58
 			return
57
 		# Stack trace everything else
59
 		# Stack trace everything else
58
-		traceback.print_exception(type(exception), exception, exception.__traceback__)
60
+		# traceback.print_exception(type(exception), exception, exception.__traceback__)
61
+
62
+	async def on_error(self, event_method, args=None, kwargs=None):
63
+		print('Event caused error')
64
+		print(f'	event method: {event_method}')
65
+		print(f'	event args: {args}')
66
+		print(f'	event kwargs: {kwargs}')
67
+		print(traceback.format_exc())
59
 
68
 
60
 async def start_bot():
69
 async def start_bot():
61
 	intents = Intents.default()
70
 	intents = Intents.default()
76
 	await bot.add_cog(CrossPostCog(bot))
85
 	await bot.add_cog(CrossPostCog(bot))
77
 	await bot.add_cog(JoinAgeCog(bot))
86
 	await bot.add_cog(JoinAgeCog(bot))
78
 	await bot.add_cog(JoinRaidCog(bot))
87
 	await bot.add_cog(JoinRaidCog(bot))
79
-	await bot.add_cog(LogCog(bot))
88
+	await bot.add_cog(LoggingCog(bot))
80
 	await bot.add_cog(PatternCog(bot))
89
 	await bot.add_cog(PatternCog(bot))
81
 	await bot.add_cog(URLSpamCog(bot))
90
 	await bot.add_cog(URLSpamCog(bot))
82
 	await bot.add_cog(UsernamePatternCog(bot))
91
 	await bot.add_cog(UsernamePatternCog(bot))
84
 	await bot.start(CONFIG['client_token'], reconnect=True)
93
 	await bot.start(CONFIG['client_token'], reconnect=True)
85
 	print('\nBot aborted')
94
 	print('\nBot aborted')
86
 
95
 
87
-asyncio.run(start_bot())
96
+try:
97
+	asyncio.run(start_bot())
98
+except KeyboardInterrupt:
99
+	pass

+ 1
- 1
rocketbot/cogs/basecog.py Просмотреть файл

213
 		if reaction is None or not reaction.is_enabled:
213
 		if reaction is None or not reaction.is_enabled:
214
 			# Can't use this reaction with this message
214
 			# Can't use this reaction with this message
215
 			return
215
 			return
216
-		if not member.permissions_in(channel).ban_members:
216
+		if not channel.permissions_for(member).ban_members:
217
 			# Not a mod (could make permissions configurable per BotMessageReaction some day)
217
 			# Not a mod (could make permissions configurable per BotMessageReaction some day)
218
 			return
218
 			return
219
 		await self.on_mod_react(bot_message, reaction, member)
219
 		await self.on_mod_react(bot_message, reaction, member)

+ 1
- 1
rocketbot/cogs/crosspostcog.py Просмотреть файл

79
 		self.max_spam_contexts = 12
79
 		self.max_spam_contexts = 12
80
 
80
 
81
 	async def __record_message(self, message: Message) -> None:
81
 	async def __record_message(self, message: Message) -> None:
82
-		if message.author.permissions_in(message.channel).ban_members:
82
+		if message.channel.permissions_for(message.author).ban_members:
83
 			# User exempt from spam detection
83
 			# User exempt from spam detection
84
 			return
84
 			return
85
 		if len(message.content) < self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH):
85
 		if len(message.content) < self.get_guild_setting(message.guild, self.SETTING_MIN_LENGTH):

+ 10
- 0
rocketbot/cogs/generalcog.py Просмотреть файл

29
 		self.is_connected = True
29
 		self.is_connected = True
30
 
30
 
31
 	@commands.Cog.listener()
31
 	@commands.Cog.listener()
32
+	async def on_disconnect(self):
33
+		'Event handler'
34
+		print('on_disconnect')
35
+
36
+	@commands.Cog.listener()
32
 	async def on_ready(self):
37
 	async def on_ready(self):
33
 		'Event handler'
38
 		'Event handler'
34
 		print('on_ready')
39
 		print('on_ready')
35
 		self.is_ready = True
40
 		self.is_ready = True
36
 
41
 
42
+	@commands.Cog.listener()
43
+	async def on_resumed(self):
44
+		'Event handler'
45
+		print('on_resumed')
46
+
37
 	@commands.command(
47
 	@commands.command(
38
 		brief='Posts a test warning',
48
 		brief='Posts a test warning',
39
 		description='Tests whether a warning channel is configured for this ' + \
49
 		description='Tests whether a warning channel is configured for this ' + \

+ 491
- 103
rocketbot/cogs/logcog.py Просмотреть файл

4
 import weakref
4
 import weakref
5
 from collections.abc import Sequence
5
 from collections.abc import Sequence
6
 from datetime import datetime, timedelta
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
 from discord.abc import GuildChannel
8
 from discord.abc import GuildChannel
9
 from discord.ext import commands
9
 from discord.ext import commands
10
 from discord.utils import escape_markdown
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
 from config import CONFIG
14
 from config import CONFIG
14
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
15
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
15
 from rocketbot.collections import AgeBoundList
16
 from rocketbot.collections import AgeBoundList
16
 from rocketbot.storage import Storage
17
 from rocketbot.storage import Storage
17
 
18
 
18
-class LogCog(BaseCog, name='Logging'):
19
+class LoggingCog(BaseCog, name='Logging'):
19
 	"""
20
 	"""
20
 	Cog for logging notable events to a designated logging channel.
21
 	Cog for logging notable events to a designated logging channel.
21
 	"""
22
 	"""
22
 	SETTING_ENABLED = CogSetting('enabled', bool,
23
 	SETTING_ENABLED = CogSetting('enabled', bool,
23
 			brief='logging',
24
 			brief='logging',
24
 			description='Whether this cog is enabled for a guild.')
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
 	def __init__(self, bot):
27
 	def __init__(self, bot):
36
 		super().__init__(bot)
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
 	@commands.group(
31
 	@commands.group(
43
 		brief='Manages event logging',
32
 		brief='Manages event logging',
44
 	)
33
 	)
45
 	@commands.has_permissions(ban_members=True)
34
 	@commands.has_permissions(ban_members=True)
46
 	@commands.guild_only()
35
 	@commands.guild_only()
47
-	async def log(self, context: commands.Context):
36
+	async def logging(self, context: commands.Context):
48
 		'Logging command group'
37
 		'Logging command group'
49
 		if context.invoked_subcommand is None:
38
 		if context.invoked_subcommand is None:
50
 			await context.send_help()
39
 			await context.send_help()
53
 
42
 
54
 	@commands.Cog.listener()
43
 	@commands.Cog.listener()
55
 	async def on_guild_channel_delete(self, channel: GuildChannel) -> None:
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
 	@commands.Cog.listener()
59
 	@commands.Cog.listener()
59
 	async def on_guild_channel_create(self, channel: GuildChannel) -> None:
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
 	@commands.Cog.listener()
75
 	@commands.Cog.listener()
63
 	async def on_guild_channel_update(self, before: GuildChannel, after: GuildChannel) -> None:
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
 	# Events - Guilds
106
 	# Events - Guilds
67
 
107
 
87
 
127
 
88
 	@commands.Cog.listener()
128
 	@commands.Cog.listener()
89
 	async def on_invite_create(self, invite: Invite) -> None:
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
 	@commands.Cog.listener()
146
 	@commands.Cog.listener()
93
 	async def on_invite_delete(self, invite: Invite) -> None:
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
 	# Events - Members
163
 	# Events - Members
97
 
164
 
98
 	@commands.Cog.listener()
165
 	@commands.Cog.listener()
99
 	async def on_member_join(self, member: Member) -> None:
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
 	@commands.Cog.listener()
229
 	@commands.Cog.listener()
103
 	async def on_member_remove(self, member: Member) -> None:
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
 	@commands.Cog.listener()
263
 	@commands.Cog.listener()
107
 	async def on_member_update(self, before: Member, after: Member) -> None:
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
 	@commands.Cog.listener()
325
 	@commands.Cog.listener()
111
 	async def on_user_update(self, before: User, after: User) -> None:
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
 	@commands.Cog.listener()
357
 	@commands.Cog.listener()
115
 	async def on_member_ban(self, user: Union[User, Member]) -> None:
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
 	@commands.Cog.listener()
403
 	@commands.Cog.listener()
119
 	async def on_member_unban(self, guild: Guild, user: Union[User, Member]) -> None:
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
 	# Events - Messages
416
 	# Events - Messages
123
 
417
 
124
 	@commands.Cog.listener()
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
 		# print(f"Saw message {message.id} \"{message.content}\"")
425
 		# print(f"Saw message {message.id} \"{message.content}\"")
127
-		pass
128
 
426
 
129
 	@commands.Cog.listener()
427
 	@commands.Cog.listener()
130
 	async def on_message_edit(self, before: Message, after: Message) -> None:
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
 		await bot_message.update()
461
 		await bot_message.update()
136
 
462
 
137
 	@commands.Cog.listener()
463
 	@commands.Cog.listener()
138
 	async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
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
 		if payload.cached_message:
487
 		if payload.cached_message:
140
 			return  # already handled by on_message_edit
488
 			return  # already handled by on_message_edit
141
 		guild = await self.bot.fetch_guild(payload.guild_id)
489
 		guild = await self.bot.fetch_guild(payload.guild_id)
142
 		if not guild:
490
 		if not guild:
143
 			return
491
 			return
492
+		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
493
+			return
144
 		channel = await guild.fetch_channel(payload.channel_id)
494
 		channel = await guild.fetch_channel(payload.channel_id)
145
 		if not channel:
495
 		if not channel:
146
 			return
496
 			return
147
 		message = await channel.fetch_message(payload.message_id)
497
 		message = await channel.fetch_message(payload.message_id)
148
 		if not message:
498
 		if not message:
149
 			return
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
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
503
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
154
 		await bot_message.update()
504
 		await bot_message.update()
155
 
505
 
156
 	@commands.Cog.listener()
506
 	@commands.Cog.listener()
157
 	async def on_raw_message_delete(self, payload: RawMessageDeleteEvent) -> None:
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
 		if payload.cached_message:
517
 		if payload.cached_message:
160
 			message = payload.cached_message
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
 			bot_message = BotMessage(message.guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
526
 			bot_message = BotMessage(message.guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
164
 			await bot_message.update()
527
 			await bot_message.update()
165
 		else:
528
 		else:
166
-			print(f'Looking up guild {payload.guild_id}')
167
 			guild = await self.bot.fetch_guild(payload.guild_id)
529
 			guild = await self.bot.fetch_guild(payload.guild_id)
168
 			if not guild:
530
 			if not guild:
169
 				return
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
 			channel = await guild.fetch_channel(payload.channel_id)
534
 			channel = await guild.fetch_channel(payload.channel_id)
172
 			if not channel:
535
 			if not channel:
173
 				return
536
 				return
177
 
540
 
178
 	@commands.Cog.listener()
541
 	@commands.Cog.listener()
179
 	async def on_raw_bulk_message_delete(self, payload: RawBulkMessageDeleteEvent) -> None:
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
 		guild = await self.bot.fetch_guild(payload.guild_id)
553
 		guild = await self.bot.fetch_guild(payload.guild_id)
181
 		if not guild:
554
 		if not guild:
182
 			return
555
 			return
556
+		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
557
+			return
183
 		channel = await guild.fetch_channel(payload.channel_id)
558
 		channel = await guild.fetch_channel(payload.channel_id)
184
 		count = len(payload.message_ids)
559
 		count = len(payload.message_ids)
185
 		cached_count = len(payload.cached_messages)
560
 		cached_count = len(payload.cached_messages)
193
 		await bot_message.update()
568
 		await bot_message.update()
194
 
569
 
195
 		for message in payload.cached_messages:
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
 			bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
573
 			bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
199
 			await bot_message.update()
574
 			await bot_message.update()
200
 
575
 
202
 
577
 
203
 	@commands.Cog.listener()
578
 	@commands.Cog.listener()
204
 	async def on_guild_role_create(self, role: Role) -> None:
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
 	@commands.Cog.listener()
594
 	@commands.Cog.listener()
208
 	async def on_guild_role_delete(self, role: Role) -> None:
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
 	@commands.Cog.listener()
610
 	@commands.Cog.listener()
212
 	async def on_guild_role_update(self, before: Role, after: Role) -> None:
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
 	# Events - Threads
651
 	# Events - Threads
216
 
652
 
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})'

+ 2
- 2
rocketbot/cogs/patterncog.py Просмотреть файл

40
 		patterns: dict[str, PatternStatement] = Storage.get_state_value(guild,
40
 		patterns: dict[str, PatternStatement] = Storage.get_state_value(guild,
41
 			'PatternCog.patterns')
41
 			'PatternCog.patterns')
42
 		if patterns is None:
42
 		if patterns is None:
43
-			jsons: list[dict] = self.get_guild_setting(guild, self.SETTING_PATTERNS)
43
+			jsons: list[dict] = self.get_guild_setting(guild, self.SETTING_PATTERNS) or []
44
 			pattern_list: list[PatternStatement] = []
44
 			pattern_list: list[PatternStatement] = []
45
 			for json in jsons:
45
 			for json in jsons:
46
 				try:
46
 				try:
88
 				message.content is None or \
88
 				message.content is None or \
89
 				message.content == '':
89
 				message.content == '':
90
 			return
90
 			return
91
-		if message.author.permissions_in(message.channel).ban_members:
91
+		if message.channel.permissions_for(message.author).ban_members:
92
 			# Ignore mods
92
 			# Ignore mods
93
 			return
93
 			return
94
 
94
 

+ 14
- 1
rocketbot/cogsetting.py Просмотреть файл

115
 				commands.guild_only(),
115
 				commands.guild_only(),
116
 			])
116
 			])
117
 		command.cog = cog
117
 		command.cog = cog
118
+		self.__fix_command(command)
118
 		return command
119
 		return command
119
 
120
 
120
 	def __make_setter_command(self, cog: Cog) -> Command:
121
 	def __make_setter_command(self, cog: Cog) -> Command:
169
 				commands.has_permissions(ban_members=True),
170
 				commands.has_permissions(ban_members=True),
170
 				commands.guild_only(),
171
 				commands.guild_only(),
171
 			])
172
 			])
172
-		# Passing `cog` in init gets ignored and set to `None` so set after.
173
+		# HACK: Passing `cog` in init gets ignored and set to `None` so set after.
173
 		# This ensures the callback is passed the cog as `self` argument.
174
 		# This ensures the callback is passed the cog as `self` argument.
174
 		command.cog = cog
175
 		command.cog = cog
176
+		self.__fix_command(command)
175
 		return command
177
 		return command
176
 
178
 
177
 	def __make_enable_command(self, cog: Cog) -> Command:
179
 	def __make_enable_command(self, cog: Cog) -> Command:
195
 				commands.guild_only(),
197
 				commands.guild_only(),
196
 			])
198
 			])
197
 		command.cog = cog
199
 		command.cog = cog
200
+		self.__fix_command(command)
198
 		return command
201
 		return command
199
 
202
 
200
 	def __make_disable_command(self, cog: Cog) -> Command:
203
 	def __make_disable_command(self, cog: Cog) -> Command:
218
 				commands.guild_only(),
221
 				commands.guild_only(),
219
 			])
222
 			])
220
 		command.cog = cog
223
 		command.cog = cog
224
+		self.__fix_command(command)
221
 		return command
225
 		return command
222
 
226
 
227
+	def __fix_command(self, command: Command) -> None:
228
+		"""
229
+		HACK: Fixes bug in discord.py 2.3.2 where it's requiring the user to
230
+		supply the context argument. This removes that argument from the list.
231
+		"""
232
+		params = command.params
233
+		del params['context']
234
+		command.params = params
235
+
223
 	@classmethod
236
 	@classmethod
224
 	def set_up_all(cls, cog: Cog, bot: Bot, settings: list) -> None:
237
 	def set_up_all(cls, cog: Cog, bot: Bot, settings: list) -> None:
225
 		"""
238
 		"""

Загрузка…
Отмена
Сохранить