瀏覽代碼

Other slash commands working

pull/13/head
Rocketsoup 2 月之前
父節點
當前提交
6bcca013c4

+ 10
- 0
rocketbot/bot.py 查看文件

@@ -16,6 +16,7 @@ class Rocketbot(commands.Bot):
16 16
 	"""
17 17
 	def __init__(self, command_prefix, **kwargs):
18 18
 		super().__init__(command_prefix, **kwargs)
19
+		self.__commands_set_up = False
19 20
 
20 21
 	async def on_command_error(self, context: commands.Context, exception: BaseException) -> None:
21 22
 		bot_log(None, None, f'Command error')
@@ -43,6 +44,15 @@ class Rocketbot(commands.Bot):
43 44
 			traceback.format_exc())
44 45
 
45 46
 	async def on_ready(self):
47
+		if not self.__commands_set_up:
48
+			await self.__set_up_commands()
49
+		bot_log(None, None, 'Bot done initializing')
50
+		print('----------------------------------------------------------')
51
+
52
+	async def __set_up_commands(self):
53
+		if self.__commands_set_up:
54
+			return
55
+		self.__commands_set_up = True
46 56
 		for cog in self.cogs.values():
47 57
 			if isinstance(cog, BaseCog):
48 58
 				bcog: BaseCog = cog

+ 6
- 11
rocketbot/cogs/autokickcog.py 查看文件

@@ -53,7 +53,12 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
53 53
 	STATE_KEY_RECENT_KICKS = "AutoKickCog.recent_joins"
54 54
 
55 55
 	def __init__(self, bot):
56
-		super().__init__(bot, 'autokick')
56
+		super().__init__(
57
+			bot,
58
+			config_prefix='autokick',
59
+			name='auto-kick',
60
+			short_description='Automatically kicks all new users as soon as they join.',
61
+		)
57 62
 		self.add_setting(AutoKickCog.SETTING_ENABLED)
58 63
 		self.add_setting(AutoKickCog.SETTING_BAN_COUNT)
59 64
 		self.add_setting(AutoKickCog.SETTING_OFFLINE_ONLY)
@@ -61,16 +66,6 @@ class AutoKickCog(BaseCog, name='Auto Kick'):
61 66
 		timer: Loop = self.status_check_timer
62 67
 		timer.start()
63 68
 
64
-	@commands.group(
65
-		brief='Automatically kicks all new users as soon as they join',
66
-	)
67
-	@commands.has_permissions(ban_members=True)
68
-	@commands.guild_only()
69
-	async def autokick(self, context: commands.Context):
70
-		"""Auto-kick"""
71
-		if context.invoked_subcommand is None:
72
-			await context.send_help()
73
-
74 69
 	@commands.Cog.listener()
75 70
 	async def on_member_join(self, member: Member) -> None:
76 71
 		"""Event handler"""

+ 32
- 6
rocketbot/cogs/basecog.py 查看文件

@@ -4,16 +4,19 @@ Base cog class and helper classes.
4 4
 from datetime import datetime, timedelta, timezone
5 5
 from typing import Optional, ForwardRef
6 6
 
7
-from discord import Guild, Member, Message, RawReactionActionEvent, TextChannel
7
+import discord
8
+from discord import Guild, Interaction, Member, Message, RawReactionActionEvent, TextChannel
8 9
 from discord.abc import GuildChannel
9
-from discord.ext import commands
10
+from discord.app_commands import AppCommandError
11
+from discord.app_commands.errors import CommandInvokeError
12
+from discord.ext.commands import Cog
10 13
 
11 14
 from config import CONFIG
12 15
 from rocketbot.botmessage import BotMessage, BotMessageReaction
13 16
 from rocketbot.cogsetting import CogSetting
14 17
 from rocketbot.collections import AgeBoundDict
15 18
 from rocketbot.storage import Storage
16
-from rocketbot.utils import bot_log
19
+from rocketbot.utils import bot_log, dump_stacktrace
17 20
 
18 21
 Rocketbot = ForwardRef('rocketbot.bot.Rocketbot')
19 22
 
@@ -22,14 +25,21 @@ class WarningContext:
22 25
 		self.member = member
23 26
 		self.last_warned = warn_time
24 27
 
25
-class BaseCog(commands.Cog):
28
+class BaseCog(Cog):
26 29
 	STATE_KEY_RECENT_WARNINGS = "BaseCog.recent_warnings"
27 30
 
28 31
 	"""
29 32
 	Superclass for all Rocketbot cogs. Provides lots of conveniences for
30 33
 	common tasks.
31 34
 	"""
32
-	def __init__(self, bot: Rocketbot, config_prefix: Optional[str]):
35
+	def __init__(
36
+			self,
37
+			bot: Rocketbot,
38
+			config_prefix: Optional[str],
39
+			name: str,
40
+			short_description: str,
41
+			long_description: Optional[str] = None,
42
+	):
33 43
 		"""
34 44
 		Parameters
35 45
 		----------
@@ -44,6 +54,22 @@ class BaseCog(commands.Cog):
44 54
 		self.are_settings_setup: bool = False
45 55
 		self.settings: list[CogSetting] = []
46 56
 		self.config_prefix: Optional[str] = config_prefix
57
+		self.name: str = name
58
+		self.short_description: str = short_description
59
+		self.long_description: str = long_description
60
+
61
+	async def cog_app_command_error(self, interaction: Interaction, error: AppCommandError) -> None:
62
+		if isinstance(error, CommandInvokeError):
63
+			error = error.original
64
+		dump_stacktrace(error)
65
+		message = f"\nException: {error.__class__.__name__}, "\
66
+				  f"Command: {interaction.command.qualified_name if interaction.command else None}, "\
67
+				  f"User: {interaction.user}, "\
68
+				  f"Time: {discord.utils.format_dt(interaction.created_at, style='F')}"
69
+		try:
70
+			await interaction.response.send_message(f"An error occurred: {message}", ephemeral=True)
71
+		except discord.InteractionResponded:
72
+			await interaction.followup.send(f"An error occurred: {message}", ephemeral=True)
47 73
 
48 74
 	# Config
49 75
 
@@ -189,7 +215,7 @@ class BaseCog(commands.Cog):
189 215
 		await message.update()
190 216
 		return message.is_sent()
191 217
 
192
-	@commands.Cog.listener()
218
+	@Cog.listener()
193 219
 	async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
194 220
 		"""Event handler"""
195 221
 		# Avoid any unnecessary requests. Gets called for every reaction

+ 74
- 55
rocketbot/cogs/configcog.py 查看文件

@@ -1,8 +1,10 @@
1 1
 """
2 2
 Cog handling general configuration for a guild.
3 3
 """
4
-from discord import Guild, TextChannel
5
-from discord.ext import commands
4
+from typing import Union, Optional
5
+
6
+from discord import Guild, Permissions, TextChannel, Interaction, Role, User
7
+from discord.app_commands import Group
6 8
 from discord.ext.commands import Bot
7 9
 
8 10
 from config import CONFIG
@@ -15,92 +17,109 @@ class ConfigCog(BaseCog, name='Configuration'):
15 17
 	"""
16 18
 
17 19
 	def __init__(self, bot: Bot) -> None:
18
-		super().__init__(bot, 'config')
20
+		super().__init__(
21
+			bot,
22
+			config_prefix='config',
23
+			name='configuration',
24
+			short_description='Manages general bot configuration.',
25
+		)
19 26
 
20
-	@commands.group(
21
-		brief='Manages general bot configuration'
27
+	config = Group(
28
+		name='config',
29
+		description='Manages general bot configuration',
30
+		guild_only=True,
31
+		default_permissions=Permissions(Permissions.manage_messages.flag)
22 32
 	)
23
-	@commands.has_permissions(ban_members=True)
24
-	@commands.guild_only()
25
-	async def config(self, context: commands.Context):
26
-		"""General guild configuration command group"""
27
-		if context.invoked_subcommand is None:
28
-			await context.send_help()
29 33
 
30 34
 	@config.command(
31
-		brief='Sets the mod warning channel',
32
-		description='Run this command in the channel where bot messages ' +
33
-			'intended for server moderators should be sent. Other bot ' +
34
-			'messages may still be posted in the channel a command was ' +
35
-			'invoked in. If no output channel is set, mod-related messages ' +
36
-			'will not be posted!',
35
+		description='Sets the mod warning channel',
36
+		extras={
37
+			'long_description': 'Run this command in the channel where bot messages ' +
38
+				'intended for server moderators should be sent. Other bot ' +
39
+				'messages may still be posted in the channel a command was ' +
40
+				'invoked in. If no output channel is set, mod-related messages ' +
41
+				'will not be posted!',
42
+		}
37 43
 	)
38
-	async def setwarningchannel(self, context: commands.Context) -> None:
44
+	async def setwarningchannel(self, interaction: Interaction) -> None:
39 45
 		"""Command handler"""
40
-		guild: Guild = context.guild
41
-		channel: TextChannel = context.channel
46
+		guild: Guild = interaction.guild
47
+		channel: TextChannel = interaction.channel
42 48
 		Storage.set_config_value(guild, ConfigKey.WARNING_CHANNEL_ID,
43
-			context.channel.id)
44
-		await context.message.reply(
49
+			interaction.channel.id)
50
+		await interaction.response.send_message(
45 51
 			f'{CONFIG["success_emoji"]} Warning channel updated to {channel.mention}.',
46
-			mention_author=False)
52
+			ephemeral=True,
53
+		)
47 54
 
48 55
 	@config.command(
49
-		brief='Shows the mod warning channel',
50
-		description='Shows the configured channel (if any) where mod ' + \
51
-			'warnings will be posted.',
56
+		description='Shows the mod warning channel',
57
+		extras={
58
+			'long_description': 'Shows the configured channel (if any) where mod ' + \
59
+				'warnings will be posted.',
60
+		},
52 61
 	)
53
-	async def getwarningchannel(self, context: commands.Context) -> None:
62
+	async def getwarningchannel(self, interaction: Interaction) -> None:
54 63
 		"""Command handler"""
55
-		guild: Guild = context.guild
64
+		guild: Guild = interaction.guild
56 65
 		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
57 66
 		if channel_id is None:
58
-			await context.message.reply(
67
+			await interaction.response.send_message(
59 68
 				f'{CONFIG["info_emoji"]} No warning channel is configured.',
60
-				mention_author=False)
69
+				ephemeral=True,
70
+			)
61 71
 		else:
62 72
 			channel = guild.get_channel(channel_id)
63
-			await context.message.reply(
73
+			await interaction.response.send_message(
64 74
 				f'{CONFIG["info_emoji"]} Warning channel is configured as {channel.mention}.',
65
-				mention_author=False)
75
+				ephemeral=True,
76
+			)
66 77
 
67 78
 	@config.command(
68
-		brief='Sets a user/role to mention in warning messages',
69
-		usage='<@user|@role>',
70
-		description='Configures an role or other prefix to include at the ' +
71
-			'beginning of warning messages. If the intent is to get the ' +
72
-			'attention of certain users, be sure to specify a properly ' +
73
-			'formed @ tag, not just the name of the user/role.'
79
+		description='Sets a user/role to mention in warning messages',
80
+		extras={
81
+			'usage': '<@user|@role>',
82
+			'long_description': 'Configures an role or other prefix to include at the ' +
83
+				'beginning of warning messages. If the intent is to get the ' +
84
+				'attention of certain users, be sure to specify a properly ' +
85
+				'formed @ tag, not just the name of the user/role.',
86
+		},
74 87
 	)
75 88
 	async def setwarningmention(self,
76
-			context: commands.Context,
77
-			mention: str = None) -> None:
89
+			interaction: Interaction,
90
+			mention: Optional[Union[User, Role]] = None) -> None:
78 91
 		"""Command handler"""
79
-		guild: Guild = context.guild
80
-		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention)
92
+		guild: Guild = interaction.guild
93
+		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention.mention if mention else None)
81 94
 		if mention is None:
82
-			await context.message.reply(
95
+			await interaction.response.send_message(
83 96
 				f'{CONFIG["success_emoji"]} Warning messages will not tag anyone.',
84
-				mention_author=False)
97
+				ephemeral=True,
98
+			)
85 99
 		else:
86
-			await context.message.reply(
100
+			await interaction.response.send_message(
87 101
 				f'{CONFIG["success_emoji"]} Warning messages will now tag {mention}.',
88
-				mention_author=False)
102
+				ephemeral=True,
103
+			)
89 104
 
90 105
 	@config.command(
91
-		brief='Shows the user/role to mention in warning messages',
92
-		description='Shows the text, if any, that will be prefixed on any ' +
93
-			'warning messages.'
106
+		description='Shows the user/role to mention in warning messages',
107
+		extras={
108
+			'long_description': 'Shows the text, if any, that will be prefixed on any ' +
109
+				'warning messages.',
110
+		},
94 111
 	)
95
-	async def getwarningmention(self, context: commands.Context) -> None:
112
+	async def getwarningmention(self, interaction: Interaction) -> None:
96 113
 		"""Command handler"""
97
-		guild: Guild = context.guild
114
+		guild: Guild = interaction.guild
98 115
 		mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
99 116
 		if mention is None:
100
-			await context.message.reply(
117
+			await interaction.response.send_message(
101 118
 				f'{CONFIG["info_emoji"]} No warning mention configured.',
102
-				mention_author=False)
119
+				ephemeral=True,
120
+			)
103 121
 		else:
104
-			await context.message.reply(
122
+			await interaction.response.send_message(
105 123
 				f'{CONFIG["info_emoji"]} Warning messages will tag {mention}',
106
-				mention_author=False)
124
+				ephemeral=True,
125
+			)

+ 9
- 14
rocketbot/cogs/crosspostcog.py 查看文件

@@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone
6 6
 from typing import Optional
7 7
 
8 8
 from discord import Member, Message, utils as discordutils, TextChannel
9
-from discord.ext import commands
9
+from discord.ext.commands import Cog
10 10
 
11 11
 from config import CONFIG
12 12
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
@@ -83,7 +83,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
83 83
 			'messages like "lol" or a single emoji.',
84 84
 		usage='<character_count:int>',
85 85
 		min_value=1)
86
-	SETTING_TIMESPAN = CogSetting('timespan', float,
86
+	SETTING_TIMESPAN = CogSetting('timespan', timedelta,
87 87
 		brief='time window to look for dupe messages',
88 88
 		description='The number of seconds of message history to look at ' + \
89 89
 			'when looking for duplicates. Shorter values are preferred, ' + \
@@ -95,7 +95,12 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
95 95
 	STATE_KEY_SPAM_CONTEXT = "CrossPostCog.spam_context"
96 96
 
97 97
 	def __init__(self, bot):
98
-		super().__init__(bot, 'crosspost')
98
+		super().__init__(
99
+			bot,
100
+			config_prefix='crosspost',
101
+			name='crosspost detection',
102
+			short_description='Manages crosspost detection and handling.',
103
+		)
99 104
 		self.add_setting(CrossPostCog.SETTING_ENABLED)
100 105
 		self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
101 106
 		self.add_setting(CrossPostCog.SETTING_DUPE_WARN_COUNT)
@@ -328,7 +333,7 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
328 333
 		# print(message)
329 334
 		pass
330 335
 
331
-	@commands.Cog.listener()
336
+	@Cog.listener()
332 337
 	async def on_message(self, message: Message):
333 338
 		"""Event handler"""
334 339
 		if message.author is None or \
@@ -340,13 +345,3 @@ class CrossPostCog(BaseCog, name='Crosspost Detection'):
340 345
 			return
341 346
 		self.__trace("--ON MESSAGE--")
342 347
 		await self.__record_message(message)
343
-
344
-	@commands.group(
345
-		brief='Manages crosspost detection and handling',
346
-	)
347
-	@commands.has_permissions(ban_members=True)
348
-	@commands.guild_only()
349
-	async def crosspost(self, context: commands.Context):
350
-		"""Detects members posting messages in multiple channels in a short period of time."""
351
-		if context.invoked_subcommand is None:
352
-			await context.send_help()

+ 73
- 87
rocketbot/cogs/generalcog.py 查看文件

@@ -1,18 +1,18 @@
1 1
 """
2 2
 Cog for handling most ungrouped commands and basic behaviors.
3 3
 """
4
-import re
5 4
 from datetime import datetime, timedelta, timezone
6 5
 from typing import Optional
7 6
 
8
-from discord import Message
7
+from discord import Interaction, Message, User
8
+from discord.app_commands import command, default_permissions, guild_only, Transform
9 9
 from discord.errors import DiscordException
10
-from discord.ext import commands
10
+from discord.ext.commands import Cog
11 11
 
12 12
 from config import CONFIG
13 13
 from rocketbot.bot import Rocketbot
14 14
 from rocketbot.cogs.basecog import BaseCog, BotMessage
15
-from rocketbot.utils import timedelta_from_str, describe_timedelta, dump_stacktrace
15
+from rocketbot.utils import describe_timedelta, TimeDeltaTransformer
16 16
 from rocketbot.storage import ConfigKey, Storage
17 17
 
18 18
 class GeneralCog(BaseCog, name='General'):
@@ -21,15 +21,18 @@ class GeneralCog(BaseCog, name='General'):
21 21
 	first cog added to the bot.
22 22
 	"""
23 23
 	def __init__(self, bot: Rocketbot):
24
-		super().__init__(bot, None)
24
+		super().__init__(
25
+			bot,
26
+			config_prefix=None,
27
+			name='',
28
+			short_description='',
29
+		)
25 30
 		self.is_connected = False
26
-		self.is_ready = False
27
-		self.is_first_ready = True
28 31
 		self.is_first_connect = True
29 32
 		self.last_disconnect_time: Optional[datetime] = None
30 33
 		self.noteworthy_disconnect_duration = timedelta(seconds=5)
31 34
 
32
-	@commands.Cog.listener()
35
+	@Cog.listener()
33 36
 	async def on_connect(self):
34 37
 		"""Event handler"""
35 38
 		if self.is_first_connect:
@@ -42,119 +45,102 @@ class GeneralCog(BaseCog, name='General'):
42 45
 				self.log(None, f'Reconnected after {disconnect_duration.total_seconds()} seconds')
43 46
 		self.is_connected = True
44 47
 
45
-	@commands.Cog.listener()
48
+	@Cog.listener()
46 49
 	async def on_disconnect(self):
47 50
 		"""Event handler"""
48 51
 		self.last_disconnect_time = datetime.now(timezone.utc)
49 52
 		# self.log(None, 'Disconnected')
50 53
 
51
-	@commands.Cog.listener()
52
-	async def on_ready(self):
53
-		"""Event handler"""
54
-		self.log(None, 'Bot done initializing')
55
-		self.is_ready = True
56
-		if self.is_first_ready:
57
-			print('----------------------------------------------------------')
58
-			self.is_first_ready = False
59
-
60
-	@commands.Cog.listener()
54
+	@Cog.listener()
61 55
 	async def on_resumed(self):
62 56
 		"""Event handler"""
63 57
 		disconnect_duration = datetime.now(timezone.utc) - self.last_disconnect_time if self.last_disconnect_time else None
64 58
 		if disconnect_duration is not None and disconnect_duration > self.noteworthy_disconnect_duration:
65 59
 			self.log(None, f'Session resumed after {disconnect_duration.total_seconds()} seconds')
66 60
 
67
-	@commands.command(
68
-		brief='Posts a test warning',
69
-		description='Tests whether a warning channel is configured for this ' + \
70
-			'guild by posting a test warning. If a mod mention is ' + \
71
-			'configured, that user/role will be tagged in the test warning.',
61
+	@command(
62
+		description='Posts a test warning',
63
+		extras={
64
+			'long_description': 'Tests whether a warning channel is configured for this ' + \
65
+				'guild by posting a test warning. If a mod mention is ' + \
66
+				'configured, that user/role will be tagged in the test warning.',
67
+		},
72 68
 	)
73
-	@commands.has_permissions(ban_members=True)
74
-	@commands.guild_only()
75
-	async def testwarn(self, context):
69
+	@guild_only()
70
+	@default_permissions(manage_messages=True)
71
+	async def testwarn(self, interaction: Interaction):
76 72
 		"""Command handler"""
77
-		if Storage.get_config_value(context.guild, ConfigKey.WARNING_CHANNEL_ID) is None:
78
-			await context.message.reply(
73
+		if Storage.get_config_value(interaction.guild, ConfigKey.WARNING_CHANNEL_ID) is None:
74
+			await interaction.response.send_message(
79 75
 				f'{CONFIG["warning_emoji"]} No warning channel set!',
80
-				mention_author=False)
76
+				ephemeral=True,
77
+			)
81 78
 		else:
82 79
 			bm = BotMessage(
83
-				context.guild,
84
-				f'Test warning message (requested by {context.author.name})',
80
+				interaction.guild,
81
+				f'Test warning message (requested by {interaction.user.name})',
85 82
 				type=BotMessage.TYPE_MOD_WARNING)
86 83
 			await self.post_message(bm)
84
+			await interaction.response.send_message(
85
+				'Warning issued',
86
+				ephemeral=True,
87
+			)
87 88
 
88
-	@commands.command(
89
-		brief='Simple test reply',
90
-		description='Replies to the command message. Useful to ensure the ' + \
91
-			'bot is working properly.',
89
+	@command(
90
+		description='Simple test reply',
91
+		extras={
92
+			'long_description': 'Replies to the command message. Useful to ensure the ' + \
93
+				'bot is working properly.',
94
+		},
92 95
 	)
93
-	async def hello(self, context):
96
+	async def hello(self, interaction: Interaction):
94 97
 		"""Command handler"""
95
-		await context.message.reply(
96
-			f'Hey, {context.author.name}!',
97
-		 	mention_author=False)
98
+		await interaction.response.send_message(
99
+			f'Hey, {interaction.user.name}!',
100
+		 	ephemeral=True,
101
+		)
98 102
 
99
-	@commands.command(
100
-		brief='Shuts down the bot',
101
-		description='Causes the bot script to terminate. Only usable by a ' + \
102
-			'user with server admin permissions.',
103
+	@command(
104
+		description='Shuts down the bot',
105
+		extras={
106
+			'long_description': 'Causes the bot script to terminate. Only usable by a ' + \
107
+				'user with server admin permissions.',
108
+		},
103 109
 	)
104
-	@commands.has_permissions(administrator=True)
105
-	@commands.guild_only()
106
-	async def shutdown(self, context: commands.Context):
110
+	@guild_only()
111
+	@default_permissions(manage_messages=True)
112
+	async def shutdown(self, interaction: Interaction):
107 113
 		"""Command handler"""
108
-		await context.message.add_reaction('👋')
114
+		await interaction.response.send_message('👋', ephemeral=True)
109 115
 		await self.bot.close()
110 116
 
111
-	@commands.command(
112
-		brief='Mass deletes messages',
113
-		description='Deletes recent messages by the given user. The user ' +
114
-			'can be either an @ mention or a numeric user ID. The age is ' +
115
-			'a duration, such as "30s", "5m", "1h30m". Only the most ' +
116
-			'recent 100 messages in each channel are searched.',
117
-		usage='<user:id|mention> <age:timespan>'
117
+	@command(
118
+		description='Mass deletes messages',
119
+		extras={
120
+			'long_description': 'Deletes recent messages by the given user. The user ' +
121
+				'can be either an @ mention or a numeric user ID. The age is ' +
122
+				'a duration, such as "30s", "5m", "1h30m". Only the most ' +
123
+				'recent 100 messages in each channel are searched.',
124
+			'usage': '<user:id|mention> <age:timespan>',
125
+		},
118 126
 	)
119
-	@commands.has_permissions(manage_messages=True)
120
-	@commands.guild_only()
121
-	async def deletemessages(self, context, user: str, age: str) -> None:
127
+	@guild_only()
128
+	@default_permissions(manage_messages=True)
129
+	async def deletemessages(self, interaction: Interaction, user: User, age: Transform[timedelta, TimeDeltaTransformer]) -> None:
122 130
 		"""Command handler"""
123
-		member_id = self.__parse_member_id(user)
124
-		if member_id is None:
125
-			await context.message.reply(
126
-				f'{CONFIG["failure_emoji"]} user must be a mention or numeric user id',
127
-				mention_author=False)
128
-			return
129
-		try:
130
-			age_delta: timedelta = timedelta_from_str(age)
131
-		except ValueError:
132
-			await context.message.reply(
133
-				f'{CONFIG["failure_emoji"]} age must be a timespan, like "30s", "10m", "1h30m"',
134
-				mention_author=False)
135
-			return
136
-		cutoff: datetime = datetime.now(timezone.utc) - age_delta
131
+		member_id = user.id
132
+		cutoff: datetime = datetime.now(timezone.utc) - age
137 133
 		def predicate(message: Message) -> bool:
138 134
 			return str(message.author.id) == member_id and message.created_at >= cutoff
139 135
 		deleted_messages = []
140
-		for channel in context.guild.text_channels:
136
+		for channel in interaction.guild.text_channels:
141 137
 			try:
142 138
 				deleted_messages += await channel.purge(limit=100, check=predicate)
143 139
 			except DiscordException:
144 140
 				# XXX: Sloppily glossing over access errors instead of checking access
145 141
 				pass
146
-		await context.message.reply(
142
+		await interaction.response.send_message(
147 143
 			f'{CONFIG["success_emoji"]} Deleted {len(deleted_messages)} ' + \
148
-			f'messages by <@!{member_id}> from the past {describe_timedelta(age_delta)}.',
149
-			mention_author=False)
150
-
151
-	def __parse_member_id(self, arg: str) -> Optional[str]:
152
-		p = re.compile('^<@!?([0-9]+)>$')
153
-		m = p.match(arg)
154
-		if m:
155
-			return m.group(1)
156
-		p = re.compile('^([0-9]+)$')
157
-		m = p.match(arg)
158
-		if m:
159
-			return m.group(1)
160
-		return None
144
+			f'messages by {user.mention}> from the past {describe_timedelta(age)}.',
145
+			ephemeral=True,
146
+		)

+ 39
- 27
rocketbot/cogs/joinagecog.py 查看文件

@@ -1,26 +1,30 @@
1 1
 import weakref
2 2
 
3 3
 from datetime import datetime, timedelta, timezone
4
-from discord import Guild, Member
5
-from discord.ext import commands
4
+from typing import Optional
5
+
6
+from discord import Guild, Member, Interaction
7
+from discord.app_commands import Group, guild_only, default_permissions, Transform
8
+from discord.ext.commands import Cog
6 9
 
7 10
 from config import CONFIG
8 11
 from rocketbot.bot import Rocketbot
9 12
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
10 13
 from rocketbot.collections import AgeBoundList
11 14
 from rocketbot.storage import Storage
12
-from rocketbot.utils import timedelta_from_str
15
+from rocketbot.utils import TimeDeltaTransformer
16
+
13 17
 
14 18
 class JoinAgeQueryContext:
15 19
 	"""
16 20
 	Data about a join age query
17 21
 	"""
18
-	def __init__(self, join_members: list, timespan: str):
22
+	def __init__(self, join_members: list[Member], timespan: timedelta):
19 23
 		self.join_members = list(join_members)
20
-		self.timespan = timespan
21
-		self.kicked_members = set()
22
-		self.banned_members = set()
23
-		self.results_message_ref = None
24
+		self.timespan: timedelta = timespan
25
+		self.kicked_members: set[Member] = set()
26
+		self.banned_members: set[Member] = set()
27
+		self.results_message_ref: Optional[weakref.ReferenceType[BotMessage]] = None
24 28
 
25 29
 class JoinAgeCog(BaseCog, name='Join Age'):
26 30
 	"""
@@ -29,7 +33,7 @@ class JoinAgeCog(BaseCog, name='Join Age'):
29 33
 	SETTING_ENABLED = CogSetting('enabled', bool,
30 34
 		brief='join age',
31 35
 		description='Whether this cog is enabled for a guild.')
32
-	SETTING_JOIN_TIME = CogSetting('jointime', float,
36
+	SETTING_JOIN_TIME = CogSetting('jointime', timedelta,
33 37
 		brief='maximum length of time to track new joins',
34 38
 		description='The number of seconds of join history to maintain.',
35 39
 		usage='<seconds:float>',
@@ -38,37 +42,41 @@ class JoinAgeCog(BaseCog, name='Join Age'):
38 42
 	STATE_KEY_RECENT_JOINS = "JoinAgeCog.recent_joins"
39 43
 
40 44
 	def __init__(self, bot: Rocketbot):
41
-		super().__init__(bot, 'joinage')
45
+		super().__init__(
46
+			bot,
47
+			config_prefix='joinage',
48
+			name='join age',
49
+			short_description='Tracks recently joined users with options to mass kick or ban.',
50
+		)
42 51
 		self.add_setting(JoinAgeCog.SETTING_ENABLED)
43 52
 		self.add_setting(JoinAgeCog.SETTING_JOIN_TIME)
44 53
 
45
-	@commands.group(
46
-		brief='Tracks recently joined users with options to mass kick or ban',
54
+	joinage = Group(
55
+		name='joinage',
56
+		description='Queries for users who joined in the past span of time',
57
+		extras={
58
+			'long_description': 'Searches for users who joined the server recently. ' + \
59
+				'Can use time spans like 30s, 5m, 1h, 7d, etc.',
60
+			'usage': '<time_period>',
61
+		}
47 62
 	)
48
-	@commands.has_permissions(ban_members=True)
49
-	@commands.guild_only()
50
-	async def joinage(self, context: commands.Context):
51
-		"""Join age tracking"""
52
-		if context.invoked_subcommand is None:
53
-			await context.send_help()
54 63
 
55 64
 	@joinage.command(
56
-		brief='Queries for users who joined in the past span of time',
57
-		description='Searches for users who joined the server recently. ' + \
58
-			'Can use time spans like 30s, 5m, 1h, 7d, etc.',
59
-		usage='<time_period>'
65
+		name='search',
66
+		description='Searches for users who joined recently',
60 67
 	)
61
-	async def search(self, context: commands.Context, timespan: str):
68
+	@guild_only()
69
+	@default_permissions(ban_members=True)
70
+	async def search(self, interaction: Interaction, timespan: Transform[timedelta, TimeDeltaTransformer]) -> None:
62 71
 		"""Command handler"""
63
-		guild: Guild = context.guild
72
+		guild: Guild = interaction.guild
64 73
 		recent_joins: AgeBoundList[Member, datetime, timedelta] = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
65 74
 		if recent_joins is None:
66 75
 			max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
67 76
 			recent_joins = AgeBoundList(max_age, lambda i, member0 : member0.joined_at)
68 77
 			Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
69 78
 		results: list = []
70
-		ts: timedelta = timedelta_from_str(timespan)
71
-		cutoff: datetime = datetime.now(timezone.utc) - ts
79
+		cutoff: datetime = datetime.now(timezone.utc) - timespan
72 80
 		for member in recent_joins:
73 81
 			if member.joined_at > cutoff:
74 82
 				results.append(member)
@@ -80,6 +88,10 @@ class JoinAgeCog(BaseCog, name='Join Age'):
80 88
 		ctx.results_message_ref = weakref.ref(msg)
81 89
 		await self.__update_results_message(ctx)
82 90
 		await self.post_message(msg)
91
+		await interaction.response.send_message(
92
+			"Search started",
93
+			ephemeral=True,
94
+		)
83 95
 
84 96
 	async def on_mod_react(self,
85 97
 			bot_message: BotMessage,
@@ -105,7 +117,7 @@ class JoinAgeCog(BaseCog, name='Join Age'):
105 117
 			await self.__update_results_message(ctx)
106 118
 			self.log(guild, f'Users banned by {reacted_by.name}')
107 119
 
108
-	@commands.Cog.listener()
120
+	@Cog.listener()
109 121
 	async def on_member_join(self, member: Member) -> None:
110 122
 		"""Event handler"""
111 123
 		guild: Guild = member.guild

+ 9
- 14
rocketbot/cogs/joinraidcog.py 查看文件

@@ -4,7 +4,7 @@ Cog for detecting large numbers of guild joins in a short period of time.
4 4
 import weakref
5 5
 from datetime import datetime, timedelta
6 6
 from discord import Guild, Member
7
-from discord.ext import commands
7
+from discord.ext.commands import Cog
8 8
 
9 9
 from config import CONFIG
10 10
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
@@ -38,7 +38,7 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
38 38
 				'window to trigger a mod warning.',
39 39
 			usage='<count:int>',
40 40
 			min_value=2)
41
-	SETTING_JOIN_TIME = CogSetting('jointime', float,
41
+	SETTING_JOIN_TIME = CogSetting('jointime', timedelta,
42 42
 			brief='time window length to look for joins',
43 43
 			description='The number of seconds of join history to look ' + \
44 44
 				'at when counting recent joins. If joincount or more ' + \
@@ -51,21 +51,16 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
51 51
 	STATE_KEY_LAST_RAID = "JoinRaidCog.last_raid"
52 52
 
53 53
 	def __init__(self, bot):
54
-		super().__init__(bot, 'joinraid')
54
+		super().__init__(
55
+			bot,
56
+			config_prefix='joinraid',
57
+			name='join raid',
58
+			short_description='Manages join raid detection and handling.',
59
+		)
55 60
 		self.add_setting(JoinRaidCog.SETTING_ENABLED)
56 61
 		self.add_setting(JoinRaidCog.SETTING_JOIN_COUNT)
57 62
 		self.add_setting(JoinRaidCog.SETTING_JOIN_TIME)
58 63
 
59
-	@commands.group(
60
-		brief='Manages join raid detection and handling',
61
-	)
62
-	@commands.has_permissions(ban_members=True)
63
-	@commands.guild_only()
64
-	async def joinraid(self, context: commands.Context):
65
-		"""Join raid detection command group"""
66
-		if context.invoked_subcommand is None:
67
-			await context.send_help()
68
-
69 64
 	async def on_mod_react(self,
70 65
 			bot_message: BotMessage,
71 66
 			reaction: BotMessageReaction,
@@ -90,7 +85,7 @@ class JoinRaidCog(BaseCog, name='Join Raids'):
90 85
 			await self.__update_warning_message(raid)
91 86
 			self.log(guild, f'Join raid users banned by {reacted_by.name}')
92 87
 
93
-	@commands.Cog.listener()
88
+	@Cog.listener()
94 89
 	async def on_member_join(self, member: Member) -> None:
95 90
 		"""Event handler"""
96 91
 		guild: Guild = member.guild

+ 36
- 40
rocketbot/cogs/logcog.py 查看文件

@@ -2,11 +2,12 @@
2 2
 Cog for detecting large numbers of guild joins in a short period of time.
3 3
 """
4 4
 from collections.abc import Sequence
5
-from datetime import datetime, timezone, timedelta
5
+from datetime import datetime, timedelta, timezone
6 6
 
7 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
-from discord.ext import commands, tasks
9
+from discord.ext import tasks
10
+from discord.ext.commands import Cog
10 11
 from discord.utils import escape_markdown
11 12
 from typing import Optional, Tuple, Union, Callable
12 13
 import difflib
@@ -44,7 +45,12 @@ class LoggingCog(BaseCog, name='Logging'):
44 45
 	STATE_EVENT_BUFFER = 'LoggingCog.eventBuffer'
45 46
 
46 47
 	def __init__(self, bot):
47
-		super().__init__(bot, 'logging')
48
+		super().__init__(
49
+			bot,
50
+			config_prefix='logging',
51
+			name='logging',
52
+			short_description='Manages event logging',
53
+		)
48 54
 		self.add_setting(LoggingCog.SETTING_ENABLED)
49 55
 		self.flush_buffers.start()
50 56
 		self.buffered_guilds: set[Guild] = set()
@@ -52,19 +58,9 @@ class LoggingCog(BaseCog, name='Logging'):
52 58
 	def cog_unload(self) -> None:
53 59
 		self.flush_buffers.cancel()
54 60
 
55
-	@commands.group(
56
-		brief='Manages event logging',
57
-	)
58
-	@commands.has_permissions(ban_members=True)
59
-	@commands.guild_only()
60
-	async def logging(self, context: commands.Context):
61
-		"""Logging command group"""
62
-		if context.invoked_subcommand is None:
63
-			await context.send_help()
64
-
65 61
 	# Events - Channels
66 62
 
67
-	@commands.Cog.listener()
63
+	@Cog.listener()
68 64
 	async def on_guild_channel_delete(self, channel: GuildChannel) -> None:
69 65
 		"""
70 66
 		Called whenever a guild channel is deleted or created.
@@ -80,7 +76,7 @@ class LoggingCog(BaseCog, name='Logging'):
80 76
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
81 77
 		await bot_message.update()
82 78
 
83
-	@commands.Cog.listener()
79
+	@Cog.listener()
84 80
 	async def on_guild_channel_create(self, channel: GuildChannel) -> None:
85 81
 		"""
86 82
 		Called whenever a guild channel is deleted or created.
@@ -96,7 +92,7 @@ class LoggingCog(BaseCog, name='Logging'):
96 92
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
97 93
 		await bot_message.update()
98 94
 
99
-	@commands.Cog.listener()
95
+	@Cog.listener()
100 96
 	async def on_guild_channel_update(self, before: GuildChannel, after: GuildChannel) -> None:
101 97
 		"""
102 98
 		Called whenever a guild channel is updated. e.g. changed name, topic,
@@ -129,27 +125,27 @@ class LoggingCog(BaseCog, name='Logging'):
129 125
 
130 126
 	# Events - Guilds
131 127
 
132
-	@commands.Cog.listener()
128
+	@Cog.listener()
133 129
 	async def on_guild_available(self, guild: Guild) -> None:
134 130
 		pass
135 131
 
136
-	@commands.Cog.listener()
132
+	@Cog.listener()
137 133
 	async def on_guild_unavailable(self, guild: Guild) -> None:
138 134
 		pass
139 135
 
140
-	@commands.Cog.listener()
136
+	@Cog.listener()
141 137
 	async def on_guild_update(self, before: Guild, after: Guild) -> None:
142 138
 		pass
143 139
 
144
-	@commands.Cog.listener()
140
+	@Cog.listener()
145 141
 	async def on_guild_emojis_update(self, guild: Guild, before: Sequence[Emoji], after: Sequence[Emoji]) -> None:
146 142
 		pass
147 143
 
148
-	@commands.Cog.listener()
144
+	@Cog.listener()
149 145
 	async def on_guild_stickers_update(self, guild: Guild, before: Sequence[GuildSticker], after: Sequence[GuildSticker]) -> None:
150 146
 		pass
151 147
 
152
-	@commands.Cog.listener()
148
+	@Cog.listener()
153 149
 	async def on_invite_create(self, invite: Invite) -> None:
154 150
 		"""
155 151
 		Called when an `Invite` is created. You must have manage_channels to receive this.
@@ -167,7 +163,7 @@ class LoggingCog(BaseCog, name='Logging'):
167 163
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
168 164
 		await bot_message.update()
169 165
 
170
-	@commands.Cog.listener()
166
+	@Cog.listener()
171 167
 	async def on_invite_delete(self, invite: Invite) -> None:
172 168
 		"""
173 169
 		Called when an `Invite` is deleted. You must have manage_channels to receive this.
@@ -186,7 +182,7 @@ class LoggingCog(BaseCog, name='Logging'):
186 182
 
187 183
 	# Events - Members
188 184
 
189
-	@commands.Cog.listener()
185
+	@Cog.listener()
190 186
 	async def on_member_join(self, member: Member) -> None:
191 187
 		"""
192 188
 		Called when a Member joins a Guild.
@@ -250,7 +246,7 @@ class LoggingCog(BaseCog, name='Logging'):
250 246
 			bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
251 247
 		await bot_message.update()
252 248
 
253
-	@commands.Cog.listener()
249
+	@Cog.listener()
254 250
 	async def on_member_remove(self, member: Member) -> None:
255 251
 		"""
256 252
 		Called when a Member leaves a Guild.
@@ -284,7 +280,7 @@ class LoggingCog(BaseCog, name='Logging'):
284 280
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
285 281
 		await bot_message.update()
286 282
 
287
-	@commands.Cog.listener()
283
+	@Cog.listener()
288 284
 	async def on_member_update(self, before: Member, after: Member) -> None:
289 285
 		"""
290 286
 		Called when a Member updates their profile.
@@ -346,7 +342,7 @@ class LoggingCog(BaseCog, name='Logging'):
346 342
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
347 343
 		await bot_message.update()
348 344
 
349
-	@commands.Cog.listener()
345
+	@Cog.listener()
350 346
 	async def on_user_update(self, before: User, after: User) -> None:
351 347
 		"""
352 348
 		Called when a User updates their profile.
@@ -378,7 +374,7 @@ class LoggingCog(BaseCog, name='Logging'):
378 374
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
379 375
 		await bot_message.update()
380 376
 
381
-	@commands.Cog.listener()
377
+	@Cog.listener()
382 378
 	async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
383 379
 		"""
384 380
 		Called when user gets banned from a Guild.
@@ -419,7 +415,7 @@ class LoggingCog(BaseCog, name='Logging'):
419 415
 				return entry
420 416
 		return None
421 417
 
422
-	@commands.Cog.listener()
418
+	@Cog.listener()
423 419
 	async def on_member_unban(self, guild: Guild, user: Union[User, Member]) -> None:
424 420
 		"""
425 421
 		Called when a User gets unbanned from a Guild.
@@ -472,7 +468,7 @@ class LoggingCog(BaseCog, name='Logging'):
472 468
 	async def before_flush_buffers_start(self) -> None:
473 469
 		await self.bot.wait_until_ready()
474 470
 
475
-	@commands.Cog.listener()
471
+	@Cog.listener()
476 472
 	async def on_message(self, message: Message) -> None:
477 473
 		"""
478 474
 		Called when a Message is created and sent.
@@ -481,7 +477,7 @@ class LoggingCog(BaseCog, name='Logging'):
481 477
 		"""
482 478
 		# print(f"Saw message {message.id} \"{message.content}\"")
483 479
 
484
-	@commands.Cog.listener()
480
+	@Cog.listener()
485 481
 	async def on_message_edit(self, before: Message, after: Message) -> None:
486 482
 		"""
487 483
 		Called when a Message receives an update event. If the message is not
@@ -512,7 +508,7 @@ class LoggingCog(BaseCog, name='Logging'):
512 508
 
513 509
 		self.__buffer_event(guild, 'edit', BufferedMessageEditEvent(guild, channel, before, after))
514 510
 
515
-	@commands.Cog.listener()
511
+	@Cog.listener()
516 512
 	async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
517 513
 		"""
518 514
 		Called when a message is edited. Unlike on_message_edit(), this is called
@@ -632,7 +628,7 @@ class LoggingCog(BaseCog, name='Logging'):
632 628
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG, suppress_embeds=True)
633 629
 		await bot_message.update()
634 630
 
635
-	@commands.Cog.listener()
631
+	@Cog.listener()
636 632
 	async def on_raw_message_delete(self, payload: RawMessageDeleteEvent) -> None:
637 633
 		"""
638 634
 		Called when a message is deleted. Unlike on_message_delete(), this is
@@ -660,7 +656,7 @@ class LoggingCog(BaseCog, name='Logging'):
660 656
 			return
661 657
 		self.__buffer_event(guild, 'delete', BufferedMessageDeleteEvent(guild, channel, payload.message_id, message))
662 658
 
663
-	@commands.Cog.listener()
659
+	@Cog.listener()
664 660
 	async def on_raw_bulk_message_delete(self, payload: RawBulkMessageDeleteEvent) -> None:
665 661
 		"""
666 662
 		Called when a bulk delete is triggered. Unlike on_bulk_message_delete(),
@@ -740,7 +736,7 @@ class LoggingCog(BaseCog, name='Logging'):
740 736
 
741 737
 	# Events - Roles
742 738
 
743
-	@commands.Cog.listener()
739
+	@Cog.listener()
744 740
 	async def on_guild_role_create(self, role: Role) -> None:
745 741
 		"""
746 742
 		Called when a Guild creates or deletes a new Role.
@@ -756,7 +752,7 @@ class LoggingCog(BaseCog, name='Logging'):
756 752
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
757 753
 		await bot_message.update()
758 754
 
759
-	@commands.Cog.listener()
755
+	@Cog.listener()
760 756
 	async def on_guild_role_delete(self, role: Role) -> None:
761 757
 		"""
762 758
 		Called when a Guild creates or deletes a new Role.
@@ -772,7 +768,7 @@ class LoggingCog(BaseCog, name='Logging'):
772 768
 		bot_message = BotMessage(guild, text, BotMessage.TYPE_LOG)
773 769
 		await bot_message.update()
774 770
 
775
-	@commands.Cog.listener()
771
+	@Cog.listener()
776 772
 	async def on_guild_role_update(self, before: Role, after: Role) -> None:
777 773
 		"""
778 774
 		Called when a Role is changed guild-wide.
@@ -815,15 +811,15 @@ class LoggingCog(BaseCog, name='Logging'):
815 811
 
816 812
 	# Events - Threads
817 813
 
818
-	@commands.Cog.listener()
814
+	@Cog.listener()
819 815
 	async def on_thread_create(self, thread: Thread) -> None:
820 816
 		pass
821 817
 
822
-	@commands.Cog.listener()
818
+	@Cog.listener()
823 819
 	async def on_thread_update(self, before: Thread, after: Thread) -> None:
824 820
 		pass
825 821
 
826
-	@commands.Cog.listener()
822
+	@Cog.listener()
827 823
 	async def on_thread_delete(self, thread: Thread) -> None:
828 824
 		pass
829 825
 

+ 6
- 1
rocketbot/cogs/patterncog.py 查看文件

@@ -37,7 +37,12 @@ class PatternCog(BaseCog, name='Pattern Matching'):
37 37
 	SETTING_PATTERNS = CogSetting('patterns', None)
38 38
 
39 39
 	def __init__(self, bot: Rocketbot):
40
-		super().__init__(bot, 'patterns')
40
+		super().__init__(
41
+			bot,
42
+			config_prefix='patterns',
43
+			name='patterns',
44
+			short_description='Manages message pattern matching.',
45
+		)
41 46
 
42 47
 	def __get_patterns(self, guild: Guild) -> dict[str, PatternStatement]:
43 48
 		"""

+ 11
- 16
rocketbot/cogs/urlspamcog.py 查看文件

@@ -6,7 +6,7 @@ from datetime import timedelta
6 6
 from typing import Literal
7 7
 
8 8
 from discord import Member, Message, utils as discordutils
9
-from discord.ext import commands
9
+from discord.ext.commands import Cog
10 10
 from discord.utils import escape_markdown
11 11
 
12 12
 from config import CONFIG
@@ -36,7 +36,7 @@ class URLSpamCog(BaseCog, name='URL Spam'):
36 36
 			brief='action to take on spam',
37 37
 			description='The action to take on detected URL spam.',
38 38
 			enum_values={'nothing', 'modwarn', 'delete', 'kick', 'ban'})
39
-	SETTING_JOIN_AGE = CogSetting('joinage', float,
39
+	SETTING_JOIN_AGE = CogSetting('joinage', timedelta,
40 40
 			brief='seconds since member joined',
41 41
 			description='The minimum seconds since the user joined the ' + \
42 42
 				'server before they can post URLs. URLs posted by users ' + \
@@ -54,23 +54,18 @@ class URLSpamCog(BaseCog, name='URL Spam'):
54 54
 				'chatwarn', 'chatwarndelete', 'delete', 'kick', 'ban'})
55 55
 
56 56
 	def __init__(self, bot):
57
-		super().__init__(bot, 'urlspam')
57
+		super().__init__(
58
+			bot,
59
+			config_prefix='urlspam',
60
+			name='URL spam',
61
+			short_description='Manages URL spam detection.',
62
+		)
58 63
 		self.add_setting(URLSpamCog.SETTING_ENABLED)
59 64
 		self.add_setting(URLSpamCog.SETTING_ACTION)
60 65
 		self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
61 66
 		self.add_setting(URLSpamCog.SETTING_DECEPTIVE_ACTION)
62 67
 
63
-	@commands.group(
64
-		brief='Manages URL spam detection',
65
-	)
66
-	@commands.has_permissions(ban_members=True)
67
-	@commands.guild_only()
68
-	async def urlspam(self, context: commands.Context):
69
-		"""URL spam command group"""
70
-		if context.invoked_subcommand is None:
71
-			await context.send_help()
72
-
73
-	@commands.Cog.listener()
68
+	@Cog.listener()
74 69
 	async def on_message(self, message: Message):
75 70
 		"""Event listener"""
76 71
 		if message.author is None or \
@@ -228,7 +223,7 @@ class URLSpamCog(BaseCog, name='URL Spam'):
228 223
 					return True
229 224
 		return False
230 225
 
231
-	def is_url(self, s: str):
226
+	def is_url(self, s: str) -> bool:
232 227
 		"""Tests if a string is strictly a URL"""
233 228
 		ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
234 229
 		ipv4_host_pattern = r'[0-9\.]+'
@@ -239,7 +234,7 @@ class URLSpamCog(BaseCog, name='URL Spam'):
239 234
 		pattern = r'^http[s]?://' + host_pattern + port_pattern + path_pattern + '$'
240 235
 		return re.match(pattern, s, re.IGNORECASE) is not None
241 236
 
242
-	def is_casual_url(self, s: str):
237
+	def is_casual_url(self, s: str) -> bool:
243 238
 		"""Tests if a string is a "casual URL" with no scheme included"""
244 239
 		ipv6_host_pattern = r'\[[0-9a-fA-F:]+\]'
245 240
 		ipv4_host_pattern = r'[0-9\.]+'

+ 57
- 32
rocketbot/cogs/usernamecog.py 查看文件

@@ -3,8 +3,9 @@ Cog for detecting username patterns.
3 3
 """
4 4
 from typing import Optional
5 5
 
6
-from discord import Guild, Member
7
-from discord.ext import commands
6
+from discord import Guild, Member, Permissions, Interaction
7
+from discord.app_commands import Group
8
+from discord.ext.commands import Cog
8 9
 
9 10
 from config import CONFIG
10 11
 from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
@@ -51,7 +52,12 @@ class UsernamePatternCog(BaseCog, name='Username Pattern'):
51 52
 	SETTING_PATTERNS = CogSetting('patterns', None)
52 53
 
53 54
 	def __init__(self, bot):
54
-		super().__init__(bot, 'username')
55
+		super().__init__(
56
+			bot,
57
+			config_prefix='username',
58
+			name='username patterns',
59
+			short_description='Manages username pattern detection.',
60
+		)
55 61
 		self.add_setting(UsernamePatternCog.SETTING_ENABLED)
56 62
 
57 63
 	def __get_patterns(self, guild: Guild) -> list[str]:
@@ -73,64 +79,83 @@ class UsernamePatternCog(BaseCog, name='Username Pattern'):
73 79
 		"""
74 80
 		cls.set_guild_setting(guild, cls.SETTING_PATTERNS, patterns)
75 81
 
76
-	@commands.group(
77
-		brief='Manages username pattern detection'
82
+	username = Group(
83
+		name='username',
84
+		description='Manages username pattern detection.',
85
+		guild_only=True,
86
+		default_permissions=Permissions(Permissions.manage_messages.flag),
78 87
 	)
79
-	@commands.has_permissions(ban_members=True)
80
-	@commands.guild_only()
81
-	async def username(self, context: commands.Context):
82
-		"""Username pattern command group"""
83
-		if context.invoked_subcommand is None:
84
-			await context.send_help()
85 88
 
86 89
 	@username.command(
87
-		brief='Adds a username pattern',
88
-		description='Adds a username pattern.',
89
-		usage='<pattern>'
90
+		description='Adds a username pattern',
91
+		extras={
92
+			'long_description': 'Adds a username pattern.',
93
+			'usage': '<pattern>',
94
+		},
90 95
 	)
91
-	async def add(self, context: commands.Context, pattern: str) -> None:
96
+	async def add(self, interaction: Interaction, pattern: str) -> None:
92 97
 		"""Command handler"""
93 98
 		norm_pattern = pattern.lower()
94
-		patterns: list[str] = self.__get_patterns(context.guild)
99
+		patterns: list[str] = self.__get_patterns(interaction.guild)
95 100
 		if norm_pattern in patterns:
96
-			await context.reply(f'Pattern `{norm_pattern}` already added.', mention_author=False)
101
+			await interaction.response.send_message(
102
+				f'Pattern `{norm_pattern}` already added.',
103
+				ephemeral=True
104
+			)
97 105
 			return
98 106
 		patterns.append(norm_pattern)
99
-		self.__save_patterns(context.guild, patterns)
100
-		await context.reply(f'Pattern `{norm_pattern}` added.', mention_author=False)
107
+		self.__save_patterns(interaction.guild, patterns)
108
+		await interaction.response.send_message(
109
+			f'Pattern `{norm_pattern}` added.',
110
+			ephemeral=True
111
+		)
101 112
 
102 113
 	@username.command(
103
-		brief='Removes a username pattern',
104
-		description='Removes an existing username pattern',
105
-		usage='<pattern>'
114
+		description='Removes a username pattern',
115
+		extras={
116
+			'long_description': 'Removes an existing username pattern',
117
+			'usage': '<pattern>',
118
+		},
106 119
 	)
107
-	async def remove(self, context: commands.Context, pattern: str) -> None:
120
+	async def remove(self, interaction: Interaction, pattern: str) -> None:
108 121
 		"""Command handler"""
109 122
 		norm_pattern = pattern.lower()
110
-		guild: Guild = context.guild
123
+		guild: Guild = interaction.guild
111 124
 		patterns: list[str] = self.__get_patterns(guild)
112 125
 		len_before = len(patterns)
113 126
 		patterns = list(filter(lambda p: p != norm_pattern, patterns))
114 127
 		if len(patterns) == len_before:
115
-			await context.reply(f'Pattern `{norm_pattern}` not found.', mention_author=False)
128
+			await interaction.response.send_message(
129
+				f'Pattern `{norm_pattern}` not found.',
130
+				ephemeral=True,
131
+			)
116 132
 			return
117 133
 		self.__save_patterns(guild, patterns)
118
-		await context.reply(f'Pattern `{norm_pattern}` removed.', mention_author=False)
134
+		await interaction.response.send_message(
135
+			f'Pattern `{norm_pattern}` removed.',
136
+			ephemeral=True,
137
+		)
119 138
 
120 139
 	@username.command(
121
-		brief='Lists username patterns'
140
+		description='Lists username patterns'
122 141
 	)
123
-	async def list(self, context: commands.Context) -> None:
142
+	async def list(self, interaction: Interaction) -> None:
124 143
 		"""Command handler"""
125
-		guild: Guild = context.guild
144
+		guild: Guild = interaction.guild
126 145
 		patterns: list[str] = self.__get_patterns(guild)
127 146
 		if len(patterns) == 0:
128
-			await context.reply('No patterns defined', mention_author=False)
147
+			await interaction.response.send_message(
148
+				'No patterns defined',
149
+				ephemeral=True,
150
+			)
129 151
 		else:
130 152
 			msg = 'Patterns:\n\n> `' + '`\n> `'.join(patterns) + '`'
131
-			await context.reply(msg, mention_author=False)
153
+			await interaction.response.send_message(
154
+				msg,
155
+				ephemeral=True,
156
+			)
132 157
 
133
-	@commands.Cog.listener()
158
+	@Cog.listener()
134 159
 	async def on_member_join(self, member: Member) -> None:
135 160
 		"""Event handler"""
136 161
 		for pattern in self.__get_patterns(member.guild):

+ 23
- 25
rocketbot/cogsetting.py 查看文件

@@ -1,17 +1,17 @@
1 1
 """
2 2
 A guild configuration setting available for editing via bot commands.
3 3
 """
4
-import inspect
5
-from typing import Any, Optional, Type, TypeVar, Coroutine, Literal
4
+from datetime import timedelta
5
+from typing import Any, Optional, Type, TypeVar, Literal
6 6
 
7
-from discord import Interaction, Permissions, permissions
8
-from discord.app_commands import Range, guild_only, default_permissions
7
+from discord import Interaction, Permissions
8
+from discord.app_commands import Range, Transform
9 9
 from discord.app_commands.commands import Command, Group, CommandCallback
10 10
 from discord.ext.commands import Bot
11 11
 
12 12
 from config import CONFIG
13 13
 from rocketbot.storage import Storage
14
-from rocketbot.utils import bot_log
14
+from rocketbot.utils import bot_log, TimeDeltaTransformer
15 15
 
16 16
 # def _fix_command(command: Command) -> None:
17 17
 # 	"""
@@ -132,10 +132,11 @@ class CogSetting:
132 132
 				)
133 133
 		setattr(cog.__class__, f'_cmd_get_{setting.name}', getter)
134 134
 		getter.__qualname__ = f'{self.__class__.__name__}._cmd_get_{setting.name}'
135
+		getter.__self__ = cog
135 136
 		bot_log(None, cog.__class__, f"Creating /get {setting_name}")
136 137
 		command = Command(
137 138
 			name=setting_name,
138
-			description=f'Shows value of {setting_name}',
139
+			description=f'Shows {self.brief}.',
139 140
 			callback=getter,
140 141
 			parent=CogSetting.__get_group,
141 142
 		)
@@ -163,47 +164,41 @@ class CogSetting:
163 164
 				ephemeral=True
164 165
 			)
165 166
 			await self.on_setting_updated(interaction.guild, setting)
166
-			self.log(interaction.guild, f'{interaction.message.author.name} set {key} to {new_value}')
167
+			self.log(interaction.guild, f'{interaction.user.name} set {key} to {new_value}')
167 168
 
168
-		type_str: str = 'any'
169 169
 		setter: CommandCallback = setter_general
170 170
 		if self.datatype == int:
171 171
 			if self.min_value is not None or self.max_value is not None:
172
-				type_str = f'discord.app_commands.Range[int, {self.min_value}, {self.max_value}]'
173 172
 				r_min = self.min_value
174 173
 				r_max = self.max_value
175 174
 				async def setter_range(self, interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None:
176 175
 					await self.setter_general(interaction, new_value)
177 176
 				setter = setter_range
178 177
 			else:
179
-				type_str = 'int'
180 178
 				async def setter_int(self, interaction: Interaction, new_value: int) -> None:
181 179
 					await self.setter_general(interaction, new_value)
182 180
 				setter = setter_int
183 181
 		elif self.datatype == float:
184
-			type_str = 'float'
185 182
 			async def setter_float(self, interaction: Interaction, new_value: float) -> None:
186 183
 				await self.setter_general(interaction, new_value)
187 184
 			setter = setter_float
185
+		elif self.datatype == timedelta:
186
+			async def setter_timedelta(self, interaction: Interaction, new_value: Transform[timedelta, TimeDeltaTransformer]) -> None:
187
+				await self.setter_general(interaction, new_value)
188
+			setter = setter_timedelta
188 189
 		elif getattr(self.datatype, '__origin__', None) == Literal:
189
-			value_list = '"' + '", "'.join(self.enum_values) + '"'
190
-			type_str = f'typing.Literal[{value_list}]'
191
-			values = self.enum_values
192 190
 			dt = self.datatype
193 191
 			async def setter_enum(self, interaction: Interaction, new_value: dt) -> None:
194 192
 				await self.setter_general(interaction, new_value)
195
-
196 193
 			setter = setter_enum
197 194
 		elif self.datatype == str:
198 195
 			if self.enum_values is not None:
199 196
 				raise ValueError('Type for a setting with enum values should be typing.Literal')
200 197
 			else:
201
-				type_str = 'str'
202 198
 				async def setter_str(self, interaction: Interaction, new_value: str) -> None:
203 199
 					await self.setter_general(interaction, new_value)
204 200
 				setter = setter_str
205 201
 		elif setting.datatype == bool:
206
-			type_str = f'bool'
207 202
 			async def setter_bool(self, interaction: Interaction, new_value: bool) -> None:
208 203
 				await self.setter_general(interaction, new_value)
209 204
 			setter = setter_bool
@@ -211,10 +206,11 @@ class CogSetting:
211 206
 			raise ValueError(f'Invalid type {self.datatype}')
212 207
 		setattr(cog.__class__, f'_cmd_set_{setting.name}', setter)
213 208
 		setter.__qualname__ = f'{cog.__class__.__name__}._cmd_set_{setting.name}'
214
-		bot_log(None, cog.__class__, f"Creating /set {setting_name} {type_str}")
209
+		setter.__self__ = cog
210
+		bot_log(None, cog.__class__, f"Creating /set {setting_name} {self.datatype}")
215 211
 		command = Command(
216 212
 			name=setting_name,
217
-			description=f'Sets value of {setting_name}',
213
+			description=f'Sets {self.brief}.',
218 214
 			callback=setter,
219 215
 			parent=CogSetting.__set_group,
220 216
 		)
@@ -226,7 +222,7 @@ class CogSetting:
226 222
 
227 223
 	def __make_enable_command(self, cog: BaseCog) -> Command:
228 224
 		setting: CogSetting = self
229
-		async def enabler(self, interaction: Interaction) -> None:
225
+		async def enabler(self: BaseCog, interaction: Interaction) -> None:
230 226
 			print(f"invoking enable for {self.config_prefix}")
231 227
 			key = f'{self.__class__.__name__}.{setting.name}'
232 228
 			Storage.set_config_value(interaction.guild, key, True)
@@ -235,9 +231,10 @@ class CogSetting:
235 231
 				ephemeral=True
236 232
 			)
237 233
 			await self.on_setting_updated(interaction.guild, setting)
238
-			self.log(interaction.guild, f'{interaction.message.author.name} enabled {self.__class__.__name__}')
234
+			self.log(interaction.guild, f'{interaction.user.name} enabled {self.__class__.__name__}')
239 235
 		setattr(cog.__class__, f'_cmd_enable', enabler)
240 236
 		enabler.__qualname__ = f'{cog.__class__.__name__}._cmd_enable'
237
+		enabler.__self__ = cog
241 238
 		bot_log(None, cog.__class__, f"Creating /enable {cog.config_prefix}")
242 239
 
243 240
 		command = Command(
@@ -252,7 +249,7 @@ class CogSetting:
252 249
 
253 250
 	def __make_disable_command(self, cog: BaseCog) -> Command:
254 251
 		setting: CogSetting = self
255
-		async def disabler(self, interaction: Interaction) -> None:
252
+		async def disabler(self: BaseCog, interaction: Interaction) -> None:
256 253
 			print(f"invoking disable for {self.config_prefix}")
257 254
 			key = f'{self.__class__.__name__}.{setting.name}'
258 255
 			Storage.set_config_value(interaction.guild, key, False)
@@ -261,9 +258,10 @@ class CogSetting:
261 258
 				ephemeral=True
262 259
 			)
263 260
 			await self.on_setting_updated(interaction.guild, setting)
264
-			self.log(interaction.guild, f'{interaction.message.author.name} disabled {self.__class__.__name__}')
261
+			self.log(interaction.guild, f'{interaction.user.name} disabled {self.__class__.__name__}')
265 262
 		setattr(cog.__class__, f'_cmd_disable', disabler)
266 263
 		disabler.__qualname__ = f'{cog.__class__.__name__}._cmd_disable'
264
+		disabler.__self__ = cog
267 265
 		bot_log(None, cog.__class__, f"Creating /disable {cog.config_prefix}")
268 266
 
269 267
 		command = Command(
@@ -314,12 +312,12 @@ class CogSetting:
314 312
 		)
315 313
 		cls.__enable_group = Group(
316 314
 			name='enable',
317
-			description='Enables a set of bot functionality for this guild',
315
+			description='Enables bot functionality for this guild',
318 316
 			default_permissions=cls.permissions
319 317
 		)
320 318
 		cls.__disable_group = Group(
321 319
 			name='disable',
322
-			description='Disables a set of bot functionality for this guild',
320
+			description='Disables bot functionality for this guild',
323 321
 			default_permissions=cls.permissions
324 322
 		)
325 323
 		bot.tree.add_command(cls.__set_group)

+ 38
- 4
rocketbot/utils.py 查看文件

@@ -17,16 +17,32 @@ def dump_stacktrace(e: BaseException) -> None:
17 17
 
18 18
 def timedelta_from_str(s: str) -> timedelta:
19 19
 	"""
20
-	Parses a timespan. Format examples:
20
+	Parses a timespan.
21
+
22
+	Format examples:
21 23
 	"30m"
22 24
 	"10s"
23 25
 	"90d"
24 26
 	"1h30m"
25 27
 	"73d18h22m52s"
28
+
29
+	Parameters
30
+	----------
31
+	s : str
32
+		string to parse
33
+
34
+	Returns
35
+	-------
36
+	timedelta
37
+
38
+	Raises
39
+	------
40
+	ValueError
41
+		if parsing fails
26 42
 	"""
27
-	p: re.Pattern = re.compile('^(?:[0-9]+[dhms])+$')
43
+	p: re.Pattern = re.compile('^(?:[0-9]+[a-zA-Z])+$')
28 44
 	if p.match(s) is None:
29
-		raise ValueError("Illegal timespan value '{s}'.")
45
+		raise ValueError(f'Illegal timespan value "{s}". Examples: 30s, 5m, 1h30m, 30d')
30 46
 	p = re.compile('([0-9]+)([dhms])')
31 47
 	days: int = 0
32 48
 	hours: int = 0
@@ -34,7 +50,7 @@ def timedelta_from_str(s: str) -> timedelta:
34 50
 	seconds: int = 0
35 51
 	for m in p.finditer(s):
36 52
 		scalar = int(m.group(1))
37
-		unit = m.group(2)
53
+		unit = m.group(2).lower()
38 54
 		if unit == 'd':
39 55
 			days = scalar
40 56
 		elif unit == 'h':
@@ -43,6 +59,8 @@ def timedelta_from_str(s: str) -> timedelta:
43 59
 			minutes = scalar
44 60
 		elif unit == 's':
45 61
 			seconds = scalar
62
+		else:
63
+			raise ValueError(f'Invalid unit "{unit}". Valid units: "s"=seconds, "m"=minutes, "h"=hours, "d"=days')
46 64
 	return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
47 65
 
48 66
 def str_from_timedelta(td: timedelta) -> str:
@@ -152,3 +170,19 @@ def str_from_quoted_str(val: str) -> str:
152 170
 	if len(val) < 2 or val[0:1] not in __QUOTE_CHARS or val[-1:] not in __QUOTE_CHARS:
153 171
 		raise ValueError(f'Not a quoted string: {val}')
154 172
 	return val[1:-1]
173
+
174
+from discord import Interaction
175
+from discord.app_commands import Transformer
176
+from discord.ext.commands import BadArgument
177
+
178
+class TimeDeltaTransformer(Transformer):
179
+	async def transform(self, interaction: Interaction, value: Any) -> timedelta:
180
+		try:
181
+			return timedelta_from_str(str(value))
182
+		except ValueError as e:
183
+			print("Invalid time delta:", e)
184
+			raise BadArgument(str(e))
185
+
186
+	@property
187
+	def _error_display_name(self) -> str:
188
+		return 'timedelta'

Loading…
取消
儲存